171 Commits

Author SHA1 Message Date
ctwj
8a79e7e87b update: api log 2025-11-21 23:06:41 +08:00
Kerwin
f67f820ef8 updaet: nginx config 2025-11-21 18:42:50 +08:00
Kerwin
1af7fbd355 fix: 修复搜索结果中没有key的问题 2025-11-21 14:04:40 +08:00
Kerwin
8e35a6e507 update: nginx config 2025-11-21 10:57:15 +08:00
Kerwin
d113dcd926 update: add site_url config 2025-11-21 09:30:33 +08:00
ctwj
8708e869a4 add: sitemap 2025-11-21 01:47:02 +08:00
ctwj
6c84b8d7b7 update: ui 2025-11-21 00:21:55 +08:00
ctwj
fc4cf8ecfb update: 添加tg调试代码 2025-11-21 00:03:07 +08:00
ctwj
447512d809 Update ChangeLog.md 2025-11-20 19:29:27 +08:00
ctwj
64e7169140 Merge pull request #22 from ctwj/feat_detail
feat: add detail page
2025-11-20 19:28:29 +08:00
Kerwin
99d2c7f20f updat: config 2025-11-20 19:27:39 +08:00
Kerwin
9e6b5a58c4 update: details 2025-11-20 17:29:33 +08:00
Kerwin
040e6bc6bf update: log 2025-11-20 16:05:41 +08:00
ctwj
3370f75d5e update check 2025-11-20 08:34:56 +08:00
Kerwin
11a3204c18 update: check 2025-11-19 16:50:47 +08:00
Kerwin
5276112e48 update: copyright-claims 2025-11-19 13:40:13 +08:00
ctwj
3bd0fde82f update: report 2025-11-19 08:32:01 +08:00
ctwj
61e5cbf80d add report 2025-11-19 02:22:04 +08:00
Kerwin
57f7bab443 add share 2025-11-18 23:51:49 +08:00
Kerwin
242e12c29c update: cache 2025-11-18 18:14:25 +08:00
Kerwin
f9a1043431 update: og image 2025-11-18 15:28:08 +08:00
ctwj
5dc431ab24 update: detail ui 2025-11-18 00:49:57 +08:00
Kerwin
c50282bec8 add: details 2025-11-17 18:51:04 +08:00
Kerwin
b99a97c0a9 update: ui 2025-11-14 17:49:09 +08:00
Kerwin
5c1aaf245d update: index page resource change to 50 2025-11-14 17:46:28 +08:00
Kerwin
30448841f6 fix: 优化ui 2025-11-13 15:25:29 +08:00
ctwj
7cddb243bc Merge pull request #21 from ctwj/feat_seo
Feat seo
2025-11-13 01:02:49 +08:00
ctwj
c15132b45a update: seo 2025-11-13 00:34:26 +08:00
ctwj
04b3838cea update: seo 2025-11-12 08:39:25 +08:00
ctwj
70276b68ee update: seo 2025-11-12 08:26:56 +08:00
ctwj
fe8aaff92e update: seo 2025-11-12 00:57:41 +08:00
ctwj
236051f6c4 Merge pull request #20 from ctwj/feat_xunlei_opt
Feat xunlei opt
2025-11-11 23:35:42 +08:00
ctwj
01bc8f0450 update: ui 2025-11-11 23:01:49 +08:00
ctwj
5b7e7b73ad update: xunlei 2025-11-11 01:53:11 +08:00
ctwj
0e88374905 Merge branch 'main' of https://github.com/ctwj/urldb 2025-11-11 01:37:45 +08:00
ctwj
ca175ec59d update: xunlei 2025-11-11 01:36:33 +08:00
Kerwin
ec4e0762d5 update: 迅雷使用账密方式登录 2025-11-10 14:29:28 +08:00
Kerwin
081a3a7222 fix: 修复机器人停止了还能回复消息的问题 2025-11-10 10:51:33 +08:00
ctwj
6b8d2b3cf0 update: 优化推送策略 2025-11-07 23:21:04 +08:00
ctwj
9333f9da94 fix: 修复多个三方统计只生效一个的问题 2025-11-07 22:35:06 +08:00
Kerwin
806a724fb5 fix: 优化日志 2025-11-07 18:52:27 +08:00
Kerwin
487f5c9559 update: 日志优化 2025-11-07 18:50:08 +08:00
Kerwin
18b7f89c49 update: version 1.3.4 2025-11-06 20:02:29 +08:00
Kerwin
db902f3742 chore: bump version to v1.3.4 2025-11-06 19:09:48 +08:00
Kerwin
42baa891f8 fix: 修复应为推送导致的程序崩溃 2025-11-06 19:07:03 +08:00
Kerwin
02d5d00510 update: 优化平台账号管理 2025-11-05 20:52:32 +08:00
ctwj
d95c69142a Update README with WeChat auto-reply link
Added link for WeChat official account auto-reply.
2025-11-04 16:11:38 +08:00
Kerwin
2638ccb1e4 fix: 修复nginx启动失败的问题 2025-11-03 14:11:10 +08:00
ctwj
886d91ab10 Update version history to v1.3.3 2025-11-03 14:00:21 +08:00
Kerwin
ddad95be41 update: version to 1.3.3 2025-11-03 12:29:55 +08:00
Kerwin
273800459f chore: bump version to v1.3.3 2025-11-03 11:50:08 +08:00
Kerwin
dbe24af4ac fix: docker nginx start fail 2025-11-03 11:49:33 +08:00
ctwj
a598ef508c Add entry for version 1.3.3 in ChangeLog 2025-11-03 00:00:54 +08:00
ctwj
1ca4cce6bc Merge pull request #19 from ctwj/feat_wechat
feat: 新增公众号自动回复
2025-11-02 23:56:56 +08:00
ctwj
270022188e update: 公众奥自动回复 2025-11-02 23:55:28 +08:00
Kerwin
7e80a1c2b2 update: version 1.3.2 2025-11-01 10:14:55 +08:00
Kerwin
6e7914f056 chore: bump version to v1.3.2 2025-11-01 10:09:06 +08:00
ctwj
dbde0e1675 update: wechat 2025-11-01 08:59:25 +08:00
ctwj
b840680df0 update: 完善公众号自动回复 2025-10-31 23:32:57 +08:00
ctwj
651987731b update: wechat 2025-10-31 20:14:17 +08:00
ctwj
fb26d166d6 update: bot参数 2025-10-31 16:10:32 +08:00
ctwj
8baf5c6c3d update: wechat 2025-10-31 13:36:07 +08:00
Kerwin
005aa71cc2 update: index.vue 2025-10-28 14:19:24 +08:00
Kerwin
61beed6788 update: 日志优化 2025-10-28 11:07:00 +08:00
Kerwin
53aebf2a15 add: 新增系统日志 2025-10-28 09:40:55 +08:00
ctwj
1fe9487833 update: seo优化 2025-10-28 00:33:16 +08:00
ctwj
6476ce1369 Merge pull request #17 from ctwj/feat_qrcode_1
二维码美化
2025-10-27 23:42:44 +08:00
ctwj
1ad3a07930 update: 二维码 2025-10-27 23:41:35 +08:00
Kerwin
22fd1dcf81 update: ui 2025-10-27 19:23:39 +08:00
Kerwin
f8cfe307ae Merge branch 'feat_qrcode' of https://github.com/ctwj/urldb into feat_qrcode_1 2025-10-27 19:10:46 +08:00
Kerwin
84ee0d9e53 update: qrcode 2025-10-27 19:09:13 +08:00
Kerwin
40e3350a4b opt: 优化数据库连接池,配置管理,错误处理 2025-10-27 18:21:59 +08:00
ctwj
013fe71925 Merge branch 'main' of https://github.com/ctwj/urldb 2025-10-26 10:16:59 +08:00
ctwj
6be7ae871d update: version 1.3.1 2025-10-26 10:16:52 +08:00
ctwj
89e2aca968 chore: bump version to v1.3.1 2025-10-26 10:16:00 +08:00
ctwj
f006d84b03 Update README with v1.3.1 features 2025-10-25 11:25:56 +08:00
Kerwin
7ce3839b9b chore: bump version to v1.3.1 2025-10-25 10:59:06 +08:00
ctwj
52ea019374 update: tgbot限制放开为3个 2025-10-25 09:42:19 +08:00
ctwj
4c738d1030 update: 移除Telegram Bot 中的 https://pan.l9.lc 2025-10-25 08:41:14 +08:00
ctwj
ec00f2d823 Merge branch 'main' of https://github.com/ctwj/urldb 2025-10-25 00:46:28 +08:00
ctwj
54542ff8ee update: 首页时间显示问题优化 2025-10-25 00:46:17 +08:00
ctwj
0050c6bba3 Update contact information in README.md
Removed contact section and added group chat information.
2025-10-22 16:51:43 +08:00
ctwj
4ceed8fd4b Update README with Telegram channels and links
Added links to Telegram resources and demo.
2025-10-22 16:36:34 +08:00
ctwj
2e5dd8360e update: components.d.ts 2025-10-21 00:41:16 +08:00
ctwj
40ad48f5cf update: 公告支持html 2025-10-20 23:57:27 +08:00
ctwj
921bdc43cb Update ChangeLog for version 1.3.1 2025-10-20 01:57:46 +08:00
ctwj
0df7d8bf23 add: 首页添加公告和右下角浮动按钮 2025-10-20 01:52:19 +08:00
ctwj
fdc75705aa update: 添加右下角浮动按钮 2025-10-19 13:00:19 +08:00
ctwj
a28dd4840b Merge branch 'main' of https://github.com/ctwj/urldb 2025-10-19 08:58:08 +08:00
ctwj
061b94cf61 fix: 修复首页的今日资源数不对滴问题 2025-10-19 08:56:11 +08:00
ctwj
0d28b322b7 Remove Docker build instructions from README
Removed Docker build and push instructions from README.
2025-10-19 08:39:48 +08:00
ctwj
ee06e110bd Update Telegram link in README.md 2025-10-19 08:33:54 +08:00
ctwj
7acfa300ea update: 优化tgBot 2025-10-17 00:32:25 +08:00
ctwj
b4689d2f99 Update README.md 2025-10-15 11:41:47 +08:00
Kerwin
6074d91467 update: 列表添加图片显示 2025-10-14 16:37:11 +08:00
Kerwin
e30e381adf add: default cover 2025-10-14 14:28:56 +08:00
Kerwin
516746f722 update: tgbot 优化 2025-10-10 19:17:03 +08:00
Kerwin
4da07b3ea4 update: 优化 Meilisearch tag值 2025-10-09 17:52:49 +08:00
Kerwin
da8a2ad169 Merge branch 'main' of https://github.com/ctwj/urldb 2025-10-09 17:05:03 +08:00
Kerwin
e2832b9e36 update: 删除资源时,同步删除Meilisearch中的数据 2025-10-09 17:03:03 +08:00
ctwj
bdb43531e8 update: 优化API日志显示 2025-10-07 21:57:13 +08:00
ctwj
51dbf0f03a update: 新增api访问日志 2025-10-07 02:30:01 +08:00
ctwj
10294e093f Update release.yml 2025-09-29 09:55:13 +08:00
Kerwin
6816ab0550 chore: version to 1.3.0 2025-09-29 09:41:52 +08:00
Kerwin
357e09ef52 chore: bump version to v1.3.0 2025-09-28 18:03:25 +08:00
ctwj
3a50af844e Update ChangeLog.md 2025-09-27 16:16:38 +08:00
ctwj
01c371b503 Merge pull request #16 from ctwj/feat_expansion
update: expansion
2025-09-27 16:15:39 +08:00
ctwj
338a535531 update: expansion 2025-09-27 16:14:43 +08:00
ctwj
19e92719c3 Merge pull request #15 from ctwj/feat_expansion
feat: expansion
2025-09-26 17:59:45 +08:00
Kerwin
2727bef91b update: 扩容显示优化 2025-09-26 17:46:55 +08:00
Kerwin
193ed24316 update: 更新扩容功能 2025-09-26 17:25:30 +08:00
Kerwin
ba155bd253 update: default logo 2025-09-26 13:44:17 +08:00
Kerwin
4ca6e05fe0 Merge branch 'main' of https://github.com/ctwj/urldb 2025-09-25 18:59:52 +08:00
Kerwin
169706bfbc update: add logo 2025-09-25 18:59:42 +08:00
ctwj
2568d9b6a4 Update ChangeLog.md 2025-09-25 18:04:12 +08:00
Kerwin
d3279ded92 update: only audo delete resource message 2025-09-24 10:27:14 +08:00
ctwj
5bcf1bb5ef update: 添加推送消息的图片处理 2025-09-24 00:04:57 +08:00
ctwj
547b58c7ba Merge pull request #14 from ctwj/feat_tg
feat: 添加telegram bot
2025-09-23 18:31:17 +08:00
Kerwin
b9fbe58a3d update: ui 2025-09-23 18:15:05 +08:00
ctwj
6b92061d09 update: msg 2025-09-22 23:55:27 +08:00
Kerwin
3aa2963211 update: test 2025-09-22 18:02:10 +08:00
ctwj
6fa9036705 update: tg bot 2025-09-22 07:58:06 +08:00
ctwj
091be5ef70 update: tg bot 2025-09-21 00:11:10 +08:00
Kerwin
a24d32776c update: tg bot 2025-09-19 18:37:50 +08:00
Kerwin
982e4f942e update: 更新删除功能 2025-09-18 18:34:35 +08:00
Kerwin
9d2c4e8978 update: ui 2025-09-17 18:45:12 +08:00
Kerwin
cd8c519b3a update: tg 2025-09-17 14:31:12 +08:00
ctwj
1eb37baa87 add: log 2025-09-17 00:09:59 +08:00
Kerwin
b97f56c455 update: 更新 api 机器人 2025-09-16 18:23:06 +08:00
ctwj
8ced3d0327 add: tgbot 2025-09-16 00:07:02 +08:00
ctwj
bada678490 Merge pull request #13 from ctwj/feat_expansion
Feat expansion
2025-09-15 17:06:36 +08:00
Kerwin
8be837fcbf update: 完善扩容 2025-09-15 17:04:02 +08:00
ctwj
cb0c77a565 update: 新增豆瓣排行数据 2025-09-15 08:17:32 +08:00
ctwj
2ef6e4debb Merge pull request #12 from ctwj/feat_expansion
feat: 后端UI框架优化
2025-09-14 10:54:09 +08:00
ctwj
5a4d3b9eb4 update: ui 更新 2025-09-14 10:52:58 +08:00
ctwj
ade5e4d2ed Update ChangeLog.md 2025-09-14 10:33:05 +08:00
ctwj
595a0a917c Update README.md 2025-09-14 10:32:24 +08:00
ctwj
d23a6b26e4 update: ui 2025-09-14 10:26:58 +08:00
Kerwin
9690a63646 update: expansion ui 2025-09-12 18:22:14 +08:00
Kerwin
2a5bf19e7d update: 添加扩容UI 2025-09-12 18:06:09 +08:00
Kerwin
eeeb2aefbb update: add actions 2025-09-11 09:28:51 +08:00
Kerwin
9c838e369f update: components.d.ts 2025-09-10 15:20:46 +08:00
ctwj
5a4918812a Merge pull request #11 from ctwj/feat_hot
feat: 热播剧更新
2025-09-09 19:18:21 +08:00
Kerwin
08af3d9b6f update: 优化热播剧 2025-09-09 19:16:09 +08:00
Kerwin
cafe2ce406 update: 2025-09-09 16:27:07 +08:00
Kerwin
e481775e27 update:hot 2025-09-08 21:03:59 +08:00
ctwj
4c9cef249e Merge pull request #10 from ctwj/feat_xunlei
更新首页UI
2025-09-08 01:37:12 +08:00
ctwj
056aa229fe update: ui 2025-09-08 01:36:20 +08:00
ctwj
6f8bcfd356 首页样式优化,显示标签 2025-09-08 01:11:26 +08:00
ctwj
5b0e4ea4a7 Merge pull request #9 from ctwj/feat_xunlei
Feat: 添加 xunlei 支持
2025-09-05 16:29:16 +08:00
Kerwin
fc77d43614 Merge branch 'main' of https://github.com/ctwj/urldb into feat_xunlei 2025-09-05 16:28:46 +08:00
Kerwin
67828458b0 update: 修复xunlei shareId获取错误的问题 2025-09-05 16:23:46 +08:00
ctwj
e51446abf8 update: 转存任务优化 2025-09-05 01:28:24 +08:00
Kerwin
1d6929db00 update: 移除自动转存的任务 2025-09-04 18:18:45 +08:00
Kerwin
b58e805718 update: 自动转存 2025-09-04 18:10:00 +08:00
Kerwin
aa1aa47eba update: 自动转存 2025-09-04 18:09:27 +08:00
Kerwin
3aed6bd24d fix: 迅雷取shareId失败的问题 2025-09-04 16:14:42 +08:00
Kerwin
1c71156784 update: 优化网盘操作,移除特殊操作 2025-09-04 11:09:11 +08:00
ctwj
f2ee574fae update: 完成了账号添加和刷新容量 2025-09-04 01:02:07 +08:00
Kerwin
074058ac5c update: pan 2025-09-03 23:39:26 +08:00
Kerwin
07cb6977e4 update: 更新添加迅雷云盘账号逻辑 2025-09-03 18:07:14 +08:00
Kerwin
baae1da1e0 update: 移除单例 2025-09-03 16:49:07 +08:00
Kerwin
9e7b214812 update: ui 2025-09-03 15:44:47 +08:00
ctwj
37004107d0 udpate: add xunlei ck 2025-09-03 00:48:10 +08:00
Kerwin
4aab45cda5 update: xunlei 2025-09-02 18:30:55 +08:00
ctwj
2853287b1d update: config xunlei 2025-09-02 00:06:51 +08:00
Kerwin
46e5cee810 fix: QQ机器人返回数据不正确的问题 2025-09-01 09:38:15 +08:00
Kerwin
fac32cdfe6 chore: version to 1.2.5 2025-08-28 15:08:27 +08:00
Kerwin
800b511116 add: qrcode 2025-08-25 13:05:25 +08:00
ctwj
bfaf93c849 update: xunlei package 2025-08-20 23:05:03 +08:00
ctwj
1b898eda37 update: xunlei api 2025-08-20 00:13:31 +08:00
206 changed files with 33411 additions and 4742 deletions

62
.github/workflows/release.yml vendored Normal file
View 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

7
.gitignore vendored
View File

@@ -124,3 +124,10 @@ dist/
# Air live reload
tmp/
#
data/
.claude/
main
output.zip
CLAUDE.md

View File

@@ -1,3 +1,28 @@
### v1.3.4
1. 添加详情页
### v1.3.3
1. 公众号自动回复
### v1.3.2
1. 二维码美化
2. TelegramBot参数调整
3. 修复一些问题
### 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. 搜索增强,毫秒级响应,关键字高亮显示

View File

@@ -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,20 +38,21 @@
- [文档说明](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)
- [微信公众号自动回复](https://ecn5khs4t956.feishu.cn/wiki/APOEwOyDYicKGHk7gTzcQKpynkf?from=from_copylink)
### v1.2.4
### v1.3.3
1. 新增公众号自动回复
2. 修复一些问题
1. 搜索增强,毫秒级响应,关键字高亮显示
2. 修复版本显示不正确的问题
3. 配置项新增Meilisearch配置
[详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
当前特性
1. 支持API手动批量录入资源
2. 支持,自动判断资源有效性
3. 支持自动转存Quark
4. 支持平台多账号管理Quark
3. 支持自动转存
4. 支持平台多账号管理
5. 支持简单的数据统计
6. 支持Meilisearch
@@ -118,17 +123,12 @@ PORT=8080
# 时区配置
TIMEZONE=Asia/Shanghai
```
### 镜像构建
# 日志配置
LOG_LEVEL=INFO # 日志级别 (DEBUG, INFO, WARN, ERROR, FATAL)
DEBUG=false # 调试模式开关
STRUCTURED_LOG=false # 结构化日志开关 (JSON格式)
```
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
```
---
## 📄 许可证
@@ -148,11 +148,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)
---

View File

@@ -1 +1 @@
1.2.5
1.3.4

View File

@@ -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 != "" {

View File

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

View File

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

View File

@@ -29,16 +29,13 @@ 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{
quarkInstance := &QuarkPanService{
BasePanService: NewBasePanService(config),
}
@@ -58,7 +55,6 @@ func NewQuarkPanService(config *PanConfig) *QuarkPanService {
"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.UpdateConfig(config)
@@ -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

View File

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

View File

@@ -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
View 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":""}
================================

View File

@@ -0,0 +1,39 @@
package pan
import "encoding/json"
// XunleiAccountCredentials 迅雷账号凭据结构
type XunleiAccountCredentials struct {
Username string `json:"username"` // 手机号(不包含+86前缀
Password string `json:"password"` // 密码
RefreshToken string `json:"refresh_token"` // 当前有效的refresh_token
}
// ParseCredentialsFromCk 从ck字段解析账号凭据
func ParseCredentialsFromCk(ck string) (*XunleiAccountCredentials, error) {
var credentials XunleiAccountCredentials
if err := json.Unmarshal([]byte(ck), &credentials); err != nil {
return nil, err
}
return &credentials, nil
}
// IsAccountCredentials 检查ck是否包含账号密码信息
func IsAccountCredentials(ck string) bool {
var credentials map[string]interface{}
if err := json.Unmarshal([]byte(ck), &credentials); err != nil {
return false
}
_, hasUsername := credentials["username"]
_, hasPassword := credentials["password"]
return hasUsername && hasPassword
}
// ToJsonString 转换为JSON字符串
func (c *XunleiAccountCredentials) ToJsonString() (string, error) {
data, err := json.Marshal(c)
if err != nil {
return "", err
}
return string(data), nil
}

232
common/xunlei_login.go Normal file
View File

@@ -0,0 +1,232 @@
package pan
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"strings"
"time"
)
// 新增常量定义
const (
XLUSER_CLIENT_ID = "XW5SkOhLDjnOZP7J" // 登录
PAN_CLIENT_ID = "Xqp0kJBXWhwaTpB6" // 获取文件列表
CLIENT_SECRET = "Og9Vr1L8Ee6bh0olFxFDRg"
CLIENT_VERSION = "1.92.9" // 更新为与xunlei_3项目相同的版本
PACKAG_ENAME = "pan.xunlei.com"
)
var SALTS = []string{
"QG3/GhopO+5+T",
"1Sv94+ANND3lDmmw",
"q2eTxRva8b3B5d",
"m2",
"VIc5CZRBMU71ENfbOh0+RgWIuzLy",
"66M8Wpw6nkBEekOtL6e",
"N0rucK7S8W/vrRkfPto5urIJJS8dVY0S",
"oLAR7pdUVUAp9xcuHWzrU057aUhdCJrt",
"6lxcykBSsfI//GR9",
"r50cz+1I4gbU/fk8",
"tdwzrTc4SNFC4marNGTgf05flC85A",
"qvNVUDFjfsOMqvdi2gB8gCvtaJAIqxXs",
}
// captchaSign 生成验证码签名 - 完全复制自xunlei_3项目
func (x *XunleiPanService) captchaSign(clientId string, deviceID string, timestamp string) string {
sign := clientId + CLIENT_VERSION + PACKAG_ENAME + deviceID + timestamp
log.Printf("urldb 签名基础字符串: %s", sign)
for _, salt := range SALTS { // salt =
hash := md5.Sum([]byte(sign + salt))
sign = hex.EncodeToString(hash[:])
}
log.Printf("urldb 最终签名: 1.%s", sign)
return fmt.Sprintf("1.%s", sign)
}
// getTimestamp 获取当前时间戳
func (x *XunleiPanService) getTimestamp() int64 {
return time.Now().UnixMilli()
}
// LoginWithCredentials 使用账号密码登录
func (x *XunleiPanService) LoginWithCredentials(username, password string) (XunleiTokenData, error) {
loginURL := "https://xluser-ssl.xunlei.com/v1/auth/signin"
// 初始化验证码 - 完全模仿xunlei_3的CaptchaInit方法
captchaURL := "https://xluser-ssl.xunlei.com/v1/shield/captcha/init"
// 构造meta参数完全模仿xunlei_3只包含phone_number
meta := map[string]interface{}{
"phone_number": "+86" + username,
}
// 构造验证码请求完全模仿xunlei_3
captchaBody := map[string]interface{}{
"client_id": XLUSER_CLIENT_ID,
"action": "POST:/v1/auth/signin",
"device_id": x.deviceId,
"meta": meta,
}
log.Printf("发送验证码初始化请求: %+v", captchaBody)
resp, err := x.sendCaptchaRequest(captchaURL, captchaBody)
if err != nil {
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: %v", err)
}
if resp["captcha_token"] == nil {
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: 响应中没有captcha_token")
}
captchaToken, ok := resp["captcha_token"].(string)
if !ok {
return XunleiTokenData{}, fmt.Errorf("获取验证码失败: captcha_token格式错误")
}
log.Printf("成功获取captcha_token: %s", captchaToken)
// 构造登录请求数据
loginData := map[string]interface{}{
"client_id": XLUSER_CLIENT_ID,
"client_secret": CLIENT_SECRET,
"password": password,
"username": "+86 " + username,
"captcha_token": captchaToken,
}
// 发送登录请求
userInfo, err := x.sendCaptchaRequest(loginURL, loginData)
if err != nil {
return XunleiTokenData{}, fmt.Errorf("登录请求失败: %v", err)
}
// 提取token信息
accessToken, ok := userInfo["access_token"].(string)
if !ok {
return XunleiTokenData{}, fmt.Errorf("登录响应中没有access_token")
}
refreshToken, ok := userInfo["refresh_token"].(string)
if !ok {
return XunleiTokenData{}, fmt.Errorf("登录响应中没有refresh_token")
}
sub, ok := userInfo["sub"].(string)
if !ok {
sub = ""
}
// 计算过期时间
expiresIn := int64(3600) // 默认1小时
if exp, ok := userInfo["expires_in"].(float64); ok {
expiresIn = int64(exp)
}
expiresAt := time.Now().Unix() + expiresIn - 60 // 减去60秒缓冲
log.Printf("登录成功获取到token")
return XunleiTokenData{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: expiresIn,
ExpiresAt: expiresAt,
Sub: sub,
TokenType: "Bearer",
UserId: sub,
}, nil
}
// sendCaptchaRequest 发送验证码请求 - 完全复制xunlei_3的sendRequest实现
func (x *XunleiPanService) sendCaptchaRequest(url string, data map[string]interface{}) (map[string]interface{}, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
log.Printf("发送验证码请求URL: %s", url)
log.Printf("发送验证码请求数据: %s", string(jsonData))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
// 完全复制xunlei_3的请求头设置
reqHeaders := x.getHeadersForRequest(nil)
// 添加特定的headers
reqHeaders["Content-Type"] = "application/x-www-form-urlencoded"
reqHeaders["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"
for k, v := range reqHeaders {
req.Header.Set(k, v)
}
// 根据URL确定使用哪个client_id
if strings.Contains(url, "shield/captcha/init") {
// 对于验证码初始化如果数据中指定了client_id则使用该client_id
if clientID, ok := data["client_id"].(string); ok {
req.Header.Set("X-Client-Id", clientID)
} else {
// 默认使用PAN_CLIENT_ID用于API相关的验证码
req.Header.Set("X-Client-Id", PAN_CLIENT_ID)
}
} else if strings.Contains(url, "auth/") {
// 对于认证相关的请求使用登录相关的client_id
req.Header.Set("X-Client-Id", XLUSER_CLIENT_ID)
} else {
// 对于一般的API请求使用PAN_CLIENT_ID
req.Header.Set("X-Client-Id", PAN_CLIENT_ID)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
log.Printf("验证码响应状态码: %d", resp.StatusCode)
log.Printf("验证码响应内容: %s", string(body))
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %v, raw: %s", err, string(body))
}
log.Printf("解析后的响应: %+v", result)
return result, nil
}
// getHeadersForRequest 获取请求头
func (x *XunleiPanService) getHeadersForRequest(accessToken *string) map[string]string {
headers := map[string]string{
"Content-Type": "application/json; charset=utf-8",
}
// 这里我们简化处理,因为验证码请求不需要这些
// if x.CaptchaToken != nil {
// headers["User-Agent"] = x.buildCustomUserAgent()
// headers["X-Captcha-Token"] = *x.CaptchaToken
// } else {
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
// }
// if accessToken != nil {
// headers["Authorization"] = fmt.Sprintf("Bearer %s", *accessToken)
// }
// if x.DeviceID != "" {
// headers["X-Device-Id"] = x.DeviceID
// }
return headers
}

897
common/xunlei_pan.bak Normal file
View File

@@ -0,0 +1,897 @@
package pan
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
)
// CaptchaData 存储在数据库中的验证码令牌数据
type CaptchaData struct {
CaptchaToken string `json:"captcha_token"`
ExpiresAt int64 `json:"expires_at"`
}
// XunleiExtraData 所有额外数据的容器
type XunleiTokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"`
Sub string `json:"sub"`
TokenType string `json:"token_type"`
UserId string `json:"user_id"`
}
type XunleiExtraData struct {
Captcha *CaptchaData
Token *XunleiTokenData
}
type XunleiPanService struct {
*BasePanService
configMutex sync.RWMutex
clientId string
deviceId string
entity entity.Cks
cksRepo repo.CksRepository
extra XunleiExtraData // 需要保存到数据库的token信息
}
// 配置化 API Host
func (x *XunleiPanService) apiHost(apiType string) string {
if apiType == "user" {
return "https://xluser-ssl.xunlei.com"
}
return "https://api-pan.xunlei.com"
}
func (x *XunleiPanService) setCommonHeader(req *http.Request) {
for k, v := range x.headers {
req.Header.Set(k, v)
}
}
// NewXunleiPanService 创建迅雷网盘服务
func NewXunleiPanService(config *PanConfig) *XunleiPanService {
xunleiInstance := &XunleiPanService{
BasePanService: NewBasePanService(config),
clientId: "Xqp0kJBXWhwaTpB6",
deviceId: "925b7631473a13716b791d7f28289cad",
extra: XunleiExtraData{}, // Initialize extra with zero values
}
xunleiInstance.SetHeaders(map[string]string{
"Accept": "*/;",
"Accept-Encoding": "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": xunleiInstance.clientId,
"x-device-id": xunleiInstance.deviceId,
})
xunleiInstance.UpdateConfig(config)
return xunleiInstance
}
// SetCKSRepository 设置 CksRepository 和 entity
func (x *XunleiPanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
x.cksRepo = cksRepo
x.entity = entity
var extra XunleiExtraData
if err := json.Unmarshal([]byte(x.entity.Extra), &extra); err != nil {
log.Printf("解析 extra 数据失败: %v使用空数据", err)
}
x.extra = extra
}
// GetXunleiInstance 获取迅雷网盘服务单例实例
func GetXunleiInstance() *XunleiPanService {
return NewXunleiPanService(nil)
}
func (x *XunleiPanService) GetAccessTokenByRefreshToken(refreshToken string) (XunleiTokenData, error) {
// 构造请求体
body := map[string]interface{}{
"client_id": x.clientId,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
}
// 过滤 headers移除 Authorization 和 x-captcha-token
filteredHeaders := make(map[string]string)
for k, v := range x.headers {
if k != "Authorization" && k != "x-captcha-token" {
filteredHeaders[k] = v
}
}
// 调用 API 获取新的 token
resp, err := x.requestXunleiApi("https://xluser-ssl.xunlei.com/v1/auth/token", "POST", body, nil, filteredHeaders)
if err != nil {
return XunleiTokenData{}, fmt.Errorf("获取 access_token 请求失败: %v", err)
}
// 正确做法:用 exists 判断
if _, exists := resp["access_token"]; exists {
// 会输出,即使值为 nil
} else {
return XunleiTokenData{}, fmt.Errorf("获取 access_token 请求失败: %v 不存在", "access_token")
}
// 计算过期时间(当前时间 + expires_in - 60 秒缓冲)
currentTime := time.Now().Unix()
expiresAt := currentTime + int64(resp["expires_in"].(float64)) - 60
resp["expires_at"] = expiresAt
jsonBytes, _ := json.Marshal(resp)
var result XunleiTokenData
json.Unmarshal(jsonBytes, &result)
return result, nil
}
// getAccessToken 获取 Access Token内部包含缓存判断、刷新、保存- 匹配 PHP 版本
func (x *XunleiPanService) getAccessToken() (string, error) {
// 检查 Access Token 是否有效
currentTime := time.Now().Unix()
if x.extra.Token != nil && x.extra.Token.AccessToken != "" && x.extra.Token.ExpiresAt > currentTime {
return x.extra.Token.AccessToken, nil
}
newData, err := x.GetAccessTokenByRefreshToken(x.extra.Token.RefreshToken)
if err != nil {
return "", fmt.Errorf("获取 access_token 失败: %v", err)
}
x.extra.Token.AccessToken = newData.AccessToken
x.extra.Token.ExpiresAt = newData.ExpiresAt
// 保存到数据库
extraBytes, err := json.Marshal(x.extra)
if err != nil {
return "", fmt.Errorf("序列化 extra 数据失败: %v", err)
}
x.entity.Extra = string(extraBytes)
if err := x.cksRepo.UpdateWithAllFields(&x.entity); err != nil {
return "", fmt.Errorf("保存 access_token 到数据库失败: %v", err)
}
return newData.AccessToken, nil
}
// getCaptchaToken 获取 captcha_token - 匹配 PHP 版本
func (x *XunleiPanService) getCaptchaToken() (string, error) {
// 检查 Captcha Token 是否有效
currentTime := time.Now().Unix()
if x.extra.Captcha != nil && x.extra.Captcha.CaptchaToken != "" && x.extra.Captcha.ExpiresAt > currentTime {
return x.extra.Captcha.CaptchaToken, nil
}
// 构造请求体
body := map[string]interface{}{
"client_id": x.clientId,
"action": "get:/drive/v1/share",
"device_id": x.deviceId,
"meta": map[string]interface{}{
"username": "",
"phone_number": "",
"email": "",
"package_name": "pan.xunlei.com",
"client_version": "1.45.0",
"captcha_sign": "1.fe2108ad808a74c9ac0243309242726c",
"timestamp": "1645241033384",
"user_id": "0",
},
}
captchaHeaders := map[string]string{
"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",
}
// 调用 API 获取 captcha_token
resp, err := x.requestXunleiApi("https://xluser-ssl.xunlei.com/v1/shield/captcha/init", "POST", body, nil, captchaHeaders)
if err != nil {
return "", fmt.Errorf("获取 captcha_token 请求失败: %v", err)
}
if resp["captcha_token"] != nil && resp["captcha_token"] != "" {
//
} else {
return "", fmt.Errorf("获取 captcha_token 失败: %v", resp)
}
// 计算过期时间(当前时间 + expires_in - 10 秒缓冲)
expiresAt := currentTime + int64(resp["expires_in"].(float64)) - 10
// 更新 extra 数据
if x.extra.Captcha == nil {
x.extra.Captcha = &CaptchaData{}
}
x.extra.Captcha.CaptchaToken = resp["captcha_token"].(string)
x.extra.Captcha.ExpiresAt = expiresAt
// 保存到数据库
extraBytes, err := json.Marshal(x.extra)
if err != nil {
return "", fmt.Errorf("序列化 extra 数据失败: %v", err)
}
x.entity.Extra = string(extraBytes)
if err := x.cksRepo.UpdateWithAllFields(&x.entity); err != nil {
return "", fmt.Errorf("保存 captcha_token 到数据库失败: %v", err)
}
return resp["captcha_token"].(string), nil
}
// requestXunleiApi 迅雷 API 通用请求方法 - 使用 BasePanService 方法
func (x *XunleiPanService) requestXunleiApi(url string, method string, data map[string]interface{}, queryParams map[string]string, headers map[string]string) (map[string]interface{}, error) {
var respData []byte
var err error
// 先更新当前请求的 headers
originalHeaders := make(map[string]string)
for k, v := range x.headers {
originalHeaders[k] = v
}
// 临时设置请求的 headers
for k, v := range headers {
x.SetHeader(k, v)
}
defer func() {
// 恢复原始 headers
for k, v := range originalHeaders {
x.SetHeader(k, v)
}
}()
// 根据方法调用相应的 BasePanService 方法
if method == "GET" {
respData, err = x.HTTPGet(url, queryParams)
} else if method == "POST" {
respData, err = x.HTTPPost(url, data, queryParams)
} else {
return nil, fmt.Errorf("不支持的HTTP方法: %s", method)
}
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(respData, &result); err != nil {
return nil, fmt.Errorf("JSON 解析失败: %v, raw: %s", err, string(respData))
}
return result, nil
}
func (x *XunleiPanService) UpdateConfig(config *PanConfig) {
if config == nil {
return
}
x.configMutex.Lock()
defer x.configMutex.Unlock()
x.config = config
if config.Cookie != "" {
x.SetHeader("Cookie", config.Cookie)
}
}
// GetServiceType 获取服务类型
func (x *XunleiPanService) GetServiceType() ServiceType {
return Xunlei
}
func extractCode(url string) string {
// 查找 pwd= 的位置
if pwdIndex := strings.Index(url, "pwd="); pwdIndex != -1 {
code := url[pwdIndex+4:]
// 移除 # 及后面的内容(如果存在)
if hashIndex := strings.Index(code, "#"); hashIndex != -1 {
code = code[:hashIndex]
}
return code
}
return ""
}
// Transfer 转存分享链接 - 实现 PanService 接口,匹配 XunleiPan.php 的逻辑
func (x *XunleiPanService) Transfer(shareID string) (*TransferResult, error) {
// 读取配置(线程安全)
x.configMutex.RLock()
config := x.config
x.configMutex.RUnlock()
log.Printf("开始处理迅雷分享: %s", shareID)
// 1⃣ 获取 AccessToken 和 CaptchaToken
accessToken, err := x.getAccessToken()
if err != nil {
return ErrorResult(fmt.Sprintf("获取accessToken失败: %v", err)), nil
}
captchaToken, err := x.getCaptchaToken()
if err != nil {
return ErrorResult(fmt.Sprintf("获取captchaToken失败: %v", err)), nil
}
// 转存模式:实现完整的转存流程
thisCode := extractCode(config.URL)
// 获取分享详情
shareDetail, err := x.getShare(shareID, thisCode, accessToken, captchaToken)
if err != nil {
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", err)), nil
}
if shareDetail["share_status"].(string) != "OK" {
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", "分享状态异常")), nil
}
if shareDetail["file_num"].(string) == "0" {
return ErrorResult(fmt.Sprintf("获取分享详情失败: %v", "文件列表为空")), nil
}
parent_id := "" // 默认存储路径
// 检查是否为检验模式
if config.IsType == 1 {
// 检验模式:直接获取分享信息
urls := map[string]interface{}{
"title": shareDetail["title"],
"share_url": config.URL,
"stoken": "",
}
return SuccessResult("检验成功", urls), nil
}
// files := shareDetail["files"].([]interface{})
// fileIDs := make([]string, 0)
// for _, file := range files {
// fileMap := file.(map[string]interface{})
// if fid, ok := fileMap["id"].(string); ok {
// fileIDs = append(fileIDs, fid)
// }
// }
// 处理广告过滤(这里简化处理)
// TODO: 添加广告文件过滤逻辑
// 转存资源
restoreResult, err := x.getRestore(shareID, shareDetail, accessToken, captchaToken, parent_id)
if err != nil {
return ErrorResult(fmt.Sprintf("转存失败: %v", err)), nil
}
// 获取转存任务信息
taskID := restoreResult["restore_task_id"].(string)
// 等待转存完成
taskResp, err := x.waitForTask(taskID, accessToken, captchaToken)
if err != nil {
return ErrorResult(fmt.Sprintf("等待转存完成失败: %v", err)), nil
}
// 获取任务结果以获取文件ID
existingFileIds := make([]string, 0)
if params, ok2 := taskResp["params"].(map[string]interface{}); ok2 {
if traceIds, ok3 := params["trace_file_ids"].(string); ok3 {
traceData := make(map[string]interface{})
json.Unmarshal([]byte(traceIds), &traceData)
for _, fid := range traceData {
existingFileIds = append(existingFileIds, fid.(string))
}
}
}
// 创建分享链接
expirationDays := "-1"
if config.ExpiredType == 2 {
expirationDays = "2"
}
// 根据share_id获取到分享链接
shareResult, err := x.getSharePassword(existingFileIds, accessToken, captchaToken, expirationDays)
if err != nil {
return ErrorResult(fmt.Sprintf("创建分享链接失败: %v", err)), nil
}
var fid string
if len(existingFileIds) > 1 {
fid = strings.Join(existingFileIds, ",")
} else {
fid = existingFileIds[0]
}
result := map[string]interface{}{
"title": "",
"shareUrl": shareResult["share_url"].(string) + "?pwd=" + shareResult["pass_code"].(string),
"code": shareResult["pass_code"].(string),
"fid": fid,
}
return SuccessResult("转存成功", result), nil
}
// waitForTask 等待任务完成 - 使用 HTTPGet 方法
func (x *XunleiPanService) waitForTask(taskID string, accessToken, captchaToken string) (map[string]interface{}, error) {
maxRetries := 50
retryDelay := 2 * time.Second
for retryIndex := 0; retryIndex < maxRetries; retryIndex++ {
result, err := x.getTaskStatus(taskID, retryIndex, accessToken, captchaToken)
if err != nil {
return nil, err
}
if int64(result["progress"].(float64)) == 100 { // 任务完成
return result, nil
}
time.Sleep(retryDelay)
}
return nil, fmt.Errorf("任务超时")
}
// getTaskStatus 获取任务状态 - 使用 HTTPGet 方法
func (x *XunleiPanService) getTaskStatus(taskID string, retryIndex int, accessToken, captchaToken string) (map[string]interface{}, error) {
apiURL := x.apiHost("") + "/drive/v1/tasks/" + taskID
queryParams := map[string]string{}
// 设置 request 所需的 headers
headers := map[string]string{
"Authorization": "Bearer " + accessToken,
"x-captcha-token": captchaToken,
}
resp, err := x.requestXunleiApi(apiURL, "GET", nil, queryParams, headers)
if err != nil {
return nil, err
}
return resp, nil
}
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
func (x *XunleiPanService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
return nil, nil
}
// getShare 获取分享详情 - 匹配 PHP 版本
func (x *XunleiPanService) getShare(shareID, passCode, accessToken, captchaToken string) (map[string]interface{}, error) {
// 设置 headers
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
queryParams := map[string]string{
"share_id": shareID,
"pass_code": passCode,
"limit": "100",
"pass_code_token": "",
"page_token": "",
"thumbnail_size": "SIZE_SMALL",
}
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share", "GET", nil, queryParams, headers)
}
// getRestore 转存到网盘 - 匹配 PHP 版本
func (x *XunleiPanService) getRestore(shareID string, infoData map[string]interface{}, accessToken, captchaToken, parentID string) (map[string]interface{}, error) {
ids := make([]string, 0)
if files, ok := infoData["files"].([]interface{}); ok {
for _, file := range files {
if fileMap, ok2 := file.(map[string]interface{}); ok2 {
if id, ok3 := fileMap["id"].(string); ok3 {
ids = append(ids, id)
}
}
}
}
passCodeToken := ""
if token, ok := infoData["pass_code_token"]; ok {
if tokenStr, ok2 := token.(string); ok2 {
passCodeToken = tokenStr
}
}
data := map[string]interface{}{
"parent_id": parentID,
"share_id": shareID,
"pass_code_token": passCodeToken,
"ancestor_ids": []string{},
"specify_parent_id": true,
"file_ids": ids,
}
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share/restore", "POST", data, nil, headers)
}
// getTasks 获取转存任务状态 - 匹配 PHP 版本
func (x *XunleiPanService) getTasks(taskID, accessToken, captchaToken string) (map[string]interface{}, error) {
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/tasks/"+taskID, "GET", nil, nil, headers)
}
// getSharePassword 创建分享链接 - 匹配 PHP 版本
func (x *XunleiPanService) getSharePassword(fileIDs []string, accessToken, captchaToken, expirationDays string) (map[string]interface{}, error) {
data := map[string]interface{}{
"file_ids": fileIDs,
"share_to": "copy",
"params": map[string]interface{}{
"subscribe_push": "false",
"WithPassCodeInLink": "true",
},
"title": "云盘资源分享",
"restore_limit": "-1",
"expiration_days": expirationDays,
}
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
return x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/share", "POST", data, nil, headers)
}
// getShareInfo 获取分享信息(用于检验模式)
func (x *XunleiPanService) getShareInfo(shareID string) (*XLShareInfo, error) {
// 使用现有的 GetShareFolder 方法获取分享信息
shareDetail, err := x.GetShareFolder(shareID, "", "")
if err != nil {
return nil, err
}
// 构造分享信息
shareInfo := &XLShareInfo{
ShareID: shareID,
Title: fmt.Sprintf("迅雷分享_%s", shareID),
Files: make([]XLFileInfo, 0),
}
// 处理文件信息
for _, file := range shareDetail.Data.Files {
shareInfo.Files = append(shareInfo.Files, XLFileInfo{
FileID: file.FileID,
Name: file.Name,
})
}
return shareInfo, nil
}
// GetFiles 获取文件列表 - 匹配 PHP 版本接口调用
func (x *XunleiPanService) GetFiles(pdirFid string) (*TransferResult, error) {
log.Printf("开始获取迅雷网盘文件列表目录ID: %s", pdirFid)
// 获取 tokens
accessToken, err := x.getAccessToken()
if err != nil {
return ErrorResult(fmt.Sprintf("获取accessToken失败: %v", err)), nil
}
captchaToken, err := x.getCaptchaToken()
if err != nil {
return ErrorResult(fmt.Sprintf("获取captchaToken失败: %v", err)), nil
}
// 设置 headers
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
filters := map[string]interface{}{
"phase": map[string]interface{}{
"eq": "PHASE_TYPE_COMPLETE",
},
"trashed": map[string]interface{}{
"eq": false,
},
}
filtersStr, _ := json.Marshal(filters)
queryParams := map[string]string{
"parent_id": pdirFid,
"filters": string(filtersStr),
"with_audit": "true",
"thumbnail_size": "SIZE_SMALL",
"limit": "50",
}
result, err := x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/files", "GET", nil, queryParams, headers)
if err != nil {
return ErrorResult(fmt.Sprintf("获取文件列表失败: %v", err)), nil
}
if code, ok := result["code"].(float64); ok && code != 0 {
return ErrorResult("获取文件列表失败"), nil
}
if data, ok := result["data"].(map[string]interface{}); ok {
if files, ok2 := data["files"]; ok2 {
return SuccessResult("获取成功", files), nil
}
}
return SuccessResult("获取成功", []interface{}{}), nil
}
// DeleteFiles 删除文件 - 实现 PanService 接口
func (x *XunleiPanService) DeleteFiles(fileList []string) (*TransferResult, error) {
log.Printf("开始删除迅雷网盘文件,文件数量: %d", len(fileList))
// 使用现有的 ShareBatchDelete 方法删除分享
result, err := x.ShareBatchDelete(fileList)
if err != nil {
return ErrorResult(fmt.Sprintf("删除文件失败: %v", err)), nil
}
if result.Code != 0 {
return ErrorResult(fmt.Sprintf("删除文件失败: %s", result.Msg)), nil
}
return SuccessResult("删除成功", nil), nil
}
// GetUserInfo 获取用户信息 - 实现 PanService 接口cookie 参数为 refresh_token先获取 access_token 再访问 API
func (x *XunleiPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
userInfo := &UserInfo{}
accessToken, err := x.getAccessToken()
if err != nil {
return nil, err
}
captchaToken, err := x.getCaptchaToken()
if err != nil {
return nil, err
}
headers := make(map[string]string)
for k, v := range x.headers {
headers[k] = v
}
headers["Authorization"] = "Bearer " + accessToken
headers["x-captcha-token"] = captchaToken
resp, err := x.requestXunleiApi("https://api-pan.xunlei.com/drive/v1/about", "GET", nil, nil, headers)
if err != nil {
return nil, fmt.Errorf("获取用户信息失败: %v", err)
}
limit := resp["quota"].(map[string]interface{})["limit"].(string)
limitInt, _ := strconv.ParseInt(limit, 10, 64)
used := resp["quota"].(map[string]interface{})["usage"].(string)
usedInt, _ := strconv.ParseInt(used, 10, 64)
userInfo.TotalSpace = limitInt
userInfo.UsedSpace = usedInt
// 获取用户信息
respData, err := x.requestXunleiApi(x.apiHost("user")+"/v1/user/me", "GET", nil, nil, headers)
if err != nil {
return nil, fmt.Errorf("获取用户信息失败: %v", err)
}
vipInfo := respData["vip_info"].([]interface{})
isVip := vipInfo[0].(map[string]interface{})["is_vip"].(string) != "0"
userInfo.Username = respData["name"].(string)
userInfo.ServiceType = x.GetServiceType().String()
userInfo.VIPStatus = isVip
return userInfo, nil
}
// GetShareList 严格对齐 GET + query使用 BasePanService
func (x *XunleiPanService) GetShareList(pageToken string) (*XLShareListResp, error) {
api := x.apiHost("") + "/drive/v1/share/list"
queryParams := map[string]string{
"limit": "100",
"thumbnail_size": "SIZE_SMALL",
}
if pageToken != "" {
queryParams["page_token"] = pageToken
}
respData, err := x.HTTPGet(api, queryParams)
if err != nil {
return nil, fmt.Errorf("获取分享列表失败: %v", err)
}
var data XLShareListResp
if err := json.Unmarshal(respData, &data); err != nil {
return nil, fmt.Errorf("解析分享列表失败: %v", err)
}
return &data, nil
}
// FileBatchShare 创建分享(使用 BasePanService
func (x *XunleiPanService) FileBatchShare(ids []string, needPassword bool, expirationDays int) (*XLBatchShareResp, error) {
apiURL := x.apiHost("") + "/drive/v1/share/batch"
body := map[string]interface{}{
"file_ids": ids,
"need_password": needPassword,
"expiration_days": expirationDays,
}
respData, err := x.HTTPPost(apiURL, body, nil)
if err != nil {
return nil, fmt.Errorf("创建分享失败: %v", err)
}
var data XLBatchShareResp
if err := json.Unmarshal(respData, &data); err != nil {
return nil, fmt.Errorf("解析分享响应失败: %v", err)
}
return &data, nil
}
// ShareBatchDelete 取消分享(使用 BasePanService
func (x *XunleiPanService) ShareBatchDelete(ids []string) (*XLCommonResp, error) {
apiURL := x.apiHost("") + "/drive/v1/share/batch/delete"
body := map[string]interface{}{
"share_ids": ids,
}
respData, err := x.HTTPPost(apiURL, body, nil)
if err != nil {
return nil, fmt.Errorf("删除分享失败: %v", err)
}
var data XLCommonResp
if err := json.Unmarshal(respData, &data); err != nil {
return nil, fmt.Errorf("解析删除响应失败: %v", err)
}
return &data, nil
}
// GetShareFolder 获取分享内容(使用 BasePanService
func (x *XunleiPanService) GetShareFolder(shareID, passCodeToken, parentID string) (*XLShareFolderResp, error) {
apiURL := x.apiHost("") + "/drive/v1/share/detail"
body := map[string]interface{}{
"share_id": shareID,
"pass_code_token": passCodeToken,
"parent_id": parentID,
"limit": 100,
"thumbnail_size": "SIZE_LARGE",
"order": "6",
}
respData, err := x.HTTPPost(apiURL, body, nil)
if err != nil {
return nil, fmt.Errorf("获取分享文件夹失败: %v", err)
}
var data XLShareFolderResp
if err := json.Unmarshal(respData, &data); err != nil {
return nil, fmt.Errorf("解析分享文件夹失败: %v", err)
}
return &data, nil
}
// Restore 转存(使用 BasePanService
func (x *XunleiPanService) Restore(shareID, passCodeToken string, fileIDs []string) (*XLRestoreResp, error) {
apiURL := x.apiHost("") + "/drive/v1/share/restore"
body := map[string]interface{}{
"share_id": shareID,
"pass_code_token": passCodeToken,
"file_ids": fileIDs,
"folder_type": "NORMAL",
"specify_parent_id": true,
"parent_id": "",
}
respData, err := x.HTTPPost(apiURL, body, nil)
if err != nil {
return nil, fmt.Errorf("转存失败: %v", err)
}
var data XLRestoreResp
if err := json.Unmarshal(respData, &data); err != nil {
return nil, fmt.Errorf("解析转存响应失败: %v", err)
}
return &data, nil
}
// 结构体完全对齐 xunleix
type XLShareListResp struct {
Data struct {
List []struct {
ShareID string `json:"share_id"`
Title string `json:"title"`
} `json:"list"`
} `json:"data"`
Code int `json:"code"`
Msg string `json:"msg"`
}
type XLBatchShareResp struct {
Data struct {
ShareURL string `json:"share_url"`
} `json:"data"`
Code int `json:"code"`
Msg string `json:"msg"`
}
type XLCommonResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
type XLShareFolderResp struct {
Data struct {
Files []struct {
FileID string `json:"file_id"`
Name string `json:"name"`
} `json:"files"`
} `json:"data"`
Code int `json:"code"`
Msg string `json:"msg"`
}
type XLRestoreResp struct {
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
Code int `json:"code"`
Msg string `json:"msg"`
}
// 新增辅助结构体
type XLShareInfo struct {
ShareID string `json:"share_id"`
Title string `json:"title"`
Files []XLFileInfo `json:"files"`
}
type XLFileInfo struct {
FileID string `json:"file_id"`
Name string `json:"name"`
}
type XLTaskResult struct {
Status int `json:"status"`
TaskID string `json:"task_id"`
Data struct {
ShareID string `json:"share_id"`
} `json:"data"`
}

File diff suppressed because it is too large Load Diff

676
config/config.go Normal file
View File

@@ -0,0 +1,676 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/utils"
)
// ConfigManager 统一配置管理器
type ConfigManager struct {
repo *repo.RepositoryManager
// 内存缓存
cache map[string]*ConfigItem
cacheMutex sync.RWMutex
cacheOnce sync.Once
// 配置更新通知
configUpdateCh chan string
watchers []chan string
watcherMutex sync.Mutex
// 加载时间
lastLoadTime time.Time
}
// ConfigItem 配置项结构
type ConfigItem struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
UpdatedAt time.Time `json:"updated_at"`
Group string `json:"group"` // 配置分组
Category string `json:"category"` // 配置分类
IsSensitive bool `json:"is_sensitive"` // 是否是敏感信息
}
// ConfigGroup 配置分组
type ConfigGroup string
const (
GroupDatabase ConfigGroup = "database"
GroupServer ConfigGroup = "server"
GroupSecurity ConfigGroup = "security"
GroupSearch ConfigGroup = "search"
GroupTelegram ConfigGroup = "telegram"
GroupCache ConfigGroup = "cache"
GroupMeilisearch ConfigGroup = "meilisearch"
GroupSEO ConfigGroup = "seo"
GroupAutoProcess ConfigGroup = "auto_process"
GroupOther ConfigGroup = "other"
)
// NewConfigManager 创建配置管理器
func NewConfigManager(repoManager *repo.RepositoryManager) *ConfigManager {
cm := &ConfigManager{
repo: repoManager,
cache: make(map[string]*ConfigItem),
configUpdateCh: make(chan string, 100), // 缓冲通道防止阻塞
}
// 启动配置更新监听器
go cm.startConfigUpdateListener()
return cm
}
// startConfigUpdateListener 启动配置更新监听器
func (cm *ConfigManager) startConfigUpdateListener() {
for key := range cm.configUpdateCh {
cm.notifyWatchers(key)
}
}
// notifyWatchers 通知所有监听器配置已更新
func (cm *ConfigManager) notifyWatchers(key string) {
cm.watcherMutex.Lock()
defer cm.watcherMutex.Unlock()
for _, watcher := range cm.watchers {
select {
case watcher <- key:
default:
// 如果通道阻塞,跳过该监听器
utils.Warn("配置监听器通道阻塞,跳过通知: %s", key)
}
}
}
// AddConfigWatcher 添加配置变更监听器
func (cm *ConfigManager) AddConfigWatcher() chan string {
cm.watcherMutex.Lock()
defer cm.watcherMutex.Unlock()
watcher := make(chan string, 10) // 为每个监听器创建缓冲通道
cm.watchers = append(cm.watchers, watcher)
return watcher
}
// GetConfig 获取配置项
func (cm *ConfigManager) GetConfig(key string) (*ConfigItem, error) {
// 先尝试从内存缓存获取
item, exists := cm.getCachedConfig(key)
if exists {
return item, nil
}
// 如果缓存中没有,从数据库获取
config, err := cm.repo.SystemConfigRepository.FindByKey(key)
if err != nil {
return nil, err
}
// 将数据库配置转换为ConfigItem并缓存
item = &ConfigItem{
Key: config.Key,
Value: config.Value,
Type: config.Type,
UpdatedAt: time.Now(),
}
if group := cm.getGroupByConfigKey(key); group != "" {
item.Group = string(group)
}
if category := cm.getCategoryByConfigKey(key); category != "" {
item.Category = category
}
item.IsSensitive = cm.isSensitiveConfig(key)
// 缓存配置
cm.setCachedConfig(key, item)
return item, nil
}
// GetConfigValue 获取配置值
func (cm *ConfigManager) GetConfigValue(key string) (string, error) {
item, err := cm.GetConfig(key)
if err != nil {
return "", err
}
return item.Value, nil
}
// GetConfigBool 获取布尔值配置
func (cm *ConfigManager) GetConfigBool(key string) (bool, error) {
value, err := cm.GetConfigValue(key)
if err != nil {
return false, err
}
switch strings.ToLower(value) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off", "":
return false, nil
default:
return false, fmt.Errorf("无法将配置值 '%s' 转换为布尔值", value)
}
}
// GetConfigInt 获取整数值配置
func (cm *ConfigManager) GetConfigInt(key string) (int, error) {
value, err := cm.GetConfigValue(key)
if err != nil {
return 0, err
}
return strconv.Atoi(value)
}
// GetConfigInt64 获取64位整数值配置
func (cm *ConfigManager) GetConfigInt64(key string) (int64, error) {
value, err := cm.GetConfigValue(key)
if err != nil {
return 0, err
}
return strconv.ParseInt(value, 10, 64)
}
// GetConfigFloat64 获取浮点数配置
func (cm *ConfigManager) GetConfigFloat64(key string) (float64, error) {
value, err := cm.GetConfigValue(key)
if err != nil {
return 0, err
}
return strconv.ParseFloat(value, 64)
}
// SetConfig 设置配置值
func (cm *ConfigManager) SetConfig(key, value string) error {
// 更新数据库
config := &entity.SystemConfig{
Key: key,
Value: value,
Type: "string", // 默认类型,实际类型应该从现有配置中获取
}
// 获取现有配置以确定类型
existing, err := cm.repo.SystemConfigRepository.FindByKey(key)
if err == nil {
config.Type = existing.Type
} else {
// 如果配置不存在,尝试从默认配置中获取类型
config.Type = cm.getDefaultConfigType(key)
}
// 保存到数据库
err = cm.repo.SystemConfigRepository.UpsertConfigs([]entity.SystemConfig{*config})
if err != nil {
return fmt.Errorf("保存配置失败: %v", err)
}
// 更新缓存
item := &ConfigItem{
Key: config.Key,
Value: config.Value,
Type: config.Type,
UpdatedAt: time.Now(),
}
if group := cm.getGroupByConfigKey(key); group != "" {
item.Group = string(group)
}
if category := cm.getCategoryByConfigKey(key); category != "" {
item.Category = category
}
item.IsSensitive = cm.isSensitiveConfig(key)
cm.setCachedConfig(key, item)
// 发送更新通知
cm.configUpdateCh <- key
utils.Info("配置已更新: %s = %s", key, value)
return nil
}
// SetConfigWithType 设置配置值(指定类型)
func (cm *ConfigManager) SetConfigWithType(key, value, configType string) error {
config := &entity.SystemConfig{
Key: key,
Value: value,
Type: configType,
}
err := cm.repo.SystemConfigRepository.UpsertConfigs([]entity.SystemConfig{*config})
if err != nil {
return fmt.Errorf("保存配置失败: %v", err)
}
// 更新缓存
item := &ConfigItem{
Key: config.Key,
Value: config.Value,
Type: config.Type,
UpdatedAt: time.Now(),
}
if group := cm.getGroupByConfigKey(key); group != "" {
item.Group = string(group)
}
if category := cm.getCategoryByConfigKey(key); category != "" {
item.Category = category
}
item.IsSensitive = cm.isSensitiveConfig(key)
cm.setCachedConfig(key, item)
// 发送更新通知
cm.configUpdateCh <- key
utils.Info("配置已更新: %s = %s (type: %s)", key, value, configType)
return nil
}
// getGroupByConfigKey 根据配置键获取分组
func (cm *ConfigManager) getGroupByConfigKey(key string) ConfigGroup {
switch {
case strings.HasPrefix(key, "database_"), strings.HasPrefix(key, "db_"):
return GroupDatabase
case strings.HasPrefix(key, "server_"), strings.HasPrefix(key, "port"), strings.HasPrefix(key, "host"):
return GroupServer
case strings.HasPrefix(key, "api_"), strings.HasPrefix(key, "jwt_"), strings.HasPrefix(key, "password"):
return GroupSecurity
case strings.Contains(key, "meilisearch"):
return GroupMeilisearch
case strings.Contains(key, "telegram"):
return GroupTelegram
case strings.Contains(key, "cache"), strings.Contains(key, "redis"):
return GroupCache
case strings.Contains(key, "seo"), strings.Contains(key, "title"), strings.Contains(key, "keyword"):
return GroupSEO
case strings.Contains(key, "auto_"):
return GroupAutoProcess
case strings.Contains(key, "forbidden"), strings.Contains(key, "ad_"):
return GroupOther
default:
return GroupOther
}
}
// getCategoryByConfigKey 根据配置键获取分类
func (cm *ConfigManager) getCategoryByConfigKey(key string) string {
switch {
case key == entity.ConfigKeySiteTitle || key == entity.ConfigKeySiteDescription:
return "basic_info"
case key == entity.ConfigKeyKeywords || key == entity.ConfigKeyAuthor:
return "seo"
case key == entity.ConfigKeyAutoProcessReadyResources || key == entity.ConfigKeyAutoProcessInterval:
return "auto_process"
case key == entity.ConfigKeyAutoTransferEnabled || key == entity.ConfigKeyAutoTransferLimitDays:
return "auto_transfer"
case key == entity.ConfigKeyMeilisearchEnabled || key == entity.ConfigKeyMeilisearchHost:
return "search"
case key == entity.ConfigKeyTelegramBotEnabled || key == entity.ConfigKeyTelegramBotApiKey:
return "telegram"
case key == entity.ConfigKeyMaintenanceMode || key == entity.ConfigKeyEnableRegister:
return "system"
case key == entity.ConfigKeyForbiddenWords || key == entity.ConfigKeyAdKeywords:
return "filtering"
default:
return "other"
}
}
// isSensitiveConfig 判断是否是敏感配置
func (cm *ConfigManager) isSensitiveConfig(key string) bool {
switch key {
case entity.ConfigKeyApiToken,
entity.ConfigKeyMeilisearchMasterKey,
entity.ConfigKeyTelegramBotApiKey,
entity.ConfigKeyTelegramProxyUsername,
entity.ConfigKeyTelegramProxyPassword:
return true
default:
return strings.Contains(strings.ToLower(key), "password") ||
strings.Contains(strings.ToLower(key), "secret") ||
strings.Contains(strings.ToLower(key), "key") ||
strings.Contains(strings.ToLower(key), "token")
}
}
// getDefaultConfigType 获取默认配置类型
func (cm *ConfigManager) getDefaultConfigType(key string) string {
switch key {
case entity.ConfigKeyAutoProcessReadyResources,
entity.ConfigKeyAutoTransferEnabled,
entity.ConfigKeyAutoFetchHotDramaEnabled,
entity.ConfigKeyMaintenanceMode,
entity.ConfigKeyEnableRegister,
entity.ConfigKeyMeilisearchEnabled,
entity.ConfigKeyTelegramBotEnabled:
return entity.ConfigTypeBool
case entity.ConfigKeyAutoProcessInterval,
entity.ConfigKeyAutoTransferLimitDays,
entity.ConfigKeyAutoTransferMinSpace,
entity.ConfigKeyPageSize:
return entity.ConfigTypeInt
case entity.ConfigKeyAnnouncements:
return entity.ConfigTypeJSON
default:
return entity.ConfigTypeString
}
}
// LoadAllConfigs 加载所有配置到缓存
func (cm *ConfigManager) LoadAllConfigs() error {
configs, err := cm.repo.SystemConfigRepository.FindAll()
if err != nil {
return fmt.Errorf("加载所有配置失败: %v", err)
}
cm.cacheMutex.Lock()
defer cm.cacheMutex.Unlock()
// 清空现有缓存
cm.cache = make(map[string]*ConfigItem)
// 更新缓存
for _, config := range configs {
item := &ConfigItem{
Key: config.Key,
Value: config.Value,
Type: config.Type,
UpdatedAt: time.Now(), // 实际应该从数据库获取
}
if group := cm.getGroupByConfigKey(config.Key); group != "" {
item.Group = string(group)
}
if category := cm.getCategoryByConfigKey(config.Key); category != "" {
item.Category = category
}
item.IsSensitive = cm.isSensitiveConfig(config.Key)
cm.cache[config.Key] = item
}
cm.lastLoadTime = time.Now()
utils.Info("已加载 %d 个配置项到缓存", len(configs))
return nil
}
// RefreshConfigCache 刷新配置缓存
func (cm *ConfigManager) RefreshConfigCache() error {
return cm.LoadAllConfigs()
}
// GetCachedConfig 获取缓存的配置
func (cm *ConfigManager) getCachedConfig(key string) (*ConfigItem, bool) {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
item, exists := cm.cache[key]
return item, exists
}
// setCachedConfig 设置缓存的配置
func (cm *ConfigManager) setCachedConfig(key string, item *ConfigItem) {
cm.cacheMutex.Lock()
defer cm.cacheMutex.Unlock()
cm.cache[key] = item
}
// GetConfigByGroup 按分组获取配置
func (cm *ConfigManager) GetConfigByGroup(group ConfigGroup) (map[string]*ConfigItem, error) {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
result := make(map[string]*ConfigItem)
for key, item := range cm.cache {
if ConfigGroup(item.Group) == group {
result[key] = item
}
}
return result, nil
}
// GetConfigByCategory 按分类获取配置
func (cm *ConfigManager) GetConfigByCategory(category string) (map[string]*ConfigItem, error) {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
result := make(map[string]*ConfigItem)
for key, item := range cm.cache {
if item.Category == category {
result[key] = item
}
}
return result, nil
}
// DeleteConfig 删除配置
func (cm *ConfigManager) DeleteConfig(key string) error {
// 先查找配置获取ID
config, err := cm.repo.SystemConfigRepository.FindByKey(key)
if err != nil {
return fmt.Errorf("查找配置失败: %v", err)
}
// 从数据库删除
err = cm.repo.SystemConfigRepository.Delete(config.ID)
if err != nil {
return fmt.Errorf("删除配置失败: %v", err)
}
// 从缓存中移除
cm.cacheMutex.Lock()
delete(cm.cache, key)
cm.cacheMutex.Unlock()
utils.Info("配置已删除: %s", key)
return nil
}
// GetSensitiveConfigKeys 获取所有敏感配置键
func (cm *ConfigManager) GetSensitiveConfigKeys() []string {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
var sensitiveKeys []string
for key, item := range cm.cache {
if item.IsSensitive {
sensitiveKeys = append(sensitiveKeys, key)
}
}
return sensitiveKeys
}
// GetConfigWithMask 获取配置值(敏感配置会被遮蔽)
func (cm *ConfigManager) GetConfigWithMask(key string) (*ConfigItem, error) {
item, err := cm.GetConfig(key)
if err != nil {
return nil, err
}
if item.IsSensitive {
// 创建副本并遮蔽敏感值
maskedItem := *item
maskedItem.Value = cm.maskSensitiveValue(item.Value)
return &maskedItem, nil
}
return item, nil
}
// maskSensitiveValue 遮蔽敏感值
func (cm *ConfigManager) maskSensitiveValue(value string) string {
if len(value) <= 4 {
return "****"
}
// 保留前2个和后2个字符中间用****替代
return value[:2] + "****" + value[len(value)-2:]
}
// GetConfigAsJSON 获取配置为JSON格式
func (cm *ConfigManager) GetConfigAsJSON() ([]byte, error) {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
// 创建副本,敏感配置使用遮蔽值
configMap := make(map[string]*ConfigItem)
for key, item := range cm.cache {
if item.IsSensitive {
maskedItem := *item
maskedItem.Value = cm.maskSensitiveValue(item.Value)
configMap[key] = &maskedItem
} else {
configMap[key] = item
}
}
return json.MarshalIndent(configMap, "", " ")
}
// GetConfigStatistics 获取配置统计信息
func (cm *ConfigManager) GetConfigStatistics() map[string]interface{} {
cm.cacheMutex.RLock()
defer cm.cacheMutex.RUnlock()
stats := map[string]interface{}{
"total_configs": len(cm.cache),
"last_load_time": cm.lastLoadTime,
"cache_size_bytes": len(cm.cache) * 100, // 估算每个配置约100字节
"groups": make(map[string]int),
"types": make(map[string]int),
"categories": make(map[string]int),
"sensitive_configs": 0,
"config_keys": make([]string, 0),
}
groups := make(map[string]int)
types := make(map[string]int)
categories := make(map[string]int)
for key, item := range cm.cache {
// 统计分组
groups[item.Group]++
// 统计类型
types[item.Type]++
// 统计分类
categories[item.Category]++
// 统计敏感配置
if item.IsSensitive {
stats["sensitive_configs"] = stats["sensitive_configs"].(int) + 1
}
// 添加配置键到列表
keys := stats["config_keys"].([]string)
keys = append(keys, key)
stats["config_keys"] = keys
}
stats["groups"] = groups
stats["types"] = types
stats["categories"] = categories
return stats
}
// GetEnvironmentConfig 从环境变量获取配置
func (cm *ConfigManager) GetEnvironmentConfig(key string) (string, bool) {
value := os.Getenv(key)
if value != "" {
return value, true
}
// 尝试使用大写版本的键
value = os.Getenv(strings.ToUpper(key))
if value != "" {
return value, true
}
// 尝试使用大写带下划线的格式
upperKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
value = os.Getenv(upperKey)
if value != "" {
return value, true
}
return "", false
}
// GetConfigWithEnvFallback 获取配置,环境变量优先
func (cm *ConfigManager) GetConfigWithEnvFallback(configKey, envKey string) (string, error) {
// 优先从环境变量获取
if envValue, exists := cm.GetEnvironmentConfig(envKey); exists {
return envValue, nil
}
// 如果环境变量不存在,从数据库获取
return cm.GetConfigValue(configKey)
}
// GetConfigIntWithEnvFallback 获取整数配置,环境变量优先
func (cm *ConfigManager) GetConfigIntWithEnvFallback(configKey, envKey string) (int, error) {
// 优先从环境变量获取
if envValue, exists := cm.GetEnvironmentConfig(envKey); exists {
return strconv.Atoi(envValue)
}
// 如果环境变量不存在,从数据库获取
return cm.GetConfigInt(configKey)
}
// GetConfigBoolWithEnvFallback 获取布尔配置,环境变量优先
func (cm *ConfigManager) GetConfigBoolWithEnvFallback(configKey, envKey string) (bool, error) {
// 优先从环境变量获取
if envValue, exists := cm.GetEnvironmentConfig(envKey); exists {
switch strings.ToLower(envValue) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off", "":
return false, nil
default:
return false, fmt.Errorf("无法将环境变量值 '%s' 转换为布尔值", envValue)
}
}
// 如果环境变量不存在,从数据库获取
return cm.GetConfigBool(configKey)
}

124
config/global.go Normal file
View File

@@ -0,0 +1,124 @@
package config
import (
"sync"
)
var (
globalConfigManager *ConfigManager
once sync.Once
)
// SetGlobalConfigManager 设置全局配置管理器
func SetGlobalConfigManager(cm *ConfigManager) {
globalConfigManager = cm
}
// GetGlobalConfigManager 获取全局配置管理器
func GetGlobalConfigManager() *ConfigManager {
return globalConfigManager
}
// GetConfig 获取配置值(全局函数)
func GetConfig(key string) (*ConfigItem, error) {
if globalConfigManager == nil {
return nil, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfig(key)
}
// GetConfigValue 获取配置值(全局函数)
func GetConfigValue(key string) (string, error) {
if globalConfigManager == nil {
return "", ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigValue(key)
}
// GetConfigBool 获取布尔配置值(全局函数)
func GetConfigBool(key string) (bool, error) {
if globalConfigManager == nil {
return false, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigBool(key)
}
// GetConfigInt 获取整数配置值(全局函数)
func GetConfigInt(key string) (int, error) {
if globalConfigManager == nil {
return 0, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigInt(key)
}
// GetConfigInt64 获取64位整数配置值全局函数
func GetConfigInt64(key string) (int64, error) {
if globalConfigManager == nil {
return 0, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigInt64(key)
}
// GetConfigFloat64 获取浮点数配置值(全局函数)
func GetConfigFloat64(key string) (float64, error) {
if globalConfigManager == nil {
return 0, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigFloat64(key)
}
// SetConfig 设置配置值(全局函数)
func SetConfig(key, value string) error {
if globalConfigManager == nil {
return ErrConfigManagerNotInitialized
}
return globalConfigManager.SetConfig(key, value)
}
// SetConfigWithType 设置配置值(指定类型,全局函数)
func SetConfigWithType(key, value, configType string) error {
if globalConfigManager == nil {
return ErrConfigManagerNotInitialized
}
return globalConfigManager.SetConfigWithType(key, value, configType)
}
// GetConfigWithEnvFallback 获取配置值(环境变量优先,全局函数)
func GetConfigWithEnvFallback(configKey, envKey string) (string, error) {
if globalConfigManager == nil {
return "", ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigWithEnvFallback(configKey, envKey)
}
// GetConfigIntWithEnvFallback 获取整数配置值(环境变量优先,全局函数)
func GetConfigIntWithEnvFallback(configKey, envKey string) (int, error) {
if globalConfigManager == nil {
return 0, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigIntWithEnvFallback(configKey, envKey)
}
// GetConfigBoolWithEnvFallback 获取布尔配置值(环境变量优先,全局函数)
func GetConfigBoolWithEnvFallback(configKey, envKey string) (bool, error) {
if globalConfigManager == nil {
return false, ErrConfigManagerNotInitialized
}
return globalConfigManager.GetConfigBoolWithEnvFallback(configKey, envKey)
}
// ErrConfigManagerNotInitialized 配置管理器未初始化错误
var ErrConfigManagerNotInitialized = &ConfigError{
Code: "CONFIG_MANAGER_NOT_INITIALIZED",
Message: "配置管理器未初始化",
}
// ConfigError 配置错误
type ConfigError struct {
Code string
Message string
}
func (e *ConfigError) Error() string {
return e.Message
}

31
config/sync.go Normal file
View File

@@ -0,0 +1,31 @@
package config
import (
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/utils"
)
// SyncWithRepository 同步配置管理器与Repository的缓存
func (cm *ConfigManager) SyncWithRepository(repoManager *repo.RepositoryManager) {
// 监听配置变更事件并同步缓存
// 这是一个抽象概念实际实现需要修改Repository接口
// 当配置更新时通知Repository清理缓存
go func() {
watcher := cm.AddConfigWatcher()
for {
select {
case key := <-watcher:
// 通知Repository层清理缓存如果Repository支持
utils.Debug("配置 %s 已更新可能需要同步到Repository缓存", key)
}
}
}()
}
// UpdateRepositoryCache 当配置管理器更新配置时通知Repository层同步
func (cm *ConfigManager) UpdateRepositoryCache(repoManager *repo.RepositoryManager) {
// 这个函数需要Repository支持特定的缓存清理方法
// 由于现有Repository没有提供这样的接口我们只能依赖数据库同步
utils.Info("配置已通过配置管理器更新Repository层将从数据库重新加载")
}

View File

@@ -2,7 +2,9 @@ package db
import (
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/ctwj/urldb/db/entity"
@@ -45,8 +47,22 @@ func InitDB() error {
host, port, user, password, dbname)
var err error
// 配置慢查询日志
slowThreshold := getEnvInt("DB_SLOW_THRESHOLD_MS", 200)
logLevel := logger.Info
if os.Getenv("ENV") == "production" {
logLevel = logger.Warn
}
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: time.Duration(slowThreshold) * time.Millisecond,
LogLevel: logLevel,
Colorful: true,
},
),
})
if err != nil {
return err
@@ -58,10 +74,17 @@ func InitDB() error {
return err
}
// 设置连接池参数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接数
sqlDB.SetMaxOpenConns(100) // 最大打开连接数
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大生命周期
// 优化数据库连接池参数
maxOpenConns := getEnvInt("DB_MAX_OPEN_CONNS", 50)
maxIdleConns := getEnvInt("DB_MAX_IDLE_CONNS", 20)
connMaxLifetime := getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 30)
sqlDB.SetMaxOpenConns(maxOpenConns) // 最大打开连接数
sqlDB.SetMaxIdleConns(maxIdleConns) // 最大空闲连接数
sqlDB.SetConnMaxLifetime(time.Duration(connMaxLifetime) * time.Minute) // 连接最大生命周期
utils.Info("数据库连接池配置 - 最大连接: %d, 空闲连接: %d, 生命周期: %d分钟",
maxOpenConns, maxIdleConns, connMaxLifetime)
// 检查是否需要迁移(只在开发环境或首次启动时)
if shouldRunMigration() {
@@ -82,6 +105,12 @@ func InitDB() error {
&entity.Task{},
&entity.TaskItem{},
&entity.File{},
&entity.TelegramChannel{},
&entity.APIAccessLog{},
&entity.APIAccessLogStats{},
&entity.APIAccessLogSummary{},
&entity.Report{},
&entity.CopyrightClaim{},
)
if err != nil {
utils.Fatal("数据库迁移失败: %v", err)
@@ -146,6 +175,7 @@ func autoMigrate() error {
&entity.SearchStat{},
&entity.HotDrama{},
&entity.File{},
&entity.TelegramChannel{},
)
}
@@ -181,7 +211,15 @@ func createIndexes(db *gorm.DB) {
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_resource_id ON resource_tags(resource_id)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_tag_id ON resource_tags(tag_id)")
utils.Info("数据库索引创建完成已移除全文搜索索引准备使用Meilisearch")
// API访问日志表索引 - 高性能查询优化
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_created_at ON api_access_logs(created_at DESC)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_endpoint_status ON api_access_logs(endpoint, response_status)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_ip_created ON api_access_logs(ip, created_at DESC)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_method_endpoint ON api_access_logs(method, endpoint)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_response_time ON api_access_logs(processing_time)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_api_access_logs_error_logs ON api_access_logs(response_status, created_at DESC) WHERE response_status >= 400")
utils.Info("数据库索引创建完成已移除全文搜索索引准备使用Meilisearch新增API访问日志性能索引")
}
// insertDefaultDataIfEmpty 只在数据库为空时插入默认数据
@@ -295,3 +333,19 @@ func insertDefaultDataIfEmpty() error {
utils.Info("默认数据插入完成")
return nil
}
// getEnvInt 获取环境变量中的整数值,如果不存在则返回默认值
func getEnvInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
utils.Warn("环境变量 %s 的值 '%s' 不是有效的整数,使用默认值 %d", key, value, defaultValue)
return defaultValue
}
return intValue
}

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

View File

@@ -12,6 +12,7 @@ import (
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
response := dto.ResourceResponse{
ID: resource.ID,
Key: resource.Key,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
@@ -36,6 +37,18 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
response.CategoryName = resource.Category.Name
}
// 设置平台信息
if resource.Pan.ID != 0 {
panResponse := dto.PanResponse{
ID: resource.Pan.ID,
Name: resource.Pan.Name,
Key: resource.Pan.Key,
Icon: resource.Pan.Icon,
Remark: resource.Pan.Remark,
}
response.Pan = &panResponse
}
// 转换标签
response.Tags = make([]dto.TagResponse, len(resource.Tags))
for i, tag := range resource.Tags {
@@ -73,6 +86,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()
}
@@ -80,7 +96,7 @@ func ToResourceResponseFromMeilisearch(doc interface{}) dto.ResourceResponse {
response.FileSize = fileSizeField.String()
}
if keyField := docValue.FieldByName("Key"); keyField.IsValid() {
// Key字段在ResourceResponse中不存在跳过
response.Key = keyField.String()
}
if categoryField := docValue.FieldByName("Category"); categoryField.IsValid() {
response.CategoryName = categoryField.String()

View File

@@ -0,0 +1,95 @@
package converter
import (
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
)
// CopyrightClaimToResponseWithResources 将版权申述实体和关联资源转换为响应对象
func CopyrightClaimToResponseWithResources(claim *entity.CopyrightClaim, resources []*entity.Resource) *dto.CopyrightClaimResponse {
if claim == nil {
return nil
}
// 转换关联的资源信息
var resourceInfos []dto.ResourceInfo
for _, resource := range resources {
categoryName := ""
if resource.Category.ID != 0 {
categoryName = resource.Category.Name
}
panName := ""
if resource.Pan.ID != 0 {
panName = resource.Pan.Name
}
resourceInfo := dto.ResourceInfo{
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
Category: categoryName,
PanName: panName,
ViewCount: resource.ViewCount,
IsValid: resource.IsValid,
CreatedAt: resource.CreatedAt.Format(time.RFC3339),
}
resourceInfos = append(resourceInfos, resourceInfo)
}
return &dto.CopyrightClaimResponse{
ID: claim.ID,
ResourceKey: claim.ResourceKey,
Identity: claim.Identity,
ProofType: claim.ProofType,
Reason: claim.Reason,
ContactInfo: claim.ContactInfo,
ClaimantName: claim.ClaimantName,
ProofFiles: claim.ProofFiles,
UserAgent: claim.UserAgent,
IPAddress: claim.IPAddress,
Status: claim.Status,
Note: claim.Note,
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
Resources: resourceInfos,
}
}
// CopyrightClaimToResponse 将版权申述实体转换为响应对象(不包含资源详情)
func CopyrightClaimToResponse(claim *entity.CopyrightClaim) *dto.CopyrightClaimResponse {
if claim == nil {
return nil
}
return &dto.CopyrightClaimResponse{
ID: claim.ID,
ResourceKey: claim.ResourceKey,
Identity: claim.Identity,
ProofType: claim.ProofType,
Reason: claim.Reason,
ContactInfo: claim.ContactInfo,
ClaimantName: claim.ClaimantName,
ProofFiles: claim.ProofFiles,
UserAgent: claim.UserAgent,
IPAddress: claim.IPAddress,
Status: claim.Status,
Note: claim.Note,
CreatedAt: claim.CreatedAt.Format(time.RFC3339),
UpdatedAt: claim.UpdatedAt.Format(time.RFC3339),
Resources: []dto.ResourceInfo{}, // 空的资源列表
}
}
// CopyrightClaimsToResponse 将版权申述实体列表转换为响应对象列表
func CopyrightClaimsToResponse(claims []*entity.CopyrightClaim) []*dto.CopyrightClaimResponse {
var responses []*dto.CopyrightClaimResponse
for _, claim := range claims {
responses = append(responses, CopyrightClaimToResponse(claim))
}
return responses
}

View File

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

View File

@@ -0,0 +1,89 @@
package converter
import (
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
)
// ReportToResponseWithResources 将举报实体和关联资源转换为响应对象
func ReportToResponseWithResources(report *entity.Report, resources []*entity.Resource) *dto.ReportResponse {
if report == nil {
return nil
}
// 转换关联的资源信息
var resourceInfos []dto.ResourceInfo
for _, resource := range resources {
categoryName := ""
if resource.Category.ID != 0 {
categoryName = resource.Category.Name
}
panName := ""
if resource.Pan.ID != 0 {
panName = resource.Pan.Name
}
resourceInfo := dto.ResourceInfo{
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
Category: categoryName,
PanName: panName,
ViewCount: resource.ViewCount,
IsValid: resource.IsValid,
CreatedAt: resource.CreatedAt.Format(time.RFC3339),
}
resourceInfos = append(resourceInfos, resourceInfo)
}
return &dto.ReportResponse{
ID: report.ID,
ResourceKey: report.ResourceKey,
Reason: report.Reason,
Description: report.Description,
Contact: report.Contact,
UserAgent: report.UserAgent,
IPAddress: report.IPAddress,
Status: report.Status,
Note: report.Note,
CreatedAt: report.CreatedAt.Format(time.RFC3339),
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
Resources: resourceInfos,
}
}
// ReportToResponse 将举报实体转换为响应对象(不包含资源详情)
func ReportToResponse(report *entity.Report) *dto.ReportResponse {
if report == nil {
return nil
}
return &dto.ReportResponse{
ID: report.ID,
ResourceKey: report.ResourceKey,
Reason: report.Reason,
Description: report.Description,
Contact: report.Contact,
UserAgent: report.UserAgent,
IPAddress: report.IPAddress,
Status: report.Status,
Note: report.Note,
CreatedAt: report.CreatedAt.Format(time.RFC3339),
UpdatedAt: report.UpdatedAt.Format(time.RFC3339),
Resources: []dto.ResourceInfo{}, // 空的资源列表
}
}
// ReportsToResponse 将举报实体列表转换为响应对象列表
func ReportsToResponse(reports []*entity.Report) []*dto.ReportResponse {
var responses []*dto.ReportResponse
for _, report := range reports {
responses = append(responses, ReportToResponse(report))
}
return responses
}

View File

@@ -1,6 +1,7 @@
package converter
import (
"encoding/json"
"strconv"
"time"
@@ -90,6 +91,29 @@ 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
case entity.ConfigKeyQrCodeStyle:
response.QrCodeStyle = config.Value
case entity.ConfigKeyWebsiteURL:
response.SiteURL = config.Value
}
}
@@ -221,6 +245,39 @@ 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 req.QrCodeStyle != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyQrCodeStyle, Value: *req.QrCodeStyle, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyQrCodeStyle)
}
if req.SiteURL != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyWebsiteURL, Value: *req.SiteURL, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyWebsiteURL)
}
// 记录更新的配置项
if len(updatedKeys) > 0 {
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
@@ -229,7 +286,7 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
return configs
}
// SystemConfigToPublicResponse 返回不含 api_token 的系统配置响应
// SystemConfigToPublicResponse 返回不含敏感配置的系统配置响应
func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]interface{} {
response := map[string]interface{}{
entity.ConfigResponseFieldID: 0,
@@ -241,27 +298,16 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
"site_logo": "",
entity.ConfigResponseFieldAutoProcessReadyResources: false,
entity.ConfigResponseFieldAutoProcessInterval: 30,
entity.ConfigResponseFieldAutoTransferEnabled: false,
entity.ConfigResponseFieldAutoTransferLimitDays: 0,
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
entity.ConfigResponseFieldForbiddenWords: "",
entity.ConfigResponseFieldAdKeywords: "",
entity.ConfigResponseFieldAutoInsertAd: "",
entity.ConfigResponseFieldPageSize: 100,
entity.ConfigResponseFieldMaintenanceMode: false,
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
entity.ConfigResponseFieldThirdPartyStatsCode: "",
entity.ConfigResponseFieldMeilisearchEnabled: false,
entity.ConfigResponseFieldMeilisearchHost: "localhost",
entity.ConfigResponseFieldMeilisearchPort: "7700",
entity.ConfigResponseFieldMeilisearchMasterKey: "",
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
entity.ConfigResponseFieldWebsiteURL: "",
}
// 将键值对转换为map
// 将键值对转换为map,过滤掉敏感配置
for _, config := range configs {
switch config.Key {
case entity.ConfigKeySiteTitle:
@@ -276,32 +322,6 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
response[entity.ConfigResponseFieldCopyright] = config.Value
case entity.ConfigKeySiteLogo:
response["site_logo"] = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
}
case entity.ConfigKeyAutoProcessInterval:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoProcessInterval] = val
}
case entity.ConfigKeyAutoTransferEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferEnabled] = val
}
case entity.ConfigKeyAutoTransferLimitDays:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferLimitDays] = val
}
case entity.ConfigKeyAutoTransferMinSpace:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferMinSpace] = val
}
case entity.ConfigKeyAutoFetchHotDramaEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoFetchHotDramaEnabled] = val
}
case entity.ConfigKeyForbiddenWords:
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
case entity.ConfigKeyAdKeywords:
response[entity.ConfigResponseFieldAdKeywords] = config.Value
case entity.ConfigKeyAutoInsertAd:
@@ -320,18 +340,49 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
}
case entity.ConfigKeyThirdPartyStatsCode:
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
case entity.ConfigKeyMeilisearchEnabled:
case entity.ConfigKeyEnableAnnouncements:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldMeilisearchEnabled] = val
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
case entity.ConfigKeyQrCodeStyle:
response["qr_code_style"] = config.Value
case entity.ConfigKeyWebsiteURL:
response[entity.ConfigResponseFieldWebsiteURL] = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["auto_process_ready_resources"] = val
}
case entity.ConfigKeyAutoTransferEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["auto_transfer_enabled"] = val
}
// 跳过不需要返回给公众的配置
case entity.ConfigKeyAutoProcessInterval:
case entity.ConfigKeyAutoTransferLimitDays:
case entity.ConfigKeyAutoTransferMinSpace:
case entity.ConfigKeyAutoFetchHotDramaEnabled:
case entity.ConfigKeyMeilisearchEnabled:
case entity.ConfigKeyMeilisearchHost:
response[entity.ConfigResponseFieldMeilisearchHost] = config.Value
case entity.ConfigKeyMeilisearchPort:
response[entity.ConfigResponseFieldMeilisearchPort] = config.Value
case entity.ConfigKeyMeilisearchMasterKey:
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
case entity.ConfigKeyMeilisearchIndexName:
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
case entity.ConfigKeyForbiddenWords:
// 这些配置不返回给公众
continue
}
}
@@ -372,5 +423,12 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
EnableAnnouncements: false,
Announcements: "",
EnableFloatButtons: false,
WechatSearchImage: entity.ConfigDefaultWechatSearchImage,
TelegramQrImage: entity.ConfigDefaultTelegramQrImage,
QrCodeStyle: entity.ConfigDefaultQrCodeStyle,
SiteURL: entity.ConfigDefaultWebsiteURL,
}
}

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

View File

@@ -0,0 +1,88 @@
package converter
import (
"strconv"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
)
// WechatBotConfigRequestToSystemConfigs 将微信机器人配置请求转换为系统配置实体
func WechatBotConfigRequestToSystemConfigs(req dto.WechatBotConfigRequest) []entity.SystemConfig {
configs := []entity.SystemConfig{
{Key: entity.ConfigKeyWechatBotEnabled, Value: wechatBoolToString(req.Enabled)},
{Key: entity.ConfigKeyWechatAppId, Value: req.AppID},
{Key: entity.ConfigKeyWechatAppSecret, Value: req.AppSecret},
{Key: entity.ConfigKeyWechatToken, Value: req.Token},
{Key: entity.ConfigKeyWechatEncodingAesKey, Value: req.EncodingAesKey},
{Key: entity.ConfigKeyWechatWelcomeMessage, Value: req.WelcomeMessage},
{Key: entity.ConfigKeyWechatAutoReplyEnabled, Value: wechatBoolToString(req.AutoReplyEnabled)},
{Key: entity.ConfigKeyWechatSearchLimit, Value: wechatIntToString(req.SearchLimit)},
}
return configs
}
// SystemConfigToWechatBotConfig 将系统配置转换为微信机器人配置响应
func SystemConfigToWechatBotConfig(configs []entity.SystemConfig) dto.WechatBotConfigResponse {
resp := dto.WechatBotConfigResponse{
Enabled: false,
AppID: "",
AppSecret: "",
Token: "",
EncodingAesKey: "",
WelcomeMessage: "欢迎关注老九网盘资源库!发送关键词即可搜索资源。",
AutoReplyEnabled: true,
SearchLimit: 5,
}
for _, config := range configs {
switch config.Key {
case entity.ConfigKeyWechatBotEnabled:
resp.Enabled = config.Value == "true"
case entity.ConfigKeyWechatAppId:
resp.AppID = config.Value
case entity.ConfigKeyWechatAppSecret:
resp.AppSecret = config.Value
case entity.ConfigKeyWechatToken:
resp.Token = config.Value
case entity.ConfigKeyWechatEncodingAesKey:
resp.EncodingAesKey = config.Value
case entity.ConfigKeyWechatWelcomeMessage:
if config.Value != "" {
resp.WelcomeMessage = config.Value
}
case entity.ConfigKeyWechatAutoReplyEnabled:
resp.AutoReplyEnabled = config.Value == "true"
case entity.ConfigKeyWechatSearchLimit:
if config.Value != "" {
resp.SearchLimit = wechatStringToInt(config.Value)
}
}
}
return resp
}
// 辅助函数 - 使用大写名称避免与其他文件中的函数冲突
func wechatBoolToString(b bool) string {
if b {
return "true"
}
return "false"
}
func wechatIntToString(i int) string {
return strconv.Itoa(i)
}
func wechatStringToInt(s string) int {
if s == "" {
return 0
}
i, err := strconv.Atoi(s)
if err != nil {
return 0
}
return i
}

55
db/dto/api_access_log.go Normal file
View 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"`
}

46
db/dto/copyright_claim.go Normal file
View File

@@ -0,0 +1,46 @@
package dto
// CopyrightClaimCreateRequest 版权申述创建请求
type CopyrightClaimCreateRequest struct {
ResourceKey string `json:"resource_key" validate:"required,max=255"`
Identity string `json:"identity" validate:"required,max=50"`
ProofType string `json:"proof_type" validate:"required,max=50"`
Reason string `json:"reason" validate:"required,max=2000"`
ContactInfo string `json:"contact_info" validate:"required,max=255"`
ClaimantName string `json:"claimant_name" validate:"required,max=100"`
ProofFiles string `json:"proof_files" validate:"omitempty,max=2000"`
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
}
// CopyrightClaimUpdateRequest 版权申述更新请求
type CopyrightClaimUpdateRequest struct {
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
Note string `json:"note" validate:"omitempty,max=1000"`
}
// CopyrightClaimResponse 版权申述响应
type CopyrightClaimResponse struct {
ID uint `json:"id"`
ResourceKey string `json:"resource_key"`
Identity string `json:"identity"`
ProofType string `json:"proof_type"`
Reason string `json:"reason"`
ContactInfo string `json:"contact_info"`
ClaimantName string `json:"claimant_name"`
ProofFiles string `json:"proof_files"`
UserAgent string `json:"user_agent"`
IPAddress string `json:"ip_address"`
Status string `json:"status"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Resources []ResourceInfo `json:"resources"`
}
// CopyrightClaimListRequest 版权申述列表请求
type CopyrightClaimListRequest struct {
Page int `query:"page" validate:"omitempty,min=1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
}

View File

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

55
db/dto/report.go Normal file
View File

@@ -0,0 +1,55 @@
package dto
// ReportCreateRequest 举报创建请求
type ReportCreateRequest struct {
ResourceKey string `json:"resource_key" validate:"required,max=255"`
Reason string `json:"reason" validate:"required,max=100"`
Description string `json:"description" validate:"required,max=1000"`
Contact string `json:"contact" validate:"omitempty,max=255"`
UserAgent string `json:"user_agent" validate:"omitempty,max=1000"`
IPAddress string `json:"ip_address" validate:"omitempty,max=45"`
}
// ReportUpdateRequest 举报更新请求
type ReportUpdateRequest struct {
Status string `json:"status" validate:"required,oneof=pending approved rejected"`
Note string `json:"note" validate:"omitempty,max=1000"`
}
// ResourceInfo 资源信息
type ResourceInfo struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
Category string `json:"category"`
PanName string `json:"pan_name"`
ViewCount int `json:"view_count"`
IsValid bool `json:"is_valid"`
CreatedAt string `json:"created_at"`
}
// ReportResponse 举报响应
type ReportResponse struct {
ID uint `json:"id"`
ResourceKey string `json:"resource_key"`
Reason string `json:"reason"`
Description string `json:"description"`
Contact string `json:"contact"`
UserAgent string `json:"user_agent"`
IPAddress string `json:"ip_address"`
Status string `json:"status"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Resources []ResourceInfo `json:"resources"` // 关联的资源列表
}
// ReportListRequest 举报列表请求
type ReportListRequest struct {
Page int `query:"page" validate:"omitempty,min=1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100"`
Status string `query:"status" validate:"omitempty,oneof=pending approved rejected"`
}

View File

@@ -13,6 +13,7 @@ type SearchResponse struct {
// ResourceResponse 资源响应
type ResourceResponse struct {
ID uint `json:"id"`
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
@@ -32,6 +33,7 @@ type ResourceResponse struct {
ErrorMsg string `json:"error_msg"`
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
SyncedAt *time.Time `json:"synced_at"`
Pan *PanResponse `json:"pan,omitempty"` // 平台信息
// 高亮字段
TitleHighlight string `json:"title_highlight,omitempty"`
DescriptionHighlight string `json:"description_highlight,omitempty"`
@@ -84,6 +86,7 @@ type CksResponse struct {
VipStatus bool `json:"vip_status"`
ServiceType string `json:"service_type"`
Remark string `json:"remark"`
TransferredCount int64 `json:"transferred_count"` // 已转存资源数
Pan *PanResponse `json:"pan,omitempty"`
}

View File

@@ -42,6 +42,17 @@ 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"`
QrCodeStyle *string `json:"qr_code_style,omitempty"`
// 网站URL配置
SiteURL *string `json:"site_url,omitempty"`
}
// SystemConfigResponse 系统配置响应
@@ -90,6 +101,17 @@ 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"`
QrCodeStyle string `json:"qr_code_style"`
// 网站URL配置
SiteURL string `json:"site_url"`
}
// SystemConfigItem 单个配置项

105
db/dto/telegram_channel.go Normal file
View 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"`
}

25
db/dto/wechat_bot.go Normal file
View File

@@ -0,0 +1,25 @@
package dto
// WechatBotConfigRequest 微信公众号机器人配置请求
type WechatBotConfigRequest struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
EncodingAesKey string `json:"encoding_aes_key"`
WelcomeMessage string `json:"welcome_message"`
AutoReplyEnabled bool `json:"auto_reply_enabled"`
SearchLimit int `json:"search_limit"`
}
// WechatBotConfigResponse 微信公众号机器人配置响应
type WechatBotConfigResponse struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
EncodingAesKey string `json:"encoding_aes_key"`
WelcomeMessage string `json:"welcome_message"`
AutoReplyEnabled bool `json:"auto_reply_enabled"`
SearchLimit int `json:"search_limit"`
}

View 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"`
}

View File

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

View File

@@ -0,0 +1,32 @@
package entity
import (
"gorm.io/gorm"
"time"
)
// CopyrightClaim 版权申述实体
type CopyrightClaim struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
Identity string `gorm:"type:varchar(50);not null" json:"identity"` // 申述人身份
ProofType string `gorm:"type:varchar(50);not null" json:"proof_type"` // 证明类型
Reason string `gorm:"type:text;not null" json:"reason"` // 申述理由
ContactInfo string `gorm:"type:varchar(255);not null" json:"contact_info"` // 联系信息
ClaimantName string `gorm:"type:varchar(100);not null" json:"claimant_name"` // 申述人姓名
ProofFiles string `gorm:"type:text" json:"proof_files"` // 证明文件JSON格式
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
ProcessedBy *uint `json:"processed_by"` // 处理人ID
Note string `gorm:"type:text" json:"note"` // 处理备注
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at"`
}
// TableName 表名
func (CopyrightClaim) TableName() string {
return "copyright_claims"
}

View File

@@ -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'"` // 数据来源

29
db/entity/report.go Normal file
View File

@@ -0,0 +1,29 @@
package entity
import (
"gorm.io/gorm"
"time"
)
// Report 举报实体
type Report struct {
ID uint `gorm:"primaryKey" json:"id"`
ResourceKey string `gorm:"type:varchar(255);not null;index" json:"resource_key"` // 资源唯一标识
Reason string `gorm:"type:varchar(100);not null" json:"reason"` // 举报原因
Description string `gorm:"type:text" json:"description"` // 详细描述
Contact string `gorm:"type:varchar(255)" json:"contact"` // 联系方式
UserAgent string `gorm:"type:text" json:"user_agent"` // 用户代理
IPAddress string `gorm:"type:varchar(45)" json:"ip_address"` // IP地址
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // 处理状态: pending, approved, rejected
ProcessedAt *time.Time `json:"processed_at"` // 处理时间
ProcessedBy *uint `json:"processed_by"` // 处理人ID
Note string `gorm:"type:text" json:"note"` // 处理备注
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at"`
}
// TableName 表名
func (Report) TableName() string {
return "reports"
}

View File

@@ -42,6 +42,46 @@ 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"
// 微信公众号配置
ConfigKeyWechatBotEnabled = "wechat_bot_enabled"
ConfigKeyWechatAppId = "wechat_app_id"
ConfigKeyWechatAppSecret = "wechat_app_secret"
ConfigKeyWechatToken = "wechat_token"
ConfigKeyWechatEncodingAesKey = "wechat_encoding_aes_key"
ConfigKeyWechatWelcomeMessage = "wechat_welcome_message"
ConfigKeyWechatAutoReplyEnabled = "wechat_auto_reply_enabled"
ConfigKeyWechatSearchLimit = "wechat_search_limit"
// 界面配置
ConfigKeyEnableAnnouncements = "enable_announcements"
ConfigKeyAnnouncements = "announcements"
ConfigKeyEnableFloatButtons = "enable_float_buttons"
ConfigKeyWechatSearchImage = "wechat_search_image"
ConfigKeyTelegramQrImage = "telegram_qr_image"
ConfigKeyQrCodeStyle = "qr_code_style"
// Sitemap配置
ConfigKeySitemapConfig = "sitemap_config"
ConfigKeySitemapLastGenerateTime = "sitemap_last_generate_time"
ConfigKeySitemapAutoGenerateEnabled = "sitemap_auto_generate_enabled"
// 网站URL配置
ConfigKeyWebsiteURL = "website_url"
)
// ConfigType 配置类型常量
@@ -98,6 +138,41 @@ 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"
// 微信公众号配置字段
ConfigResponseFieldWechatBotEnabled = "wechat_bot_enabled"
ConfigResponseFieldWechatAppId = "wechat_app_id"
ConfigResponseFieldWechatAppSecret = "wechat_app_secret"
ConfigResponseFieldWechatToken = "wechat_token"
ConfigResponseFieldWechatEncodingAesKey = "wechat_encoding_aes_key"
ConfigResponseFieldWechatWelcomeMessage = "wechat_welcome_message"
ConfigResponseFieldWechatAutoReplyEnabled = "wechat_auto_reply_enabled"
ConfigResponseFieldWechatSearchLimit = "wechat_search_limit"
// 界面配置字段
ConfigResponseFieldEnableAnnouncements = "enable_announcements"
ConfigResponseFieldAnnouncements = "announcements"
ConfigResponseFieldEnableFloatButtons = "enable_float_buttons"
ConfigResponseFieldWechatSearchImage = "wechat_search_image"
ConfigResponseFieldTelegramQrImage = "telegram_qr_image"
ConfigResponseFieldQrCodeStyle = "qr_code_style"
// 网站URL配置字段
ConfigResponseFieldWebsiteURL = "site_url"
)
// ConfigDefaultValue 配置默认值常量
@@ -141,4 +216,39 @@ 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 = ""
// 微信公众号配置默认值
ConfigDefaultWechatBotEnabled = "false"
ConfigDefaultWechatAppId = ""
ConfigDefaultWechatAppSecret = ""
ConfigDefaultWechatToken = ""
ConfigDefaultWechatEncodingAesKey = ""
ConfigDefaultWechatWelcomeMessage = "欢迎关注老九网盘资源库!发送关键词即可搜索资源。"
ConfigDefaultWechatAutoReplyEnabled = "true"
ConfigDefaultWechatSearchLimit = "5"
// 界面配置默认值
ConfigDefaultEnableAnnouncements = "false"
ConfigDefaultAnnouncements = ""
ConfigDefaultEnableFloatButtons = "false"
ConfigDefaultWechatSearchImage = ""
ConfigDefaultTelegramQrImage = ""
ConfigDefaultQrCodeStyle = "Plain"
// 网站URL配置默认值
ConfigDefaultWebsiteURL = ""
)

View File

@@ -23,6 +23,7 @@ type TaskType string
const (
TaskTypeBatchTransfer TaskType = "batch_transfer" // 批量转存
TaskTypeExpansion TaskType = "expansion" // 账号扩容
)
// Task 任务表

View 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"
}

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

View File

@@ -12,6 +12,7 @@ type BaseRepository[T any] interface {
Update(entity *T) error
Delete(id uint) error
FindWithPagination(page, limit int) ([]T, int64, error)
GetDB() *gorm.DB
}
// BaseRepositoryImpl 基础Repository实现

View File

@@ -1,7 +1,10 @@
package repo
import (
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm"
)
@@ -10,6 +13,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
@@ -65,14 +69,31 @@ func (r *CksRepositoryImpl) FindAll() ([]entity.Cks, error) {
// FindByID 根据ID查找Cks预加载Pan关联数据
func (r *CksRepositoryImpl) FindByID(id uint) (*entity.Cks, error) {
startTime := utils.GetCurrentTime()
var cks entity.Cks
err := r.db.Preload("Pan").First(&cks, id).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Debug("FindByID失败: ID=%d, 错误=%v, 查询耗时=%v", id, err, queryDuration)
return nil, err
}
utils.Debug("FindByID成功: ID=%d, 查询耗时=%v", id, queryDuration)
return &cks, nil
}
func (r *CksRepositoryImpl) FindByIds(ids []uint) ([]*entity.Cks, error) {
startTime := utils.GetCurrentTime()
var cks []*entity.Cks
err := r.db.Preload("Pan").Where("id IN ?", ids).Find(&cks).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Debug("FindByIds失败: IDs数量=%d, 错误=%v, 查询耗时=%v", len(ids), err, queryDuration)
return nil, err
}
utils.Debug("FindByIds成功: 找到%d个账号查询耗时=%v", len(cks), queryDuration)
return cks, nil
}
// UpdateWithAllFields 更新Cks包括零值字段
func (r *CksRepositoryImpl) UpdateWithAllFields(cks *entity.Cks) error {
return r.db.Save(cks).Error

View File

@@ -0,0 +1,87 @@
package repo
import (
"gorm.io/gorm"
"github.com/ctwj/urldb/db/entity"
)
// CopyrightClaimRepository 版权申述Repository接口
type CopyrightClaimRepository interface {
BaseRepository[entity.CopyrightClaim]
GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error)
List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error)
UpdateStatus(id uint, status string, processedBy *uint, note string) error
// 兼容原有方法名
GetByID(id uint) (*entity.CopyrightClaim, error)
}
// CopyrightClaimRepositoryImpl 版权申述Repository实现
type CopyrightClaimRepositoryImpl struct {
BaseRepositoryImpl[entity.CopyrightClaim]
}
// NewCopyrightClaimRepository 创建版权申述Repository
func NewCopyrightClaimRepository(db *gorm.DB) CopyrightClaimRepository {
return &CopyrightClaimRepositoryImpl{
BaseRepositoryImpl: BaseRepositoryImpl[entity.CopyrightClaim]{db: db},
}
}
// Create 创建版权申述
func (r *CopyrightClaimRepositoryImpl) Create(claim *entity.CopyrightClaim) error {
return r.GetDB().Create(claim).Error
}
// GetByID 根据ID获取版权申述
func (r *CopyrightClaimRepositoryImpl) GetByID(id uint) (*entity.CopyrightClaim, error) {
var claim entity.CopyrightClaim
err := r.GetDB().Where("id = ?", id).First(&claim).Error
return &claim, err
}
// GetByResourceKey 获取某个资源的所有版权申述
func (r *CopyrightClaimRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.CopyrightClaim, error) {
var claims []*entity.CopyrightClaim
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&claims).Error
return claims, err
}
// List 获取版权申述列表
func (r *CopyrightClaimRepositoryImpl) List(status string, page, pageSize int) ([]*entity.CopyrightClaim, int64, error) {
var claims []*entity.CopyrightClaim
var total int64
query := r.GetDB().Model(&entity.CopyrightClaim{})
if status != "" {
query = query.Where("status = ?", status)
}
// 获取总数
query.Count(&total)
// 分页查询
offset := (page - 1) * pageSize
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&claims).Error
return claims, total, err
}
// Update 更新版权申述
func (r *CopyrightClaimRepositoryImpl) Update(claim *entity.CopyrightClaim) error {
return r.GetDB().Save(claim).Error
}
// UpdateStatus 更新版权申述状态
func (r *CopyrightClaimRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
return r.GetDB().Model(&entity.CopyrightClaim{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,
"processed_at": gorm.Expr("NOW()"),
"processed_by": processedBy,
"note": note,
}).Error
}
// Delete 删除版权申述
func (r *CopyrightClaimRepositoryImpl) Delete(id uint) error {
return r.GetDB().Delete(&entity.CopyrightClaim{}, id).Error
}

View File

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

View File

@@ -20,6 +20,10 @@ type RepositoryManager struct {
TaskRepository TaskRepository
TaskItemRepository TaskItemRepository
FileRepository FileRepository
TelegramChannelRepository TelegramChannelRepository
APIAccessLogRepository APIAccessLogRepository
ReportRepository ReportRepository
CopyrightClaimRepository CopyrightClaimRepository
}
// NewRepositoryManager 创建Repository管理器
@@ -39,5 +43,9 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
TaskRepository: NewTaskRepository(db),
TaskItemRepository: NewTaskItemRepository(db),
FileRepository: NewFileRepository(db),
TelegramChannelRepository: NewTelegramChannelRepository(db),
APIAccessLogRepository: NewAPIAccessLogRepository(db),
ReportRepository: NewReportRepository(db),
CopyrightClaimRepository: NewCopyrightClaimRepository(db),
}
}

114
db/repo/pagination.go Normal file
View File

@@ -0,0 +1,114 @@
package repo
import (
"gorm.io/gorm"
)
// PaginationResult 分页查询结果
type PaginationResult[T any] struct {
Data []T `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// PaginationOptions 分页查询选项
type PaginationOptions struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
OrderBy string `json:"order_by"`
OrderDir string `json:"order_dir"` // asc or desc
Preloads []string `json:"preloads"` // 需要预加载的关联
Filters map[string]interface{} `json:"filters"` // 过滤条件
}
// DefaultPaginationOptions 默认分页选项
func DefaultPaginationOptions() *PaginationOptions {
return &PaginationOptions{
Page: 1,
PageSize: 20,
OrderBy: "id",
OrderDir: "desc",
Preloads: []string{},
Filters: make(map[string]interface{}),
}
}
// PaginatedQuery 通用分页查询函数
func PaginatedQuery[T any](db *gorm.DB, options *PaginationOptions) (*PaginationResult[T], error) {
// 验证分页参数
if options.Page < 1 {
options.Page = 1
}
if options.PageSize < 1 || options.PageSize > 1000 {
options.PageSize = 20
}
// 应用预加载
query := db.Model(new(T))
for _, preload := range options.Preloads {
query = query.Preload(preload)
}
// 应用过滤条件
for key, value := range options.Filters {
// 处理特殊过滤条件
switch key {
case "search":
// 搜索条件需要特殊处理
if searchStr, ok := value.(string); ok && searchStr != "" {
query = query.Where("title ILIKE ? OR description ILIKE ?", "%"+searchStr+"%", "%"+searchStr+"%")
}
case "category_id":
if categoryID, ok := value.(uint); ok {
query = query.Where("category_id = ?", categoryID)
}
case "pan_id":
if panID, ok := value.(uint); ok {
query = query.Where("pan_id = ?", panID)
}
case "is_valid":
if isValid, ok := value.(bool); ok {
query = query.Where("is_valid = ?", isValid)
}
case "is_public":
if isPublic, ok := value.(bool); ok {
query = query.Where("is_public = ?", isPublic)
}
default:
// 通用过滤条件
query = query.Where(key+" = ?", value)
}
}
// 应用排序
orderClause := options.OrderBy + " " + options.OrderDir
query = query.Order(orderClause)
// 计算偏移量
offset := (options.Page - 1) * options.PageSize
// 获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// 查询数据
var data []T
if err := query.Offset(offset).Limit(options.PageSize).Find(&data).Error; err != nil {
return nil, err
}
// 计算总页数
totalPages := int((total + int64(options.PageSize) - 1) / int64(options.PageSize))
return &PaginationResult[T]{
Data: data,
Total: total,
Page: options.Page,
PageSize: options.PageSize,
TotalPages: totalPages,
}, nil
}

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
package repo
import (
"gorm.io/gorm"
"github.com/ctwj/urldb/db/entity"
)
// ReportRepository 举报Repository接口
type ReportRepository interface {
BaseRepository[entity.Report]
GetByResourceKey(resourceKey string) ([]*entity.Report, error)
List(status string, page, pageSize int) ([]*entity.Report, int64, error)
UpdateStatus(id uint, status string, processedBy *uint, note string) error
// 兼容原有方法名
GetByID(id uint) (*entity.Report, error)
}
// ReportRepositoryImpl 举报Repository实现
type ReportRepositoryImpl struct {
BaseRepositoryImpl[entity.Report]
}
// NewReportRepository 创建举报Repository
func NewReportRepository(db *gorm.DB) ReportRepository {
return &ReportRepositoryImpl{
BaseRepositoryImpl: BaseRepositoryImpl[entity.Report]{db: db},
}
}
// Create 创建举报
func (r *ReportRepositoryImpl) Create(report *entity.Report) error {
return r.GetDB().Create(report).Error
}
// GetByID 根据ID获取举报
func (r *ReportRepositoryImpl) GetByID(id uint) (*entity.Report, error) {
var report entity.Report
err := r.GetDB().Where("id = ?", id).First(&report).Error
return &report, err
}
// GetByResourceKey 获取某个资源的所有举报
func (r *ReportRepositoryImpl) GetByResourceKey(resourceKey string) ([]*entity.Report, error) {
var reports []*entity.Report
err := r.GetDB().Where("resource_key = ?", resourceKey).Find(&reports).Error
return reports, err
}
// List 获取举报列表
func (r *ReportRepositoryImpl) List(status string, page, pageSize int) ([]*entity.Report, int64, error) {
var reports []*entity.Report
var total int64
query := r.GetDB().Model(&entity.Report{})
if status != "" {
query = query.Where("status = ?", status)
}
// 获取总数
query.Count(&total)
// 分页查询
offset := (page - 1) * pageSize
err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&reports).Error
return reports, total, err
}
// Update 更新举报
func (r *ReportRepositoryImpl) Update(report *entity.Report) error {
return r.GetDB().Save(report).Error
}
// UpdateStatus 更新举报状态
func (r *ReportRepositoryImpl) UpdateStatus(id uint, status string, processedBy *uint, note string) error {
return r.GetDB().Model(&entity.Report{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,
"processed_at": gorm.Expr("NOW()"),
"processed_by": processedBy,
"note": note,
}).Error
}
// Delete 删除举报
func (r *ReportRepositoryImpl) Delete(id uint) error {
return r.GetDB().Delete(&entity.Report{}, id).Error
}

View File

@@ -2,9 +2,12 @@ package repo
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm"
)
@@ -42,6 +45,12 @@ 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)
DeleteRelatedResources(ckID uint) (int64, error)
CountResourcesByCkID(ckID uint) (int64, error)
FindByResourceKey(key string) ([]entity.Resource, error)
FindByKey(key string) ([]entity.Resource, error)
GetHotResources(limit int) ([]entity.Resource, error)
}
// ResourceRepositoryImpl Resource的Repository实现
@@ -67,38 +76,21 @@ func (r *ResourceRepositoryImpl) FindWithRelations() ([]entity.Resource, error)
// FindWithRelationsPaginated 分页查找包含关联关系的资源
func (r *ResourceRepositoryImpl) FindWithRelationsPaginated(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 优化查询:只预加载必要的关联,并添加排序
db := r.db.Model(&entity.Resource{}).
Preload("Category").
Preload("Pan").
Order("updated_at DESC") // 按更新时间倒序,显示最新内容
// 获取总数(使用缓存键)
cacheKey := fmt.Sprintf("resources_total_%d_%d", page, limit)
if cached, exists := r.cache[cacheKey]; exists {
if totalCached, ok := cached.(int64); ok {
total = totalCached
// 使用新的分页查询功能
options := &PaginationOptions{
Page: page,
PageSize: limit,
OrderBy: "updated_at",
OrderDir: "desc",
Preloads: []string{"Category", "Pan"},
}
} else {
if err := db.Count(&total).Error; err != nil {
result, err := PaginatedQuery[entity.Resource](r.db, options)
if err != nil {
return nil, 0, err
}
// 缓存总数5分钟
r.cache[cacheKey] = total
go func() {
time.Sleep(5 * time.Minute)
delete(r.cache, cacheKey)
}()
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
return result.Data, result.Total, nil
}
// FindByCategoryID 根据分类ID查找
@@ -217,6 +209,7 @@ func (r *ResourceRepositoryImpl) SearchByPanID(query string, panID uint, page, l
// SearchWithFilters 根据参数进行搜索
func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}) ([]entity.Resource, int64, error) {
startTime := utils.GetCurrentTime()
var resources []entity.Resource
var total int64
@@ -254,6 +247,23 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
Where("resource_tags.tag_id = ?", tagEntity.ID)
}
}
case "tag_ids": // 添加tag_ids参数支持标签ID列表
if tagIdsStr, ok := value.(string); ok && tagIdsStr != "" {
// 将逗号分隔的标签ID字符串转换为整数ID数组
tagIdStrs := strings.Split(tagIdsStr, ",")
var tagIds []uint
for _, idStr := range tagIdStrs {
idStr = strings.TrimSpace(idStr) // 去除空格
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
tagIds = append(tagIds, uint(id))
}
}
if len(tagIds) > 0 {
// 通过中间表查找包含任一标签的资源
db = db.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
Where("resource_tags.tag_id IN ?", tagIds)
}
}
case "pan_id": // 添加pan_id参数支持
if panID, ok := value.(uint); ok {
db = db.Where("pan_id = ?", panID)
@@ -291,6 +301,20 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
db = db.Where("pan_id = ?", panEntity.ID)
}
}
case "exclude_ids": // 添加exclude_ids参数支持
if excludeIDs, ok := value.([]uint); ok && len(excludeIDs) > 0 {
// 限制排除ID的数量避免SQL语句过长
maxExcludeIDs := 5000 // 限制排除ID数量避免SQL语句过长
if len(excludeIDs) > maxExcludeIDs {
// 只取最近的maxExcludeIDs个ID进行排除
startIndex := len(excludeIDs) - maxExcludeIDs
truncatedExcludeIDs := excludeIDs[startIndex:]
db = db.Where("id NOT IN ?", truncatedExcludeIDs)
utils.Debug("SearchWithFilters: 排除ID数量过多截取最近%d个ID", len(truncatedExcludeIDs))
} else {
db = db.Where("id NOT IN ?", excludeIDs)
}
}
}
}
@@ -333,9 +357,37 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
// 计算偏移量
offset := (page - 1) * pageSize
// 获取分页数据,按更新时间倒序
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
fmt.Printf("查询结果: 总数=%d, 当前页数据量=%d, pageSize=%d\n", total, len(resources), pageSize)
// 处理排序参数
orderBy := "updated_at"
orderDir := "DESC"
if orderByVal, ok := params["order_by"].(string); ok && orderByVal != "" {
// 验证排序字段防止SQL注入
validOrderByFields := map[string]bool{
"created_at": true,
"updated_at": true,
"view_count": true,
"title": true,
"id": true,
}
if validOrderByFields[orderByVal] {
orderBy = orderByVal
}
}
if orderDirVal, ok := params["order_dir"].(string); ok && orderDirVal != "" {
// 验证排序方向
if orderDirVal == "ASC" || orderDirVal == "DESC" {
orderDir = orderDirVal
}
}
// 获取分页数据,应用排序
queryStart := utils.GetCurrentTime()
err := db.Order(fmt.Sprintf("%s %s", orderBy, orderDir)).Offset(offset).Limit(pageSize).Find(&resources).Error
queryDuration := time.Since(queryStart)
totalDuration := time.Since(startTime)
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 排序=%s %s, 查询耗时=%v, 总耗时=%v", total, len(resources), orderBy, orderDir, queryDuration, totalDuration)
return resources, total, err
}
@@ -468,11 +520,15 @@ func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime t
// GetByURL 根据URL获取资源
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
startTime := utils.GetCurrentTime()
var resource entity.Resource
err := r.db.Where("url = ?", url).First(&resource).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Debug("GetByURL失败: URL=%s, 错误=%v, 查询耗时=%v", url, err, queryDuration)
return nil, err
}
utils.Debug("GetByURL成功: URL=%s, 查询耗时=%v", url, queryDuration)
return &resource, nil
}
@@ -568,6 +624,7 @@ func (r *ResourceRepositoryImpl) FindSyncedToMeilisearch(page, limit int) ([]ent
Where("synced_to_meilisearch = ?", true).
Preload("Category").
Preload("Pan").
Preload("Tags").
Order("updated_at DESC")
// 获取总数
@@ -600,6 +657,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")
// 获取总数
@@ -611,3 +669,133 @@ 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
}
// DeleteRelatedResources 删除关联资源,清空 fid、ck_id 和 save_url 三个字段
func (r *ResourceRepositoryImpl) DeleteRelatedResources(ckID uint) (int64, error) {
result := r.db.Model(&entity.Resource{}).
Where("ck_id = ?", ckID).
Updates(map[string]interface{}{
"fid": nil, // 清空 fid 字段
"ck_id": 0, // 清空 ck_id 字段
"save_url": "", // 清空 save_url 字段
})
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// CountResourcesByCkID 统计指定账号ID的资源数量
func (r *ResourceRepositoryImpl) CountResourcesByCkID(ckID uint) (int64, error) {
var count int64
err := r.db.Model(&entity.Resource{}).
Where("ck_id = ?", ckID).
Count(&count).Error
return count, err
}
// FindByKey 根据Key查找资源同一组资源
func (r *ResourceRepositoryImpl) FindByKey(key string) ([]entity.Resource, error) {
var resources []entity.Resource
err := r.db.Where("key = ?", key).
Preload("Category").
Preload("Pan").
Preload("Tags").
Order("pan_id ASC").
Find(&resources).Error
return resources, err
}
// GetHotResources 获取热门资源(按查看次数排序,去重,限制数量)
func (r *ResourceRepositoryImpl) GetHotResources(limit int) ([]entity.Resource, error) {
var resources []entity.Resource
// 按key分组获取每个key中查看次数最高的资源然后按查看次数排序
err := r.db.Table("resources").
Select(`
resources.*,
ROW_NUMBER() OVER (PARTITION BY key ORDER BY view_count DESC) as rn
`).
Where("is_public = ? AND view_count > 0", true).
Preload("Category").
Preload("Pan").
Preload("Tags").
Order("view_count DESC").
Limit(limit * 2). // 获取更多数据以确保去重后有足够的结果
Find(&resources).Error
if err != nil {
return nil, err
}
// 按key去重保留每个key的第一个即查看次数最高的
seenKeys := make(map[string]bool)
var hotResources []entity.Resource
for _, resource := range resources {
if !seenKeys[resource.Key] {
seenKeys[resource.Key] = true
hotResources = append(hotResources, resource)
if len(hotResources) >= limit {
break
}
}
}
return hotResources, nil
}
// FindByResourceKey 根据资源Key查找资源
func (r *ResourceRepositoryImpl) FindByResourceKey(key string) ([]entity.Resource, error) {
var resources []entity.Resource
err := r.GetDB().Where("key = ?", key).Find(&resources).Error
if err != nil {
return nil, err
}
return resources, nil
}

View File

@@ -3,6 +3,7 @@ package repo
import (
"fmt"
"sync"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
@@ -100,8 +101,11 @@ func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig
// GetOrCreateDefault 获取配置或创建默认配置
func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig, error) {
startTime := utils.GetCurrentTime()
configs, err := r.FindAll()
initialQueryDuration := time.Since(startTime)
if err != nil {
utils.Error("获取所有系统配置失败: %v耗时: %v", err, initialQueryDuration)
return nil, err
}
@@ -133,13 +137,25 @@ 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},
{Key: entity.ConfigKeyQrCodeStyle, Value: entity.ConfigDefaultQrCodeStyle, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyWebsiteURL, Value: entity.ConfigDefaultWebsiteURL, Type: entity.ConfigTypeString},
}
createStart := utils.GetCurrentTime()
err = r.UpsertConfigs(defaultConfigs)
createDuration := time.Since(createStart)
if err != nil {
utils.Error("创建默认系统配置失败: %v耗时: %v", err, createDuration)
return nil, err
}
totalDuration := time.Since(startTime)
utils.Info("创建默认系统配置成功,数量: %d总耗时: %v", len(defaultConfigs), totalDuration)
return defaultConfigs, nil
}
@@ -169,6 +185,12 @@ 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},
entity.ConfigKeyWebsiteURL: {Key: entity.ConfigKeyWebsiteURL, Value: entity.ConfigDefaultWebsiteURL, Type: entity.ConfigTypeString},
}
// 检查现有配置中是否有缺失的配置项
@@ -187,17 +209,24 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
// 如果有缺失的配置项,则添加它们
if len(missingConfigs) > 0 {
upsertStart := utils.GetCurrentTime()
err = r.UpsertConfigs(missingConfigs)
upsertDuration := time.Since(upsertStart)
if err != nil {
utils.Error("添加缺失的系统配置失败: %v耗时: %v", err, upsertDuration)
return nil, err
}
utils.Debug("添加缺失的系统配置完成,数量: %d耗时: %v", len(missingConfigs), upsertDuration)
// 重新获取所有配置
configs, err = r.FindAll()
if err != nil {
utils.Error("重新获取所有系统配置失败: %v", err)
return nil, err
}
}
totalDuration := time.Since(startTime)
utils.Debug("GetOrCreateDefault完成总数: %d总耗时: %v", len(configs), totalDuration)
return configs, nil
}

View File

@@ -1,7 +1,10 @@
package repo
import (
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm"
)
@@ -58,8 +61,15 @@ func (r *TaskItemRepositoryImpl) DeleteByTaskID(taskID uint) error {
// GetByTaskIDAndStatus 根据任务ID和状态获取任务项
func (r *TaskItemRepositoryImpl) GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error) {
startTime := utils.GetCurrentTime()
var items []*entity.TaskItem
err := r.db.Where("task_id = ? AND status = ?", taskID, status).Order("id ASC").Find(&items).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Error("GetByTaskIDAndStatus失败: 任务ID=%d, 状态=%s, 错误=%v, 查询耗时=%v", taskID, status, err, queryDuration)
return nil, err
}
utils.Debug("GetByTaskIDAndStatus成功: 任务ID=%d, 状态=%s, 数量=%d, 查询耗时=%v", taskID, status, len(items), queryDuration)
return items, err
}
@@ -93,19 +103,36 @@ func (r *TaskItemRepositoryImpl) GetListByTaskID(taskID uint, page, pageSize int
// UpdateStatus 更新任务项状态
func (r *TaskItemRepositoryImpl) UpdateStatus(id uint, status string) error {
return r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Update("status", status).Error
startTime := utils.GetCurrentTime()
err := r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Update("status", status).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatus失败: ID=%d, 状态=%s, 错误=%v, 更新耗时=%v", id, status, err, updateDuration)
return err
}
utils.Debug("UpdateStatus成功: ID=%d, 状态=%s, 更新耗时=%v", id, status, updateDuration)
return nil
}
// UpdateStatusAndOutput 更新任务项状态和输出数据
func (r *TaskItemRepositoryImpl) UpdateStatusAndOutput(id uint, status, outputData string) error {
return r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Updates(map[string]interface{}{
startTime := utils.GetCurrentTime()
err := r.db.Model(&entity.TaskItem{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,
"output_data": outputData,
}).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatusAndOutput失败: ID=%d, 状态=%s, 错误=%v, 更新耗时=%v", id, status, err, updateDuration)
return err
}
utils.Debug("UpdateStatusAndOutput成功: ID=%d, 状态=%s, 更新耗时=%v", id, status, updateDuration)
return nil
}
// GetStatsByTaskID 获取任务项统计信息
func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int, error) {
startTime := utils.GetCurrentTime()
var results []struct {
Status string
Count int
@@ -117,7 +144,9 @@ func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int,
Group("status").
Find(&results).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Error("GetStatsByTaskID失败: 任务ID=%d, 错误=%v, 查询耗时=%v", taskID, err, queryDuration)
return nil, err
}
@@ -134,12 +163,22 @@ func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int,
stats["total"] += result.Count
}
totalDuration := time.Since(startTime)
utils.Debug("GetStatsByTaskID成功: 任务ID=%d, 统计信息=%v, 查询耗时=%v, 总耗时=%v", taskID, stats, queryDuration, totalDuration)
return stats, nil
}
// ResetProcessingItems 重置处理中的任务项为pending状态
func (r *TaskItemRepositoryImpl) ResetProcessingItems(taskID uint) error {
return r.db.Model(&entity.TaskItem{}).
startTime := utils.GetCurrentTime()
err := r.db.Model(&entity.TaskItem{}).
Where("task_id = ? AND status = ?", taskID, "processing").
Update("status", "pending").Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("ResetProcessingItems失败: 任务ID=%d, 错误=%v, 更新耗时=%v", taskID, err, updateDuration)
return err
}
utils.Debug("ResetProcessingItems成功: 任务ID=%d, 更新耗时=%v", taskID, updateDuration)
return nil
}

View File

@@ -1,7 +1,10 @@
package repo
import (
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm"
)
@@ -15,6 +18,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 任务仓库实现
@@ -31,11 +36,15 @@ func NewTaskRepository(db *gorm.DB) TaskRepository {
// GetByID 根据ID获取任务
func (r *TaskRepositoryImpl) GetByID(id uint) (*entity.Task, error) {
startTime := utils.GetCurrentTime()
var task entity.Task
err := r.db.First(&task, id).Error
queryDuration := time.Since(startTime)
if err != nil {
utils.Debug("GetByID失败: ID=%d, 错误=%v, 查询耗时=%v", id, err, queryDuration)
return nil, err
}
utils.Debug("GetByID成功: ID=%d, 查询耗时=%v", id, queryDuration)
return &task, nil
}
@@ -51,6 +60,7 @@ func (r *TaskRepositoryImpl) Delete(id uint) error {
// GetList 获取任务列表
func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string) ([]*entity.Task, int64, error) {
startTime := utils.GetCurrentTime()
var tasks []*entity.Task
var total int64
@@ -58,79 +68,178 @@ 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)
}
// 获取总数
countStart := utils.GetCurrentTime()
err := query.Count(&total).Error
countDuration := time.Since(countStart)
if err != nil {
utils.Error("GetList获取总数失败: 错误=%v, 查询耗时=%v", err, countDuration)
return nil, 0, err
}
// 分页查询
offset := (page - 1) * pageSize
queryStart := utils.GetCurrentTime()
err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&tasks).Error
queryDuration := time.Since(queryStart)
if err != nil {
utils.Error("GetList查询失败: 错误=%v, 查询耗时=%v", err, queryDuration)
return nil, 0, err
}
totalDuration := time.Since(startTime)
utils.Debug("GetList完成: 任务类型=%s, 状态=%s, 页码=%d, 页面大小=%d, 总数=%d, 结果数=%d, 总耗时=%v", taskType, status, page, pageSize, total, len(tasks), totalDuration)
return tasks, total, nil
}
// UpdateStatus 更新任务状态
func (r *TaskRepositoryImpl) UpdateStatus(id uint, status string) error {
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
startTime := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatus失败: ID=%d, 状态=%s, 错误=%v, 更新耗时=%v", id, status, err, updateDuration)
return err
}
utils.Debug("UpdateStatus成功: ID=%d, 状态=%s, 更新耗时=%v", id, status, updateDuration)
return nil
}
// UpdateProgress 更新任务进度
func (r *TaskRepositoryImpl) UpdateProgress(id uint, progress float64, progressData string) error {
startTime := utils.GetCurrentTime()
// 检查progress和progress_data字段是否存在
var count int64
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'progress'").Count(&count).Error
if err != nil || count == 0 {
// 如果检查失败或字段不存在只更新processed_items等现有字段
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
updateStart := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
"processed_items": progress, // 使用progress作为processed_items的近似值
}).Error
updateDuration := time.Since(updateStart)
totalDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateProgress失败(字段不存在): ID=%d, 进度=%f, 错误=%v, 更新耗时=%v, 总耗时=%v", id, progress, err, updateDuration, totalDuration)
return err
}
utils.Debug("UpdateProgress成功(字段不存在): ID=%d, 进度=%f, 更新耗时=%v, 总耗时=%v", id, progress, updateDuration, totalDuration)
return nil
}
// 字段存在,正常更新
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
updateStart := utils.GetCurrentTime()
err = r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
"progress": progress,
"progress_data": progressData,
}).Error
updateDuration := time.Since(updateStart)
totalDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateProgress失败: ID=%d, 进度=%f, 错误=%v, 更新耗时=%v, 总耗时=%v", id, progress, err, updateDuration, totalDuration)
return err
}
utils.Debug("UpdateProgress成功: ID=%d, 进度=%f, 更新耗时=%v, 总耗时=%v", id, progress, updateDuration, totalDuration)
return nil
}
// UpdateStatusAndMessage 更新任务状态和消息
func (r *TaskRepositoryImpl) UpdateStatusAndMessage(id uint, status, message string) error {
startTime := utils.GetCurrentTime()
// 检查message字段是否存在
var count int64
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'message'").Count(&count).Error
if err != nil {
// 如果检查失败,只更新状态
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
updateStart := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
updateDuration := time.Since(updateStart)
totalDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatusAndMessage失败(检查失败): ID=%d, 状态=%s, 错误=%v, 更新耗时=%v, 总耗时=%v", id, status, err, updateDuration, totalDuration)
return err
}
utils.Debug("UpdateStatusAndMessage成功(检查失败): ID=%d, 状态=%s, 更新耗时=%v, 总耗时=%v", id, status, updateDuration, totalDuration)
return nil
}
if count > 0 {
// message字段存在更新状态和消息
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
updateStart := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,
"message": message,
}).Error
updateDuration := time.Since(updateStart)
totalDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatusAndMessage失败(字段存在): ID=%d, 状态=%s, 错误=%v, 更新耗时=%v, 总耗时=%v", id, status, err, updateDuration, totalDuration)
return err
}
utils.Debug("UpdateStatusAndMessage成功(字段存在): ID=%d, 状态=%s, 更新耗时=%v, 总耗时=%v", id, status, updateDuration, totalDuration)
return nil
} else {
// message字段不存在只更新状态
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
updateStart := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("status", status).Error
updateDuration := time.Since(updateStart)
totalDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStatusAndMessage失败(字段不存在): ID=%d, 状态=%s, 错误=%v, 更新耗时=%v, 总耗时=%v", id, status, err, updateDuration, totalDuration)
return err
}
utils.Debug("UpdateStatusAndMessage成功(字段不存在): ID=%d, 状态=%s, 更新耗时=%v, 总耗时=%v", id, status, updateDuration, totalDuration)
return nil
}
}
// UpdateTaskStats 更新任务统计信息
func (r *TaskRepositoryImpl) UpdateTaskStats(id uint, processed, success, failed int) error {
return r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
startTime := utils.GetCurrentTime()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Updates(map[string]interface{}{
"processed_items": processed,
"success_items": success,
"failed_items": failed,
}).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateTaskStats失败: ID=%d, 处理数=%d, 成功数=%d, 失败数=%d, 错误=%v, 更新耗时=%v", id, processed, success, failed, err, updateDuration)
return err
}
utils.Debug("UpdateTaskStats成功: ID=%d, 处理数=%d, 成功数=%d, 失败数=%d, 更新耗时=%v", id, processed, success, failed, updateDuration)
return nil
}
// UpdateStartedAt 更新任务开始时间
func (r *TaskRepositoryImpl) UpdateStartedAt(id uint) error {
startTime := utils.GetCurrentTime()
now := time.Now()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("started_at", now).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateStartedAt失败: ID=%d, 错误=%v, 更新耗时=%v", id, err, updateDuration)
return err
}
utils.Debug("UpdateStartedAt成功: ID=%d, 更新耗时=%v", id, updateDuration)
return nil
}
// UpdateCompletedAt 更新任务完成时间
func (r *TaskRepositoryImpl) UpdateCompletedAt(id uint) error {
startTime := utils.GetCurrentTime()
now := time.Now()
err := r.db.Model(&entity.Task{}).Where("id = ?", id).Update("completed_at", now).Error
updateDuration := time.Since(startTime)
if err != nil {
utils.Error("UpdateCompletedAt失败: ID=%d, 错误=%v, 更新耗时=%v", id, err, updateDuration)
return err
}
utils.Debug("UpdateCompletedAt成功: ID=%d, 更新耗时=%v", id, updateDuration)
return nil
}

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

View File

@@ -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
View 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];
}
}

View File

@@ -20,7 +20,7 @@ services:
- app-network
backend:
image: ctwj/urldb-backend:1.2.4
image: ctwj/urldb-backend:1.3.4
environment:
DB_HOST: postgres
DB_PORT: 5432
@@ -38,7 +38,7 @@ services:
- app-network
frontend:
image: ctwj/urldb-frontend:1.2.4
image: ctwj/urldb-frontend:1.3.4
environment:
NODE_ENV: production
NUXT_PUBLIC_API_SERVER: http://backend:8080/api

132
docs/logging.md Normal file
View File

@@ -0,0 +1,132 @@
# 日志系统说明
## 概述
本项目使用自定义的日志系统,支持多种日志级别、环境差异化配置和结构化日志记录。
## 日志级别
日志系统支持以下级别(按严重程度递增):
1. **DEBUG** - 调试信息,用于开发和故障排除
2. **INFO** - 一般信息,记录系统正常运行状态
3. **WARN** - 警告信息,表示可能的问题但不影响系统运行
4. **ERROR** - 错误信息,表示系统错误但可以继续运行
5. **FATAL** - 致命错误,系统将退出
## 环境配置
### 日志级别配置
可以通过环境变量配置日志级别:
```bash
# 设置日志级别DEBUG, INFO, WARN, ERROR, FATAL
LOG_LEVEL=DEBUG
# 或者启用调试模式等同于DEBUG级别
DEBUG=true
```
默认情况下开发环境使用DEBUG级别生产环境使用INFO级别。
### 结构化日志
可以通过环境变量启用结构化日志JSON格式
```bash
# 启用结构化日志
STRUCTURED_LOG=true
```
## 使用方法
### 基本日志记录
```go
import "github.com/ctwj/urldb/utils"
// 基本日志记录
utils.Debug("调试信息: %s", debugInfo)
utils.Info("一般信息: %s", info)
utils.Warn("警告信息: %s", warning)
utils.Error("错误信息: %s", err)
utils.Fatal("致命错误: %s", fatalErr) // 程序将退出
```
### 结构化日志记录
结构化日志允许添加额外的字段信息,便于日志分析:
```go
// 带字段的结构化日志
utils.DebugWithFields(map[string]interface{}{
"user_id": 123,
"action": "login",
"ip": "192.168.1.1",
}, "用户登录调试信息")
utils.InfoWithFields(map[string]interface{}{
"task_id": 456,
"status": "completed",
"duration_ms": 1250,
}, "任务处理完成")
utils.ErrorWithFields(map[string]interface{}{
"error_code": 500,
"error": "database connection failed",
"component": "database",
}, "数据库连接失败: %v", err)
```
## 日志输出
日志默认输出到:
- 控制台(标准输出)
- 文件logs目录下的app_日期.log文件
日志文件支持轮转单个文件最大100MB最多保留5个备份文件日志文件最长保留30天。
## 最佳实践
1. **选择合适的日志级别**
- DEBUG详细的调试信息仅在开发和故障排除时使用
- INFO重要的业务流程和状态变更
- WARN可预期的问题和异常情况
- ERROR系统错误和异常
- FATAL系统无法继续运行的致命错误
2. **使用结构化日志**
- 对于需要后续分析的日志,使用结构化日志
- 添加有意义的字段如用户ID、任务ID、请求ID等
- 避免在字段中包含敏感信息
3. **性能监控**
- 记录关键操作的执行时间
- 使用duration_ms字段记录毫秒级耗时
4. **安全日志**
- 记录所有认证和授权相关的操作
- 包含客户端IP和用户信息
- 记录失败的访问尝试
## 示例
```go
// 性能监控示例
startTime := time.Now()
// 执行操作...
duration := time.Since(startTime)
utils.DebugWithFields(map[string]interface{}{
"operation": "database_query",
"duration_ms": duration.Milliseconds(),
}, "数据库查询完成,耗时: %v", duration)
// 安全日志示例
utils.InfoWithFields(map[string]interface{}{
"user_id": userID,
"ip": clientIP,
"action": "login",
"status": "success",
}, "用户登录成功 - 用户ID: %d, IP: %s", userID, clientIP)
```

View File

@@ -15,3 +15,8 @@ TIMEZONE=Asia/Shanghai
# 文件上传配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5MB
# 日志配置
LOG_LEVEL=INFO # 日志级别 (DEBUG, INFO, WARN, ERROR, FATAL)
DEBUG=false # 调试模式开关
STRUCTURED_LOG=false

Binary file not shown.

Binary file not shown.

40
go.mod
View File

@@ -1,24 +1,43 @@
module github.com/ctwj/urldb
go 1.23.0
toolchain go1.23.3
go 1.24.0
require (
github.com/fogleman/gg v1.3.0
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
golang.org/x/crypto v0.40.0
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
github.com/silenceper/wechat/v2 v2.1.10
golang.org/x/crypto v0.41.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/image v0.32.0 // indirect
)
require (
@@ -29,7 +48,7 @@ require (
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-playground/validator/v10 v10.27.0
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -39,7 +58,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -50,10 +68,10 @@ 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/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

178
go.sum
View File

@@ -1,10 +1,24 @@
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M=
github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
@@ -12,6 +26,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
@@ -34,8 +56,13 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
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-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
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=
@@ -43,11 +70,29 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -64,6 +109,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -77,6 +124,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@@ -92,28 +141,67 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
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=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/silenceper/wechat/v2 v2.1.10 h1:jMg0//CZBIuogEvuXgxJQuJ47SsPPAqFrrbOtro2pko=
github.com/silenceper/wechat/v2 v2.1.10/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
@@ -122,41 +210,101 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View 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访问日志清理成功"})
}

View File

@@ -1,6 +1,8 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
@@ -21,7 +23,49 @@ func GetCks(c *gin.Context) {
return
}
responses := converter.ToCksResponseList(cks)
// 使用新的逻辑创建 CksResponse
var responses []dto.CksResponse
for _, ck := range cks {
// 获取平台信息
var pan *dto.PanResponse
if ck.PanID != 0 {
panEntity, err := repoManager.PanRepository.FindByID(ck.PanID)
if err == nil && panEntity != nil {
pan = &dto.PanResponse{
ID: panEntity.ID,
Name: panEntity.Name,
Key: panEntity.Key,
Icon: panEntity.Icon,
Remark: panEntity.Remark,
}
}
}
// 统计转存资源数
count, err := repoManager.ResourceRepository.CountResourcesByCkID(ck.ID)
if err != nil {
count = 0 // 统计失败时设为0
}
response := dto.CksResponse{
ID: ck.ID,
PanID: ck.PanID,
Idx: ck.Idx,
Ck: ck.Ck,
IsValid: ck.IsValid,
Space: ck.Space,
LeftSpace: ck.LeftSpace,
UsedSpace: ck.UsedSpace,
Username: ck.Username,
VipStatus: ck.VipStatus,
ServiceType: ck.ServiceType,
Remark: ck.Remark,
TransferredCount: count,
Pan: pan,
}
responses = append(responses, response)
}
SuccessResponse(c, responses)
}
@@ -51,6 +95,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,8 +110,88 @@ func CreateCks(c *gin.Context) {
return
}
var cks *entity.Cks
// 迅雷网盘,使用账号密码登录
if serviceType == panutils.Xunlei {
// 解析账号密码信息
credentials, err := panutils.ParseCredentialsFromCk(req.Ck)
if err != nil {
ErrorResponse(c, "账号密码格式错误: "+err.Error(), http.StatusBadRequest)
return
}
// 验证账号密码
if credentials.Username == "" || credentials.Password == "" {
ErrorResponse(c, "请提供完整的账号和密码", http.StatusBadRequest)
return
}
var tokenData *panutils.XunleiTokenData
var username string
// 使用账号密码登录
xunleiService := service.(*panutils.XunleiPanService)
token, err := xunleiService.LoginWithCredentials(credentials.Username, credentials.Password)
if err != nil {
ErrorResponse(c, "账号密码登录失败: "+err.Error(), http.StatusBadRequest)
return
}
tokenData = &token
username = credentials.Username
// 构建extra数据
extra := panutils.XunleiExtraData{
Token: tokenData,
Captcha: &panutils.CaptchaData{},
}
// 如果有账号密码信息保存到extra中
if credentials.Username != "" && credentials.Password != "" {
extra.Credentials = credentials
}
extraStr, _ := json.Marshal(extra)
// 声明userInfo变量
var userInfo *panutils.UserInfo
// 设置CKSRepository以便获取用户信息
xunleiService.SetCKSRepository(repoManager.CksRepository, entity.Cks{})
// 获取用户信息
userInfo, err := service.GetUserInfo(req.Ck)
userInfo, err = xunleiService.GetUserInfo(nil)
if err != nil {
log.Printf("获取迅雷用户信息失败,使用默认值: %v", err)
// 如果获取失败,使用默认值
userInfo = &panutils.UserInfo{
Username: username,
VIPStatus: false,
ServiceType: "xunlei",
TotalSpace: 0,
UsedSpace: 0,
}
}
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: string(extraStr),
Remark: req.Remark,
}
} else {
// 获取用户信息
userInfo, err := service.GetUserInfo(&req.Ck)
if err != nil {
ErrorResponse(c, "无法获取用户信息,账号创建失败: "+err.Error(), http.StatusBadRequest)
return
@@ -74,7 +200,7 @@ func CreateCks(c *gin.Context) {
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
// 创建Cks实体
cks := &entity.Cks{
cks = &entity.Cks{
PanID: req.PanID,
Idx: req.Idx,
Ck: req.Ck,
@@ -85,8 +211,10 @@ func CreateCks(c *gin.Context) {
Username: userInfo.Username,
VipStatus: userInfo.VIPStatus,
ServiceType: userInfo.ServiceType,
Extra: userInfo.ExtraData,
Remark: req.Remark,
}
}
err = repoManager.CksRepository.Create(cks)
if err != nil {
@@ -293,6 +421,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 +436,22 @@ func RefreshCapacity(c *gin.Context) {
return
}
// 获取最新的用户信息
userInfo, err := service.GetUserInfo(cks.Ck)
var userInfo *panutils.UserInfo
service.SetCKSRepository(repoManager.CksRepository, *cks) // 迅雷需要初始化 token 后才能获取,
// 根据服务类型调用不同的GetUserInfo方法
switch s := service.(type) {
case *panutils.XunleiPanService:
// 迅雷网盘使用存储在extra中的token不需要传递ck参数
userInfo, err = s.GetUserInfo(nil)
default:
// 其他网盘使用ck参数
userInfo, err = service.GetUserInfo(&cks.Ck)
}
if err != nil {
ErrorResponse(c, "无法获取用户信息,刷新失败: "+err.Error(), http.StatusBadRequest)
return
}
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
// 更新账号信息
@@ -322,7 +461,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 {
@@ -335,3 +474,25 @@ func RefreshCapacity(c *gin.Context) {
"cks": converter.ToCksResponse(cks),
})
}
// DeleteRelatedResources 删除关联资源
func DeleteRelatedResources(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
// 调用资源库删除关联资源
affectedRows, err := repoManager.ResourceRepository.DeleteRelatedResources(uint(id))
if err != nil {
ErrorResponse(c, "删除关联资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"message": "关联资源删除成功",
"affected_rows": affectedRows,
})
}

View File

@@ -0,0 +1,312 @@
package handlers
import (
"net/http"
"strconv"
"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/middleware"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
type CopyrightClaimHandler struct {
copyrightClaimRepo repo.CopyrightClaimRepository
resourceRepo repo.ResourceRepository
validate *validator.Validate
}
func NewCopyrightClaimHandler(copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) *CopyrightClaimHandler {
return &CopyrightClaimHandler{
copyrightClaimRepo: copyrightClaimRepo,
resourceRepo: resourceRepo,
validate: validator.New(),
}
}
// CreateCopyrightClaim 创建版权申述
// @Summary 创建版权申述
// @Description 提交资源版权申述
// @Tags CopyrightClaim
// @Accept json
// @Produce json
// @Param request body dto.CopyrightClaimCreateRequest true "版权申述信息"
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims [post]
func (h *CopyrightClaimHandler) CreateCopyrightClaim(c *gin.Context) {
var req dto.CopyrightClaimCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
// 创建版权申述实体
claim := &entity.CopyrightClaim{
ResourceKey: req.ResourceKey,
Identity: req.Identity,
ProofType: req.ProofType,
Reason: req.Reason,
ContactInfo: req.ContactInfo,
ClaimantName: req.ClaimantName,
ProofFiles: req.ProofFiles,
UserAgent: req.UserAgent,
IPAddress: req.IPAddress,
Status: "pending", // 默认为待处理
}
// 保存到数据库
if err := h.copyrightClaimRepo.Create(claim); err != nil {
ErrorResponse(c, "创建版权申述失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 返回响应
response := converter.CopyrightClaimToResponse(claim)
SuccessResponse(c, response)
}
// GetCopyrightClaim 获取版权申述详情
// @Summary 获取版权申述详情
// @Description 根据ID获取版权申述详情
// @Tags CopyrightClaim
// @Produce json
// @Param id path int true "版权申述ID"
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims/{id} [get]
func (h *CopyrightClaimHandler) GetCopyrightClaim(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
claim, err := h.copyrightClaimRepo.GetByID(uint(id))
if err != nil {
if err == gorm.ErrRecordNotFound {
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
return
}
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, converter.CopyrightClaimToResponse(claim))
}
// ListCopyrightClaims 获取版权申述列表
// @Summary 获取版权申述列表
// @Description 获取版权申述列表(支持分页和状态筛选)
// @Tags CopyrightClaim
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query string false "处理状态"
// @Success 200 {object} Response{data=object{items=[]dto.CopyrightClaimResponse,total=int}}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims [get]
func (h *CopyrightClaimHandler) ListCopyrightClaims(c *gin.Context) {
var req dto.CopyrightClaimListRequest
if err := c.ShouldBindQuery(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 设置默认值
if req.Page == 0 {
req.Page = 1
}
if req.PageSize == 0 {
req.PageSize = 10
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
claims, total, err := h.copyrightClaimRepo.List(req.Status, req.Page, req.PageSize)
if err != nil {
ErrorResponse(c, "获取版权申述列表失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 转换为包含资源信息的响应
var responses []*dto.CopyrightClaimResponse
for _, claim := range claims {
// 查询关联的资源信息
resources, err := h.getResourcesByResourceKey(claim.ResourceKey)
if err != nil {
// 如果查询资源失败,使用空资源列表
responses = append(responses, converter.CopyrightClaimToResponse(claim))
} else {
// 使用包含资源详情的转换函数
responses = append(responses, converter.CopyrightClaimToResponseWithResources(claim, resources))
}
}
PageResponse(c, responses, total, req.Page, req.PageSize)
}
// getResourcesByResourceKey 根据资源key获取关联的资源列表
func (h *CopyrightClaimHandler) getResourcesByResourceKey(resourceKey string) ([]*entity.Resource, error) {
// 从资源仓库获取与key关联的所有资源
resources, err := h.resourceRepo.FindByResourceKey(resourceKey)
if err != nil {
return nil, err
}
// 将 []entity.Resource 转换为 []*entity.Resource
var resourcePointers []*entity.Resource
for i := range resources {
resourcePointers = append(resourcePointers, &resources[i])
}
return resourcePointers, nil
}
// UpdateCopyrightClaim 更新版权申述状态
// @Summary 更新版权申述状态
// @Description 更新版权申述处理状态
// @Tags CopyrightClaim
// @Accept json
// @Produce json
// @Param id path int true "版权申述ID"
// @Param request body dto.CopyrightClaimUpdateRequest true "更新信息"
// @Success 200 {object} Response{data=dto.CopyrightClaimResponse}
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims/{id} [put]
func (h *CopyrightClaimHandler) UpdateCopyrightClaim(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
var req dto.CopyrightClaimUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
// 获取当前版权申述
_, err = h.copyrightClaimRepo.GetByID(uint(id))
if err != nil {
if err == gorm.ErrRecordNotFound {
ErrorResponse(c, "版权申述不存在", http.StatusNotFound)
return
}
ErrorResponse(c, "获取版权申述失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 更新状态
processedBy := uint(0) // 从上下文获取当前用户ID如果存在的话
if currentUser := c.GetUint("user_id"); currentUser > 0 {
processedBy = currentUser
}
if err := h.copyrightClaimRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
ErrorResponse(c, "更新版权申述状态失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 获取更新后的版权申述信息
updatedClaim, err := h.copyrightClaimRepo.GetByID(uint(id))
if err != nil {
ErrorResponse(c, "获取更新后版权申述信息失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, converter.CopyrightClaimToResponse(updatedClaim))
}
// DeleteCopyrightClaim 删除版权申述
// @Summary 删除版权申述
// @Description 删除版权申述记录
// @Tags CopyrightClaim
// @Produce json
// @Param id path int true "版权申述ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims/{id} [delete]
func (h *CopyrightClaimHandler) DeleteCopyrightClaim(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
if err := h.copyrightClaimRepo.Delete(uint(id)); err != nil {
ErrorResponse(c, "删除版权申述失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, nil)
}
// GetCopyrightClaimByResource 获取某个资源的版权申述列表
// @Summary 获取资源版权申述列表
// @Description 获取某个资源的所有版权申述记录
// @Tags CopyrightClaim
// @Produce json
// @Param resource_key path string true "资源Key"
// @Success 200 {object} Response{data=[]dto.CopyrightClaimResponse}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /copyright-claims/resource/{resource_key} [get]
func (h *CopyrightClaimHandler) GetCopyrightClaimByResource(c *gin.Context) {
resourceKey := c.Param("resource_key")
if resourceKey == "" {
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
return
}
claims, err := h.copyrightClaimRepo.GetByResourceKey(resourceKey)
if err != nil {
ErrorResponse(c, "获取资源版权申述失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, converter.CopyrightClaimsToResponse(claims))
}
// RegisterCopyrightClaimRoutes 注册版权申述相关路由
func RegisterCopyrightClaimRoutes(router *gin.RouterGroup, copyrightClaimRepo repo.CopyrightClaimRepository, resourceRepo repo.ResourceRepository) {
handler := NewCopyrightClaimHandler(copyrightClaimRepo, resourceRepo)
claims := router.Group("/copyright-claims")
{
claims.POST("", handler.CreateCopyrightClaim) // 创建版权申述
claims.GET("/:id", handler.GetCopyrightClaim) // 获取版权申述详情
claims.GET("", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.ListCopyrightClaims) // 获取版权申述列表
claims.PUT("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.UpdateCopyrightClaim) // 更新版权申述状态
claims.DELETE("/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handler.DeleteCopyrightClaim) // 删除版权申述
claims.GET("/resource/:resource_key", handler.GetCopyrightClaimByResource) // 获取资源版权申述列表
}
}

View File

@@ -440,3 +440,80 @@ func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
// UploadWechatVerifyFile 上传微信公众号验证文件TXT文件
// 无需认证仅支持TXT文件不记录数据库直接保存到uploads目录
func (h *FileHandler) UploadWechatVerifyFile(c *gin.Context) {
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
ErrorResponse(c, "未提供文件", http.StatusBadRequest)
return
}
// 验证文件扩展名必须是.txt
ext := strings.ToLower(filepath.Ext(file.Filename))
if ext != ".txt" {
ErrorResponse(c, "仅支持TXT文件", http.StatusBadRequest)
return
}
// 验证文件大小限制1MB
if file.Size > 1*1024*1024 {
ErrorResponse(c, "文件大小不能超过1MB", http.StatusBadRequest)
return
}
// 生成文件名(使用原始文件名,但确保是安全的)
originalName := filepath.Base(file.Filename)
safeFileName := h.makeSafeFileName(originalName)
// 确保uploads目录存在
uploadsDir := "./uploads"
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
ErrorResponse(c, "创建上传目录失败", http.StatusInternalServerError)
return
}
// 构建完整文件路径
filePath := filepath.Join(uploadsDir, safeFileName)
// 保存文件
if err := c.SaveUploadedFile(file, filePath); err != nil {
ErrorResponse(c, "保存文件失败", http.StatusInternalServerError)
return
}
// 设置文件权限
if err := os.Chmod(filePath, 0644); err != nil {
utils.Warn("设置文件权限失败: %v", err)
}
// 返回成功响应
accessURL := fmt.Sprintf("/%s", safeFileName)
response := map[string]interface{}{
"success": true,
"message": "验证文件上传成功",
"file_name": safeFileName,
"access_url": accessURL,
}
SuccessResponse(c, response)
}
// makeSafeFileName 生成安全的文件名,移除危险字符
func (h *FileHandler) makeSafeFileName(filename string) string {
// 移除路径分隔符和特殊字符
safeName := strings.ReplaceAll(filename, "/", "_")
safeName = strings.ReplaceAll(safeName, "\\", "_")
safeName = strings.ReplaceAll(safeName, "..", "_")
// 限制文件名长度
if len(safeName) > 100 {
ext := filepath.Ext(safeName)
name := safeName[:100-len(ext)]
safeName = name + ext
}
return safeName
}

View File

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

188
handlers/log_handler.go Normal file
View File

@@ -0,0 +1,188 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
// GetSystemLogs 获取系统日志
func GetSystemLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
level := c.Query("level")
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
search := c.Query("search")
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
}
}
// 使用日志查看器获取日志
logViewer := utils.NewLogViewer("logs")
// 获取日志文件列表
logFiles, err := logViewer.GetLogFiles()
if err != nil {
ErrorResponse(c, "获取日志文件失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 如果指定了日期范围,只选择对应日期的日志文件
if startDate != nil || endDate != nil {
var filteredFiles []string
for _, file := range logFiles {
fileInfo, err := utils.GetFileInfo(file)
if err != nil {
continue
}
shouldInclude := true
if startDate != nil {
if fileInfo.ModTime().Before(*startDate) {
shouldInclude = false
}
}
if endDate != nil {
if fileInfo.ModTime().After(*endDate) {
shouldInclude = false
}
}
if shouldInclude {
filteredFiles = append(filteredFiles, file)
}
}
logFiles = filteredFiles
}
// 限制读取的文件数量以提高性能
if len(logFiles) > 10 {
logFiles = logFiles[:10] // 只处理最近的10个文件
}
var allLogs []utils.LogEntry
for _, file := range logFiles {
// 读取日志文件
fileLogs, err := logViewer.ParseLogEntriesFromFile(file, level, search)
if err != nil {
utils.Error("解析日志文件失败 %s: %v", file, err)
continue
}
allLogs = append(allLogs, fileLogs...)
}
// 按时间排序(最新的在前)
utils.SortLogEntriesByTime(allLogs, false)
// 应用分页
start := (page - 1) * pageSize
end := start + pageSize
if start > len(allLogs) {
start = len(allLogs)
}
if end > len(allLogs) {
end = len(allLogs)
}
pagedLogs := allLogs[start:end]
SuccessResponse(c, gin.H{
"data": pagedLogs,
"total": len(allLogs),
"page": page,
"limit": pageSize,
})
}
// GetSystemLogFiles 获取系统日志文件列表
func GetSystemLogFiles(c *gin.Context) {
logViewer := utils.NewLogViewer("logs")
files, err := logViewer.GetLogFiles()
if err != nil {
ErrorResponse(c, "获取日志文件列表失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 获取每个文件的详细信息
var fileInfos []gin.H
for _, file := range files {
info, err := utils.GetFileInfo(file)
if err != nil {
continue
}
fileInfos = append(fileInfos, gin.H{
"name": info.Name(),
"size": info.Size(),
"mod_time": info.ModTime(),
"path": file,
})
}
SuccessResponse(c, gin.H{
"data": fileInfos,
})
}
// GetSystemLogSummary 获取系统日志统计摘要
func GetSystemLogSummary(c *gin.Context) {
logViewer := utils.NewLogViewer("logs")
files, err := logViewer.GetLogFiles()
if err != nil {
ErrorResponse(c, "获取日志文件列表失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 获取统计信息
stats, err := logViewer.GetLogStats(files)
if err != nil {
ErrorResponse(c, "获取日志统计信息失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"summary": stats,
"files_count": len(files),
})
}
// ClearSystemLogs 清理系统日志
func ClearSystemLogs(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
}
logViewer := utils.NewLogViewer("logs")
err = logViewer.CleanOldLogs(days)
if err != nil {
ErrorResponse(c, "清理系统日志失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "系统日志清理成功"})
}

565
handlers/og_image.go Normal file
View File

@@ -0,0 +1,565 @@
package handlers
import (
"bytes"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/ctwj/urldb/utils"
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/gin-gonic/gin"
"github.com/fogleman/gg"
"image/color"
"image"
_ "image/jpeg"
_ "image/png"
"io"
)
// OGImageHandler 处理OG图片生成请求
type OGImageHandler struct{}
// NewOGImageHandler 创建新的OG图片处理器
func NewOGImageHandler() *OGImageHandler {
return &OGImageHandler{}
}
// Resource 简化的资源结构体
type Resource struct {
Title string
Description string
Cover string
Key string
}
// getResourceByKey 通过key获取资源信息
func (h *OGImageHandler) getResourceByKey(key string) (*Resource, error) {
// 这里简化处理,实际应该从数据库查询
// 为了演示,我们先返回一个模拟的资源
// 在实际应用中,您需要连接数据库并查询
// 模拟数据库查询 - 实际应用中请替换为真实的数据库查询
dbInstance := db.DB
if dbInstance == nil {
return nil, fmt.Errorf("数据库连接失败")
}
var resource entity.Resource
result := dbInstance.Where("key = ?", key).First(&resource)
if result.Error != nil {
return nil, result.Error
}
return &Resource{
Title: resource.Title,
Description: resource.Description,
Cover: resource.Cover,
Key: resource.Key,
}, nil
}
// GenerateOGImage 生成OG图片
func (h *OGImageHandler) GenerateOGImage(c *gin.Context) {
// 获取请求参数
key := strings.TrimSpace(c.Query("key"))
title := strings.TrimSpace(c.Query("title"))
description := strings.TrimSpace(c.Query("description"))
siteName := strings.TrimSpace(c.Query("site_name"))
theme := strings.TrimSpace(c.Query("theme"))
coverUrl := strings.TrimSpace(c.Query("cover"))
width, _ := strconv.Atoi(c.Query("width"))
height, _ := strconv.Atoi(c.Query("height"))
// 如果提供了key从数据库获取资源信息
if key != "" {
resource, err := h.getResourceByKey(key)
if err == nil && resource != nil {
if title == "" {
title = resource.Title
}
if description == "" {
description = resource.Description
}
if coverUrl == "" && resource.Cover != "" {
coverUrl = resource.Cover
}
}
}
// 设置默认值
if title == "" {
title = "老九网盘资源数据库"
}
if siteName == "" {
siteName = "老九网盘"
}
if width <= 0 || width > 2000 {
width = 1200
}
if height <= 0 || height > 2000 {
height = 630
}
// 获取当前请求的域名
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
host := c.Request.Host
domain := scheme + "://" + host
// 生成图片
imageBuffer, err := createOGImage(title, description, siteName, theme, width, height, coverUrl, key, domain)
if err != nil {
utils.Error("生成OG图片失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate image: " + err.Error(),
})
return
}
// 返回图片
c.Data(http.StatusOK, "image/png", imageBuffer.Bytes())
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
}
// createOGImage 创建OG图片
func createOGImage(title, description, siteName, theme string, width, height int, coverUrl, key, domain string) (*bytes.Buffer, error) {
dc := gg.NewContext(width, height)
// 设置圆角裁剪区域
cornerRadius := 20.0
dc.DrawRoundedRectangle(0, 0, float64(width), float64(height), cornerRadius)
// 设置背景色
bgColor := getBackgroundColor(theme)
dc.SetColor(bgColor)
dc.Fill()
// 绘制渐变效果
gradient := gg.NewLinearGradient(0, 0, float64(width), float64(height))
gradient.AddColorStop(0, getGradientStartColor(theme))
gradient.AddColorStop(1, getGradientEndColor(theme))
dc.SetFillStyle(gradient)
dc.Fill()
// 定义布局区域
imageAreaWidth := width / 3 // 左侧1/3用于图片
textAreaWidth := width * 2 / 3 // 右侧2/3用于文案
textAreaX := imageAreaWidth // 文案区域起始X坐标
// 统一的字体加载函数,确保中文显示正常
loadChineseFont := func(fontSize float64) bool {
// 优先使用项目字体
if err := dc.LoadFontFace("font/SourceHanSansSC-Regular.otf", fontSize); err == nil {
return true
}
// Windows系统常见字体按优先级顺序尝试
commonFonts := []string{
"C:/Windows/Fonts/msyh.ttc", // 微软雅黑
"C:/Windows/Fonts/simhei.ttf", // 黑体
"C:/Windows/Fonts/simsun.ttc", // 宋体
}
for _, fontPath := range commonFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
return true
}
}
// 如果都失败了,尝试使用粗体版本
boldFonts := []string{
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
"C:/Windows/Fonts/simhei.ttf", // 黑体
}
for _, fontPath := range boldFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
return true
}
}
return false
}
// 加载基础字体24px
fontLoaded := loadChineseFont(24)
dc.SetHexColor("#ffffff")
// 绘制封面图片(如果存在)
if coverUrl != "" {
if err := drawCoverImageInLeftArea(dc, coverUrl, width, height, imageAreaWidth); err != nil {
utils.Error("绘制封面图片失败: %v", err)
}
}
// 设置站点标识
dc.DrawStringAnchored(siteName, float64(textAreaX)+60, 50, 0, 0.5)
// 绘制标题
dc.SetHexColor("#ffffff")
// 标题在右侧区域显示,考虑文案宽度限制
maxTitleWidth := float64(textAreaWidth - 120) // 右侧区域减去左右边距
// 动态调整字体大小以适应文案区域,使用统一的字体加载逻辑
fontSize := 48.0
titleFontLoaded := false
for fontSize > 24 { // 最小字体24
// 优先使用项目粗体字体
if err := dc.LoadFontFace("font/SourceHanSansSC-Bold.otf", fontSize); err == nil {
titleWidth, _ := dc.MeasureString(title)
if titleWidth <= maxTitleWidth {
titleFontLoaded = true
break
}
} else {
// 尝试系统粗体字体
boldFonts := []string{
"C:/Windows/Fonts/msyhbd.ttc", // 微软雅黑粗体
"C:/Windows/Fonts/simhei.ttf", // 黑体
}
for _, fontPath := range boldFonts {
if err := dc.LoadFontFace(fontPath, fontSize); err == nil {
titleWidth, _ := dc.MeasureString(title)
if titleWidth <= maxTitleWidth {
titleFontLoaded = true
break
}
break // 找到可用字体就跳出内层循环
}
}
if titleFontLoaded {
break
}
}
fontSize -= 4
}
// 如果粗体字体都失败了,使用常规字体
if !titleFontLoaded {
loadChineseFont(36) // 使用稍大的常规字体
}
// 标题左对齐显示在右侧区域
titleX := float64(textAreaX) + 60
titleY := float64(height)/2 - 80
dc.DrawString(title, titleX, titleY)
// 绘制描述
if description != "" {
dc.SetHexColor("#e5e7eb")
// 使用统一的字体加载逻辑
loadChineseFont(28)
// 自动换行处理,适配右侧区域宽度
wrappedDesc := wrapText(dc, description, float64(textAreaWidth-120))
descY := titleY + 60 // 标题下方
for i, line := range wrappedDesc {
y := descY + float64(i)*30 // 行高30像素
dc.DrawString(line, titleX, y)
}
}
// 添加装饰性元素
drawDecorativeElements(dc, width, height, theme)
// 绘制底部URL访问地址
if key != "" && domain != "" {
resourceURL := domain + "/r/" + key
dc.SetHexColor("#d1d5db") // 浅灰色
// 使用统一的字体加载逻辑
loadChineseFont(20)
// URL位置底部居中距离底部边缘40像素给更多空间
urlY := float64(height) - 40
dc.DrawStringAnchored(resourceURL, float64(width)/2, urlY, 0.5, 0.5)
}
// 添加调试信息(仅在开发环境)
if title == "DEBUG" {
dc.SetHexColor("#ff0000")
dc.DrawString("Font loaded: "+strconv.FormatBool(fontLoaded), 50, float64(height)-80)
}
// 生成图片
buf := &bytes.Buffer{}
err := dc.EncodePNG(buf)
if err != nil {
return nil, err
}
return buf, nil
}
// getBackgroundColor 获取背景色
func getBackgroundColor(theme string) color.RGBA {
switch theme {
case "dark":
return color.RGBA{31, 41, 55, 255} // slate-800
case "blue":
return color.RGBA{29, 78, 216, 255} // blue-700
case "green":
return color.RGBA{6, 95, 70, 255} // emerald-800
case "purple":
return color.RGBA{109, 40, 217, 255} // violet-700
default:
return color.RGBA{55, 65, 81, 255} // gray-800
}
}
// getGradientStartColor 获取渐变起始色
func getGradientStartColor(theme string) color.Color {
switch theme {
case "dark":
return color.RGBA{15, 23, 42, 255} // slate-900
case "blue":
return color.RGBA{30, 58, 138, 255} // blue-900
case "green":
return color.RGBA{6, 78, 59, 255} // emerald-900
case "purple":
return color.RGBA{91, 33, 182, 255} // violet-800
default:
return color.RGBA{31, 41, 55, 255} // gray-800
}
}
// getGradientEndColor 获取渐变结束色
func getGradientEndColor(theme string) color.Color {
switch theme {
case "dark":
return color.RGBA{55, 65, 81, 255} // slate-700
case "blue":
return color.RGBA{59, 130, 246, 255} // blue-500
case "green":
return color.RGBA{16, 185, 129, 255} // emerald-500
case "purple":
return color.RGBA{139, 92, 246, 255} // violet-500
default:
return color.RGBA{75, 85, 99, 255} // gray-600
}
}
// wrapText 文本自动换行处理
func wrapText(dc *gg.Context, text string, maxWidth float64) []string {
var lines []string
words := []rune(text)
currentLine := ""
for _, word := range words {
testLine := currentLine + string(word)
width, _ := dc.MeasureString(testLine)
if width > maxWidth && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = string(word)
} else {
currentLine = testLine
}
}
if currentLine != "" {
lines = append(lines, currentLine)
}
// 最多显示3行
if len(lines) > 3 {
lines = lines[:3]
// 在最后一行添加省略号
if len(lines[2]) > 3 {
lines[2] = lines[2][:len(lines[2])-3] + "..."
}
}
return lines
}
// drawDecorativeElements 绘制装饰性元素
func drawDecorativeElements(dc *gg.Context, width, height int, theme string) {
// 绘制装饰性圆点
dc.SetHexColor("#ffffff")
dc.SetLineWidth(2)
for i := 0; i < 5; i++ {
x := float64(100 + i*150)
y := float64(100 + (i%2)*200)
dc.DrawCircle(x, y, 8)
dc.Stroke()
}
// 绘制底部装饰线
dc.DrawLine(60, float64(height-80), float64(width-60), float64(height-80))
dc.Stroke()
}
// drawCoverImageInLeftArea 在左侧1/3区域绘制封面图片
func drawCoverImageInLeftArea(dc *gg.Context, coverUrl string, width, height int, imageAreaWidth int) error {
// 下载封面图片
resp, err := http.Get(coverUrl)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取图片数据
imgData, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 解码图片
img, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return err
}
// 获取图片尺寸和宽高比
bounds := img.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
aspectRatio := float64(imgWidth) / float64(imgHeight)
// 计算图片区域的可显示尺寸,留出边距
padding := 40
maxImageWidth := imageAreaWidth - padding*2
maxImageHeight := height - padding*2
var scaledImg image.Image
var drawWidth, drawHeight, drawX, drawY int
// 判断是竖图还是横图,采用不同的缩放策略
if aspectRatio < 1.0 {
// 竖图:充满整个左侧区域(去掉边距)
drawHeight = height - padding*2 // 留上下边距
drawWidth = int(float64(drawHeight) * aspectRatio)
// 如果宽度超出左侧区域,则以宽度为准充满整个区域宽度
if drawWidth > imageAreaWidth - padding*2 {
drawWidth = imageAreaWidth - padding*2
drawHeight = int(float64(drawWidth) / aspectRatio)
}
// 缩放图片
scaledImg = scaleImage(img, drawWidth, drawHeight)
// 垂直居中,水平居左
drawX = padding
drawY = (height - drawHeight) / 2
} else {
// 横图:优先占满宽度
drawWidth = maxImageWidth
drawHeight = int(float64(drawWidth) / aspectRatio)
// 如果高度超出限制,则以高度为准
if drawHeight > maxImageHeight {
drawHeight = maxImageHeight
drawWidth = int(float64(drawHeight) * aspectRatio)
}
// 缩放图片
scaledImg = scaleImage(img, drawWidth, drawHeight)
// 水平居中,垂直居中
drawX = (imageAreaWidth - drawWidth) / 2
drawY = (height - drawHeight) / 2
}
// 绘制图片
dc.DrawImage(scaledImg, drawX, drawY)
// 添加半透明遮罩效果,让文字更清晰(仅在有图片时添加)
maskColor := color.RGBA{0, 0, 0, 80} // 半透明黑色,透明度稍低
dc.SetColor(maskColor)
dc.DrawRectangle(float64(drawX), float64(drawY), float64(drawWidth), float64(drawHeight))
dc.Fill()
return nil
}
// scaleImage 图片缩放函数
func scaleImage(img image.Image, width, height int) image.Image {
// 使用 gg 库的 Scale 变换来实现缩放
srcWidth := img.Bounds().Dx()
srcHeight := img.Bounds().Dy()
// 创建目标尺寸的画布
dc := gg.NewContext(width, height)
// 计算缩放比例
scaleX := float64(width) / float64(srcWidth)
scaleY := float64(height) / float64(srcHeight)
// 应用缩放变换并绘制图片
dc.Scale(scaleX, scaleY)
dc.DrawImage(img, 0, 0)
return dc.Image()
}
// drawCoverImage 绘制封面图片(保留原函数作为备用)
func drawCoverImage(dc *gg.Context, coverUrl string, width, height int) error {
// 下载封面图片
resp, err := http.Get(coverUrl)
if err != nil {
return err
}
defer resp.Body.Close()
// 读取图片数据
imgData, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// 解码图片
img, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return err
}
// 计算封面图片的位置和大小,放置在左侧
coverWidth := 200 // 封面图宽度
coverHeight := 280 // 封面图高度
coverX := 50
coverY := (height - coverHeight) / 2
// 绘制封面图片(按比例缩放)
bounds := img.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
// 计算缩放比例,保持宽高比
scaleX := float64(coverWidth) / float64(imgWidth)
scaleY := float64(coverHeight) / float64(imgHeight)
scale := scaleX
if scaleY < scaleX {
scale = scaleY
}
// 计算缩放后的尺寸
newWidth := int(float64(imgWidth) * scale)
newHeight := int(float64(imgHeight) * scale)
// 居中绘制
offsetX := coverX + (coverWidth-newWidth)/2
offsetY := coverY + (coverHeight-newHeight)/2
dc.DrawImage(img, offsetX, offsetY)
// 添加半透明遮罩效果,让文字更清晰
maskColor := color.RGBA{0, 0, 0, 120} // 半透明黑色
dc.SetColor(maskColor)
dc.DrawRectangle(float64(coverX), float64(coverY), float64(coverWidth), float64(coverHeight))
dc.Fill()
return nil
}

View File

@@ -1,8 +1,10 @@
package handlers
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
@@ -69,6 +71,32 @@ 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访问日志 - 使用简单日志记录
h.recordAPIAccessToDB(ip, userAgent, endpoint, method, requestParams,
c.Writer.Status(), responseData, processCount, errorMessage, processingTime)
}
// AddBatchResources godoc
// @Summary 批量添加资源
// @Description 通过公开API批量添加多个资源到待处理列表
@@ -83,17 +111,28 @@ 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
}
// 记录API访问安全日志
clientIP := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
utils.Info("PublicAPI.AddBatchResources - API访问 - IP: %s, UserAgent: %s, 资源数量: %d", clientIP, userAgent, len(req.Resources))
// 收集所有待提交的URL去重
urlSet := make(map[string]struct{})
for _, resource := range req.Resources {
@@ -125,6 +164,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 +196,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,7 +221,11 @@ 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()
// 记录API访问安全日志
clientIP := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
keyword := c.Query("keyword")
tag := c.Query("tag")
category := c.Query("category")
@@ -187,6 +233,9 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
utils.Info("PublicAPI.SearchResources - API访问 - IP: %s, UserAgent: %s, Keyword: %s, Tag: %s, Category: %s, PanID: %s",
clientIP, userAgent, keyword, tag, category, panID)
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
@@ -236,6 +285,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,6 +326,7 @@ 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
}
@@ -304,6 +355,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
"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, // 添加封面字段
}
// 添加违禁词标记
@@ -314,12 +366,13 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
// 构建响应数据
responseData := gin.H{
"data": resourceResponses,
"list": resourceResponses,
"total": total,
"page": page,
"page_size": pageSize,
"limit": pageSize,
}
h.logAPIAccess(c, startTime, len(resourceResponses), responseData, "")
SuccessResponse(c, responseData)
}
@@ -337,9 +390,16 @@ 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()
// 记录API访问安全日志
clientIP := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
utils.Info("PublicAPI.GetHotDramas - API访问 - IP: %s, UserAgent: %s", clientIP, userAgent)
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
@@ -353,6 +413,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
}
@@ -376,10 +437,65 @@ 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)
}
// recordAPIAccessToDB 记录API访问日志到数据库
func (h *PublicAPIHandler) recordAPIAccessToDB(ip, userAgent, endpoint, method string,
requestParams interface{}, responseStatus int, responseData interface{},
processCount int, errorMessage string, processingTime int64) {
// 判断是否为关键端点,需要强制记录日志
isKeyEndpoint := strings.Contains(endpoint, "/api/public/resources/batch-add") ||
strings.Contains(endpoint, "/api/admin/") ||
strings.Contains(endpoint, "/telegram/webhook") ||
strings.Contains(endpoint, "/api/public/resources/search") ||
strings.Contains(endpoint, "/api/public/hot-drama")
// 只记录重要的API访问有错误或处理时间较长的或者是关键端点
if errorMessage == "" && processingTime < 1000 && responseStatus < 400 && !isKeyEndpoint {
return // 跳过正常的快速请求,但记录关键端点
}
// 转换参数为JSON字符串
var requestParamsStr, responseDataStr string
if requestParams != nil {
if jsonBytes, err := json.Marshal(requestParams); err == nil {
requestParamsStr = string(jsonBytes)
}
}
if responseData != nil {
if jsonBytes, err := json.Marshal(responseData); err == nil {
responseDataStr = string(jsonBytes)
}
}
// 创建日志记录
logEntry := &entity.APIAccessLog{
IP: ip,
UserAgent: userAgent,
Endpoint: endpoint,
Method: method,
RequestParams: requestParamsStr,
ResponseStatus: responseStatus,
ResponseData: responseDataStr,
ProcessCount: processCount,
ErrorMessage: errorMessage,
ProcessingTime: processingTime,
}
// 异步保存到数据库避免影响API性能
go func() {
if err := repoManager.APIAccessLogRepository.Create(logEntry); err != nil {
// 记录失败只输出到系统日志不影响API
utils.Error("保存API访问日志失败: %v", err)
}
}()
}

310
handlers/report_handler.go Normal file
View File

@@ -0,0 +1,310 @@
package handlers
import (
"net/http"
"strconv"
"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/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
type ReportHandler struct {
reportRepo repo.ReportRepository
resourceRepo repo.ResourceRepository
validate *validator.Validate
}
func NewReportHandler(reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) *ReportHandler {
return &ReportHandler{
reportRepo: reportRepo,
resourceRepo: resourceRepo,
validate: validator.New(),
}
}
// CreateReport 创建举报
// @Summary 创建举报
// @Description 提交资源举报
// @Tags Report
// @Accept json
// @Produce json
// @Param request body dto.ReportCreateRequest true "举报信息"
// @Success 200 {object} Response{data=dto.ReportResponse}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /reports [post]
func (h *ReportHandler) CreateReport(c *gin.Context) {
var req dto.ReportCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
// 创建举报实体
report := &entity.Report{
ResourceKey: req.ResourceKey,
Reason: req.Reason,
Description: req.Description,
Contact: req.Contact,
UserAgent: req.UserAgent,
IPAddress: req.IPAddress,
Status: "pending", // 默认为待处理
}
// 保存到数据库
if err := h.reportRepo.Create(report); err != nil {
ErrorResponse(c, "创建举报失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 返回响应
response := converter.ReportToResponse(report)
SuccessResponse(c, response)
}
// GetReport 获取举报详情
// @Summary 获取举报详情
// @Description 根据ID获取举报详情
// @Tags Report
// @Produce json
// @Param id path int true "举报ID"
// @Success 200 {object} Response{data=dto.ReportResponse}
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /reports/{id} [get]
func (h *ReportHandler) GetReport(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
report, err := h.reportRepo.GetByID(uint(id))
if err != nil {
if err == gorm.ErrRecordNotFound {
ErrorResponse(c, "举报不存在", http.StatusNotFound)
return
}
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
return
}
response := converter.ReportToResponse(report)
SuccessResponse(c, response)
}
// ListReports 获取举报列表
// @Summary 获取举报列表
// @Description 获取举报列表(支持分页和状态筛选)
// @Tags Report
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query string false "处理状态"
// @Success 200 {object} Response{data=object{items=[]dto.ReportResponse,total=int}}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /reports [get]
func (h *ReportHandler) ListReports(c *gin.Context) {
var req dto.ReportListRequest
if err := c.ShouldBindQuery(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 设置默认值
if req.Page == 0 {
req.Page = 1
}
if req.PageSize == 0 {
req.PageSize = 10
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
reports, total, err := h.reportRepo.List(req.Status, req.Page, req.PageSize)
if err != nil {
ErrorResponse(c, "获取举报列表失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 获取每个举报关联的资源
var reportResponses []*dto.ReportResponse
for _, report := range reports {
// 通过资源key查找关联的资源
resources, err := h.getResourcesByResourceKey(report.ResourceKey)
if err != nil {
// 如果获取资源失败,仍然返回基本的举报信息
reportResponses = append(reportResponses, converter.ReportToResponse(report))
} else {
// 使用包含资源详情的转换函数
response := converter.ReportToResponseWithResources(report, resources)
reportResponses = append(reportResponses, response)
}
}
PageResponse(c, reportResponses, total, req.Page, req.PageSize)
}
// getResourcesByResourceKey 根据资源key获取关联的资源列表
func (h *ReportHandler) getResourcesByResourceKey(resourceKey string) ([]*entity.Resource, error) {
// 从资源仓库获取与key关联的所有资源
resources, err := h.resourceRepo.FindByResourceKey(resourceKey)
if err != nil {
return nil, err
}
// 将 []entity.Resource 转换为 []*entity.Resource
var resourcePointers []*entity.Resource
for i := range resources {
resourcePointers = append(resourcePointers, &resources[i])
}
return resourcePointers, nil
}
// UpdateReport 更新举报状态
// @Summary 更新举报状态
// @Description 更新举报处理状态
// @Tags Report
// @Accept json
// @Produce json
// @Param id path int true "举报ID"
// @Param request body dto.ReportUpdateRequest true "更新信息"
// @Success 200 {object} Response{data=dto.ReportResponse}
// @Failure 400 {object} Response
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /reports/{id} [put]
func (h *ReportHandler) UpdateReport(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
var req dto.ReportUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 验证参数
if err := h.validate.Struct(req); err != nil {
ErrorResponse(c, "参数验证失败: "+err.Error(), http.StatusBadRequest)
return
}
// 获取当前举报
_, err = h.reportRepo.GetByID(uint(id))
if err != nil {
if err == gorm.ErrRecordNotFound {
ErrorResponse(c, "举报不存在", http.StatusNotFound)
return
}
ErrorResponse(c, "获取举报失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 更新状态
processedBy := uint(0) // 从上下文获取当前用户ID如果存在的话
if currentUser := c.GetUint("user_id"); currentUser > 0 {
processedBy = currentUser
}
if err := h.reportRepo.UpdateStatus(uint(id), req.Status, &processedBy, req.Note); err != nil {
ErrorResponse(c, "更新举报状态失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 获取更新后的举报信息
updatedReport, err := h.reportRepo.GetByID(uint(id))
if err != nil {
ErrorResponse(c, "获取更新后举报信息失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, converter.ReportToResponse(updatedReport))
}
// DeleteReport 删除举报
// @Summary 删除举报
// @Description 删除举报记录
// @Tags Report
// @Produce json
// @Param id path int true "举报ID"
// @Success 200 {object} Response
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /reports/{id} [delete]
func (h *ReportHandler) DeleteReport(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
if err := h.reportRepo.Delete(uint(id)); err != nil {
ErrorResponse(c, "删除举报失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, nil)
}
// GetReportByResource 获取某个资源的举报列表
// @Summary 获取资源举报列表
// @Description 获取某个资源的所有举报记录
// @Tags Report
// @Produce json
// @Param resource_key path string true "资源Key"
// @Success 200 {object} Response{data=[]dto.ReportResponse}
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /reports/resource/{resource_key} [get]
func (h *ReportHandler) GetReportByResource(c *gin.Context) {
resourceKey := c.Param("resource_key")
if resourceKey == "" {
ErrorResponse(c, "资源Key不能为空", http.StatusBadRequest)
return
}
reports, err := h.reportRepo.GetByResourceKey(resourceKey)
if err != nil {
ErrorResponse(c, "获取资源举报失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, converter.ReportsToResponse(reports))
}
// RegisterReportRoutes 注册举报相关路由
func RegisterReportRoutes(router *gin.RouterGroup, reportRepo repo.ReportRepository, resourceRepo repo.ResourceRepository) {
handler := NewReportHandler(reportRepo, resourceRepo)
reports := router.Group("/reports")
{
reports.POST("", handler.CreateReport) // 创建举报
reports.GET("/:id", handler.GetReport) // 获取举报详情
reports.GET("", handler.ListReports) // 获取举报列表
reports.PUT("/:id", handler.UpdateReport) // 更新举报状态
reports.DELETE("/:id", handler.DeleteReport) // 删除举报
reports.GET("/resource/:resource_key", handler.GetReportByResource) // 获取资源举报列表
}
}

View File

@@ -4,8 +4,11 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
pan "github.com/ctwj/urldb/common"
panutils "github.com/ctwj/urldb/common"
commonutils "github.com/ctwj/urldb/common/utils"
"github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/db/dto"
@@ -143,6 +146,12 @@ func GetResources(c *gin.Context) {
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
}
@@ -156,6 +165,7 @@ func GetResources(c *gin.Context) {
resourceResponse := gin.H{
"id": processedResource.ID,
"key": processedResource.Key, // 添加key字段
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
"url": processedResource.URL,
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
@@ -169,6 +179,21 @@ func GetResources(c *gin.Context) {
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)
}
@@ -202,6 +227,51 @@ func GetResourceByID(c *gin.Context) {
SuccessResponse(c, response)
}
// GetResourcesByKey 根据Key获取资源组
func GetResourcesByKey(c *gin.Context) {
key := c.Param("key")
if key == "" {
ErrorResponse(c, "Key参数不能为空", http.StatusBadRequest)
return
}
resources, err := repoManager.ResourceRepository.FindByKey(key)
if err != nil {
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
if len(resources) == 0 {
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
// 转换为响应格式并处理违禁词
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{}
}
var responses []dto.ResourceResponse
for _, resource := range resources {
response := converter.ToResourceResponse(&resource)
// 检查违禁词
forbiddenInfo := utils.CheckResourceForbiddenWords(response.Title, response.Description, cleanWords)
response.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
response.ForbiddenWords = forbiddenInfo.ForbiddenWords
responses = append(responses, response)
}
SuccessResponse(c, gin.H{
"resources": responses,
"total": len(responses),
"key": key,
})
}
// CheckResourceExists 检查资源是否存在测试FindExists函数
func CheckResourceExists(c *gin.Context) {
url := c.Query("url")
@@ -374,12 +444,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": "资源删除成功"})
}
@@ -492,12 +579,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": "批量删除成功"})
}
@@ -549,8 +663,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",
@@ -560,9 +674,6 @@ func GetResourceLink(c *gin.Context) {
return
}
// 夸克资源处理逻辑
utils.Info("夸克资源处理开始")
// 如果已存在转存链接,直接返回
if resource.SaveURL != "" {
utils.Info("已存在转存链接,直接返回: %s", resource.SaveURL)
@@ -630,6 +741,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"`
}
@@ -638,18 +750,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{
@@ -658,6 +763,7 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
}
}
// 测试阶段,移除最小限制
// 获取最小存储空间配置
autoTransferMinSpace, err := repoManager.SystemConfigRepository.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
@@ -669,23 +775,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()
@@ -696,6 +803,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)
@@ -729,6 +838,9 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
}
}
// 设置账号信息
service.SetCKSRepository(repoManager.CksRepository, account)
// 提取分享ID
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
if shareID == "" {
@@ -739,7 +851,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{
@@ -762,10 +874,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
@@ -783,9 +900,595 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
return TransferResult{
Success: true,
SaveURL: saveURL,
Fid: fid,
}
}
// GetHotResources 获取热门资源
func GetHotResources(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
utils.Info("获取热门资源请求 - limit: %d", limit)
// 限制最大请求数量
if limit > 20 {
limit = 20
}
if limit <= 0 {
limit = 10
}
// 使用公共缓存机制
cacheKey := fmt.Sprintf("hot_resources_%d", limit)
ttl := time.Hour // 1小时缓存
cacheManager := utils.GetHotResourcesCache()
// 尝试从缓存获取
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
utils.Info("使用热门资源缓存 - key: %s", cacheKey)
c.Header("Cache-Control", "public, max-age=3600")
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(cachedData.([]gin.H))))
// 转换为正确的类型
if data, ok := cachedData.([]gin.H); ok {
SuccessResponse(c, gin.H{
"data": data,
"total": len(data),
"limit": limit,
"cached": true,
})
}
return
}
// 缓存未命中,从数据库获取
resources, err := repoManager.ResourceRepository.GetHotResources(limit)
if err != nil {
utils.Error("获取热门资源失败: %v", err)
ErrorResponse(c, "获取热门资源失败", http.StatusInternalServerError)
return
}
// 获取违禁词配置
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{}
}
// 处理违禁词并转换为响应格式
var resourceResponses []gin.H
for _, resource := range resources {
// 检查违禁词
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
resourceResponse := gin.H{
"id": resource.ID,
"key": resource.Key,
"title": forbiddenInfo.ProcessedTitle,
"url": resource.URL,
"description": forbiddenInfo.ProcessedDesc,
"pan_id": resource.PanID,
"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"),
"cover": resource.Cover,
"author": resource.Author,
"file_size": resource.FileSize,
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
// 添加标签信息
var tagResponses []gin.H
if len(resource.Tags) > 0 {
for _, tag := range resource.Tags {
tagResponse := gin.H{
"id": tag.ID,
"name": tag.Name,
"description": tag.Description,
}
tagResponses = append(tagResponses, tagResponse)
}
}
resourceResponse["tags"] = tagResponses
resourceResponses = append(resourceResponses, resourceResponse)
}
// 存储到缓存
cacheManager.Set(cacheKey, resourceResponses)
utils.Info("热门资源已缓存 - key: %s, count: %d", cacheKey, len(resourceResponses))
// 设置缓存头
c.Header("Cache-Control", "public, max-age=3600")
c.Header("ETag", fmt.Sprintf("hot-resources-%d", len(resourceResponses)))
SuccessResponse(c, gin.H{
"data": resourceResponses,
"total": len(resourceResponses),
"limit": limit,
"cached": false,
})
}
// GetRelatedResources 获取相关资源
func GetRelatedResources(c *gin.Context) {
// 获取查询参数
key := c.Query("key") // 当前资源的key
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
utils.Info("获取相关资源请求 - key: %s, limit: %d", key, limit)
if key == "" {
ErrorResponse(c, "缺少资源key参数", http.StatusBadRequest)
return
}
// 首先通过key获取当前资源信息
currentResources, err := repoManager.ResourceRepository.FindByKey(key)
if err != nil {
utils.Error("获取当前资源失败: %v", err)
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
if len(currentResources) == 0 {
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
currentResource := &currentResources[0] // 取第一个资源作为当前资源
var resources []entity.Resource
var total int64
// 获取当前资源的标签ID列表
var tagIDsList []string
if currentResource.Tags != nil {
for _, tag := range currentResource.Tags {
tagIDsList = append(tagIDsList, strconv.Itoa(int(tag.ID)))
}
}
utils.Info("当前资源标签: %v", tagIDsList)
// 1. 优先使用Meilisearch进行标签搜索
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
service := meilisearchManager.GetService()
if service != nil {
// 使用标签进行搜索
filters := make(map[string]interface{})
filters["tag_ids"] = tagIDsList
// 使用当前资源的标题作为搜索关键词,提高相关性
searchQuery := currentResource.Title
if searchQuery == "" {
searchQuery = strings.Join(tagIDsList, " ") // 如果没有标题,使用标签作为搜索词
}
docs, docTotal, err := service.Search(searchQuery, filters, page, limit)
if err == nil && len(docs) > 0 {
// 转换为Resource实体
for _, doc := range docs {
// 排除当前资源
if doc.Key == key {
continue
}
resource := entity.Resource{
ID: doc.ID,
Title: doc.Title,
Description: doc.Description,
URL: doc.URL,
SaveURL: doc.SaveURL,
FileSize: doc.FileSize,
Key: doc.Key,
PanID: doc.PanID,
ViewCount: 0, // Meilisearch文档中没有ViewCount字段设为默认值
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
Cover: doc.Cover,
Author: doc.Author,
}
resources = append(resources, resource)
}
total = docTotal
utils.Info("Meilisearch搜索到 %d 个相关资源", len(resources))
} else {
utils.Error("Meilisearch搜索失败回退到标签搜索: %v", err)
}
}
}
// 2. 如果Meilisearch未启用、搜索失败或没有结果使用数据库标签搜索
if len(resources) == 0 {
params := map[string]interface{}{
"page": page,
"page_size": limit,
"is_public": true,
"order_by": "updated_at",
"order_dir": "desc",
}
// 使用当前资源的标签进行搜索
if len(tagIDsList) > 0 {
params["tag_ids"] = strings.Join(tagIDsList, ",")
} else {
// 如果没有标签,使用当前资源的分类作为搜索条件
if currentResource.CategoryID != nil && *currentResource.CategoryID > 0 {
params["category_id"] = *currentResource.CategoryID
}
}
var err error
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
utils.Error("搜索相关资源失败: %v", err)
ErrorResponse(c, "搜索相关资源失败", http.StatusInternalServerError)
return
}
// 排除当前资源
var filteredResources []entity.Resource
for _, resource := range resources {
if resource.Key != key {
filteredResources = append(filteredResources, resource)
}
}
resources = filteredResources
total = int64(len(filteredResources))
}
utils.Info("标签搜索到 %d 个相关资源", len(resources))
// 获取违禁词配置
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{}
}
// 处理违禁词并转换为响应格式
var resourceResponses []gin.H
for _, resource := range resources {
// 检查违禁词
forbiddenInfo := utils.CheckResourceForbiddenWords(resource.Title, resource.Description, cleanWords)
resourceResponse := gin.H{
"id": resource.ID,
"key": resource.Key,
"title": forbiddenInfo.ProcessedTitle,
"url": resource.URL,
"description": forbiddenInfo.ProcessedDesc,
"pan_id": resource.PanID,
"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"),
"cover": resource.Cover,
"author": resource.Author,
"file_size": resource.FileSize,
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
// 添加标签信息
var tagResponses []gin.H
if len(resource.Tags) > 0 {
for _, tag := range resource.Tags {
tagResponse := gin.H{
"id": tag.ID,
"name": tag.Name,
"description": tag.Description,
}
tagResponses = append(tagResponses, tagResponse)
}
}
resourceResponse["tags"] = tagResponses
resourceResponses = append(resourceResponses, resourceResponse)
}
// 构建响应数据
responseData := gin.H{
"data": resourceResponses,
"total": total,
"page": page,
"page_size": limit,
"source": "database",
}
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(tagIDsList) > 0 {
responseData["source"] = "meilisearch"
}
SuccessResponse(c, responseData)
}
// CheckResourceValidity 检查资源链接有效性
func CheckResourceValidity(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
return
}
// 查询资源信息
resource, err := repoManager.ResourceRepository.FindByID(uint(id))
if err != nil {
ErrorResponse(c, "资源不存在", http.StatusNotFound)
return
}
utils.Info("开始检测资源有效性 - ID: %d, URL: %s", resource.ID, resource.URL)
// 检查缓存
cacheKey := fmt.Sprintf("resource_validity_%d", resource.ID)
cacheManager := utils.GetResourceValidityCache()
ttl := 5 * time.Minute // 5分钟缓存
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
if result, ok := cachedData.(gin.H); ok {
utils.Info("使用资源有效性缓存 - ID: %d", resource.ID)
result["cached"] = true
SuccessResponse(c, result)
return
}
}
// 执行检测:只使用深度检测实现
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
if err != nil {
utils.Error("深度检测资源链接失败 - ID: %d, Error: %v", resource.ID, err)
// 深度检测失败,但不标记为无效(用户可自行验证)
result := gin.H{
"resource_id": resource.ID,
"url": resource.URL,
"is_valid": resource.IsValid, // 保持原始状态
"last_checked": time.Now().Format(time.RFC3339),
"error": err.Error(),
"detection_method": detectionMethod,
"cached": false,
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
}
cacheManager.Set(cacheKey, result)
SuccessResponse(c, result)
return
}
// 只有明确检测出无效的资源才更新数据库状态
// 如果检测成功且结果与数据库状态不同,则更新
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
resource.IsValid = isValid
updateErr := repoManager.ResourceRepository.Update(resource)
if updateErr != nil {
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", resource.ID, updateErr)
} else {
utils.Info("更新资源有效性状态 - ID: %d, Status: %v, Method: %s", resource.ID, isValid, detectionMethod)
}
}
// 构建检测结果
result := gin.H{
"resource_id": resource.ID,
"url": resource.URL,
"is_valid": isValid,
"last_checked": time.Now().Format(time.RFC3339),
"detection_method": detectionMethod,
"cached": false,
}
// 缓存检测结果
cacheManager.Set(cacheKey, result)
utils.Info("资源有效性检测完成 - ID: %d, Valid: %v, Method: %s", resource.ID, isValid, detectionMethod)
SuccessResponse(c, result)
}
// performAdvancedValidityCheck 执行深度检测(只使用具体网盘服务)
func performAdvancedValidityCheck(resource *entity.Resource) (bool, string, error) {
// 提取分享ID和服务类型
shareID, serviceType := panutils.ExtractShareId(resource.URL)
if serviceType == panutils.NotFound {
return false, "unsupported", fmt.Errorf("不支持的网盘服务: %s", resource.URL)
}
utils.Info("开始深度检测 - Service: %s, ShareID: %s", serviceType.String(), shareID)
// 根据服务类型选择检测策略
switch serviceType {
case panutils.Quark:
return performQuarkValidityCheck(resource, shareID)
case panutils.Alipan:
return performAlipanValidityCheck(resource, shareID)
case panutils.BaiduPan, panutils.UC, panutils.Xunlei, panutils.Tianyi, panutils.Pan123, panutils.Pan115:
// 这些网盘暂未实现深度检测,返回不支持提示
return false, "unsupported", fmt.Errorf("当前网盘类型 %s 暂不支持深度检测,请等待后续更新", serviceType.String())
default:
return false, "unsupported", fmt.Errorf("未知的网盘服务类型: %s", serviceType.String())
}
}
// performQuarkValidityCheck 夸克网盘深度检测
func performQuarkValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
// 获取夸克网盘账号
panID, err := getQuarkPanID()
if err != nil {
return false, "quark_failed", fmt.Errorf("获取夸克平台ID失败: %v", err)
}
accounts, err := repoManager.CksRepository.FindByPanID(panID)
if err != nil {
return false, "quark_failed", fmt.Errorf("获取夸克网盘账号失败: %v", err)
}
if len(accounts) == 0 {
return false, "quark_failed", fmt.Errorf("没有可用的夸克网盘账号")
}
// 选择第一个有效账号
var selectedAccount *entity.Cks
for _, account := range accounts {
if account.IsValid {
selectedAccount = &account
break
}
}
if selectedAccount == nil {
return false, "quark_failed", fmt.Errorf("没有有效的夸克网盘账号")
}
// 创建网盘服务配置
config := &pan.PanConfig{
URL: resource.URL,
Code: "",
IsType: 1, // 只获取基本信息,不转存
ExpiredType: 1,
AdFid: "",
Stoken: "",
Cookie: selectedAccount.Ck,
}
// 创建夸克网盘服务
factory := pan.NewPanFactory()
panService, err := factory.CreatePanService(resource.URL, config)
if err != nil {
return false, "quark_failed", fmt.Errorf("创建夸克网盘服务失败: %v", err)
}
// 执行深度检测Transfer方法
utils.Info("执行夸克网盘深度检测 - ShareID: %s", shareID)
result, err := panService.Transfer(shareID)
if err != nil {
return false, "quark_failed", fmt.Errorf("夸克网盘检测失败: %v", err)
}
if !result.Success {
return false, "quark_failed", fmt.Errorf("夸克网盘链接无效: %s", result.Message)
}
utils.Info("夸克网盘深度检测成功 - ShareID: %s", shareID)
return true, "quark_deep", nil
}
// performAlipanValidityCheck 阿里云盘深度检测
func performAlipanValidityCheck(resource *entity.Resource, shareID string) (bool, string, error) {
// 阿里云盘深度检测暂未实现
utils.Info("阿里云盘暂不支持深度检测 - ShareID: %s", shareID)
return false, "unsupported", fmt.Errorf("阿里云盘暂不支持深度检测,请等待后续更新")
}
// BatchCheckResourceValidity 批量检查资源链接有效性
func BatchCheckResourceValidity(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
ErrorResponse(c, "ID列表不能为空", http.StatusBadRequest)
return
}
if len(req.IDs) > 20 {
ErrorResponse(c, "单次最多检测20个资源", http.StatusBadRequest)
return
}
utils.Info("开始批量检测资源有效性 - Count: %d", len(req.IDs))
cacheManager := utils.GetResourceValidityCache()
ttl := 5 * time.Minute
results := make([]gin.H, 0, len(req.IDs))
for _, id := range req.IDs {
// 查询资源信息
resource, err := repoManager.ResourceRepository.FindByID(id)
if err != nil {
results = append(results, gin.H{
"resource_id": id,
"is_valid": false,
"error": "资源不存在",
"cached": false,
})
continue
}
// 检查缓存
cacheKey := fmt.Sprintf("resource_validity_%d", id)
if cachedData, found := cacheManager.Get(cacheKey, ttl); found {
if result, ok := cachedData.(gin.H); ok {
result["cached"] = true
results = append(results, result)
continue
}
}
// 执行深度检测
isValid, detectionMethod, err := performAdvancedValidityCheck(resource)
if err != nil {
// 深度检测失败,但不标记为无效(用户可自行验证)
result := gin.H{
"resource_id": id,
"url": resource.URL,
"is_valid": resource.IsValid, // 保持原始状态
"last_checked": time.Now().Format(time.RFC3339),
"error": err.Error(),
"detection_method": detectionMethod,
"cached": false,
"note": "当前网盘暂不支持自动检测,建议用户自行验证",
}
cacheManager.Set(cacheKey, result)
results = append(results, result)
continue
}
// 只有明确检测出无效的资源才更新数据库状态
if detectionMethod == "quark_deep" && isValid != resource.IsValid {
resource.IsValid = isValid
updateErr := repoManager.ResourceRepository.Update(resource)
if updateErr != nil {
utils.Error("更新资源有效性状态失败 - ID: %d, Error: %v", id, updateErr)
}
}
result := gin.H{
"resource_id": id,
"url": resource.URL,
"is_valid": isValid,
"last_checked": time.Now().Format(time.RFC3339),
"detection_method": detectionMethod,
"cached": false,
}
cacheManager.Set(cacheKey, result)
results = append(results, result)
}
utils.Info("批量检测资源有效性完成 - Count: %d", len(results))
SuccessResponse(c, gin.H{
"results": results,
"total": len(results),
})
}
// getQuarkPanID 获取夸克网盘ID
func getQuarkPanID() (uint, error) {
// 通过FindAll方法查找所有平台然后过滤出quark平台

View File

@@ -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": "手动触发自动转存定时任务成功"})
}

411
handlers/sitemap_handler.go Normal file
View File

@@ -0,0 +1,411 @@
package handlers
import (
"encoding/xml"
"fmt"
"net/http"
"strconv"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/scheduler"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
resourceRepo repo.ResourceRepository
systemConfigRepo repo.SystemConfigRepository
hotDramaRepo repo.HotDramaRepository
readyResourceRepo repo.ReadyResourceRepository
panRepo repo.PanRepository
cksRepo repo.CksRepository
tagRepo repo.TagRepository
categoryRepo repo.CategoryRepository
)
// SetSitemapDependencies 注册Sitemap处理器依赖
func SetSitemapDependencies(
resourceRepository repo.ResourceRepository,
systemConfigRepository repo.SystemConfigRepository,
hotDramaRepository repo.HotDramaRepository,
readyResourceRepository repo.ReadyResourceRepository,
panRepository repo.PanRepository,
cksRepository repo.CksRepository,
tagRepository repo.TagRepository,
categoryRepository repo.CategoryRepository,
) {
resourceRepo = resourceRepository
systemConfigRepo = systemConfigRepository
hotDramaRepo = hotDramaRepository
readyResourceRepo = readyResourceRepository
panRepo = panRepository
cksRepo = cksRepository
tagRepo = tagRepository
categoryRepo = categoryRepository
}
const SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
// SitemapIndex sitemap索引结构
type SitemapIndex struct {
XMLName xml.Name `xml:"sitemapindex"`
XMLNS string `xml:"xmlns,attr"`
Sitemaps []Sitemap `xml:"sitemap"`
}
// Sitemap 单个sitemap信息
type Sitemap struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
}
// UrlSet sitemap内容
type UrlSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []Url `xml:"url"`
}
// Url 单个URL信息
type Url struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
ChangeFreq string `xml:"changefreq"`
Priority float64 `xml:"priority"`
}
// SitemapConfig sitemap配置
type SitemapConfig struct {
AutoGenerate bool `json:"auto_generate"`
LastGenerate time.Time `json:"last_generate"`
LastUpdate time.Time `json:"last_update"`
}
// GetSitemapConfig 获取sitemap配置
func GetSitemapConfig(c *gin.Context) {
// 从全局调度器获取配置
enabled, err := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).GetSitemapConfig()
if err != nil && err != gorm.ErrRecordNotFound {
// 如果获取失败,尝试从配置表中获取
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
if err != nil && err != gorm.ErrRecordNotFound {
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
return
}
enabled = configStr == "1" || configStr == "true"
}
// 获取最后生成时间(从配置中获取)
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
if err != nil && err != gorm.ErrRecordNotFound {
// 如果获取失败,只返回启用状态
config := SitemapConfig{
AutoGenerate: enabled,
LastGenerate: time.Time{}, // 空时间
LastUpdate: time.Now(),
}
SuccessResponse(c, config)
return
}
var lastGenerateTime time.Time
if configStr != "" {
lastGenerateTime, _ = time.Parse("2006-01-02 15:04:05", configStr)
}
config := SitemapConfig{
AutoGenerate: enabled,
LastGenerate: lastGenerateTime,
LastUpdate: time.Now(),
}
SuccessResponse(c, config)
}
// UpdateSitemapConfig 更新sitemap配置
func UpdateSitemapConfig(c *gin.Context) {
var config SitemapConfig
if err := c.ShouldBindJSON(&config); err != nil {
ErrorResponse(c, "参数错误", http.StatusBadRequest)
return
}
// 更新调度器配置
if err := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).UpdateSitemapConfig(config.AutoGenerate); err != nil {
ErrorResponse(c, "更新调度器配置失败", http.StatusInternalServerError)
return
}
// 保存自动生成功能状态
autoGenerateStr := "0"
if config.AutoGenerate {
autoGenerateStr = "1"
}
autoGenerateConfig := entity.SystemConfig{
Key: entity.ConfigKeySitemapAutoGenerateEnabled,
Value: autoGenerateStr,
Type: "bool",
}
// 保存最后生成时间
lastGenerateStr := config.LastGenerate.Format("2006-01-02 15:04:05")
lastGenerateConfig := entity.SystemConfig{
Key: entity.ConfigKeySitemapLastGenerateTime,
Value: lastGenerateStr,
Type: "string",
}
configs := []entity.SystemConfig{autoGenerateConfig, lastGenerateConfig}
if err := systemConfigRepo.UpsertConfigs(configs); err != nil {
ErrorResponse(c, "保存配置失败", http.StatusInternalServerError)
return
}
// 根据配置启动或停止调度器
if config.AutoGenerate {
scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).StartSitemapScheduler()
} else {
scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).StopSitemapScheduler()
}
SuccessResponse(c, config)
}
// GenerateSitemap 手动生成sitemap
func GenerateSitemap(c *gin.Context) {
// 获取资源总数
var total int64
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
return
}
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
// 获取全局调度器并立即执行sitemap生成
globalScheduler := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
)
// 手动触发sitemap生成
globalScheduler.TriggerSitemapGeneration()
// 记录最后生成时间为当前时间
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
lastGenerateConfig := entity.SystemConfig{
Key: entity.ConfigKeySitemapLastGenerateTime,
Value: lastGenerateStr,
Type: "string",
}
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
return
}
result := map[string]interface{}{
"total_resources": total,
"total_pages": totalPages,
"status": "started",
"message": fmt.Sprintf("开始生成 %d 个sitemap文件", totalPages),
}
SuccessResponse(c, result)
}
// GetSitemapStatus 获取sitemap生成状态
func GetSitemapStatus(c *gin.Context) {
// 获取资源总数
var total int64
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
return
}
// 计算需要生成的sitemap文件数量
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
// 获取最后生成时间
lastGenerateStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapLastGenerateTime)
if err != nil {
// 如果没有记录,使用当前时间
lastGenerateStr = time.Now().Format("2006-01-02 15:04:05")
}
lastGenerate, err := time.Parse("2006-01-02 15:04:05", lastGenerateStr)
if err != nil {
lastGenerate = time.Now()
}
// 检查调度器是否运行
isRunning := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).IsSitemapSchedulerRunning()
// 获取自动生成功能状态
autoGenerateEnabled, err := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
).GetSitemapConfig()
if err != nil {
// 如果调度器获取失败,从配置中获取
configStr, err := systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapAutoGenerateEnabled)
if err != nil {
autoGenerateEnabled = false
} else {
autoGenerateEnabled = configStr == "1" || configStr == "true"
}
}
result := map[string]interface{}{
"total_resources": total,
"total_pages": totalPages,
"last_generate": lastGenerate.Format("2006-01-02 15:04:05"),
"status": "ready",
"is_running": isRunning,
"auto_generate": autoGenerateEnabled,
}
SuccessResponse(c, result)
}
// SitemapIndexHandler sitemap索引文件处理器
func SitemapIndexHandler(c *gin.Context) {
// 获取资源总数
var total int64
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源总数失败"})
return
}
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
// 构建主机URL
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if host == "" {
host = "localhost:8080" // 默认值
}
baseURL := fmt.Sprintf("%s://%s", scheme, host)
// 创建sitemap列表 - 现在文件保存在data/sitemap目录通过/file/sitemap/路径访问
var sitemaps []Sitemap
for i := 0; i < totalPages; i++ {
sitemapURL := fmt.Sprintf("%s/file/sitemap/sitemap-%d.xml", baseURL, i)
sitemaps = append(sitemaps, Sitemap{
Loc: sitemapURL,
LastMod: time.Now().Format("2006-01-02"),
})
}
sitemapIndex := SitemapIndex{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
Sitemaps: sitemaps,
}
c.Header("Content-Type", "application/xml")
c.XML(http.StatusOK, sitemapIndex)
}
// SitemapPageHandler sitemap页面处理器
func SitemapPageHandler(c *gin.Context) {
pageStr := c.Param("page")
page, err := strconv.Atoi(pageStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的页面参数"})
return
}
offset := page * SITEMAP_MAX_URLS
limit := SITEMAP_MAX_URLS
var resources []entity.Resource
if err := resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取资源数据失败"})
return
}
var urls []Url
for _, resource := range resources {
lastMod := resource.UpdatedAt
if resource.CreatedAt.After(lastMod) {
lastMod = resource.CreatedAt
}
urls = append(urls, Url{
Loc: fmt.Sprintf("/r/%s", resource.Key),
LastMod: lastMod.Format("2006-01-01"), // 只保留日期部分
ChangeFreq: "weekly",
Priority: 0.8,
})
}
urlSet := UrlSet{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
c.Header("Content-Type", "application/xml")
c.XML(http.StatusOK, urlSet)
}
// 手动生成完整sitemap文件
func GenerateFullSitemap(c *gin.Context) {
// 获取资源总数
var total int64
if err := resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
ErrorResponse(c, "获取资源总数失败", http.StatusInternalServerError)
return
}
// 获取全局调度器并立即执行sitemap生成
globalScheduler := scheduler.GetGlobalScheduler(
hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo,
panRepo, cksRepo, tagRepo, categoryRepo,
)
// 手动触发sitemap生成
globalScheduler.TriggerSitemapGeneration()
// 记录最后生成时间为当前时间
lastGenerateStr := time.Now().Format("2006-01-02 15:04:05")
lastGenerateConfig := entity.SystemConfig{
Key: entity.ConfigKeySitemapLastGenerateTime,
Value: lastGenerateStr,
Type: "string",
}
if err := systemConfigRepo.UpsertConfigs([]entity.SystemConfig{lastGenerateConfig}); err != nil {
ErrorResponse(c, "更新最后生成时间失败", http.StatusInternalServerError)
return
}
result := map[string]interface{}{
"message": "Sitemap生成任务已启动",
"total_resources": total,
"status": "processing",
"estimated_time": fmt.Sprintf("%d秒", total/1000), // 估算时间
}
SuccessResponse(c, result)
}

View File

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

View File

@@ -125,10 +125,15 @@ 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
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("UpdateSystemConfig - 管理员更新系统配置 - 管理员: %s, IP: %s", adminUsername, clientIP)
// 调试信息
utils.Info("接收到的配置请求: %+v", req)
@@ -140,42 +145,61 @@ func UpdateSystemConfig(c *gin.Context) {
utils.Info("当前配置数量: %d", len(currentConfigs))
}
// 验证参数 - 只验证提交的字段
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) {
// 验证参数 - 只验证提交的字段,仅在验证失败时记录日志
if req.SiteTitle != nil {
if len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100 {
utils.Warn("配置验证失败 - SiteTitle长度无效: %d", len(*req.SiteTitle))
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
return
}
}
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) {
if req.AutoProcessInterval != nil {
if *req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440 {
utils.Warn("配置验证失败 - AutoProcessInterval超出范围: %d", *req.AutoProcessInterval)
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
return
}
}
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) {
if req.PageSize != nil {
if *req.PageSize < 10 || *req.PageSize > 500 {
utils.Warn("配置验证失败 - PageSize超出范围: %d", *req.PageSize)
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
return
}
}
// 验证自动转存配置
if req.AutoTransferLimitDays != nil && (*req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365) {
if req.AutoTransferLimitDays != nil {
if *req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365 {
utils.Warn("配置验证失败 - AutoTransferLimitDays超出范围: %d", *req.AutoTransferLimitDays)
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
return
}
}
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
if req.AutoTransferMinSpace != nil {
if *req.AutoTransferMinSpace < 5 || *req.AutoTransferMinSpace > 1024 {
utils.Warn("配置验证失败 - AutoTransferMinSpace超出范围: %d", *req.AutoTransferMinSpace)
ErrorResponse(c, "最小存储空间必须在5-1024GB之间", http.StatusBadRequest)
return
}
}
// 验证公告相关字段
if req.Announcements != nil {
// 简化验证,仅在需要时添加逻辑
}
// 转换为实体
configs := converter.RequestToSystemConfig(&req)
if configs == nil {
utils.Error("配置数据转换失败")
ErrorResponse(c, "数据转换失败", http.StatusInternalServerError)
return
}
utils.Info("准备更新配置,配置项数量: %d", len(configs))
// 保存配置
err = repoManager.SystemConfigRepository.UpsertConfigs(configs)
if err != nil {
@@ -184,7 +208,7 @@ func UpdateSystemConfig(c *gin.Context) {
return
}
utils.Info("配置保存成功")
utils.Info("系统配置更新成功 - 更新项数: %d", len(configs))
// 安全刷新系统配置缓存
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
@@ -297,6 +321,10 @@ func ToggleAutoProcess(c *gin.Context) {
return
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("ToggleAutoProcess - 管理员切换自动处理配置 - 管理员: %s, 启用: %t, IP: %s", adminUsername, req.AutoProcessReadyResources, clientIP)
// 获取当前配置
configs, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
if err != nil {

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -50,6 +51,10 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("CreateBatchTransferTask - 用户创建批量转存任务 - 用户: %s, 任务标题: %s, 资源数量: %d, IP: %s", username, req.Title, len(req.Resources), clientIP)
utils.Debug("创建批量转存任务: %s资源数量: %d选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
// 构建任务配置
@@ -123,6 +128,10 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("StartTask - 用户启动任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
err = h.taskManager.StartTask(uint(taskID))
if err != nil {
utils.Error("启动任务失败: %v", err)
@@ -146,6 +155,10 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("StopTask - 用户停止任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
err = h.taskManager.StopTask(uint(taskID))
if err != nil {
utils.Error("停止任务失败: %v", err)
@@ -169,6 +182,10 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("PauseTask - 用户暂停任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
err = h.taskManager.PauseTask(uint(taskID))
if err != nil {
utils.Error("暂停任务失败: %v", err)
@@ -359,8 +376,13 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("DeleteTask - 用户删除任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
// 检查任务是否在运行
if h.taskManager.IsTaskRunning(uint(taskID)) {
utils.Warn("DeleteTask - 尝试删除正在运行的任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
ErrorResponse(c, "任务正在运行中,无法删除", http.StatusBadRequest)
return
}
@@ -382,8 +404,242 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
}
utils.Debug("任务删除成功: %d", taskID)
utils.Info("DeleteTask - 任务删除成功 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
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
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("CreateExpansionTask - 用户创建扩容任务 - 用户: %s, 账号ID: %d, IP: %s", username, req.PanAccountID, clientIP)
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": "获取扩容输出数据成功",
})
}

View File

@@ -0,0 +1,561 @@
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
}
// 根据配置状态决定启动或停止机器人
botEnabled := false
for _, config := range configs {
if config.Key == "telegram_bot_enabled" {
botEnabled = config.Value == "true"
break
}
}
if botEnabled {
// 机器人已启用,尝试启动机器人
if startErr := h.telegramBotService.Start(); startErr != nil {
utils.Warn("[TELEGRAM:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
// 启动失败不影响配置保存,只记录警告
}
} else {
// 机器人已禁用,停止机器人服务
if stopErr := h.telegramBotService.Stop(); stopErr != nil {
utils.Warn("[TELEGRAM:HANDLER] 配置更新后停止机器人失败: %v", stopErr)
// 停止失败不影响配置保存,只记录警告
}
}
// 返回成功
var message string
if botEnabled {
message = "配置更新成功,机器人已尝试启动"
} else {
message = "配置更新成功,机器人已停止"
}
SuccessResponse(c, map[string]interface{}{
"success": true,
"message": 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,
})
}
// ManualPushToChannel 手动推送到频道
func (h *TelegramHandler) ManualPushToChannel(c *gin.Context) {
idStr := c.Param("id")
channelID, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
ErrorResponse(c, "无效的频道ID", http.StatusBadRequest)
return
}
err = h.telegramBotService.ManualPushToChannel(uint(channelID))
if err != nil {
ErrorResponse(c, "手动推送失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, map[string]interface{}{
"success": true,
"message": "手动推送请求已提交",
})
}
// 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"
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
@@ -8,6 +9,7 @@ import (
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/middleware"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
@@ -20,18 +22,24 @@ func Login(c *gin.Context) {
return
}
clientIP, _ := c.Get("client_ip")
utils.Info("Login - 尝试登录 - 用户名: %s, IP: %s", req.Username, clientIP)
user, err := repoManager.UserRepository.FindByUsername(req.Username)
if err != nil {
utils.Warn("Login - 用户不存在或密码错误 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized)
return
}
if !user.IsActive {
utils.Warn("Login - 账户已被禁用 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "账户已被禁用", http.StatusUnauthorized)
return
}
if !middleware.CheckPassword(req.Password, user.Password) {
utils.Warn("Login - 密码错误 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized)
return
}
@@ -42,10 +50,13 @@ func Login(c *gin.Context) {
// 生成JWT令牌
token, err := middleware.GenerateToken(user)
if err != nil {
utils.Error("Login - 生成令牌失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, "生成令牌失败", http.StatusInternalServerError)
return
}
utils.Info("Login - 登录成功 - 用户名: %s(ID:%d), IP: %s", req.Username, user.ID, clientIP)
response := dto.LoginResponse{
Token: token,
User: converter.ToUserResponse(user),
@@ -62,9 +73,13 @@ func Register(c *gin.Context) {
return
}
clientIP, _ := c.Get("client_ip")
utils.Info("Register - 尝试注册 - 用户名: %s, 邮箱: %s, IP: %s", req.Username, req.Email, clientIP)
// 检查用户名是否已存在
existingUser, _ := repoManager.UserRepository.FindByUsername(req.Username)
if existingUser != nil {
utils.Warn("Register - 用户名已存在 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名已存在", http.StatusBadRequest)
return
}
@@ -72,6 +87,7 @@ func Register(c *gin.Context) {
// 检查邮箱是否已存在
existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email)
if existingEmail != nil {
utils.Warn("Register - 邮箱已存在 - 邮箱: %s, IP: %s", req.Email, clientIP)
ErrorResponse(c, "邮箱已存在", http.StatusBadRequest)
return
}
@@ -79,6 +95,7 @@ func Register(c *gin.Context) {
// 哈希密码
hashedPassword, err := middleware.HashPassword(req.Password)
if err != nil {
utils.Error("Register - 密码加密失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return
}
@@ -93,10 +110,13 @@ func Register(c *gin.Context) {
err = repoManager.UserRepository.Create(user)
if err != nil {
utils.Error("Register - 创建用户失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
utils.Info("Register - 注册成功 - 用户名: %s(ID:%d), 邮箱: %s, IP: %s", req.Username, user.ID, req.Email, clientIP)
SuccessResponse(c, gin.H{
"message": "注册成功",
"user": converter.ToUserResponse(user),
@@ -123,9 +143,14 @@ func CreateUser(c *gin.Context) {
return
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("CreateUser - 管理员创建用户 - 管理员: %s, 新用户名: %s, IP: %s", adminUsername, req.Username, clientIP)
// 检查用户名是否已存在
existingUser, _ := repoManager.UserRepository.FindByUsername(req.Username)
if existingUser != nil {
utils.Warn("CreateUser - 用户名已存在 - 管理员: %s, 用户名: %s, IP: %s", adminUsername, req.Username, clientIP)
ErrorResponse(c, "用户名已存在", http.StatusBadRequest)
return
}
@@ -133,6 +158,7 @@ func CreateUser(c *gin.Context) {
// 检查邮箱是否已存在
existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email)
if existingEmail != nil {
utils.Warn("CreateUser - 邮箱已存在 - 管理员: %s, 邮箱: %s, IP: %s", adminUsername, req.Email, clientIP)
ErrorResponse(c, "邮箱已存在", http.StatusBadRequest)
return
}
@@ -140,6 +166,7 @@ func CreateUser(c *gin.Context) {
// 哈希密码
hashedPassword, err := middleware.HashPassword(req.Password)
if err != nil {
utils.Error("CreateUser - 密码加密失败 - 管理员: %s, 用户名: %s, IP: %s, Error: %v", adminUsername, req.Username, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return
}
@@ -154,10 +181,13 @@ func CreateUser(c *gin.Context) {
err = repoManager.UserRepository.Create(user)
if err != nil {
utils.Error("CreateUser - 创建用户失败 - 管理员: %s, 用户名: %s, IP: %s, Error: %v", adminUsername, req.Username, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
utils.Info("CreateUser - 用户创建成功 - 管理员: %s, 用户名: %s(ID:%d), 角色: %s, IP: %s", adminUsername, req.Username, user.ID, req.Role, clientIP)
SuccessResponse(c, gin.H{
"message": "用户创建成功",
"user": converter.ToUserResponse(user),
@@ -179,12 +209,21 @@ func UpdateUser(c *gin.Context) {
return
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("UpdateUser - 管理员更新用户 - 管理员: %s, 目标用户ID: %d, IP: %s", adminUsername, id, clientIP)
user, err := repoManager.UserRepository.FindByID(uint(id))
if err != nil {
utils.Warn("UpdateUser - 目标用户不存在 - 管理员: %s, 用户ID: %d, IP: %s", adminUsername, id, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound)
return
}
// 记录变更前的信息
oldInfo := fmt.Sprintf("用户名:%s,邮箱:%s,角色:%s,状态:%t", user.Username, user.Email, user.Role, user.IsActive)
utils.Debug("UpdateUser - 更新前用户信息 - 管理员: %s, 用户ID: %d, 信息: %s", adminUsername, id, oldInfo)
if req.Username != "" {
user.Username = req.Username
}
@@ -198,10 +237,15 @@ func UpdateUser(c *gin.Context) {
err = repoManager.UserRepository.Update(user)
if err != nil {
utils.Error("UpdateUser - 更新用户失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
// 记录变更后信息
newInfo := fmt.Sprintf("用户名:%s,邮箱:%s,角色:%s,状态:%t", user.Username, user.Email, user.Role, user.IsActive)
utils.Info("UpdateUser - 用户更新成功 - 管理员: %s, 用户ID: %d, 更新前: %s, 更新后: %s, IP: %s", adminUsername, id, oldInfo, newInfo, clientIP)
SuccessResponse(c, gin.H{"message": "用户更新成功"})
}
@@ -220,8 +264,13 @@ func ChangePassword(c *gin.Context) {
return
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("ChangePassword - 管理员修改用户密码 - 管理员: %s, 目标用户ID: %d, IP: %s", adminUsername, id, clientIP)
user, err := repoManager.UserRepository.FindByID(uint(id))
if err != nil {
utils.Warn("ChangePassword - 目标用户不存在 - 管理员: %s, 用户ID: %d, IP: %s", adminUsername, id, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound)
return
}
@@ -229,6 +278,7 @@ func ChangePassword(c *gin.Context) {
// 哈希新密码
hashedPassword, err := middleware.HashPassword(req.NewPassword)
if err != nil {
utils.Error("ChangePassword - 密码加密失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return
}
@@ -236,10 +286,13 @@ func ChangePassword(c *gin.Context) {
user.Password = hashedPassword
err = repoManager.UserRepository.Update(user)
if err != nil {
utils.Error("ChangePassword - 更新密码失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
utils.Info("ChangePassword - 密码修改成功 - 管理员: %s, 用户名: %s(ID:%d), IP: %s", adminUsername, user.Username, id, clientIP)
SuccessResponse(c, gin.H{"message": "密码修改成功"})
}
@@ -252,12 +305,27 @@ func DeleteUser(c *gin.Context) {
return
}
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("DeleteUser - 管理员删除用户 - 管理员: %s, 目标用户ID: %d, IP: %s", adminUsername, id, clientIP)
// 先获取用户信息用于日志记录
user, err := repoManager.UserRepository.FindByID(uint(id))
if err != nil {
utils.Warn("DeleteUser - 目标用户不存在 - 管理员: %s, 用户ID: %d, IP: %s", adminUsername, id, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound)
return
}
err = repoManager.UserRepository.Delete(uint(id))
if err != nil {
utils.Error("DeleteUser - 删除用户失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
utils.Info("DeleteUser - 用户删除成功 - 管理员: %s, 用户名: %s(ID:%d), IP: %s", adminUsername, user.Username, id, clientIP)
SuccessResponse(c, gin.H{"message": "用户删除成功"})
}
@@ -269,12 +337,18 @@ func GetProfile(c *gin.Context) {
return
}
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("GetProfile - 用户获取个人资料 - 用户名: %s(ID:%d), IP: %s", username, userID, clientIP)
user, err := repoManager.UserRepository.FindByID(userID.(uint))
if err != nil {
utils.Warn("GetProfile - 用户不存在 - 用户名: %s(ID:%d), IP: %s", username, userID, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound)
return
}
response := converter.ToUserResponse(user)
utils.Debug("GetProfile - 成功获取个人资料 - 用户名: %s(ID:%d), IP: %s", username, userID, clientIP)
SuccessResponse(c, response)
}

286
handlers/wechat_handler.go Normal file
View File

@@ -0,0 +1,286 @@
package handlers
import (
"crypto/sha1"
"encoding/xml"
"fmt"
"io"
"net/http"
"sort"
"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/ctwj/urldb/services"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
"github.com/silenceper/wechat/v2/officialaccount/message"
)
// WechatHandler 微信公众号处理器
type WechatHandler struct {
wechatService services.WechatBotService
systemConfigRepo repo.SystemConfigRepository
}
// NewWechatHandler 创建微信公众号处理器
func NewWechatHandler(
wechatService services.WechatBotService,
systemConfigRepo repo.SystemConfigRepository,
) *WechatHandler {
return &WechatHandler{
wechatService: wechatService,
systemConfigRepo: systemConfigRepo,
}
}
// HandleWechatMessage 处理微信消息推送
func (h *WechatHandler) HandleWechatMessage(c *gin.Context) {
// 验证微信消息签名
if !h.validateSignature(c) {
utils.Error("[WECHAT:VALIDATE] 签名验证失败")
c.String(http.StatusForbidden, "签名验证失败")
return
}
// 处理微信验证请求
if c.Request.Method == "GET" {
echostr := c.Query("echostr")
utils.Info("[WECHAT:VERIFY] 微信服务器验证成功, echostr=%s", echostr)
c.String(http.StatusOK, echostr)
return
}
// 读取请求体
body, err := io.ReadAll(c.Request.Body)
if err != nil {
utils.Error("[WECHAT:MESSAGE] 读取请求体失败: %v", err)
c.String(http.StatusBadRequest, "读取请求体失败")
return
}
// 解析微信消息
var msg message.MixMessage
if err := xml.Unmarshal(body, &msg); err != nil {
utils.Error("[WECHAT:MESSAGE] 解析微信消息失败: %v", err)
c.String(http.StatusBadRequest, "消息格式错误")
return
}
// 处理消息
reply, err := h.wechatService.HandleMessage(&msg)
if err != nil {
utils.Error("[WECHAT:MESSAGE] 处理微信消息失败: %v", err)
c.String(http.StatusInternalServerError, "处理失败")
return
}
utils.Info("[WECHAT:MESSAGE] 回复对象: %v", reply)
// 如果有回复内容,发送回复
if reply != nil {
// 为微信消息设置正确的ToUserName和FromUserName
switch v := reply.(type) {
case *message.Text:
if v.CommonToken.ToUserName == "" {
v.CommonToken.ToUserName = msg.FromUserName
}
if v.CommonToken.FromUserName == "" {
v.CommonToken.FromUserName = msg.ToUserName
}
if v.CommonToken.CreateTime == 0 {
v.CommonToken.CreateTime = time.Now().Unix()
}
// 确保MsgType正确设置
if v.CommonToken.MsgType == "" {
v.CommonToken.MsgType = message.MsgTypeText
}
case *message.Image:
if v.CommonToken.ToUserName == "" {
v.CommonToken.ToUserName = msg.FromUserName
}
if v.CommonToken.FromUserName == "" {
v.CommonToken.FromUserName = msg.ToUserName
}
if v.CommonToken.CreateTime == 0 {
v.CommonToken.CreateTime = time.Now().Unix()
}
// 确保MsgType正确设置
if v.CommonToken.MsgType == "" {
v.CommonToken.MsgType = message.MsgTypeImage
}
}
responseXML, err := xml.Marshal(reply)
if err != nil {
utils.Error("[WECHAT:MESSAGE] 序列化回复消息失败: %v", err)
c.String(http.StatusInternalServerError, "回复失败")
return
}
utils.Info("[WECHAT:MESSAGE] 回复XML: %s", string(responseXML))
c.Data(http.StatusOK, "application/xml", responseXML)
} else {
utils.Warn("[WECHAT:MESSAGE] 没有回复内容返回success")
c.String(http.StatusOK, "success")
}
}
// GetBotConfig 获取微信机器人配置
func (h *WechatHandler) GetBotConfig(c *gin.Context) {
configs, err := h.systemConfigRepo.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
return
}
botConfig := converter.SystemConfigToWechatBotConfig(configs)
SuccessResponse(c, botConfig)
}
// UpdateBotConfig 更新微信机器人配置
func (h *WechatHandler) UpdateBotConfig(c *gin.Context) {
var req dto.WechatBotConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return
}
// 转换为系统配置实体
configs := converter.WechatBotConfigRequestToSystemConfigs(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.wechatService.ReloadConfig(); err != nil {
ErrorResponse(c, "重新加载机器人配置失败", http.StatusInternalServerError)
return
}
// 配置更新完成后,尝试启动机器人(如果未运行且配置有效)
if startErr := h.wechatService.Start(); startErr != nil {
utils.Warn("[WECHAT:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
// 启动失败不影响配置保存,只记录警告
}
// 返回成功
SuccessResponse(c, map[string]interface{}{
"success": true,
"message": "配置更新成功,机器人已尝试启动",
})
}
// GetBotStatus 获取机器人状态
func (h *WechatHandler) GetBotStatus(c *gin.Context) {
// 获取机器人运行时状态
runtimeStatus := h.wechatService.GetRuntimeStatus()
// 获取配置状态
configs, err := h.systemConfigRepo.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
return
}
// 解析配置状态
configStatus := map[string]interface{}{
"enabled": false,
"auto_reply_enabled": false,
"app_id_configured": false,
"token_configured": false,
}
for _, config := range configs {
switch config.Key {
case entity.ConfigKeyWechatBotEnabled:
configStatus["enabled"] = config.Value == "true"
case entity.ConfigKeyWechatAutoReplyEnabled:
configStatus["auto_reply_enabled"] = config.Value == "true"
case entity.ConfigKeyWechatAppId:
configStatus["app_id_configured"] = config.Value != ""
case entity.ConfigKeyWechatToken:
configStatus["token_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)
}
// validateSignature 验证微信消息签名
func (h *WechatHandler) validateSignature(c *gin.Context) bool {
// 获取配置中的Token
configs, err := h.systemConfigRepo.GetOrCreateDefault()
if err != nil {
utils.Error("[WECHAT:VALIDATE] 获取配置失败: %v", err)
return false
}
var token string
for _, config := range configs {
if config.Key == entity.ConfigKeyWechatToken {
token = config.Value
break
}
}
utils.Debug("[WECHAT:VALIDATE] Token配置状态: %t", token != "")
if token == "" {
// 如果没有配置Token跳过签名验证开发模式
utils.Warn("[WECHAT:VALIDATE] 未配置Token跳过签名验证")
return true
}
signature := c.Query("signature")
timestamp := c.Query("timestamp")
nonce := c.Query("nonce")
utils.Debug("[WECHAT:VALIDATE] 接收到的参数 - signature: %s, timestamp: %s, nonce: %s", signature, timestamp, nonce)
// 验证签名
tmpArr := []string{token, timestamp, nonce}
sort.Strings(tmpArr)
tmpStr := strings.Join(tmpArr, "")
tmpStr = fmt.Sprintf("%x", sha1.Sum([]byte(tmpStr)))
utils.Debug("[WECHAT:VALIDATE] 计算出的签名: %s, 微信提供的签名: %s", tmpStr, signature)
if tmpStr == signature {
utils.Info("[WECHAT:VALIDATE] 签名验证成功")
return true
} else {
utils.Error("[WECHAT:VALIDATE] 签名验证失败 - 计算出的签名: %s, 微信提供的签名: %s", tmpStr, signature)
return false
}
}

241
main.go
View File

@@ -3,14 +3,20 @@ package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ctwj/urldb/config"
"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/monitor"
"github.com/ctwj/urldb/scheduler"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/task"
@@ -35,10 +41,9 @@ func main() {
}
// 初始化日志系统
if err := utils.InitLogger(nil); err != nil {
if err := utils.InitLogger(); err != nil {
log.Fatal("初始化日志系统失败:", err)
}
defer utils.GetLogger().Close()
// 加载环境变量
if err := godotenv.Load(); err != nil {
@@ -82,9 +87,22 @@ func main() {
utils.Fatal("数据库连接失败: %v", err)
}
// 日志系统已简化,无需额外初始化
// 创建Repository管理器
repoManager := repo.NewRepositoryManager(db.DB)
// 创建配置管理器
configManager := config.NewConfigManager(repoManager)
// 设置全局配置管理器
config.SetGlobalConfigManager(configManager)
// 加载所有配置到缓存
if err := configManager.LoadAllConfigs(); err != nil {
utils.Error("加载配置缓存失败: %v", err)
}
// 创建任务管理器
taskManager := task.NewTaskManager(repoManager)
@@ -92,6 +110,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 {
@@ -108,7 +130,22 @@ func main() {
utils.Info("任务管理器初始化完成")
// 创建Gin实例
r := gin.Default()
r := gin.New()
// 创建监控和错误处理器
metrics := monitor.GetGlobalMetrics()
errorHandler := monitor.GetGlobalErrorHandler()
if errorHandler == nil {
errorHandler = monitor.NewErrorHandler(1000, 24*time.Hour)
monitor.SetGlobalErrorHandler(errorHandler)
}
// 添加中间件
r.Use(gin.Logger()) // Gin日志中间件
r.Use(errorHandler.RecoverMiddleware()) // Panic恢复中间件
r.Use(errorHandler.ErrorMiddleware()) // 错误处理中间件
r.Use(metrics.MetricsMiddleware()) // 监控中间件
r.Use(gin.Recovery()) // Gin恢复中间件
// 配置CORS
config := cors.DefaultConfig()
@@ -120,9 +157,27 @@ func main() {
// 将Repository管理器注入到handlers中
handlers.SetRepositoryManager(repoManager)
// 将Repository管理器注入到services中
services.SetRepositoryManager(repoManager)
// 设置Sitemap处理器依赖
handlers.SetSitemapDependencies(
repoManager.ResourceRepository,
repoManager.SystemConfigRepository,
repoManager.HotDramaRepository,
repoManager.ReadyResourceRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 设置Meilisearch管理器到handlers中
handlers.SetMeilisearchManager(meilisearchManager)
// 设置Meilisearch管理器到services中
services.SetMeilisearchManager(meilisearchManager)
// 设置全局调度器的Meilisearch管理器
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
@@ -142,6 +197,7 @@ func main() {
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
autoSitemapEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeySitemapAutoGenerateEnabled)
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
autoFetchHotDrama,
@@ -149,6 +205,14 @@ func main() {
autoTransferEnabled,
)
// 根据系统配置启动Sitemap调度器
if autoSitemapEnabled {
globalScheduler.StartSitemapScheduler()
utils.Info("系统配置启用Sitemap自动生成功能启动定时任务")
} else {
utils.Info("系统配置禁用Sitemap自动生成功能")
}
utils.Info("调度器初始化完成")
// 设置公开API中间件的Repository管理器
@@ -166,6 +230,13 @@ func main() {
// 创建Meilisearch处理器
meilisearchHandler := handlers.NewMeilisearchHandler(meilisearchManager)
// 创建OG图片处理器
ogImageHandler := handlers.NewOGImageHandler()
// 创建举报和版权申述处理器
reportHandler := handlers.NewReportHandler(repoManager.ReportRepository, repoManager.ResourceRepository)
copyrightClaimHandler := handlers.NewCopyrightClaimHandler(repoManager.CopyrightClaimRepository, repoManager.ResourceRepository)
// API路由
api := r.Group("/api")
{
@@ -188,13 +259,18 @@ func main() {
// 资源管理
api.GET("/resources", handlers.GetResources)
api.GET("/resources/hot", handlers.GetHotResources)
api.POST("/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateResource)
api.PUT("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateResource)
api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource)
api.GET("/resources/:id", handlers.GetResourceByID)
api.GET("/resources/key/:key", handlers.GetResourcesByKey)
api.GET("/resources/check-exists", handlers.CheckResourceExists)
api.GET("/resources/related", handlers.GetRelatedResources)
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
api.GET("/resources/:id/link", handlers.GetResourceLink)
api.GET("/resources/:id/validity", handlers.CheckResourceValidity)
api.POST("/resources/validity/batch", handlers.BatchCheckResourceValidity)
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
// 分类管理
@@ -227,6 +303,7 @@ func main() {
api.DELETE("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteCks)
api.GET("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetCksByID)
api.POST("/cks/:id/refresh-capacity", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.RefreshCapacity)
api.POST("/cks/:id/delete-related-resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteRelatedResources)
// 标签管理
api.GET("/tags", handlers.GetTags)
@@ -268,6 +345,18 @@ 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-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemLogs)
api.GET("/system-logs/files", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemLogFiles)
api.GET("/system-logs/summary", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemLogSummary)
api.DELETE("/system-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearSystemLogs)
// 系统配置路由
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
@@ -281,9 +370,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)
@@ -316,8 +408,134 @@ func main() {
api.GET("/files", middleware.AuthMiddleware(), fileHandler.GetFileList)
api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles)
api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile)
// 微信公众号验证文件上传无需认证仅支持TXT文件
api.POST("/wechat/verify-file", fileHandler.UploadWechatVerifyFile)
// 创建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)
}
// 创建微信公众号机器人服务
wechatBotService := services.NewWechatBotService(
repoManager.SystemConfigRepository,
repoManager.ResourceRepository,
repoManager.ReadyResourceRepository,
)
// 启动微信公众号机器人服务
if err := wechatBotService.Start(); err != nil {
utils.Error("启动微信公众号机器人服务失败: %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)
api.POST("/telegram/manual-push/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ManualPushToChannel)
// 微信公众号相关路由
wechatHandler := handlers.NewWechatHandler(
wechatBotService,
repoManager.SystemConfigRepository,
)
api.GET("/wechat/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.GetBotConfig)
api.PUT("/wechat/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.UpdateBotConfig)
api.GET("/wechat/bot-status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), wechatHandler.GetBotStatus)
api.POST("/wechat/callback", wechatHandler.HandleWechatMessage)
api.GET("/wechat/callback", wechatHandler.HandleWechatMessage)
// OG图片生成路由
api.GET("/og-image", ogImageHandler.GenerateOGImage)
// 举报和版权申述路由
api.POST("/reports", reportHandler.CreateReport)
api.GET("/reports/:id", reportHandler.GetReport)
api.GET("/reports", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.ListReports)
api.PUT("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.UpdateReport)
api.DELETE("/reports/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), reportHandler.DeleteReport)
api.GET("/reports/resource/:resource_key", reportHandler.GetReportByResource)
api.POST("/copyright-claims", copyrightClaimHandler.CreateCopyrightClaim)
api.GET("/copyright-claims/:id", copyrightClaimHandler.GetCopyrightClaim)
api.GET("/copyright-claims", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.ListCopyrightClaims)
api.PUT("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.UpdateCopyrightClaim)
api.DELETE("/copyright-claims/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), copyrightClaimHandler.DeleteCopyrightClaim)
api.GET("/copyright-claims/resource/:resource_key", copyrightClaimHandler.GetCopyrightClaimByResource)
// Sitemap静态文件服务优先于API路由
// 提供生成的sitemap.xml索引文件
r.StaticFile("/sitemap.xml", "./data/sitemap/sitemap.xml")
// 提供生成的sitemap分页文件使用通配符路由
r.GET("/sitemap-:page", func(c *gin.Context) {
page := c.Param("page")
if !strings.HasSuffix(page, ".xml") {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.File("./data/sitemap/sitemap-" + page)
})
// Sitemap静态文件API路由API兼容
api.GET("/sitemap.xml", func(c *gin.Context) {
c.File("./data/sitemap/sitemap.xml")
})
// 提供生成的sitemap分页文件使用API路径
api.GET("/sitemap-:page", func(c *gin.Context) {
page := c.Param("page")
if !strings.HasSuffix(page, ".xml") {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
c.File("./data/sitemap/sitemap-" + page)
})
// Sitemap管理API通过管理员接口进行管理
api.GET("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapConfig)
api.POST("/sitemap/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSitemapConfig)
api.POST("/sitemap/generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateSitemap)
api.GET("/sitemap/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSitemapStatus)
api.POST("/sitemap/full-generate", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GenerateFullSitemap)
}
// 设置监控系统
monitor.SetupMonitoring(r)
// 启动监控服务器
metricsConfig := &monitor.MetricsConfig{
Enabled: true,
ListenAddress: ":9090",
MetricsPath: "/metrics",
Namespace: "urldb",
Subsystem: "api",
}
metrics.StartMetricsServer(metricsConfig)
// 静态文件服务
r.Static("/uploads", "./uploads")
@@ -336,6 +554,21 @@ func main() {
port = "8080"
}
// 设置优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// 在goroutine中启动服务器
go func() {
utils.Info("服务器启动在端口 %s", port)
r.Run(":" + port)
if err := r.Run(":" + port); err != nil && err.Error() != "http: Server closed" {
utils.Fatal("服务器启动失败: %v", err)
}
}()
// 等待信号
<-quit
utils.Info("收到关闭信号,开始优雅关闭...")
utils.Info("服务器已优雅关闭")
}

View File

@@ -27,11 +27,14 @@ 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)
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
utils.Debug("AuthMiddleware - 认证请求: %s %s, IP: %s, UserAgent: %s",
c.Request.Method, c.Request.URL.Path, clientIP, userAgent)
if authHeader == "" {
utils.Error("AuthMiddleware - 未提供认证令牌")
// utils.Warn("AuthMiddleware - 未提供认证令牌 - IP: %s, Path: %s", clientIP, c.Request.URL.Path)
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
c.Abort()
return
@@ -39,29 +42,31 @@ func AuthMiddleware() gin.HandlerFunc {
// 检查Bearer前缀
if !strings.HasPrefix(authHeader, "Bearer ") {
// utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
utils.Warn("AuthMiddleware - 无效的认证格式 - IP: %s, Header: %s", clientIP, authHeader)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
// utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
utils.Debug("AuthMiddleware - 解析令牌: %s...", tokenString[:utils.Min(len(tokenString), 10)])
claims, err := parseToken(tokenString)
if err != nil {
// utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
utils.Warn("AuthMiddleware - 令牌解析失败 - IP: %s, Error: %v", clientIP, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
c.Abort()
return
}
// utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
// utils.Info("AuthMiddleware - 认证成功 - 用户: %s(ID:%d), 角色: %s, IP: %s",
// claims.Username, claims.UserID, claims.Role, clientIP)
// 将用户信息存储到上下文中
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Set("client_ip", clientIP)
c.Next()
}
@@ -71,18 +76,23 @@ func AuthMiddleware() gin.HandlerFunc {
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
if !exists {
// c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
utils.Warn("AdminMiddleware - 未认证访问管理员接口 - IP: %s, Path: %s", clientIP, c.Request.URL.Path)
c.Abort()
return
}
if role != "admin" {
// c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
utils.Warn("AdminMiddleware - 非管理员用户尝试访问管理员接口 - 用户: %s, 角色: %s, IP: %s, Path: %s",
username, role, clientIP, c.Request.URL.Path)
c.Abort()
return
}
utils.Debug("AdminMiddleware - 管理员访问接口 - 用户: %s, IP: %s, Path: %s", username, clientIP, c.Request.URL.Path)
c.Next()
}
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"io"
"net/http"
"strings"
"time"
"github.com/ctwj/urldb/utils"
@@ -55,41 +56,71 @@ func LoggingMiddleware(next http.Handler) http.Handler {
})
}
// logRequest 记录请求日志
// logRequest 记录请求日志 - 恢复正常请求日志记录
func logRequest(r *http.Request, rw *responseWriter, duration time.Duration, requestBody []byte) {
// 获取客户端IP
clientIP := getClientIP(r)
// 获取用户代理
userAgent := r.UserAgent()
if userAgent == "" {
userAgent = "Unknown"
}
// 判断是否需要详细记录日志的条件
shouldDetailLog := rw.statusCode >= 400 || // 错误状态码
duration > 5*time.Second || // 耗时过长
shouldLogPath(r.URL.Path) || // 关键路径
isAdminPath(r.URL.Path) // 管理员路径
// 记录请求信息
utils.Info("HTTP请求 - %s %s - IP: %s - User-Agent: %s - 状态码: %d - 耗时: %v",
r.Method, r.URL.Path, clientIP, userAgent, rw.statusCode, duration)
// 如果是错误状态码,记录详细信息
// 所有API请求都记录基本信息但详细日志只记录重要请求
if rw.statusCode >= 400 {
utils.Error("HTTP错误 - %s %s - 状态码: %d - 响应体: %s",
r.Method, r.URL.Path, rw.statusCode, rw.body.String())
}
// 错误请求记录详细信息
utils.Error("HTTP异常 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
// 记录请求参数仅对POST/PUT请求
if (r.Method == "POST" || r.Method == "PUT") && len(requestBody) > 0 {
// 限制日志长度,避免日志文件过大
if len(requestBody) > 1000 {
utils.Debug("请求体(截断): %s...", string(requestBody[:1000]))
// 仅在错误状态下记录简要的请求信息
if len(requestBody) > 0 && len(requestBody) <= 500 {
utils.Error("请求详情: %s", string(requestBody))
}
} else if duration > 5*time.Second {
// 慢请求警告
utils.Warn("HTTP慢请求 - %s %s - IP: %s - 耗时: %v",
r.Method, r.URL.Path, clientIP, duration)
} else if shouldDetailLog {
// 关键路径的正常请求
utils.Info("HTTP关键请求 - %s %s - IP: %s - 状态码: %d - 耗时: %v",
r.Method, r.URL.Path, clientIP, rw.statusCode, duration)
} else {
utils.Debug("请求体: %s", string(requestBody))
// 普通API请求记录简化日志 - 使用Info级别确保能被看到
// utils.Info("HTTP请求 - %s %s - 状态码: %d - 耗时: %v",
// r.Method, r.URL.Path, rw.statusCode, duration)
}
}
// 记录查询参数
if len(r.URL.RawQuery) > 0 {
utils.Debug("查询参数: %s", r.URL.RawQuery)
// shouldLogPath 判断路径是否需要记录日志
func shouldLogPath(path string) bool {
// 定义需要记录日志的关键路径
keyPaths := []string{
"/api/public/resources",
"/api/admin/config",
"/api/admin/users",
"/telegram/webhook",
"/api/resources",
"/api/version",
"/api/cks",
"/api/pans",
"/api/categories",
"/api/tags",
"/api/tasks",
}
for _, keyPath := range keyPaths {
if strings.HasPrefix(path, keyPath) {
return true
}
}
return false
}
// isAdminPath 判断是否为管理员路径
func isAdminPath(path string) bool {
return strings.HasPrefix(path, "/api/admin/") ||
strings.HasPrefix(path, "/admin/")
}
// getClientIP 获取客户端真实IP地址

View 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 频道/群组表';

327
monitor/error_handler.go Normal file
View File

@@ -0,0 +1,327 @@
package monitor
import (
"fmt"
"net/http"
"runtime"
"strings"
"sync"
"time"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
// ErrorInfo 错误信息结构
type ErrorInfo struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Message string `json:"message"`
StackTrace string `json:"stack_trace"`
RequestInfo *RequestInfo `json:"request_info,omitempty"`
Level string `json:"level"` // error, warn, info
Count int `json:"count"`
}
// RequestInfo 请求信息结构
type RequestInfo struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
RemoteAddr string `json:"remote_addr"`
UserAgent string `json:"user_agent"`
RequestBody string `json:"request_body"`
}
// ErrorHandler 错误处理器
type ErrorHandler struct {
errors map[string]*ErrorInfo
mu sync.RWMutex
maxErrors int
retention time.Duration
}
// NewErrorHandler 创建新的错误处理器
func NewErrorHandler(maxErrors int, retention time.Duration) *ErrorHandler {
eh := &ErrorHandler{
errors: make(map[string]*ErrorInfo),
maxErrors: maxErrors,
retention: retention,
}
// 启动错误清理协程
go eh.cleanupRoutine()
return eh
}
// RecoverMiddleware panic恢复中间件
func (eh *ErrorHandler) RecoverMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// 记录错误信息
stackTrace := getStackTrace()
errorInfo := &ErrorInfo{
ID: fmt.Sprintf("panic_%d", time.Now().UnixNano()),
Timestamp: time.Now(),
Message: fmt.Sprintf("%v", err),
StackTrace: stackTrace,
RequestInfo: &RequestInfo{
Method: c.Request.Method,
URL: c.Request.URL.String(),
RemoteAddr: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
},
Level: "error",
Count: 1,
}
// 保存错误信息
eh.saveError(errorInfo)
utils.Error("Panic recovered: %v\nStack trace: %s", err, stackTrace)
// 返回错误响应
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"code": "INTERNAL_ERROR",
})
// 不继续处理
c.Abort()
}
}()
c.Next()
}
}
// ErrorMiddleware 通用错误处理中间件
func (eh *ErrorHandler) ErrorMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// 检查是否有错误
if len(c.Errors) > 0 {
for _, ginErr := range c.Errors {
errorInfo := &ErrorInfo{
ID: fmt.Sprintf("error_%d_%s", time.Now().UnixNano(), ginErr.Type),
Timestamp: time.Now(),
Message: ginErr.Error(),
Level: "error",
Count: 1,
RequestInfo: &RequestInfo{
Method: c.Request.Method,
URL: c.Request.URL.String(),
RemoteAddr: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
},
}
eh.saveError(errorInfo)
}
}
}
}
// saveError 保存错误信息
func (eh *ErrorHandler) saveError(errorInfo *ErrorInfo) {
eh.mu.Lock()
defer eh.mu.Unlock()
key := errorInfo.Message
if existing, exists := eh.errors[key]; exists {
// 如果错误已存在,增加计数
existing.Count++
existing.Timestamp = time.Now()
} else {
// 如果是新错误,添加到映射中
eh.errors[key] = errorInfo
}
// 如果错误数量超过限制,清理旧错误
if len(eh.errors) > eh.maxErrors {
eh.cleanupOldErrors()
}
}
// GetErrors 获取错误列表
func (eh *ErrorHandler) GetErrors() []*ErrorInfo {
eh.mu.RLock()
defer eh.mu.RUnlock()
errors := make([]*ErrorInfo, 0, len(eh.errors))
for _, errorInfo := range eh.errors {
errors = append(errors, errorInfo)
}
return errors
}
// GetErrorByID 根据ID获取错误
func (eh *ErrorHandler) GetErrorByID(id string) (*ErrorInfo, bool) {
eh.mu.RLock()
defer eh.mu.RUnlock()
for _, errorInfo := range eh.errors {
if errorInfo.ID == id {
return errorInfo, true
}
}
return nil, false
}
// ClearErrors 清空所有错误
func (eh *ErrorHandler) ClearErrors() {
eh.mu.Lock()
defer eh.mu.Unlock()
eh.errors = make(map[string]*ErrorInfo)
}
// cleanupOldErrors 清理旧错误
func (eh *ErrorHandler) cleanupOldErrors() {
// 简单策略:保留最近的错误,删除旧的
errors := make([]*ErrorInfo, 0, len(eh.errors))
for _, errorInfo := range eh.errors {
errors = append(errors, errorInfo)
}
// 按时间戳排序
for i := 0; i < len(errors)-1; i++ {
for j := i + 1; j < len(errors); j++ {
if errors[i].Timestamp.Before(errors[j].Timestamp) {
errors[i], errors[j] = errors[j], errors[i]
}
}
}
// 保留最新的maxErrors/2个错误
keep := eh.maxErrors / 2
if keep < 1 {
keep = 1
}
if len(errors) > keep {
// 重建错误映射
newErrors := make(map[string]*ErrorInfo)
for i := 0; i < keep; i++ {
newErrors[errors[i].Message] = errors[i]
}
eh.errors = newErrors
}
}
// cleanupRoutine 定期清理过期错误的协程
func (eh *ErrorHandler) cleanupRoutine() {
ticker := time.NewTicker(5 * time.Minute) // 每5分钟清理一次
defer ticker.Stop()
for range ticker.C {
eh.mu.Lock()
for key, errorInfo := range eh.errors {
if time.Since(errorInfo.Timestamp) > eh.retention {
delete(eh.errors, key)
}
}
eh.mu.Unlock()
}
}
// getStackTrace 获取堆栈跟踪信息
func getStackTrace() string {
var buf [4096]byte
n := runtime.Stack(buf[:], false)
return string(buf[:n])
}
// GetErrorStatistics 获取错误统计信息
func (eh *ErrorHandler) GetErrorStatistics() map[string]interface{} {
eh.mu.RLock()
defer eh.mu.RUnlock()
totalErrors := len(eh.errors)
totalCount := 0
errorTypes := make(map[string]int)
for _, errorInfo := range eh.errors {
totalCount += errorInfo.Count
// 提取错误类型(基于错误消息的前几个单词)
parts := strings.Split(errorInfo.Message, " ")
if len(parts) > 0 {
errorType := strings.Join(parts[:min(3, len(parts))], " ")
errorTypes[errorType]++
}
}
return map[string]interface{}{
"total_errors": totalErrors,
"total_count": totalCount,
"error_types": errorTypes,
"max_errors": eh.maxErrors,
"retention": eh.retention,
"active_errors": len(eh.errors),
}
}
// min 辅助函数
func min(a, b int) int {
if a < b {
return a
}
return b
}
// GlobalErrorHandler 全局错误处理器
var globalErrorHandler *ErrorHandler
// InitGlobalErrorHandler 初始化全局错误处理器
func InitGlobalErrorHandler(maxErrors int, retention time.Duration) {
globalErrorHandler = NewErrorHandler(maxErrors, retention)
}
// GetGlobalErrorHandler 获取全局错误处理器
func GetGlobalErrorHandler() *ErrorHandler {
if globalErrorHandler == nil {
InitGlobalErrorHandler(100, 24*time.Hour)
}
return globalErrorHandler
}
// Recover 全局panic恢复函数
func Recover() gin.HandlerFunc {
if globalErrorHandler == nil {
InitGlobalErrorHandler(100, 24*time.Hour)
}
return globalErrorHandler.RecoverMiddleware()
}
// Error 全局错误处理函数
func Error() gin.HandlerFunc {
if globalErrorHandler == nil {
InitGlobalErrorHandler(100, 24*time.Hour)
}
return globalErrorHandler.ErrorMiddleware()
}
// RecordError 记录错误(全局函数)
func RecordError(message string, level string) {
if globalErrorHandler == nil {
InitGlobalErrorHandler(100, 24*time.Hour)
return
}
errorInfo := &ErrorInfo{
ID: fmt.Sprintf("%s_%d", level, time.Now().UnixNano()),
Timestamp: time.Now(),
Message: message,
Level: level,
Count: 1,
}
globalErrorHandler.saveError(errorInfo)
}

458
monitor/metrics.go Normal file
View File

@@ -0,0 +1,458 @@
package monitor
import (
"fmt"
"net/http"
"runtime"
"sync"
"time"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics 监控指标
type Metrics struct {
// HTTP请求指标
RequestsTotal *prometheus.CounterVec
RequestDuration *prometheus.HistogramVec
RequestSize *prometheus.SummaryVec
ResponseSize *prometheus.SummaryVec
// 数据库指标
DatabaseQueries *prometheus.CounterVec
DatabaseErrors *prometheus.CounterVec
DatabaseDuration *prometheus.HistogramVec
// 系统指标
MemoryUsage prometheus.Gauge
Goroutines prometheus.Gauge
GCStats *prometheus.CounterVec
// 业务指标
ResourcesCreated *prometheus.CounterVec
ResourcesViewed *prometheus.CounterVec
Searches *prometheus.CounterVec
Transfers *prometheus.CounterVec
// 错误指标
ErrorsTotal *prometheus.CounterVec
// 自定义指标
CustomCounters map[string]prometheus.Counter
CustomGauges map[string]prometheus.Gauge
mu sync.RWMutex
}
// MetricsConfig 监控配置
type MetricsConfig struct {
Enabled bool
ListenAddress string
MetricsPath string
Namespace string
Subsystem string
}
// DefaultMetricsConfig 默认监控配置
func DefaultMetricsConfig() *MetricsConfig {
return &MetricsConfig{
Enabled: true,
ListenAddress: ":9090",
MetricsPath: "/metrics",
Namespace: "urldb",
Subsystem: "api",
}
}
// GlobalMetrics 全局监控实例
var (
globalMetrics *Metrics
once sync.Once
)
// NewMetrics 创建新的监控指标
func NewMetrics(config *MetricsConfig) *Metrics {
if config == nil {
config = DefaultMetricsConfig()
}
namespace := config.Namespace
subsystem := config.Subsystem
m := &Metrics{
// HTTP请求指标
RequestsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"method", "endpoint", "status"},
),
RequestDuration: promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "endpoint", "status"},
),
RequestSize: promauto.NewSummaryVec(
prometheus.SummaryOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "http_request_size_bytes",
Help: "HTTP request size in bytes",
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
},
[]string{"method", "endpoint"},
),
ResponseSize: promauto.NewSummaryVec(
prometheus.SummaryOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "http_response_size_bytes",
Help: "HTTP response size in bytes",
Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001},
},
[]string{"method", "endpoint"},
),
// 数据库指标
DatabaseQueries: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "database",
Name: "queries_total",
Help: "Total number of database queries",
},
[]string{"table", "operation"},
),
DatabaseErrors: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "database",
Name: "errors_total",
Help: "Total number of database errors",
},
[]string{"table", "operation", "error"},
),
DatabaseDuration: promauto.NewHistogramVec(
prometheus.HistogramOpts{
Namespace: namespace,
Subsystem: "database",
Name: "query_duration_seconds",
Help: "Database query duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"table", "operation"},
),
// 系统指标
MemoryUsage: promauto.NewGauge(
prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: "system",
Name: "memory_usage_bytes",
Help: "Current memory usage in bytes",
},
),
Goroutines: promauto.NewGauge(
prometheus.GaugeOpts{
Namespace: namespace,
Subsystem: "system",
Name: "goroutines",
Help: "Number of goroutines",
},
),
GCStats: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "system",
Name: "gc_stats_total",
Help: "Garbage collection statistics",
},
[]string{"type"},
),
// 业务指标
ResourcesCreated: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "business",
Name: "resources_created_total",
Help: "Total number of resources created",
},
[]string{"category", "platform"},
),
ResourcesViewed: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "business",
Name: "resources_viewed_total",
Help: "Total number of resources viewed",
},
[]string{"category"},
),
Searches: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "business",
Name: "searches_total",
Help: "Total number of searches",
},
[]string{"platform"},
),
Transfers: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "business",
Name: "transfers_total",
Help: "Total number of transfers",
},
[]string{"platform", "status"},
),
// 错误指标
ErrorsTotal: promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Subsystem: "errors",
Name: "total",
Help: "Total number of errors",
},
[]string{"type", "endpoint"},
),
// 自定义指标
CustomCounters: make(map[string]prometheus.Counter),
CustomGauges: make(map[string]prometheus.Gauge),
}
// 启动系统指标收集
go m.collectSystemMetrics()
return m
}
// GetGlobalMetrics 获取全局监控实例
func GetGlobalMetrics() *Metrics {
once.Do(func() {
globalMetrics = NewMetrics(DefaultMetricsConfig())
})
return globalMetrics
}
// SetGlobalMetrics 设置全局监控实例
func SetGlobalMetrics(metrics *Metrics) {
globalMetrics = metrics
}
// collectSystemMetrics 收集系统指标
func (m *Metrics) collectSystemMetrics() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
// 收集内存使用情况
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
m.MemoryUsage.Set(float64(ms.Alloc))
// 收集goroutine数量
m.Goroutines.Set(float64(runtime.NumGoroutine()))
// 收集GC统计
m.GCStats.WithLabelValues("alloc").Add(float64(ms.TotalAlloc))
m.GCStats.WithLabelValues("sys").Add(float64(ms.Sys))
m.GCStats.WithLabelValues("lookups").Add(float64(ms.Lookups))
m.GCStats.WithLabelValues("mallocs").Add(float64(ms.Mallocs))
m.GCStats.WithLabelValues("frees").Add(float64(ms.Frees))
}
}
// MetricsMiddleware 监控中间件
func (m *Metrics) MetricsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.FullPath()
// 如果没有匹配的路由,使用请求路径
if path == "" {
path = c.Request.URL.Path
}
// 记录请求大小
requestSize := float64(c.Request.ContentLength)
m.RequestSize.WithLabelValues(c.Request.Method, path).Observe(requestSize)
c.Next()
// 记录响应信息
status := c.Writer.Status()
latency := time.Since(start).Seconds()
responseSize := float64(c.Writer.Size())
// 更新指标
m.RequestsTotal.WithLabelValues(c.Request.Method, path, fmt.Sprintf("%d", status)).Inc()
m.RequestDuration.WithLabelValues(c.Request.Method, path, fmt.Sprintf("%d", status)).Observe(latency)
m.ResponseSize.WithLabelValues(c.Request.Method, path).Observe(responseSize)
// 如果是错误状态码,记录错误
if status >= 400 {
m.ErrorsTotal.WithLabelValues("http", path).Inc()
}
}
}
// StartMetricsServer 启动监控服务器
func (m *Metrics) StartMetricsServer(config *MetricsConfig) {
if config == nil {
config = DefaultMetricsConfig()
}
if !config.Enabled {
utils.Info("监控服务器未启用")
return
}
// 创建新的Gin路由器
router := gin.New()
router.Use(gin.Recovery())
// 注册Prometheus指标端点
router.GET(config.MetricsPath, gin.WrapH(promhttp.Handler()))
// 启动HTTP服务器
go func() {
utils.Info("监控服务器启动在 %s", config.ListenAddress)
if err := router.Run(config.ListenAddress); err != nil {
utils.Error("监控服务器启动失败: %v", err)
}
}()
utils.Info("监控服务器已启动,指标路径: %s%s", config.ListenAddress, config.MetricsPath)
}
// IncrementDatabaseQuery 增加数据库查询计数
func (m *Metrics) IncrementDatabaseQuery(table, operation string) {
m.DatabaseQueries.WithLabelValues(table, operation).Inc()
}
// IncrementDatabaseError 增加数据库错误计数
func (m *Metrics) IncrementDatabaseError(table, operation, error string) {
m.DatabaseErrors.WithLabelValues(table, operation, error).Inc()
}
// ObserveDatabaseDuration 记录数据库查询耗时
func (m *Metrics) ObserveDatabaseDuration(table, operation string, duration float64) {
m.DatabaseDuration.WithLabelValues(table, operation).Observe(duration)
}
// IncrementResourceCreated 增加资源创建计数
func (m *Metrics) IncrementResourceCreated(category, platform string) {
m.ResourcesCreated.WithLabelValues(category, platform).Inc()
}
// IncrementResourceViewed 增加资源查看计数
func (m *Metrics) IncrementResourceViewed(category string) {
m.ResourcesViewed.WithLabelValues(category).Inc()
}
// IncrementSearch 增加搜索计数
func (m *Metrics) IncrementSearch(platform string) {
m.Searches.WithLabelValues(platform).Inc()
}
// IncrementTransfer 增加转存计数
func (m *Metrics) IncrementTransfer(platform, status string) {
m.Transfers.WithLabelValues(platform, status).Inc()
}
// IncrementError 增加错误计数
func (m *Metrics) IncrementError(errorType, endpoint string) {
m.ErrorsTotal.WithLabelValues(errorType, endpoint).Inc()
}
// AddCustomCounter 添加自定义计数器
func (m *Metrics) AddCustomCounter(name, help string, labels []string) prometheus.Counter {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s_%v", name, labels)
if counter, exists := m.CustomCounters[key]; exists {
return counter
}
counter := promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: "urldb",
Name: name,
Help: help,
},
labels,
).WithLabelValues() // 如果没有标签,返回默认实例
m.CustomCounters[key] = counter
return counter
}
// AddCustomGauge 添加自定义仪表盘
func (m *Metrics) AddCustomGauge(name, help string, labels []string) prometheus.Gauge {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s_%v", name, labels)
if gauge, exists := m.CustomGauges[key]; exists {
return gauge
}
gauge := promauto.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "urldb",
Name: name,
Help: help,
},
labels,
).WithLabelValues() // 如果没有标签,返回默认实例
m.CustomGauges[key] = gauge
return gauge
}
// GetMetricsSummary 获取指标摘要
func (m *Metrics) GetMetricsSummary() map[string]interface{} {
// 这里可以实现获取当前指标摘要的逻辑
// 由于Prometheus指标不能直接读取我们只能返回一些基本的统计信息
return map[string]interface{}{
"timestamp": time.Now(),
"status": "running",
"info": "使用 /metrics 端点获取详细指标",
}
}
// HealthCheck 健康检查
func (m *Metrics) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().Unix(),
"version": "1.0.0",
})
}
// SetupHealthCheck 设置健康检查端点
func (m *Metrics) SetupHealthCheck(router *gin.Engine) {
router.GET("/health", m.HealthCheck)
router.GET("/healthz", m.HealthCheck)
}
// MetricsHandler 指标处理器
func (m *Metrics) MetricsHandler() gin.HandlerFunc {
return gin.WrapH(promhttp.Handler())
}

25
monitor/setup.go Normal file
View File

@@ -0,0 +1,25 @@
package monitor
import (
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
// SetupMonitoring 设置完整的监控系统
func SetupMonitoring(router *gin.Engine) {
// 获取全局监控实例
metrics := GetGlobalMetrics()
// 设置健康检查端点
metrics.SetupHealthCheck(router)
// 设置指标端点
router.GET("/metrics", metrics.MetricsHandler())
utils.Info("监控系统已设置完成")
}
// SetGlobalErrorHandler 设置全局错误处理器
func SetGlobalErrorHandler(eh *ErrorHandler) {
globalErrorHandler = eh
}

View File

@@ -17,6 +17,22 @@ server {
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
location /sitemap.xml {
proxy_pass http://backend/api$request_uri;
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;
}
location ~ ^/sitemap-([0-9]+)\.xml$ {
proxy_pass http://backend/api$request_uri;
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 路由 - 所有 /api/ 开头的请求转发到后端
location /api/ {
proxy_pass http://backend;
@@ -40,6 +56,23 @@ server {
error_page 502 503 504 /50x.html;
}
location ^~ /uploads/ {
proxy_pass http://backend/uploads/;
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;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
}
# 静态文件路由 - 直接转发到前端
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://frontend;
@@ -53,22 +86,25 @@ server {
add_header Cache-Control "public, immutable";
}
# 上传文件路由 - 直接访问后端的上传目录
location /uploads/ {
# 微信公众号验证文件路由 - 根目录的TXT文件直接访问后端uploads目录
location ~ ^/[^/]+\.txt$ {
# 检查文件是否存在于uploads目录
set $uploads_path /uploads$uri;
if (-f $uploads_path) {
proxy_pass http://backend;
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;
# 缓存设置
expires 1y;
add_header Cache-Control "public, immutable";
expires 1h;
add_header Cache-Control "public";
# 允许跨域访问
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
break;
}
# 如果文件不存在返回404
return 404;
}
# 健康检查

View File

@@ -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("存在违禁词 (共 %d 个)", len(matchedWords))
forbiddenResources = append(forbiddenResources, resource)
utils.Info(fmt.Sprintf("标记违禁词资源: %s (包含 %d 个违禁词)", resource.Title, len(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
}

View File

@@ -0,0 +1,96 @@
package scheduler
import (
"time"
"github.com/ctwj/urldb/utils"
)
// CacheCleaner 缓存清理调度器
type CacheCleaner struct {
baseScheduler *BaseScheduler
running bool
ticker *time.Ticker
stopChan chan bool
}
// NewCacheCleaner 创建缓存清理调度器
func NewCacheCleaner(baseScheduler *BaseScheduler) *CacheCleaner {
return &CacheCleaner{
baseScheduler: baseScheduler,
running: false,
ticker: time.NewTicker(time.Hour), // 每小时执行一次
stopChan: make(chan bool),
}
}
// Start 启动缓存清理任务
func (cc *CacheCleaner) Start() {
if cc.running {
utils.Warn("缓存清理任务已在运行中")
return
}
cc.running = true
utils.Info("启动缓存清理任务")
go func() {
for {
select {
case <-cc.ticker.C:
cc.cleanCache()
case <-cc.stopChan:
cc.running = false
utils.Info("缓存清理任务已停止")
return
}
}
}()
}
// Stop 停止缓存清理任务
func (cc *CacheCleaner) Stop() {
if !cc.running {
return
}
close(cc.stopChan)
cc.ticker.Stop()
}
// cleanCache 执行缓存清理
func (cc *CacheCleaner) cleanCache() {
utils.Debug("开始清理过期缓存")
// 清理过期缓存1小时TTL
utils.CleanAllExpiredCaches(time.Hour)
utils.Debug("定期清理过期缓存完成")
// 可以在这里添加其他缓存清理逻辑,比如:
// - 清理特定模式的缓存
// - 记录缓存统计信息
cc.logCacheStats()
}
// logCacheStats 记录缓存统计信息
func (cc *CacheCleaner) logCacheStats() {
hotCacheSize := utils.GetHotResourcesCache().Size()
relatedCacheSize := utils.GetRelatedResourcesCache().Size()
systemConfigSize := utils.GetSystemConfigCache().Size()
categoriesSize := utils.GetCategoriesCache().Size()
tagsSize := utils.GetTagsCache().Size()
totalSize := hotCacheSize + relatedCacheSize + systemConfigSize + categoriesSize + tagsSize
utils.Debug("缓存统计 - 热门资源: %d, 相关资源: %d, 系统配置: %d, 分类: %d, 标签: %d, 总计: %d",
hotCacheSize, relatedCacheSize, systemConfigSize, categoriesSize, tagsSize, totalSize)
// 如果缓存过多,可以记录警告
if totalSize > 1000 {
utils.Warn("缓存项数量过多: %d建议检查缓存策略", totalSize)
}
}
// IsRunning 检查是否正在运行
func (cc *CacheCleaner) IsRunning() bool {
return cc.running
}

View File

@@ -116,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.Debug("自动转存定时任务已在运行中")
return
}
gs.manager.StartAutoTransferScheduler()
utils.Debug("全局调度器已启动自动转存定时任务")
}
// StopAutoTransferScheduler 停止自动转存定时任务
func (gs *GlobalScheduler) StopAutoTransferScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if !gs.manager.IsAutoTransferRunning() {
utils.Debug("自动转存定时任务未在运行")
return
}
gs.manager.StopAutoTransferScheduler()
utils.Debug("全局调度器已停止自动转存定时任务")
}
// 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()
@@ -182,16 +147,57 @@ 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()
// StartSitemapScheduler 启动Sitemap调度任务
func (gs *GlobalScheduler) StartSitemapScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if gs.manager.IsSitemapRunning() {
utils.Debug("Sitemap定时任务已在运行中")
return
}
gs.manager.StartSitemapScheduler()
utils.Debug("全局调度器已启动Sitemap定时任务")
}
// StopSitemapScheduler 停止Sitemap调度任务
func (gs *GlobalScheduler) StopSitemapScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if !gs.manager.IsSitemapRunning() {
utils.Debug("Sitemap定时任务未在运行")
return
}
gs.manager.StopSitemapScheduler()
utils.Debug("全局调度器已停止Sitemap定时任务")
}
// IsSitemapSchedulerRunning 检查Sitemap定时任务是否在运行
func (gs *GlobalScheduler) IsSitemapSchedulerRunning() bool {
gs.mutex.RLock()
defer gs.mutex.RUnlock()
return gs.manager.IsSitemapRunning()
}
// UpdateSitemapConfig 更新Sitemap配置
func (gs *GlobalScheduler) UpdateSitemapConfig(enabled bool) error {
return gs.manager.UpdateSitemapConfig(enabled)
}
// GetSitemapConfig 获取Sitemap配置
func (gs *GlobalScheduler) GetSitemapConfig() (bool, error) {
return gs.manager.GetSitemapConfig()
}
// TriggerSitemapGeneration 手动触发sitemap生成
func (gs *GlobalScheduler) TriggerSitemapGeneration() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
gs.manager.TriggerSitemapGeneration()
}

View File

@@ -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,81 +138,183 @@ 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)",
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)
}
} else {
utils.Warn("电影获取数据失败或为空")
utils.Info("最近热门电影数据处理完成,共收集 %d 条数据", len(recentMovies))
return recentMovies
}
utils.Info("电影数据处理完成,共收集 %d 条数据", len(movieDramas))
return movieDramas
}
// processRecentTVs 处理最近热门剧集数据
func (h *HotDramaScheduler) processRecentTVs() []*entity.HotDrama {
utils.Info("开始处理最近热门剧集数据...")
// processTvData 处理电视剧数据
func (h *HotDramaScheduler) processTvData() []*entity.HotDrama {
utils.Info("开始处理电视剧数据...")
var recentTVs []*entity.HotDrama
var tvDramas []*entity.HotDrama
// 获取所有tv类型
tvTypes := h.doubanService.GetAllTvTypes()
utils.Info("获取到 %d 个tv类型: %v", len(tvTypes), tvTypes)
// 遍历每个type分别请求数据
for _, tvType := range tvTypes {
utils.Info("正在处理tv类型: %s", tvType)
// 使用GetTypePage方法请求数据
tvResult, err := h.doubanService.GetTypePage("tv", tvType)
items, err := h.doubanService.GetRecentHotTVs()
if err != nil {
utils.Error(fmt.Sprintf("获取tv类型 %s 数据失败: %v", tvType, err))
utils.Error(fmt.Sprintf("获取最近热门剧集失败: %v", err))
return recentTVs
}
utils.Info("最近热门剧集获取到 %d 个数据", len(items))
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("获取%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{
// 转换每个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)
}
}
// 根据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,
@@ -208,24 +327,12 @@ func (h *HotDramaScheduler) processTvData() []*entity.HotDrama {
Directors: strings.Join(item.Directors, ", "),
Actors: strings.Join(item.Actors, ", "),
PosterURL: item.Pic.Normal,
Category: "电视剧",
SubType: tvType, // 使用具体的tv类型
Category: category,
SubType: subType,
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)
}
}
utils.Info("电视剧数据处理完成,共收集 %d 条数据", len(tvDramas))
return tvDramas
}
// GetHotDramaNames 获取热播剧名称列表(公共方法)

View File

@@ -10,7 +10,7 @@ type Manager struct {
baseScheduler *BaseScheduler
hotDramaScheduler *HotDramaScheduler
readyResourceScheduler *ReadyResourceScheduler
autoTransferScheduler *AutoTransferScheduler
sitemapScheduler *SitemapScheduler
}
// NewManager 创建调度器管理器
@@ -39,13 +39,13 @@ func NewManager(
// 创建各个具体的调度器
hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
autoTransferScheduler := NewAutoTransferScheduler(baseScheduler)
sitemapScheduler := NewSitemapScheduler(baseScheduler)
return &Manager{
baseScheduler: baseScheduler,
hotDramaScheduler: hotDramaScheduler,
readyResourceScheduler: readyResourceScheduler,
autoTransferScheduler: autoTransferScheduler,
sitemapScheduler: sitemapScheduler,
}
}
@@ -56,11 +56,8 @@ func (m *Manager) StartAll() {
// 启动热播剧定时任务
m.StartHotDramaScheduler()
// 启动待处理资源自动处理任务
m.StartReadyResourceScheduler()
// 启动自动转存定时任务
m.StartAutoTransferScheduler()
// 启动待处理资源调度任务
m.readyResourceScheduler.Start()
utils.Debug("所有调度任务已启动")
}
@@ -72,11 +69,8 @@ func (m *Manager) StopAll() {
// 停止热播剧定时任务
m.StopHotDramaScheduler()
// 停止待处理资源自动处理任务
m.StopReadyResourceScheduler()
// 停止自动转存定时任务
m.StopAutoTransferScheduler()
// 停止待处理资源调度任务
m.readyResourceScheduler.Stop()
utils.Debug("所有调度任务已停止")
}
@@ -111,31 +105,46 @@ 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()
}
// StartSitemapScheduler 启动Sitemap调度任务
func (m *Manager) StartSitemapScheduler() {
m.sitemapScheduler.Start()
}
// StopSitemapScheduler 停止Sitemap调度任务
func (m *Manager) StopSitemapScheduler() {
m.sitemapScheduler.Stop()
}
// IsSitemapRunning 检查Sitemap调度任务是否在运行
func (m *Manager) IsSitemapRunning() bool {
return m.sitemapScheduler.IsRunning()
}
// GetSitemapConfig 获取Sitemap配置
func (m *Manager) GetSitemapConfig() (bool, error) {
return m.sitemapScheduler.GetSitemapConfig()
}
// UpdateSitemapConfig 更新Sitemap配置
func (m *Manager) UpdateSitemapConfig(enabled bool) error {
return m.sitemapScheduler.UpdateSitemapConfig(enabled)
}
// TriggerSitemapGeneration 手动触发sitemap生成
func (m *Manager) TriggerSitemapGeneration() {
go m.sitemapScheduler.generateSitemap()
}
// GetStatus 获取所有调度任务的状态
func (m *Manager) GetStatus() map[string]bool {
return map[string]bool{
"hot_drama": m.IsHotDramaRunning(),
"ready_resource": m.IsReadyResourceRunning(),
"auto_transfer": m.IsAutoTransferRunning(),
"sitemap": m.IsSitemapRunning(),
}
}

308
scheduler/sitemap.go Normal file
View File

@@ -0,0 +1,308 @@
package scheduler
import (
"encoding/xml"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm"
)
const (
SITEMAP_MAX_URLS = 50000 // 每个sitemap最多5万个URL
SITEMAP_DIR = "./data/sitemap" // sitemap文件目录
)
// SitemapScheduler Sitemap调度器
type SitemapScheduler struct {
*BaseScheduler
sitemapConfig entity.SystemConfig
stopChan chan bool
isRunning bool
}
// NewSitemapScheduler 创建Sitemap调度器
func NewSitemapScheduler(baseScheduler *BaseScheduler) *SitemapScheduler {
return &SitemapScheduler{
BaseScheduler: baseScheduler,
stopChan: make(chan bool),
isRunning: false,
}
}
// Start 启动Sitemap调度任务
func (s *SitemapScheduler) Start() {
if s.IsRunning() {
utils.Debug("Sitemap定时任务已在运行中")
return
}
s.SetRunning(true)
utils.Info("开始启动Sitemap定时任务")
go s.run()
}
// Stop 停止Sitemap调度任务
func (s *SitemapScheduler) Stop() {
if !s.IsRunning() {
utils.Debug("Sitemap定时任务未在运行")
return
}
utils.Info("正在停止Sitemap定时任务")
s.stopChan <- true
s.SetRunning(false)
}
// IsRunning 检查Sitemap调度任务是否在运行
func (s *SitemapScheduler) IsRunning() bool {
return s.isRunning
}
// SetRunning 设置运行状态
func (s *SitemapScheduler) SetRunning(running bool) {
s.isRunning = running
}
// GetStopChan 获取停止通道
func (s *SitemapScheduler) GetStopChan() chan bool {
return s.stopChan
}
// run 执行调度任务的主循环
func (s *SitemapScheduler) run() {
utils.Info("Sitemap定时任务开始运行")
// 立即执行一次
s.generateSitemap()
// 定时执行
ticker := time.NewTicker(24 * time.Hour) // 每24小时执行一次
defer ticker.Stop()
for {
select {
case <-ticker.C:
utils.Info("定时执行Sitemap生成任务")
s.generateSitemap()
case <-s.stopChan:
utils.Info("收到停止信号Sitemap调度任务退出")
return
}
}
}
// generateSitemap 生成sitemap
func (s *SitemapScheduler) generateSitemap() {
utils.Info("开始生成Sitemap...")
startTime := time.Now()
// 获取资源总数
var total int64
if err := s.BaseScheduler.resourceRepo.GetDB().Model(&entity.Resource{}).Count(&total).Error; err != nil {
utils.Error("获取资源总数失败: %v", err)
return
}
utils.Info("需要处理的资源总数: %d", total)
if total == 0 {
utils.Info("没有资源需要生成Sitemap")
return
}
// 计算需要多少个sitemap文件
totalPages := int((total + SITEMAP_MAX_URLS - 1) / SITEMAP_MAX_URLS)
utils.Info("需要生成 %d 个sitemap文件", totalPages)
// 确保目录存在
if err := os.MkdirAll(SITEMAP_DIR, 0755); err != nil {
utils.Error("创建sitemap目录失败: %v", err)
return
}
// 生成每个sitemap文件
for page := 0; page < totalPages; page++ {
if s.SleepWithStopCheck(100 * time.Millisecond) { // 避免过于频繁的检查
utils.Info("在生成sitemap过程中收到停止信号退出生成")
return
}
utils.Info("正在生成第 %d 个sitemap文件", page+1)
if err := s.generateSitemapPage(page); err != nil {
utils.Error("生成第 %d 个sitemap文件失败: %v", page, err)
} else {
utils.Info("成功生成第 %d 个sitemap文件", page+1)
}
}
// 生成sitemap索引文件
if err := s.generateSitemapIndex(totalPages); err != nil {
utils.Error("生成sitemap索引文件失败: %v", err)
} else {
utils.Info("成功生成sitemap索引文件")
}
// 尝试获取网站基础URL
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
if err != nil || baseURL == "" {
baseURL = "https://yoursite.com" // 默认值
}
utils.Info("Sitemap生成完成耗时: %v", time.Since(startTime))
utils.Info("Sitemap地址: %s/sitemap.xml", baseURL)
}
// generateSitemapPage 生成单个sitemap页面
func (s *SitemapScheduler) generateSitemapPage(page int) error {
offset := page * SITEMAP_MAX_URLS
limit := SITEMAP_MAX_URLS
var resources []entity.Resource
if err := s.BaseScheduler.resourceRepo.GetDB().Offset(offset).Limit(limit).Find(&resources).Error; err != nil {
return fmt.Errorf("获取资源数据失败: %w", err)
}
var urls []Url
for _, resource := range resources {
lastMod := resource.UpdatedAt
if resource.CreatedAt.After(lastMod) {
lastMod = resource.CreatedAt
}
urls = append(urls, Url{
Loc: fmt.Sprintf("/r/%s", resource.Key),
LastMod: lastMod.Format("2006-01-02"), // 只保留日期部分
ChangeFreq: "weekly",
Priority: 0.8,
})
}
urlSet := UrlSet{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}
filename := filepath.Join(SITEMAP_DIR, fmt.Sprintf("sitemap-%d.xml", page))
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer file.Close()
file.WriteString(xml.Header)
encoder := xml.NewEncoder(file)
encoder.Indent("", " ")
if err := encoder.Encode(urlSet); err != nil {
return fmt.Errorf("写入XML失败: %w", err)
}
return nil
}
// generateSitemapIndex 生成sitemap索引文件
func (s *SitemapScheduler) generateSitemapIndex(totalPages int) error {
// 构建主机URL - 这里使用默认URL实际应用中应从配置获取
baseURL, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeyWebsiteURL)
if err != nil || baseURL == "" {
baseURL = "https://yoursite.com" // 默认值
}
// 移除URL末尾的斜杠
baseURL = strings.TrimSuffix(baseURL, "/")
var sitemaps []Sitemap
for i := 0; i < totalPages; i++ {
sitemapURL := fmt.Sprintf("%s/sitemap-%d.xml", baseURL, i)
sitemaps = append(sitemaps, Sitemap{
Loc: sitemapURL,
LastMod: time.Now().Format("2006-01-02"),
})
}
sitemapIndex := SitemapIndex{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
Sitemaps: sitemaps,
}
filename := filepath.Join(SITEMAP_DIR, "sitemap.xml")
file, err := os.Create(filename)
if err != nil {
return fmt.Errorf("创建索引文件失败: %w", err)
}
defer file.Close()
file.WriteString(xml.Header)
encoder := xml.NewEncoder(file)
encoder.Indent("", " ")
if err := encoder.Encode(sitemapIndex); err != nil {
return fmt.Errorf("写入索引XML失败: %w", err)
}
return nil
}
// GetSitemapConfig 获取Sitemap配置
func (s *SitemapScheduler) GetSitemapConfig() (bool, error) {
configStr, err := s.BaseScheduler.systemConfigRepo.GetConfigValue(entity.ConfigKeySitemapConfig)
if err != nil && err != gorm.ErrRecordNotFound {
return false, err
}
// 解析配置字符串,这里简化处理
return configStr == "1" || configStr == "true", nil
}
// UpdateSitemapConfig 更新Sitemap配置
func (s *SitemapScheduler) UpdateSitemapConfig(enabled bool) error {
configStr := "0"
if enabled {
configStr = "1"
}
config := entity.SystemConfig{
Key: entity.ConfigKeySitemapConfig,
Value: configStr,
Type: "bool",
}
// 由于repository没有直接的SetConfig方法我们使用UpsertConfigs
configs := []entity.SystemConfig{config}
return s.BaseScheduler.systemConfigRepo.UpsertConfigs(configs)
}
// UrlSet sitemap内容
type UrlSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []Url `xml:"url"`
}
// Url 单个URL信息
type Url struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
ChangeFreq string `xml:"changefreq"`
Priority float64 `xml:"priority"`
}
// SitemapIndex sitemap索引结构
type SitemapIndex struct {
XMLName xml.Name `xml:"sitemapindex"`
XMLNS string `xml:"xmlns,attr"`
Sitemaps []Sitemap `xml:"sitemap"`
}
// Sitemap 单个sitemap信息
type Sitemap struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod"`
}

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