151 Commits

Author SHA1 Message Date
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
3a90a89b08 chore: bump version to v1.2.5 2025-08-28 13:33:45 +08:00
Kerwin
80a94c0f05 fix: 页面跳转问题 2025-08-27 18:38:40 +08:00
Kerwin
d49ce77350 update:remove docs 2025-08-27 16:11:19 +08:00
Kerwin
800b511116 add: qrcode 2025-08-25 13:05:25 +08:00
ctwj
292384f281 Merge pull request #6 from ctwj/fix_res
fix: 一致问题修复
2025-08-25 11:28:17 +08:00
Kerwin
b8b0cc760d update: 优化空状态显示 2025-08-25 11:27:02 +08:00
Kerwin
002267e436 fix: 修复资源自动处理的问题 2025-08-25 09:51:45 +08:00
ctwj
0d54dffa19 Merge pull request #5 from ctwj/fix_filter
update: 完善Meilisearch的同步操作
2025-08-22 14:47:55 +08:00
Kerwin
d2c9d79658 update: 处理缓存是先检测配置项是否开启 2025-08-22 14:44:36 +08:00
Kerwin
f70850d465 update: 自动同步资源到 Meilisearch 2025-08-22 14:40:32 +08:00
ctwj
223b1af714 fix: 修复违禁词正常显示的问题
fix: 修复违禁词正常显示的问题
2025-08-22 09:32:12 +08:00
Kerwin
76a64492a2 update: 弹窗优化 2025-08-21 19:07:57 +08:00
Kerwin
d6224ab25c fix: 修复封禁词,没有过滤的问题 2025-08-21 18:51:20 +08:00
Kerwin
9708157566 chore: version to 1.2.4 2025-08-21 09:23:45 +08:00
ctwj
bfaf93c849 update: xunlei package 2025-08-20 23:05:03 +08:00
Kerwin
8cf1575232 chore: bump version to v1.2.4 2025-08-20 17:26:25 +08:00
ctwj
17c05870a3 Merge pull request #3 from ctwj/feat_search_opt
feat: 新增搜索增加
2025-08-20 17:24:25 +08:00
ctwj
d531be3c36 Merge pull request #2 from ctwj/fix_version
fix: 修复版本显示不正确的问题
2025-08-20 17:19:41 +08:00
Kerwin
edde7afdc8 fix: 修复版本显示不正确的问题 2025-08-20 17:16:34 +08:00
Kerwin
77216cf380 feat: 新增搜索增加 2025-08-20 15:03:14 +08:00
ctwj
1b898eda37 update: xunlei api 2025-08-20 00:13:31 +08:00
Kerwin
da3fc11b2e fix: 修复文件管理搜索不生效的问题 2025-08-19 09:09:44 +08:00
ctwj
cbf673126e update: 首页优化 2025-08-19 01:11:09 +08:00
ctwj
aa7d6ea2fe add: sql 2025-08-18 23:02:04 +08:00
Kerwin
841eb05f68 update: AppFooter 2025-08-18 20:10:09 +08:00
Kerwin
eeca85942f update: 移除api的特殊处理,使用配置项实现 2025-08-18 19:40:10 +08:00
200 changed files with 27475 additions and 6587 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

130
BUILD.md Normal file
View File

@@ -0,0 +1,130 @@
# 编译说明
## 方案1使用编译脚本推荐
### 在Git Bash中执行
```bash
# 给脚本添加执行权限(首次使用)
chmod +x scripts/build.sh
# 编译Linux版本推荐用于服务器部署
./scripts/build.sh
# 或者明确指定编译Linux版本
./scripts/build.sh build-linux
# 或者指定目标文件名
./scripts/build.sh build-linux myapp
# 编译当前平台版本(用于本地测试)
./scripts/build.sh build
```
### 编译脚本功能:
- 自动读取 `VERSION` 文件中的版本号
- 自动获取Git提交信息和分支信息
- 自动获取构建时间
- 将版本信息编译到可执行文件中
- 支持跨平台编译默认编译Linux版本
- 使用静态链接,适合服务器部署
## 方案2手动编译
### Linux版本推荐
```bash
# 获取版本信息
VERSION=$(cat VERSION)
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
# 编译Linux版本
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' -X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' -X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' -X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" -o main .
```
### 当前平台版本:
```bash
# 获取版本信息
VERSION=$(cat VERSION)
GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
# 编译当前平台版本
go build -ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' -X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' -X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' -X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" -o main .
```
## 验证版本信息
编译完成后,可以通过以下方式验证版本信息:
```bash
# 命令行验证
./main version
# 启动服务器后通过API验证
curl http://localhost:8080/api/version
```
## 部署说明
使用方案1编译后部署时只需要
1. 复制可执行文件到服务器
2. 启动程序
**不再需要复制 `VERSION` 文件**,因为版本信息已经编译到程序中。
### 使用部署脚本(可选)
```bash
# 给部署脚本添加执行权限
chmod +x scripts/deploy-example.sh
# 部署到服务器
./scripts/deploy-example.sh root example.com /opt/urldb
```
### 使用Docker构建脚本
```bash
# 给脚本添加执行权限
chmod +x scripts/docker-build.sh
# 构建Docker镜像
./scripts/docker-build.sh build
# 构建指定版本镜像
./scripts/docker-build.sh build 1.2.4
# 推送镜像到Docker Hub
./scripts/docker-build.sh push 1.2.4
```
### 手动Docker构建
```bash
# 构建镜像
docker build --target backend -t ctwj/urldb-backend:1.2.3 .
docker build --target frontend -t ctwj/urldb-frontend:1.2.3 .
```
## 版本管理
更新版本号:
```bash
# 更新版本号
./scripts/version.sh patch # 修订版本
./scripts/version.sh minor # 次版本
./scripts/version.sh major # 主版本
# 然后重新编译
./scripts/build.sh
# 或者构建Docker镜像
./scripts/docker-build.sh build
```

View File

@@ -1,88 +0,0 @@
# 📝 更新日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
## [v1.1.0]
1. 新增违禁词功能
2. 管理后台体验优化
3. bug修复
## [v1.0.0]
1. 自动转存
2. 自动资源处理
### 新增
- 项目开源准备
- 完善文档和贡献指南
- 添加LICENSE文件
### 修复
- 修复README格式问题
- 优化项目结构说明
## [100 - 202401XX
### 新增
- 🎉 首次发布
- 📁 多平台网盘支持夸克、阿里云盘、百度网盘、UC网盘
- 🔍 智能搜索功能
- 📊 数据统计和分析
- 🏷️ 标签系统
- 👥 用户权限管理
- 📦 批量资源管理
- 🔄 自动处理功能
- 📈 热播剧管理
- ⚙️ 系统配置管理
- 🔐 JWT认证系统
- 📱 响应式设计
- 🌙 深色模式支持
- 🎨 现代化UI界面
### 技术特性
- 🦀 基于Golang 1023的高性能后端
- ⚡ Nuxt.js 3 + Vue 3前端框架
- 🗄️ PostgreSQL数据库
- 🔧 GORM ORM框架
- 🐳 Docker容器化部署
- 📝 TypeScript类型安全
### 核心功能
- 资源管理:增删改查、批量操作
- 分类管理:资源分类和标签
- 平台管理:多网盘平台支持
- 搜索统计:全文搜索和数据分析
- 系统配置:灵活的参数配置
---
## 版本说明
### 版本号格式
- **主版本号**不兼容的API修改
- **次版本号**:向下兼容的功能性新增
- **修订号**:向下兼容的问题修正
### 更新类型
- 🎉 **重大更新** - 新版本发布
-**新增功能** - 新功能或特性
- 🐛 **问题修复** - Bug修复
- 🔧 **优化改进** - 性能优化或代码改进
- 📚 **文档更新** - 文档或注释更新
- 🎨 **界面优化** - UI/UX改进
-**性能提升** - 性能相关改进
- 🔒 **安全更新** - 安全相关修复
- 🧪 **测试相关** - 测试用例或测试工具
- 🚀 **部署相关** - 部署或构建相关
---
## 贡献
如果您想为更新日志做出贡献,请:
1. 在提交代码时使用规范的提交信息2. 在Pull Request中描述您的更改
3. 遵循项目的贡献指南
---

51
ChangeLog.md Normal file
View File

@@ -0,0 +1,51 @@
### 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. 搜索增强,毫秒级响应,关键字高亮显示
2. 修复版本显示不正确的问题
3. 配置项新增Meilisearch配置
### v1.2.3
1. 添加图片上传功能
2. 添加Logo配置项首页Logo显示
3. 后台界面体验优化
### v1.2.1
1. 修复转存移除广告失败的问题和添加广告失败的问题
2. 管理后台UI优化
3. 首页添加描述显示
### v1.2.0
1. 新增手动批量转存
2. 新增QQ机器人
3. 新增任务管理功能
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
5. 新增支持第三方统计代码配置
### v1.0.0
1. 支持API手动批量录入资源
2. 支持,自动判断资源有效性
3. 支持自动转存
4. 支持平台多账号管理Quark
5. 支持简单的数据统计

View File

@@ -28,11 +28,26 @@ WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# 复制VERSION文件确保构建时能正确读取版本号 # 复制所有源代码
COPY VERSION ./
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 定义构建参数
ARG VERSION
ARG GIT_COMMIT
ARG GIT_BRANCH
ARG BUILD_TIME
# 获取版本信息并编译
RUN VERSION=${VERSION:-$(cat VERSION)} && \
GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")} && \
GIT_BRANCH=${GIT_BRANCH:-$(git branch --show-current 2>/dev/null || echo "unknown")} && \
BUILD_TIME=${BUILD_TIME:-$(date '+%Y-%m-%d %H:%M:%S')} && \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \
-ldflags "-X 'github.com/ctwj/urldb/utils.Version=${VERSION}' \
-X 'github.com/ctwj/urldb/utils.BuildTime=${BUILD_TIME}' \
-X 'github.com/ctwj/urldb/utils.GitCommit=${GIT_COMMIT}' \
-X 'github.com/ctwj/urldb/utils.GitBranch=${GIT_BRANCH}'" \
-o main .
# 后端运行阶段 # 后端运行阶段
FROM alpine:latest AS backend FROM alpine:latest AS backend

View File

@@ -10,6 +10,10 @@
**一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘 ** **一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘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) 🌐 [在线演示](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网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 | | UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 | | 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 | | 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
@@ -34,31 +38,23 @@
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink) - [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink) - [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb) - [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.3 ### v1.3.3
1. 添加图片上传功能 1. 新增公众号自动回复
2. 添加Logo配置项首页Logo显示 2. 修复一些问题
3. 后台界面体验优化
### v1.2.1
1. 修复转存移除广告失败的问题和添加广告失败的问题
2. 管理后台UI优化
3. 首页添加描述显示
### v1.2.0 [详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
1. 新增手动批量转存
2. 新增QQ机器人
3. 新增任务管理功能
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
5. 新增支持第三方统计代码配置
### v1.0.0 当前特性
1. 支持API手动批量录入资源 1. 支持API手动批量录入资源
2. 支持,自动判断资源有效性 2. 支持,自动判断资源有效性
3. 支持自动转存 3. 支持自动转存
4. 支持平台多账号管理Quark 4. 支持平台多账号管理
5. 支持简单的数据统计 5. 支持简单的数据统计
6. 支持Meilisearch
--- ---
@@ -66,7 +62,6 @@
## 📸 项目截图 ## 📸 项目截图
### 🏠 首页 ### 🏠 首页
![首页](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/index.webp) ![首页](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/index.webp)
@@ -128,17 +123,12 @@ PORT=8080
# 时区配置 # 时区配置
TIMEZONE=Asia/Shanghai 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
```
--- ---
## 📄 许可证 ## 📄 许可证
@@ -158,11 +148,8 @@ docker push ctwj/urldb-backend:1.0.7
--- ---
## 📞 联系我们 ## 📞 交流群
- **TG**: [Telegram 技术交流群](https://t.me/+QF9OMpOv-PBjZGEx)
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
- **邮箱**: 510199617@qq.com
--- ---
@@ -172,4 +159,4 @@ docker push ctwj/urldb-backend:1.0.7
Made with ❤️ by [老九] Made with ❤️ by [老九]
</div> </div>

View File

@@ -1 +1 @@
1.2.3 1.3.4

View File

@@ -8,6 +8,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/utils" "github.com/ctwj/urldb/utils"
) )
@@ -255,9 +257,9 @@ func (a *AlipanService) DeleteFiles(fileList []string) (*TransferResult, error)
} }
// GetUserInfo 获取用户信息 // GetUserInfo 获取用户信息
func (a *AlipanService) GetUserInfo(cookie string) (*UserInfo, error) { func (a *AlipanService) GetUserInfo(cookie *string) (*UserInfo, error) {
// 设置Cookie // 设置Cookie
a.SetHeader("Cookie", cookie) a.SetHeader("Cookie", *cookie)
// 获取access token // 获取access token
accessToken, err := a.manageAccessToken() accessToken, err := a.manageAccessToken()
@@ -347,6 +349,11 @@ func (a *AlipanService) getAlipan1(shareID string) (*AlipanShareInfo, error) {
return &result, nil return &result, nil
} }
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
func (a *AlipanService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
return nil, nil
}
// getAlipan2 通过分享id获取X-Share-Token // getAlipan2 通过分享id获取X-Share-Token
func (a *AlipanService) getAlipan2(shareID string) (*AlipanShareToken, error) { func (a *AlipanService) getAlipan2(shareID string) (*AlipanShareToken, error) {
data := map[string]interface{}{ data := map[string]interface{}{
@@ -399,6 +406,9 @@ func (a *AlipanService) getAlipan4(shareData map[string]interface{}) (*AlipanSha
return &result, nil return &result, nil
} }
func (u *AlipanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
}
// manageAccessToken 管理access token // manageAccessToken 管理access token
func (a *AlipanService) manageAccessToken() (string, error) { func (a *AlipanService) manageAccessToken() (string, error) {
if a.accessToken != "" { if a.accessToken != "" {

View File

@@ -2,6 +2,9 @@ package pan
import ( import (
"fmt" "fmt"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
) )
// BaiduPanService 百度网盘服务 // BaiduPanService 百度网盘服务
@@ -50,9 +53,9 @@ func (b *BaiduPanService) DeleteFiles(fileList []string) (*TransferResult, error
} }
// GetUserInfo 获取用户信息 // GetUserInfo 获取用户信息
func (b *BaiduPanService) GetUserInfo(cookie string) (*UserInfo, error) { func (b *BaiduPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
// 设置Cookie // 设置Cookie
b.SetHeader("Cookie", cookie) b.SetHeader("Cookie", *cookie)
// 调用百度网盘用户信息API // 调用百度网盘用户信息API
userInfoURL := "https://pan.baidu.com/api/gettemplatevariable" userInfoURL := "https://pan.baidu.com/api/gettemplatevariable"
@@ -101,3 +104,21 @@ func (b *BaiduPanService) GetUserInfo(cookie string) (*UserInfo, error) {
ServiceType: "baidu", ServiceType: "baidu",
}, nil }, 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" "strconv"
"strings" "strings"
"sync" "sync"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
) )
// ServiceType 定义网盘服务类型 // ServiceType 定义网盘服务类型
@@ -74,6 +77,7 @@ type UserInfo struct {
UsedSpace int64 `json:"usedSpace"` // 已使用空间 UsedSpace int64 `json:"usedSpace"` // 已使用空间
TotalSpace int64 `json:"totalSpace"` // 总空间 TotalSpace int64 `json:"totalSpace"` // 总空间
ServiceType string `json:"serviceType"` // 服务类型 ServiceType string `json:"serviceType"` // 服务类型
ExtraData string `json:"extraData"` // 额外信息
} }
// PanService 网盘服务接口 // PanService 网盘服务接口
@@ -88,10 +92,14 @@ type PanService interface {
DeleteFiles(fileList []string) (*TransferResult, error) DeleteFiles(fileList []string) (*TransferResult, error)
// GetUserInfo 获取用户信息 // GetUserInfo 获取用户信息
GetUserInfo(cookie string) (*UserInfo, error) GetUserInfo(ck *string) (*UserInfo, error)
// GetServiceType 获取服务类型 // GetServiceType 获取服务类型
GetServiceType() ServiceType GetServiceType() ServiceType
SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks)
UpdateConfig(config *PanConfig)
} }
// PanFactory 网盘工厂 // PanFactory 网盘工厂
@@ -249,6 +257,9 @@ func ExtractShareId(url string) (string, ServiceType) {
shareID = url[substring:] shareID = url[substring:]
// 去除可能的锚点 // 去除可能的锚点
if hashIndex := strings.Index(shareID, "?"); hashIndex != -1 {
shareID = shareID[:hashIndex]
}
if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 { if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 {
shareID = shareID[:hashIndex] shareID = shareID[:hashIndex]
} }

View File

@@ -29,35 +29,31 @@ var configRefreshChan = make(chan bool, 1)
// 单例相关变量 // 单例相关变量
var ( var (
quarkInstance *QuarkPanService
quarkOnce sync.Once
systemConfigRepo repo.SystemConfigRepository systemConfigRepo repo.SystemConfigRepository
systemConfigOnce sync.Once systemConfigOnce sync.Once
) )
// NewQuarkPanService 创建夸克网盘服务(单例模式) // NewQuarkPanService 创建夸克网盘服务(单例模式)
func NewQuarkPanService(config *PanConfig) *QuarkPanService { func NewQuarkPanService(config *PanConfig) *QuarkPanService {
quarkOnce.Do(func() { quarkInstance := &QuarkPanService{
quarkInstance = &QuarkPanService{ BasePanService: NewBasePanService(config),
BasePanService: NewBasePanService(config), }
}
// 设置夸克网盘的默认请求头 // 设置夸克网盘的默认请求头
quarkInstance.SetHeaders(map[string]string{ quarkInstance.SetHeaders(map[string]string{
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9", "Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/json;charset=UTF-8", "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": `"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"`,
"Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": `"Windows"`, "Sec-Ch-Ua-Platform": `"Windows"`,
"Sec-Fetch-Dest": "empty", "Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors", "Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site", "Sec-Fetch-Site": "same-site",
"Referer": "https://pan.quark.cn/", "Referer": "https://pan.quark.cn/",
"Referrer-Policy": "strict-origin-when-cross-origin", "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", "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, "Cookie": config.Cookie,
})
}) })
// 更新配置 // 更新配置
@@ -947,10 +943,10 @@ type PasswordResult struct {
} }
// GetUserInfo 获取用户信息 // GetUserInfo 获取用户信息
func (q *QuarkPanService) GetUserInfo(cookie string) (*UserInfo, error) { func (q *QuarkPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
// 临时设置cookie // 临时设置cookie
originalCookie := q.GetHeader("Cookie") originalCookie := q.GetHeader("Cookie")
q.SetHeader("Cookie", cookie) q.SetHeader("Cookie", *cookie)
defer q.SetHeader("Cookie", originalCookie) // 恢复原始cookie defer q.SetHeader("Cookie", originalCookie) // 恢复原始cookie
// 获取用户基本信息 // 获取用户基本信息
@@ -1028,6 +1024,9 @@ func (q *QuarkPanService) GetUserInfo(cookie string) (*UserInfo, error) {
}, nil }, nil
} }
func (xq *QuarkPanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
}
// formatBytes 格式化字节数为可读格式 // formatBytes 格式化字节数为可读格式
func formatBytes(bytes int64) string { func formatBytes(bytes int64) string {
const unit = 1024 const unit = 1024

View File

@@ -1,6 +1,11 @@
package pan package pan
import "fmt" import (
"fmt"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
)
// UCService UC网盘服务 // UCService UC网盘服务
type UCService struct { type UCService struct {
@@ -47,10 +52,20 @@ func (u *UCService) DeleteFiles(fileList []string) (*TransferResult, error) {
return ErrorResult("UC网盘文件删除功能暂未实现"), nil 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 获取用户信息 // GetUserInfo 获取用户信息
func (u *UCService) GetUserInfo(cookie string) (*UserInfo, error) { func (u *UCService) GetUserInfo(cookie *string) (*UserInfo, error) {
// 设置Cookie // 设置Cookie
u.SetHeader("Cookie", cookie) u.SetHeader("Cookie", *cookie)
// 调用UC网盘用户信息API // 调用UC网盘用户信息API
userInfoURL := "https://drive.uc.cn/api/user/info" userInfoURL := "https://drive.uc.cn/api/user/info"
@@ -97,3 +112,11 @@ func (u *UCService) GetUserInfo(cookie string) (*UserInfo, error) {
ServiceType: "uc", ServiceType: "uc",
}, nil }, 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: { XunleiStr: {
Domains: []string{"pan.xunlei.com"}, 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: { BaiduStr: {
Domains: []string{"pan.baidu.com", "yun.baidu.com"}, 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":""}
================================

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 ( import (
"fmt" "fmt"
"log"
"os" "os"
"strconv"
"time" "time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
@@ -45,8 +47,22 @@ func InitDB() error {
host, port, user, password, dbname) host, port, user, password, dbname)
var err error 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{ 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 { if err != nil {
return err return err
@@ -58,10 +74,17 @@ func InitDB() error {
return err return err
} }
// 设置连接池参数 // 优化数据库连接池参数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接数 maxOpenConns := getEnvInt("DB_MAX_OPEN_CONNS", 50)
sqlDB.SetMaxOpenConns(100) // 最大打开连接数 maxIdleConns := getEnvInt("DB_MAX_IDLE_CONNS", 20)
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大生命周期 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() { if shouldRunMigration() {
@@ -82,6 +105,10 @@ func InitDB() error {
&entity.Task{}, &entity.Task{},
&entity.TaskItem{}, &entity.TaskItem{},
&entity.File{}, &entity.File{},
&entity.TelegramChannel{},
&entity.APIAccessLog{},
&entity.APIAccessLogStats{},
&entity.APIAccessLogSummary{},
) )
if err != nil { if err != nil {
utils.Fatal("数据库迁移失败: %v", err) utils.Fatal("数据库迁移失败: %v", err)
@@ -146,6 +173,7 @@ func autoMigrate() error {
&entity.SearchStat{}, &entity.SearchStat{},
&entity.HotDrama{}, &entity.HotDrama{},
&entity.File{}, &entity.File{},
&entity.TelegramChannel{},
) )
} }
@@ -263,6 +291,13 @@ func insertDefaultDataIfEmpty() error {
{Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString}, {Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt}, {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool}, {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
{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},
} }
for _, config := range defaultSystemConfigs { for _, config := range defaultSystemConfigs {
@@ -288,3 +323,19 @@ func insertDefaultDataIfEmpty() error {
utils.Info("默认数据插入完成") utils.Info("默认数据插入完成")
return nil 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

@@ -1,8 +1,9 @@
package converter package converter
import ( import (
"reflect"
"time" "time"
"github.com/ctwj/urldb/db/dto" "github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
) )
@@ -10,22 +11,24 @@ import (
// ToResourceResponse 将Resource实体转换为ResourceResponse // ToResourceResponse 将Resource实体转换为ResourceResponse
func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse { func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
response := dto.ResourceResponse{ response := dto.ResourceResponse{
ID: resource.ID, ID: resource.ID,
Title: resource.Title, Title: resource.Title,
Description: resource.Description, Description: resource.Description,
URL: resource.URL, URL: resource.URL,
PanID: resource.PanID, PanID: resource.PanID,
SaveURL: resource.SaveURL, SaveURL: resource.SaveURL,
FileSize: resource.FileSize, FileSize: resource.FileSize,
CategoryID: resource.CategoryID, CategoryID: resource.CategoryID,
ViewCount: resource.ViewCount, ViewCount: resource.ViewCount,
IsValid: resource.IsValid, IsValid: resource.IsValid,
IsPublic: resource.IsPublic, IsPublic: resource.IsPublic,
CreatedAt: resource.CreatedAt, CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt, UpdatedAt: resource.UpdatedAt,
Cover: resource.Cover, Cover: resource.Cover,
Author: resource.Author, Author: resource.Author,
ErrorMsg: resource.ErrorMsg, ErrorMsg: resource.ErrorMsg,
SyncedToMeilisearch: resource.SyncedToMeilisearch,
SyncedAt: resource.SyncedAt,
} }
// 设置分类名称 // 设置分类名称
@@ -47,6 +50,92 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
return response return response
} }
// ToResourceResponseFromMeilisearch 将MeilisearchDocument转换为ResourceResponse包含高亮信息
func ToResourceResponseFromMeilisearch(doc interface{}) dto.ResourceResponse {
// 使用反射来获取MeilisearchDocument的字段
docValue := reflect.ValueOf(doc)
if docValue.Kind() == reflect.Ptr {
docValue = docValue.Elem()
}
response := dto.ResourceResponse{}
// 获取基本字段
if idField := docValue.FieldByName("ID"); idField.IsValid() {
response.ID = uint(idField.Uint())
}
if titleField := docValue.FieldByName("Title"); titleField.IsValid() {
response.Title = titleField.String()
}
if descField := docValue.FieldByName("Description"); descField.IsValid() {
response.Description = descField.String()
}
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()
}
if fileSizeField := docValue.FieldByName("FileSize"); fileSizeField.IsValid() {
response.FileSize = fileSizeField.String()
}
if keyField := docValue.FieldByName("Key"); keyField.IsValid() {
// Key字段在ResourceResponse中不存在跳过
}
if categoryField := docValue.FieldByName("Category"); categoryField.IsValid() {
response.CategoryName = categoryField.String()
}
if authorField := docValue.FieldByName("Author"); authorField.IsValid() {
response.Author = authorField.String()
}
if createdAtField := docValue.FieldByName("CreatedAt"); createdAtField.IsValid() {
response.CreatedAt = createdAtField.Interface().(time.Time)
}
if updatedAtField := docValue.FieldByName("UpdatedAt"); updatedAtField.IsValid() {
response.UpdatedAt = updatedAtField.Interface().(time.Time)
}
// 处理PanID
if panIDField := docValue.FieldByName("PanID"); panIDField.IsValid() && !panIDField.IsNil() {
panIDPtr := panIDField.Interface().(*uint)
if panIDPtr != nil {
response.PanID = panIDPtr
}
}
// 处理Tags
if tagsField := docValue.FieldByName("Tags"); tagsField.IsValid() {
tags := tagsField.Interface().([]string)
response.Tags = make([]dto.TagResponse, len(tags))
for i, tagName := range tags {
response.Tags[i] = dto.TagResponse{
Name: tagName,
}
}
}
// 处理高亮字段
if titleHighlightField := docValue.FieldByName("TitleHighlight"); titleHighlightField.IsValid() {
response.TitleHighlight = titleHighlightField.String()
}
if descHighlightField := docValue.FieldByName("DescriptionHighlight"); descHighlightField.IsValid() {
response.DescriptionHighlight = descHighlightField.String()
}
if categoryHighlightField := docValue.FieldByName("CategoryHighlight"); categoryHighlightField.IsValid() {
response.CategoryHighlight = categoryHighlightField.String()
}
if tagsHighlightField := docValue.FieldByName("TagsHighlight"); tagsHighlightField.IsValid() {
tagsHighlight := tagsHighlightField.Interface().([]string)
response.TagsHighlight = make([]string, len(tagsHighlight))
copy(response.TagsHighlight, tagsHighlight)
}
return response
}
// ToResourceResponseList 将Resource实体列表转换为ResourceResponse列表 // ToResourceResponseList 将Resource实体列表转换为ResourceResponse列表
func ToResourceResponseList(resources []entity.Resource) []dto.ResourceResponse { func ToResourceResponseList(resources []entity.Resource) []dto.ResourceResponse {
responses := make([]dto.ResourceResponse, len(resources)) responses := make([]dto.ResourceResponse, len(resources))
@@ -176,7 +265,7 @@ func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceRe
if isDeleted { if isDeleted {
deletedAt = &resource.DeletedAt.Time deletedAt = &resource.DeletedAt.Time
} }
return dto.ReadyResourceResponse{ return dto.ReadyResourceResponse{
ID: resource.ID, ID: resource.ID,
Title: resource.Title, Title: resource.Title,

View File

@@ -29,6 +29,7 @@ func HotDramaToResponse(drama *entity.HotDrama) *dto.HotDramaResponse {
PosterURL: drama.PosterURL, PosterURL: drama.PosterURL,
Category: drama.Category, Category: drama.Category,
SubType: drama.SubType, SubType: drama.SubType,
Rank: drama.Rank,
Source: drama.Source, Source: drama.Source,
DoubanID: drama.DoubanID, DoubanID: drama.DoubanID,
DoubanURI: drama.DoubanURI, DoubanURI: drama.DoubanURI,
@@ -49,6 +50,7 @@ func RequestToHotDrama(req *dto.HotDramaRequest) *entity.HotDrama {
Actors: req.Actors, Actors: req.Actors,
Category: req.Category, Category: req.Category,
SubType: req.SubType, SubType: req.SubType,
Rank: req.Rank,
Source: req.Source, Source: req.Source,
DoubanID: req.DoubanID, DoubanID: req.DoubanID,
} }

View File

@@ -1,6 +1,7 @@
package converter package converter
import ( import (
"encoding/json"
"strconv" "strconv"
"time" "time"
@@ -78,6 +79,39 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
} }
case entity.ConfigKeyThirdPartyStatsCode: case entity.ConfigKeyThirdPartyStatsCode:
response.ThirdPartyStatsCode = config.Value response.ThirdPartyStatsCode = config.Value
case entity.ConfigKeyMeilisearchEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.MeilisearchEnabled = val
}
case entity.ConfigKeyMeilisearchHost:
response.MeilisearchHost = config.Value
case entity.ConfigKeyMeilisearchPort:
response.MeilisearchPort = config.Value
case entity.ConfigKeyMeilisearchMasterKey:
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
} }
} }
@@ -187,6 +221,57 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
updatedKeys = append(updatedKeys, entity.ConfigKeyThirdPartyStatsCode) updatedKeys = append(updatedKeys, entity.ConfigKeyThirdPartyStatsCode)
} }
// Meilisearch配置 - 只处理被设置的字段
if req.MeilisearchEnabled != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchEnabled, Value: strconv.FormatBool(*req.MeilisearchEnabled), Type: entity.ConfigTypeBool})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchEnabled)
}
if req.MeilisearchHost != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchHost, Value: *req.MeilisearchHost, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchHost)
}
if req.MeilisearchPort != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchPort, Value: *req.MeilisearchPort, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchPort)
}
if req.MeilisearchMasterKey != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchMasterKey, Value: *req.MeilisearchMasterKey, Type: entity.ConfigTypeString})
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchMasterKey)
}
if req.MeilisearchIndexName != nil {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMeilisearchIndexName, Value: *req.MeilisearchIndexName, Type: entity.ConfigTypeString})
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 len(updatedKeys) > 0 { if len(updatedKeys) > 0 {
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys) utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
@@ -219,6 +304,12 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
entity.ConfigResponseFieldPageSize: 100, entity.ConfigResponseFieldPageSize: 100,
entity.ConfigResponseFieldMaintenanceMode: false, entity.ConfigResponseFieldMaintenanceMode: false,
entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能 entity.ConfigResponseFieldEnableRegister: true, // 默认开启注册功能
entity.ConfigResponseFieldThirdPartyStatsCode: "",
entity.ConfigResponseFieldMeilisearchEnabled: false,
entity.ConfigResponseFieldMeilisearchHost: "localhost",
entity.ConfigResponseFieldMeilisearchPort: "7700",
entity.ConfigResponseFieldMeilisearchMasterKey: "",
entity.ConfigResponseFieldMeilisearchIndexName: "resources",
} }
// 将键值对转换为map // 将键值对转换为map
@@ -280,6 +371,38 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
} }
case entity.ConfigKeyThirdPartyStatsCode: case entity.ConfigKeyThirdPartyStatsCode:
response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value response[entity.ConfigResponseFieldThirdPartyStatsCode] = config.Value
case entity.ConfigKeyMeilisearchEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldMeilisearchEnabled] = val
}
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.ConfigKeyEnableAnnouncements:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["enable_announcements"] = val
}
case entity.ConfigKeyAnnouncements:
if config.Value == "" || config.Value == "[]" {
response["announcements"] = ""
} else {
response["announcements"] = config.Value
}
case entity.ConfigKeyEnableFloatButtons:
if val, err := strconv.ParseBool(config.Value); err == nil {
response["enable_float_buttons"] = val
}
case entity.ConfigKeyWechatSearchImage:
response["wechat_search_image"] = config.Value
case entity.ConfigKeyTelegramQrImage:
response["telegram_qr_image"] = config.Value
case entity.ConfigKeyQrCodeStyle:
response["qr_code_style"] = config.Value
} }
} }
@@ -315,5 +438,16 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
MaintenanceMode: false, MaintenanceMode: false,
EnableRegister: true, // 默认开启注册功能 EnableRegister: true, // 默认开启注册功能
ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode, ThirdPartyStatsCode: entity.ConfigDefaultThirdPartyStatsCode,
MeilisearchEnabled: false,
MeilisearchHost: entity.ConfigDefaultMeilisearchHost,
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
EnableAnnouncements: false,
Announcements: "",
EnableFloatButtons: false,
WechatSearchImage: entity.ConfigDefaultWechatSearchImage,
TelegramQrImage: entity.ConfigDefaultTelegramQrImage,
QrCodeStyle: entity.ConfigDefaultQrCodeStyle,
} }
} }

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

View File

@@ -16,6 +16,7 @@ type HotDramaRequest struct {
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
Category string `json:"category"` Category string `json:"category"`
SubType string `json:"sub_type"` SubType string `json:"sub_type"`
Rank int `json:"rank"`
Source string `json:"source"` Source string `json:"source"`
DoubanID string `json:"douban_id"` DoubanID string `json:"douban_id"`
DoubanURI string `json:"douban_uri"` DoubanURI string `json:"douban_uri"`
@@ -41,6 +42,7 @@ type HotDramaResponse struct {
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
Category string `json:"category"` Category string `json:"category"`
SubType string `json:"sub_type"` SubType string `json:"sub_type"`
Rank int `json:"rank"`
Source string `json:"source"` Source string `json:"source"`
DoubanID string `json:"douban_id"` DoubanID string `json:"douban_id"`
DoubanURI string `json:"douban_uri"` DoubanURI string `json:"douban_uri"`

View File

@@ -12,24 +12,34 @@ type SearchResponse struct {
// ResourceResponse 资源响应 // ResourceResponse 资源响应
type ResourceResponse struct { type ResourceResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
URL string `json:"url"` URL string `json:"url"`
PanID *uint `json:"pan_id"` PanID *uint `json:"pan_id"`
SaveURL string `json:"save_url"` SaveURL string `json:"save_url"`
FileSize string `json:"file_size"` FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"` CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"` CategoryName string `json:"category_name"`
ViewCount int `json:"view_count"` ViewCount int `json:"view_count"`
IsValid bool `json:"is_valid"` IsValid bool `json:"is_valid"`
IsPublic bool `json:"is_public"` IsPublic bool `json:"is_public"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Tags []TagResponse `json:"tags"` Tags []TagResponse `json:"tags"`
Cover string `json:"cover"` Cover string `json:"cover"`
Author string `json:"author"` Author string `json:"author"`
ErrorMsg string `json:"error_msg"` ErrorMsg string `json:"error_msg"`
SyncedToMeilisearch bool `json:"synced_to_meilisearch"`
SyncedAt *time.Time `json:"synced_at"`
// 高亮字段
TitleHighlight string `json:"title_highlight,omitempty"`
DescriptionHighlight string `json:"description_highlight,omitempty"`
CategoryHighlight string `json:"category_highlight,omitempty"`
TagsHighlight []string `json:"tags_highlight,omitempty"`
// 违禁词相关字段
HasForbiddenWords bool `json:"has_forbidden_words"`
ForbiddenWords []string `json:"forbidden_words"`
} }
// CategoryResponse 分类响应 // CategoryResponse 分类响应
@@ -62,19 +72,20 @@ type PanResponse struct {
// CksResponse Cookie响应 // CksResponse Cookie响应
type CksResponse struct { type CksResponse struct {
ID uint `json:"id"` ID uint `json:"id"`
PanID uint `json:"pan_id"` PanID uint `json:"pan_id"`
Idx int `json:"idx"` Idx int `json:"idx"`
Ck string `json:"ck"` Ck string `json:"ck"`
IsValid bool `json:"is_valid"` IsValid bool `json:"is_valid"`
Space int64 `json:"space"` Space int64 `json:"space"`
LeftSpace int64 `json:"left_space"` LeftSpace int64 `json:"left_space"`
UsedSpace int64 `json:"used_space"` UsedSpace int64 `json:"used_space"`
Username string `json:"username"` Username string `json:"username"`
VipStatus bool `json:"vip_status"` VipStatus bool `json:"vip_status"`
ServiceType string `json:"service_type"` ServiceType string `json:"service_type"`
Remark string `json:"remark"` Remark string `json:"remark"`
Pan *PanResponse `json:"pan,omitempty"` TransferredCount int64 `json:"transferred_count"` // 已转存资源数
Pan *PanResponse `json:"pan,omitempty"`
} }
// ReadyResourceResponse 待处理资源响应 // ReadyResourceResponse 待处理资源响应

View File

@@ -35,6 +35,21 @@ type SystemConfigRequest struct {
// 三方统计配置 // 三方统计配置
ThirdPartyStatsCode *string `json:"third_party_stats_code,omitempty"` // 三方统计代码 ThirdPartyStatsCode *string `json:"third_party_stats_code,omitempty"` // 三方统计代码
// Meilisearch配置
MeilisearchEnabled *bool `json:"meilisearch_enabled,omitempty"`
MeilisearchHost *string `json:"meilisearch_host,omitempty"`
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"`
} }
// SystemConfigResponse 系统配置响应 // SystemConfigResponse 系统配置响应
@@ -76,6 +91,21 @@ type SystemConfigResponse struct {
// 三方统计配置 // 三方统计配置
ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码 ThirdPartyStatsCode string `json:"third_party_stats_code"` // 三方统计代码
// Meilisearch配置
MeilisearchEnabled bool `json:"meilisearch_enabled"`
MeilisearchHost string `json:"meilisearch_host"`
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"`
} }
// SystemConfigItem 单个配置项 // 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状态"` VipStatus bool `json:"vip_status" gorm:"default:false;comment:VIP状态"`
ServiceType string `json:"service_type" gorm:"size:20;comment:服务类型"` ServiceType string `json:"service_type" gorm:"size:20;comment:服务类型"`
Remark string `json:"remark" gorm:"size:64;not null;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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`

View File

@@ -27,6 +27,7 @@ type HotDrama struct {
// 分类信息 // 分类信息
Category string `json:"category" gorm:"size:50"` // 分类(电影/电视剧) Category string `json:"category" gorm:"size:50"` // 分类(电影/电视剧)
SubType string `json:"sub_type" 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'"` // 数据来源 Source string `json:"source" gorm:"size:50;default:'douban'"` // 数据来源

View File

@@ -8,26 +8,28 @@ import (
// Resource 资源模型 // Resource 资源模型
type Resource struct { type Resource struct {
ID uint `json:"id" gorm:"primaryKey;autoIncrement"` ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
Title string `json:"title" gorm:"size:255;not null;comment:资源标题"` Title string `json:"title" gorm:"size:255;not null;comment:资源标题"`
Description string `json:"description" gorm:"type:text;comment:资源描述"` Description string `json:"description" gorm:"type:text;comment:资源描述"`
URL string `json:"url" gorm:"size:128;comment:资源链接"` URL string `json:"url" gorm:"size:128;comment:资源链接"`
PanID *uint `json:"pan_id" gorm:"comment:平台ID"` PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"` SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"` FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"` CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"` ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"` IsValid bool `json:"is_valid" gorm:"default:true;comment:是否有效"`
IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"` IsPublic bool `json:"is_public" gorm:"default:true;comment:是否公开"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
Cover string `json:"cover" gorm:"size:500;comment:封面"` Cover string `json:"cover" gorm:"size:500;comment:封面"`
Author string `json:"author" gorm:"size:100;comment:作者"` Author string `json:"author" gorm:"size:100;comment:作者"`
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"` ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
CkID *uint `json:"ck_id" gorm:"comment:账号ID"` CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"` Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"` Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"`
SyncedToMeilisearch bool `json:"synced_to_meilisearch" gorm:"default:false;comment:是否已同步到Meilisearch"`
SyncedAt *time.Time `json:"synced_at" gorm:"comment:同步时间"`
// 关联关系 // 关联关系
Category Category `json:"category" gorm:"foreignKey:CategoryID"` Category Category `json:"category" gorm:"foreignKey:CategoryID"`
@@ -39,3 +41,23 @@ type Resource struct {
func (Resource) TableName() string { func (Resource) TableName() string {
return "resources" return "resources"
} }
// GetTitle 获取资源标题实现utils.Resource接口
func (r *Resource) GetTitle() string {
return r.Title
}
// GetDescription 获取资源描述实现utils.Resource接口
func (r *Resource) GetDescription() string {
return r.Description
}
// SetTitle 设置资源标题实现utils.Resource接口
func (r *Resource) SetTitle(title string) {
r.Title = title
}
// SetDescription 设置资源描述实现utils.Resource接口
func (r *Resource) SetDescription(description string) {
r.Description = description
}

View File

@@ -35,6 +35,45 @@ const (
// 三方统计配置 // 三方统计配置
ConfigKeyThirdPartyStatsCode = "third_party_stats_code" ConfigKeyThirdPartyStatsCode = "third_party_stats_code"
// Meilisearch配置
ConfigKeyMeilisearchEnabled = "meilisearch_enabled"
ConfigKeyMeilisearchHost = "meilisearch_host"
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"
) )
// ConfigType 配置类型常量 // ConfigType 配置类型常量
@@ -84,6 +123,45 @@ const (
// 三方统计配置字段 // 三方统计配置字段
ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code" ConfigResponseFieldThirdPartyStatsCode = "third_party_stats_code"
// Meilisearch配置字段
ConfigResponseFieldMeilisearchEnabled = "meilisearch_enabled"
ConfigResponseFieldMeilisearchHost = "meilisearch_host"
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"
) )
// ConfigDefaultValue 配置默认值常量 // ConfigDefaultValue 配置默认值常量
@@ -120,4 +198,43 @@ const (
// 三方统计配置默认值 // 三方统计配置默认值
ConfigDefaultThirdPartyStatsCode = "" ConfigDefaultThirdPartyStatsCode = ""
// Meilisearch配置默认值
ConfigDefaultMeilisearchEnabled = "false"
ConfigDefaultMeilisearchHost = "localhost"
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"
) )

View File

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

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

View File

@@ -104,7 +104,7 @@ func (r *FileRepositoryImpl) SearchFiles(search string, fileType, status string,
// 添加搜索条件 // 添加搜索条件
if search != "" { if search != "" {
query = query.Where("original_name LIKE ?", "%"+search+"%") query = query.Where("original_name LIKE ?", "%"+search+"%")
utils.Info("添加搜索条件: original_name LIKE '%%%s%%'", search) utils.Info("添加搜索条件: file_name LIKE '%%%s%%'", search)
} }
if fileType != "" { if fileType != "" {

View File

@@ -12,6 +12,7 @@ type HotDramaRepository interface {
FindByID(id uint) (*entity.HotDrama, error) FindByID(id uint) (*entity.HotDrama, error)
FindAll(page, pageSize int) ([]entity.HotDrama, int64, error) FindAll(page, pageSize int) ([]entity.HotDrama, int64, error)
FindByCategory(category string, 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) FindByDoubanID(doubanID string) (*entity.HotDrama, error)
Upsert(drama *entity.HotDrama) error Upsert(drama *entity.HotDrama) error
Delete(id uint) 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 { if err != nil {
return nil, 0, err 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 { if err != nil {
return nil, 0, err return nil, 0, err
} }

View File

@@ -6,38 +6,42 @@ import (
// RepositoryManager Repository管理器 // RepositoryManager Repository管理器
type RepositoryManager struct { type RepositoryManager struct {
PanRepository PanRepository PanRepository PanRepository
CksRepository CksRepository CksRepository CksRepository
ResourceRepository ResourceRepository ResourceRepository ResourceRepository
CategoryRepository CategoryRepository CategoryRepository CategoryRepository
TagRepository TagRepository TagRepository TagRepository
ReadyResourceRepository ReadyResourceRepository ReadyResourceRepository ReadyResourceRepository
UserRepository UserRepository UserRepository UserRepository
SearchStatRepository SearchStatRepository SearchStatRepository SearchStatRepository
SystemConfigRepository SystemConfigRepository SystemConfigRepository SystemConfigRepository
HotDramaRepository HotDramaRepository HotDramaRepository HotDramaRepository
ResourceViewRepository ResourceViewRepository ResourceViewRepository ResourceViewRepository
TaskRepository TaskRepository TaskRepository TaskRepository
TaskItemRepository TaskItemRepository TaskItemRepository TaskItemRepository
FileRepository FileRepository FileRepository FileRepository
TelegramChannelRepository TelegramChannelRepository
APIAccessLogRepository APIAccessLogRepository
} }
// NewRepositoryManager 创建Repository管理器 // NewRepositoryManager 创建Repository管理器
func NewRepositoryManager(db *gorm.DB) *RepositoryManager { func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
return &RepositoryManager{ return &RepositoryManager{
PanRepository: NewPanRepository(db), PanRepository: NewPanRepository(db),
CksRepository: NewCksRepository(db), CksRepository: NewCksRepository(db),
ResourceRepository: NewResourceRepository(db), ResourceRepository: NewResourceRepository(db),
CategoryRepository: NewCategoryRepository(db), CategoryRepository: NewCategoryRepository(db),
TagRepository: NewTagRepository(db), TagRepository: NewTagRepository(db),
ReadyResourceRepository: NewReadyResourceRepository(db), ReadyResourceRepository: NewReadyResourceRepository(db),
UserRepository: NewUserRepository(db), UserRepository: NewUserRepository(db),
SearchStatRepository: NewSearchStatRepository(db), SearchStatRepository: NewSearchStatRepository(db),
SystemConfigRepository: NewSystemConfigRepository(db), SystemConfigRepository: NewSystemConfigRepository(db),
HotDramaRepository: NewHotDramaRepository(db), HotDramaRepository: NewHotDramaRepository(db),
ResourceViewRepository: NewResourceViewRepository(db), ResourceViewRepository: NewResourceViewRepository(db),
TaskRepository: NewTaskRepository(db), TaskRepository: NewTaskRepository(db),
TaskItemRepository: NewTaskItemRepository(db), TaskItemRepository: NewTaskItemRepository(db),
FileRepository: NewFileRepository(db), FileRepository: NewFileRepository(db),
TelegramChannelRepository: NewTelegramChannelRepository(db),
APIAccessLogRepository: NewAPIAccessLogRepository(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 package repo
import ( import (
"fmt"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"gorm.io/gorm" "gorm.io/gorm"
@@ -10,6 +12,7 @@ import (
type PanRepository interface { type PanRepository interface {
BaseRepository[entity.Pan] BaseRepository[entity.Pan]
FindWithCks() ([]entity.Pan, error) FindWithCks() ([]entity.Pan, error)
FindIdByServiceType(serviceType string) (int, error)
} }
// PanRepositoryImpl Pan的Repository实现 // PanRepositoryImpl Pan的Repository实现
@@ -30,3 +33,12 @@ func (r *PanRepositoryImpl) FindWithCks() ([]entity.Pan, error) {
err := r.db.Preload("Cks").Find(&pans).Error err := r.db.Preload("Cks").Find(&pans).Error
return pans, err 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查找 // FindByKey 根据Key查找
func (r *ReadyResourceRepositoryImpl) FindByKey(key string) ([]entity.ReadyResource, error) { func (r *ReadyResourceRepositoryImpl) FindByKey(key string) ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource 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 return resources, err
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -34,6 +35,17 @@ type ResourceRepository interface {
GetByURL(url string) (*entity.Resource, error) GetByURL(url string) (*entity.Resource, error)
UpdateSaveURL(id uint, saveURL string) error UpdateSaveURL(id uint, saveURL string) error
CreateResourceTag(resourceTag *entity.ResourceTag) error CreateResourceTag(resourceTag *entity.ResourceTag) error
FindByIDs(ids []uint) ([]entity.Resource, error)
FindUnsyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error)
FindSyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error)
CountUnsyncedToMeilisearch() (int64, error)
CountSyncedToMeilisearch() (int64, error)
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)
} }
// ResourceRepositoryImpl Resource的Repository实现 // ResourceRepositoryImpl Resource的Repository实现
@@ -59,38 +71,21 @@ func (r *ResourceRepositoryImpl) FindWithRelations() ([]entity.Resource, error)
// FindWithRelationsPaginated 分页查找包含关联关系的资源 // FindWithRelationsPaginated 分页查找包含关联关系的资源
func (r *ResourceRepositoryImpl) FindWithRelationsPaginated(page, limit int) ([]entity.Resource, int64, error) { func (r *ResourceRepositoryImpl) FindWithRelationsPaginated(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource // 使用新的分页查询功能
var total int64 options := &PaginationOptions{
Page: page,
offset := (page - 1) * limit PageSize: limit,
OrderBy: "updated_at",
// 优化查询:只预加载必要的关联,并添加排序 OrderDir: "desc",
db := r.db.Model(&entity.Resource{}). Preloads: []string{"Category", "Pan"},
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
}
} else {
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 缓存总数5分钟
r.cache[cacheKey] = total
go func() {
time.Sleep(5 * time.Minute)
delete(r.cache, cacheKey)
}()
} }
// 获取分页数据 result, err := PaginatedQuery[entity.Resource](r.db, options)
err := db.Offset(offset).Limit(limit).Find(&resources).Error if err != nil {
return resources, total, err return nil, 0, err
}
return result.Data, result.Total, nil
} }
// FindByCategoryID 根据分类ID查找 // FindByCategoryID 根据分类ID查找
@@ -209,6 +204,7 @@ func (r *ResourceRepositoryImpl) SearchByPanID(query string, panID uint, page, l
// SearchWithFilters 根据参数进行搜索 // SearchWithFilters 根据参数进行搜索
func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}) ([]entity.Resource, int64, error) { func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}) ([]entity.Resource, int64, error) {
startTime := utils.GetCurrentTime()
var resources []entity.Resource var resources []entity.Resource
var total int64 var total int64
@@ -326,8 +322,11 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
// 获取分页数据,按更新时间倒序 // 获取分页数据,按更新时间倒序
queryStart := utils.GetCurrentTime()
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
fmt.Printf("查询结果: 总数=%d, 当前页数据量=%d, pageSize=%d\n", total, len(resources), pageSize) queryDuration := time.Since(queryStart)
totalDuration := time.Since(startTime)
utils.Debug("SearchWithFilters完成: 总数=%d, 当前页数据量=%d, 查询耗时=%v, 总耗时=%v", total, len(resources), queryDuration, totalDuration)
return resources, total, err return resources, total, err
} }
@@ -460,20 +459,222 @@ func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime t
// GetByURL 根据URL获取资源 // GetByURL 根据URL获取资源
func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) { func (r *ResourceRepositoryImpl) GetByURL(url string) (*entity.Resource, error) {
startTime := utils.GetCurrentTime()
var resource entity.Resource var resource entity.Resource
err := r.GetDB().Where("url = ?", url).First(&resource).Error err := r.db.Where("url = ?", url).First(&resource).Error
queryDuration := time.Since(startTime)
if err != nil { if err != nil {
utils.Debug("GetByURL失败: URL=%s, 错误=%v, 查询耗时=%v", url, err, queryDuration)
return nil, err return nil, err
} }
utils.Debug("GetByURL成功: URL=%s, 查询耗时=%v", url, queryDuration)
return &resource, nil return &resource, nil
} }
// UpdateSaveURL 更新资源的转存链接 // FindByIDs 根据ID列表查找资源
func (r *ResourceRepositoryImpl) FindByIDs(ids []uint) ([]entity.Resource, error) {
if len(ids) == 0 {
return []entity.Resource{}, nil
}
var resources []entity.Resource
err := r.db.Where("id IN ?", ids).Preload("Category").Preload("Pan").Preload("Tags").Find(&resources).Error
return resources, err
}
// UpdateSaveURL 更新保存URL
func (r *ResourceRepositoryImpl) UpdateSaveURL(id uint, saveURL string) error { func (r *ResourceRepositoryImpl) UpdateSaveURL(id uint, saveURL string) error {
return r.GetDB().Model(&entity.Resource{}).Where("id = ?", id).Update("save_url", saveURL).Error return r.db.Model(&entity.Resource{}).Where("id = ?", id).Update("save_url", saveURL).Error
} }
// CreateResourceTag 创建资源与标签的关联 // CreateResourceTag 创建资源与标签的关联
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error { func (r *ResourceRepositoryImpl) CreateResourceTag(resourceTag *entity.ResourceTag) error {
return r.GetDB().Create(resourceTag).Error return r.db.Create(resourceTag).Error
}
// FindUnsyncedToMeilisearch 查找未同步到Meilisearch的资源
func (r *ResourceRepositoryImpl) FindUnsyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 查询未同步的资源
db := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", false).
Preload("Category").
Preload("Pan").
Preload("Tags"). // 添加Tags预加载
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
}
// CountUnsyncedToMeilisearch 统计未同步到Meilisearch的资源数量
func (r *ResourceRepositoryImpl) CountUnsyncedToMeilisearch() (int64, error) {
var count int64
err := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", false).
Count(&count).Error
return count, err
}
// MarkAsSyncedToMeilisearch 标记资源为已同步到Meilisearch
func (r *ResourceRepositoryImpl) MarkAsSyncedToMeilisearch(ids []uint) error {
if len(ids) == 0 {
return nil
}
now := time.Now()
return r.db.Model(&entity.Resource{}).
Where("id IN ?", ids).
Updates(map[string]interface{}{
"synced_to_meilisearch": true,
"synced_at": now,
}).Error
}
// MarkAllAsUnsyncedToMeilisearch 标记所有资源为未同步到Meilisearch
func (r *ResourceRepositoryImpl) MarkAllAsUnsyncedToMeilisearch() error {
return r.db.Model(&entity.Resource{}).
Where("1 = 1"). // 添加WHERE条件以更新所有记录
Updates(map[string]interface{}{
"synced_to_meilisearch": false,
"synced_at": nil,
}).Error
}
// FindSyncedToMeilisearch 查找已同步到Meilisearch的资源
func (r *ResourceRepositoryImpl) FindSyncedToMeilisearch(page, limit int) ([]entity.Resource, int64, error) {
var resources []entity.Resource
var total int64
offset := (page - 1) * limit
// 查询已同步的资源
db := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", true).
Preload("Category").
Preload("Pan").
Preload("Tags").
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Find(&resources).Error
return resources, total, err
}
// CountSyncedToMeilisearch 统计已同步到Meilisearch的资源数量
func (r *ResourceRepositoryImpl) CountSyncedToMeilisearch() (int64, error) {
var count int64
err := r.db.Model(&entity.Resource{}).
Where("synced_to_meilisearch = ?", true).
Count(&count).Error
return count, err
}
// FindAllWithPagination 分页查找所有资源
func (r *ResourceRepositoryImpl) FindAllWithPagination(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").
Preload("Tags").
Order("updated_at DESC")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
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
} }

View File

@@ -3,6 +3,7 @@ package repo
import ( import (
"fmt" "fmt"
"sync" "sync"
"time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils" "github.com/ctwj/urldb/utils"
@@ -100,8 +101,11 @@ func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig
// GetOrCreateDefault 获取配置或创建默认配置 // GetOrCreateDefault 获取配置或创建默认配置
func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig, error) { func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig, error) {
startTime := utils.GetCurrentTime()
configs, err := r.FindAll() configs, err := r.FindAll()
initialQueryDuration := time.Since(startTime)
if err != nil { if err != nil {
utils.Error("获取所有系统配置失败: %v耗时: %v", err, initialQueryDuration)
return nil, err return nil, err
} }
@@ -121,17 +125,36 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt}, {Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool}, {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString}, {Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt}, {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool}, {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool}, {Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString}, {Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
{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},
} }
createStart := utils.GetCurrentTime()
err = r.UpsertConfigs(defaultConfigs) err = r.UpsertConfigs(defaultConfigs)
createDuration := time.Since(createStart)
if err != nil { if err != nil {
utils.Error("创建默认系统配置失败: %v耗时: %v", err, createDuration)
return nil, err return nil, err
} }
totalDuration := time.Since(startTime)
utils.Info("创建默认系统配置成功,数量: %d总耗时: %v", len(defaultConfigs), totalDuration)
return defaultConfigs, nil return defaultConfigs, nil
} }
@@ -149,12 +172,23 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
entity.ConfigKeyAutoTransferMinSpace: {Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt}, entity.ConfigKeyAutoTransferMinSpace: {Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
entity.ConfigKeyAutoFetchHotDramaEnabled: {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool}, entity.ConfigKeyAutoFetchHotDramaEnabled: {Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
entity.ConfigKeyApiToken: {Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString}, entity.ConfigKeyApiToken: {Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
entity.ConfigKeyForbiddenWords: {Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, Type: entity.ConfigTypeString},
entity.ConfigKeyAdKeywords: {Key: entity.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString}, entity.ConfigKeyAdKeywords: {Key: entity.ConfigKeyAdKeywords, Value: entity.ConfigDefaultAdKeywords, Type: entity.ConfigTypeString},
entity.ConfigKeyAutoInsertAd: {Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString}, entity.ConfigKeyAutoInsertAd: {Key: entity.ConfigKeyAutoInsertAd, Value: entity.ConfigDefaultAutoInsertAd, Type: entity.ConfigTypeString},
entity.ConfigKeyPageSize: {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt}, entity.ConfigKeyPageSize: {Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
entity.ConfigKeyMaintenanceMode: {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool}, entity.ConfigKeyMaintenanceMode: {Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
entity.ConfigKeyEnableRegister: {Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool}, entity.ConfigKeyEnableRegister: {Key: entity.ConfigKeyEnableRegister, Value: entity.ConfigDefaultEnableRegister, Type: entity.ConfigTypeBool},
entity.ConfigKeyThirdPartyStatsCode: {Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString}, entity.ConfigKeyThirdPartyStatsCode: {Key: entity.ConfigKeyThirdPartyStatsCode, Value: entity.ConfigDefaultThirdPartyStatsCode, Type: entity.ConfigTypeString},
entity.ConfigKeyMeilisearchEnabled: {Key: entity.ConfigKeyMeilisearchEnabled, Value: entity.ConfigDefaultMeilisearchEnabled, Type: entity.ConfigTypeBool},
entity.ConfigKeyMeilisearchHost: {Key: entity.ConfigKeyMeilisearchHost, Value: entity.ConfigDefaultMeilisearchHost, Type: entity.ConfigTypeString},
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},
} }
// 检查现有配置中是否有缺失的配置项 // 检查现有配置中是否有缺失的配置项
@@ -173,17 +207,24 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
// 如果有缺失的配置项,则添加它们 // 如果有缺失的配置项,则添加它们
if len(missingConfigs) > 0 { if len(missingConfigs) > 0 {
upsertStart := utils.GetCurrentTime()
err = r.UpsertConfigs(missingConfigs) err = r.UpsertConfigs(missingConfigs)
upsertDuration := time.Since(upsertStart)
if err != nil { if err != nil {
utils.Error("添加缺失的系统配置失败: %v耗时: %v", err, upsertDuration)
return nil, err return nil, err
} }
utils.Debug("添加缺失的系统配置完成,数量: %d耗时: %v", len(missingConfigs), upsertDuration)
// 重新获取所有配置 // 重新获取所有配置
configs, err = r.FindAll() configs, err = r.FindAll()
if err != nil { if err != nil {
utils.Error("重新获取所有系统配置失败: %v", err)
return nil, err return nil, err
} }
} }
totalDuration := time.Since(startTime)
utils.Debug("GetOrCreateDefault完成总数: %d总耗时: %v", len(configs), totalDuration)
return configs, nil return configs, nil
} }

View File

@@ -1,7 +1,10 @@
package repo package repo
import ( import (
"time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -58,8 +61,15 @@ func (r *TaskItemRepositoryImpl) DeleteByTaskID(taskID uint) error {
// GetByTaskIDAndStatus 根据任务ID和状态获取任务项 // GetByTaskIDAndStatus 根据任务ID和状态获取任务项
func (r *TaskItemRepositoryImpl) GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error) { func (r *TaskItemRepositoryImpl) GetByTaskIDAndStatus(taskID uint, status string) ([]*entity.TaskItem, error) {
startTime := utils.GetCurrentTime()
var items []*entity.TaskItem var items []*entity.TaskItem
err := r.db.Where("task_id = ? AND status = ?", taskID, status).Order("id ASC").Find(&items).Error 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 return items, err
} }
@@ -93,19 +103,36 @@ func (r *TaskItemRepositoryImpl) GetListByTaskID(taskID uint, page, pageSize int
// UpdateStatus 更新任务项状态 // UpdateStatus 更新任务项状态
func (r *TaskItemRepositoryImpl) UpdateStatus(id uint, status string) error { 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 更新任务项状态和输出数据 // UpdateStatusAndOutput 更新任务项状态和输出数据
func (r *TaskItemRepositoryImpl) UpdateStatusAndOutput(id uint, status, outputData string) error { 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, "status": status,
"output_data": outputData, "output_data": outputData,
}).Error }).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 获取任务项统计信息 // GetStatsByTaskID 获取任务项统计信息
func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int, error) { func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int, error) {
startTime := utils.GetCurrentTime()
var results []struct { var results []struct {
Status string Status string
Count int Count int
@@ -117,7 +144,9 @@ func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int,
Group("status"). Group("status").
Find(&results).Error Find(&results).Error
queryDuration := time.Since(startTime)
if err != nil { if err != nil {
utils.Error("GetStatsByTaskID失败: 任务ID=%d, 错误=%v, 查询耗时=%v", taskID, err, queryDuration)
return nil, err return nil, err
} }
@@ -134,12 +163,22 @@ func (r *TaskItemRepositoryImpl) GetStatsByTaskID(taskID uint) (map[string]int,
stats["total"] += result.Count stats["total"] += result.Count
} }
totalDuration := time.Since(startTime)
utils.Debug("GetStatsByTaskID成功: 任务ID=%d, 统计信息=%v, 查询耗时=%v, 总耗时=%v", taskID, stats, queryDuration, totalDuration)
return stats, nil return stats, nil
} }
// ResetProcessingItems 重置处理中的任务项为pending状态 // ResetProcessingItems 重置处理中的任务项为pending状态
func (r *TaskItemRepositoryImpl) ResetProcessingItems(taskID uint) error { 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"). Where("task_id = ? AND status = ?", taskID, "processing").
Update("status", "pending").Error 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 package repo
import ( import (
"time"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -15,6 +18,8 @@ type TaskRepository interface {
UpdateProgress(id uint, progress float64, progressData string) error UpdateProgress(id uint, progress float64, progressData string) error
UpdateStatusAndMessage(id uint, status, message string) error UpdateStatusAndMessage(id uint, status, message string) error
UpdateTaskStats(id uint, processed, success, failed int) error UpdateTaskStats(id uint, processed, success, failed int) error
UpdateStartedAt(id uint) error
UpdateCompletedAt(id uint) error
} }
// TaskRepositoryImpl 任务仓库实现 // TaskRepositoryImpl 任务仓库实现
@@ -31,11 +36,15 @@ func NewTaskRepository(db *gorm.DB) TaskRepository {
// GetByID 根据ID获取任务 // GetByID 根据ID获取任务
func (r *TaskRepositoryImpl) GetByID(id uint) (*entity.Task, error) { func (r *TaskRepositoryImpl) GetByID(id uint) (*entity.Task, error) {
startTime := utils.GetCurrentTime()
var task entity.Task var task entity.Task
err := r.db.First(&task, id).Error err := r.db.First(&task, id).Error
queryDuration := time.Since(startTime)
if err != nil { if err != nil {
utils.Debug("GetByID失败: ID=%d, 错误=%v, 查询耗时=%v", id, err, queryDuration)
return nil, err return nil, err
} }
utils.Debug("GetByID成功: ID=%d, 查询耗时=%v", id, queryDuration)
return &task, nil return &task, nil
} }
@@ -51,6 +60,7 @@ func (r *TaskRepositoryImpl) Delete(id uint) error {
// GetList 获取任务列表 // GetList 获取任务列表
func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string) ([]*entity.Task, int64, error) { func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string) ([]*entity.Task, int64, error) {
startTime := utils.GetCurrentTime()
var tasks []*entity.Task var tasks []*entity.Task
var total int64 var total int64
@@ -58,79 +68,178 @@ func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string
// 添加过滤条件 // 添加过滤条件
if taskType != "" { if taskType != "" {
query = query.Where("task_type = ?", taskType) query = query.Where("type = ?", taskType)
} }
if status != "" { if status != "" {
query = query.Where("status = ?", status) query = query.Where("status = ?", status)
} }
// 获取总数 // 获取总数
countStart := utils.GetCurrentTime()
err := query.Count(&total).Error err := query.Count(&total).Error
countDuration := time.Since(countStart)
if err != nil { if err != nil {
utils.Error("GetList获取总数失败: 错误=%v, 查询耗时=%v", err, countDuration)
return nil, 0, err return nil, 0, err
} }
// 分页查询 // 分页查询
offset := (page - 1) * pageSize offset := (page - 1) * pageSize
queryStart := utils.GetCurrentTime()
err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&tasks).Error err = query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&tasks).Error
queryDuration := time.Since(queryStart)
if err != nil { if err != nil {
utils.Error("GetList查询失败: 错误=%v, 查询耗时=%v", err, queryDuration)
return nil, 0, err 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 return tasks, total, nil
} }
// UpdateStatus 更新任务状态 // UpdateStatus 更新任务状态
func (r *TaskRepositoryImpl) UpdateStatus(id uint, status string) error { 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 更新任务进度 // UpdateProgress 更新任务进度
func (r *TaskRepositoryImpl) UpdateProgress(id uint, progress float64, progressData string) error { func (r *TaskRepositoryImpl) UpdateProgress(id uint, progress float64, progressData string) error {
startTime := utils.GetCurrentTime()
// 检查progress和progress_data字段是否存在 // 检查progress和progress_data字段是否存在
var count int64 var count int64
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'progress'").Count(&count).Error 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 { if err != nil || count == 0 {
// 如果检查失败或字段不存在只更新processed_items等现有字段 // 如果检查失败或字段不存在只更新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的近似值 "processed_items": progress, // 使用progress作为processed_items的近似值
}).Error }).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": progress,
"progress_data": progressData, "progress_data": progressData,
}).Error }).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 更新任务状态和消息 // UpdateStatusAndMessage 更新任务状态和消息
func (r *TaskRepositoryImpl) UpdateStatusAndMessage(id uint, status, message string) error { func (r *TaskRepositoryImpl) UpdateStatusAndMessage(id uint, status, message string) error {
startTime := utils.GetCurrentTime()
// 检查message字段是否存在 // 检查message字段是否存在
var count int64 var count int64
err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'message'").Count(&count).Error err := r.db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'tasks' AND column_name = 'message'").Count(&count).Error
if err != nil { 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 { if count > 0 {
// message字段存在更新状态和消息 // 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, "status": status,
"message": message, "message": message,
}).Error }).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 { } else {
// message字段不存在只更新状态 // 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 更新任务统计信息 // UpdateTaskStats 更新任务统计信息
func (r *TaskRepositoryImpl) UpdateTaskStats(id uint, processed, success, failed int) error { 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, "processed_items": processed,
"success_items": success, "success_items": success,
"failed_items": failed, "failed_items": failed,
}).Error }).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 - app-network
backend: backend:
image: ctwj/urldb-backend:1.2.3 image: ctwj/urldb-backend:1.3.3
environment: environment:
DB_HOST: postgres DB_HOST: postgres
DB_PORT: 5432 DB_PORT: 5432
@@ -38,10 +38,10 @@ services:
- app-network - app-network
frontend: frontend:
image: ctwj/urldb-frontend:1.2.3 image: ctwj/urldb-frontend:1.3.3
environment: environment:
NODE_ENV: production
NUXT_PUBLIC_API_SERVER: http://backend:8080/api NUXT_PUBLIC_API_SERVER: http://backend:8080/api
NUXT_PUBLIC_API_CLIENT: /api
depends_on: depends_on:
- backend - backend
networks: networks:

View File

@@ -1 +0,0 @@
doc.l9.lc

View File

@@ -1,51 +0,0 @@
# 🚀 urlDB - 老九网盘资源数据库
> 一个现代化的老九网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘
<div align="center">
![Go Version](https://img.shields.io/badge/Go-1230?logo=go&logoColor=white)
![Vue Version](https://img.shields.io/badge/Vue-334FC08D?logo=vue.js&logoColor=white)
![Nuxt Version](https://img.shields.io/badge/Nuxt-300.8+-00DC82?logo=nuxt.js&logoColor=white)
![License](https://img.shields.io/badge/License-GPL%20v3-blue.svg)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791go=postgresql&logoColor=white)
</div>
## 🎯 支持的网盘平台
| 平台 | 录入 | 转存 | 分享 |
|------|-------|-----|------|
| 百度网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 阿里云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 夸克网盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| 天翼云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 迅雷云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
## ✨ 功能特性
### 🎯 核心功能
- **📁 多平台网盘支持** - 支持多种主流网盘平台
- **🔍 公开API** - 支持API数据录入资源搜索
- **🏷️ 自动预处理** - 系统自动处理资源,对数据进行有效性判断
- **📊 自动转存分享** - 有效资源,如果属于支持类型将自动转存分享
- **📱 多账号管理** - 同平台支持多账号管理
## 📞 联系我们
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
- **邮箱**: 510199617@qq.com
---
<div align="center">
**如果这个项目对您有帮助,请给我们一个 ⭐ Star**
Made with ❤️ by [老九]
</div>

View File

@@ -1,177 +0,0 @@
# 文档使用说明
## 概述
本项目使用 [docsify](https://docsify.js.org/) 生成文档网站。docsify 是一个轻量级的文档生成器,无需构建静态文件,只需要一个 `index.html` 文件即可。
## 文档结构
```
docs/
├── index.html # 文档主页
├── docsify.config.js # docsify 配置文件
├── README.md # 首页内容
├── _sidebar.md # 侧边栏导航
├── start-docs.sh # 启动脚本
├── guide/ # 使用指南
│ ├── quick-start.md # 快速开始
│ ├── local-development.md # 本地开发
│ └── docker-deployment.md # Docker 部署
├── api/ # API 文档
│ └── overview.md # API 概览
├── architecture/ # 架构文档
│ └── overview.md # 架构概览
├── faq.md # 常见问题
├── changelog.md # 更新日志
└── license.md # 许可证
```
## 快速启动
### 方法一:使用启动脚本(推荐)
```bash
# 进入文档目录
cd docs
# 运行启动脚本
./start-docs.sh
```
脚本会自动:
- 检查是否安装了 docsify-cli
- 如果没有安装,会自动安装
- 启动文档服务
- 在浏览器中打开文档
### 方法二:手动启动
```bash
# 安装 docsify-cli如果未安装
npm install -g docsify-cli
# 进入文档目录
cd docs
# 启动服务
docsify serve . --port 3000 --open
```
## 访问文档
启动成功后,文档将在以下地址可用:
- 本地访问http://localhost:3000
- 局域网访问http://[你的IP]:3000
## 文档特性
### 1. 搜索功能
- 支持全文搜索
- 搜索结果高亮显示
- 支持中文搜索
### 2. 代码高亮
支持多种编程语言的语法高亮:
- Go
- JavaScript/TypeScript
- SQL
- YAML
- JSON
- Bash
### 3. 代码复制
- 一键复制代码块
- 复制成功提示
### 4. 页面导航
- 侧边栏导航
- 页面间导航
- 自动回到顶部
### 5. 响应式设计
- 支持移动端访问
- 自适应屏幕尺寸
## 自定义配置
### 修改主题
`docsify.config.js` 中修改配置:
```javascript
window.$docsify = {
name: '你的项目名称',
repo: '你的仓库地址',
// 其他配置...
}
```
### 添加新页面
1. 在相应目录下创建 `.md` 文件
2.`_sidebar.md` 中添加导航链接
3. 刷新页面即可看到新页面
### 修改样式
可以通过添加自定义 CSS 来修改样式:
```html
<!-- 在 index.html 中添加 -->
<link rel="stylesheet" href="./custom.css">
```
## 部署到生产环境
### 静态部署
docsify 生成的文档可以部署到任何静态文件服务器:
```bash
# 构建静态文件(可选)
docsify generate docs docs/_site
# 部署到 GitHub Pages
git subtree push --prefix docs origin gh-pages
```
### Docker 部署
```bash
# 使用 nginx 镜像
docker run -d -p 80:80 -v $(pwd)/docs:/usr/share/nginx/html nginx
```
## 常见问题
### Q: 启动时提示端口被占用
A: 可以指定其他端口:
```bash
docsify serve . --port 3001
```
### Q: 搜索功能不工作
A: 确保在 `index.html` 中引入了搜索插件:
```html
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
```
### Q: 代码高亮不显示
A: 确保引入了相应的 Prism.js 组件:
```html
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
```
## 维护说明
### 更新文档
1. 修改相应的 `.md` 文件
2. 刷新浏览器即可看到更新
### 添加新功能
1.`docsify.config.js` 中添加插件配置
2.`index.html` 中引入相应的插件文件
### 版本控制
建议将文档与代码一起进行版本控制,确保文档与代码版本同步。
## 相关链接
- [docsify 官方文档](https://docsify.js.org/)
- [docsify 插件市场](https://docsify.js.org/#/plugins)
- [Markdown 语法指南](https://docsify.js.org/#/zh-cn/markdown)

View File

@@ -1,15 +0,0 @@
<!-- docs/_sidebar.md -->
* [🏠 首页](/)
* [🚀 快速开始](guide/quick-start.md)
* [🐳 Docker部署](guide/docker-deployment.md)
* [💻 本地开发](guide/local-development.md)
* 📚 API 文档
* [公开API](api/overview.md)
* 📄 其他
* [常见问题](faq.md)
* [更新日志](changelog.md)
* [许可证](license.md)
* [版本管理](github-version-management.md)

View File

@@ -1,418 +0,0 @@
# API 文档概览
## 概述
老九网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
## 基础信息
- **基础URL**: `http://localhost:8080/api`
- **认证方式**: API Token
- **数据格式**: JSON
- **字符编码**: UTF-8
## 认证说明
### 认证方式
所有 API 都需要提供 API Token 进行认证,支持两种方式:
1. **请求头方式**(推荐)
```
X-API-Token: your_token_here
```
2. **查询参数方式**
```
?api_token=your_token_here
```
### 获取 Token
请联系管理员在系统配置中设置 API Token。
## API 接口列表
### 1. 单个添加资源
**接口描述**: 添加单个资源到待处理列表
**请求信息**:
- **方法**: `POST`
- **路径**: `/api/public/resources/add`
- **认证**: 必需
**请求参数**:
```json
{
"title": "资源标题",
"description": "资源描述",
"url": "资源链接",
"category": "分类名称",
"tags": "标签1,标签2",
"img": "封面图片链接",
"source": "数据来源",
"extra": "额外信息"
}
```
**响应示例**:
```json
{
"success": true,
"message": "资源添加成功,已进入待处理列表",
"data": {
"id": 123
},
"code": 200
}
```
### 2. 批量添加资源
**接口描述**: 批量添加多个资源到待处理列表
**请求信息**:
- **方法**: `POST`
- **路径**: `/api/public/resources/batch-add`
- **认证**: 必需
**请求参数**:
```json
{
"resources": [
{
"title": "资源1",
"url": "链接1",
"description": "描述1"
},
{
"title": "资源2",
"url": "链接2",
"description": "描述2"
}
]
}
```
**响应示例**:
```json
{
"success": true,
"message": "批量添加成功,共添加 2 个资源",
"data": {
"created_count": 2,
"created_ids": [123, 124]
},
"code": 200
}
```
### 3. 资源搜索
**接口描述**: 搜索资源,支持关键词、标签、分类过滤
**请求信息**:
- **方法**: `GET`
- **路径**: `/api/public/resources/search`
- **认证**: 必需
**查询参数**:
- `keyword` - 搜索关键词
- `tag` - 标签过滤
- `category` - 分类过滤
- `page` - 页码默认1
- `page_size` - 每页数量默认20最大100
**响应示例**:
```json
{
"success": true,
"message": "搜索成功",
"data": {
"resources": [
{
"id": 1,
"title": "资源标题",
"url": "资源链接",
"description": "资源描述",
"view_count": 100,
"created_at": "2024-12-19 10:00:00",
"updated_at": "2024-12-19 10:00:00"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"code": 200
}
```
### 4. 热门剧列表
**接口描述**: 获取热门剧列表,支持分页
**请求信息**:
- **方法**: `GET`
- **路径**: `/api/public/hot-dramas`
- **认证**: 必需
**查询参数**:
- `page` - 页码默认1
- `page_size` - 每页数量默认20最大100
**响应示例**:
```json
{
"success": true,
"message": "获取热门剧成功",
"data": {
"hot_dramas": [
{
"id": 1,
"title": "剧名",
"description": "剧集描述",
"img": "封面图片",
"url": "详情链接",
"rating": 8.5,
"year": "2024",
"region": "中国大陆",
"genres": "剧情,悬疑",
"category": "电视剧",
"created_at": "2024-12-19 10:00:00",
"updated_at": "2024-12-19 10:00:00"
}
],
"total": 20,
"page": 1,
"page_size": 20
},
"code": 200
}
```
## 错误码说明
### HTTP 状态码
| 状态码 | 说明 |
|--------|------|
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 认证失败Token无效或缺失 |
| 500 | 服务器内部错误 |
| 503 | 系统维护中或API Token未配置 |
### 响应格式
所有 API 响应都遵循统一的格式:
```json
{
"success": true/false,
"message": "响应消息",
"data": {}, // 响应数据
"code": 200 // 状态码
}
```
## 使用示例
### cURL 示例
```bash
# 设置API Token
API_TOKEN="your_api_token_here"
# 单个添加资源
curl -X POST "http://localhost:8080/api/public/resources/add" \
-H "Content-Type: application/json" \
-H "X-API-Token: $API_TOKEN" \
-d '{
"title": "测试资源",
"url": "https://example.com/resource",
"description": "测试描述"
}'
# 搜索资源
curl -X GET "http://localhost:8080/api/public/resources/search?keyword=测试" \
-H "X-API-Token: $API_TOKEN"
# 获取热门剧
curl -X GET "http://localhost:8080/api/public/hot-dramas?page=1&page_size=5" \
-H "X-API-Token: $API_TOKEN"
```
### JavaScript 示例
```javascript
const API_TOKEN = 'your_api_token_here';
const BASE_URL = 'http://localhost:8080/api';
// 添加资源
async function addResource(resourceData) {
const response = await fetch(`${BASE_URL}/public/resources/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': API_TOKEN
},
body: JSON.stringify(resourceData)
});
return await response.json();
}
// 搜索资源
async function searchResources(keyword, page = 1) {
const response = await fetch(
`${BASE_URL}/public/resources/search?keyword=${encodeURIComponent(keyword)}&page=${page}`,
{
headers: {
'X-API-Token': API_TOKEN
}
}
);
return await response.json();
}
// 获取热门剧
async function getHotDramas(page = 1, pageSize = 20) {
const response = await fetch(
`${BASE_URL}/public/hot-dramas?page=${page}&page_size=${pageSize}`,
{
headers: {
'X-API-Token': API_TOKEN
}
}
);
return await response.json();
}
```
### Python 示例
```python
import requests
API_TOKEN = 'your_api_token_here'
BASE_URL = 'http://localhost:8080/api'
headers = {
'X-API-Token': API_TOKEN,
'Content-Type': 'application/json'
}
# 添加资源
def add_resource(resource_data):
response = requests.post(
f'{BASE_URL}/public/resources/add',
headers=headers,
json=resource_data
)
return response.json()
# 搜索资源
def search_resources(keyword, page=1):
params = {
'keyword': keyword,
'page': page
}
response = requests.get(
f'{BASE_URL}/public/resources/search',
headers={'X-API-Token': API_TOKEN},
params=params
)
return response.json()
# 获取热门剧
def get_hot_dramas(page=1, page_size=20):
params = {
'page': page,
'page_size': page_size
}
response = requests.get(
f'{BASE_URL}/public/hot-dramas',
headers={'X-API-Token': API_TOKEN},
params=params
)
return response.json()
```
## 最佳实践
### 1. 错误处理
始终检查响应的 `success` 字段和 HTTP 状态码:
```javascript
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok || !data.success) {
console.error('API调用失败:', data.message);
// 处理错误
}
```
### 2. 分页处理
对于支持分页的接口,建议实现分页逻辑:
```javascript
async function getAllResources(keyword) {
let allResources = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await searchResources(keyword, page);
if (response.success) {
allResources.push(...response.data.resources);
hasMore = response.data.resources.length > 0;
page++;
} else {
break;
}
}
return allResources;
}
```
### 3. 请求频率限制
避免过于频繁的 API 调用,建议实现请求间隔:
```javascript
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function searchWithDelay(keyword) {
const result = await searchResources(keyword);
await delay(1000); // 等待1秒
return result;
}
```
## 注意事项
1. **Token 安全**: 请妥善保管您的 API Token不要泄露给他人
2. **请求限制**: 避免过于频繁的请求,以免影响系统性能
3. **数据格式**: 确保请求数据格式正确,特别是 JSON 格式
4. **错误处理**: 始终实现适当的错误处理机制
5. **版本兼容**: API 可能会进行版本更新,请关注更新通知
## 技术支持
如果您在使用 API 过程中遇到问题,请:
1. 检查 API Token 是否正确
2. 确认请求格式是否符合要求
3. 查看错误响应中的详细信息
4. 联系技术支持团队
---
**注意**: 本站内容由网络爬虫自动抓取。本站不储存、复制、传播任何文件仅作个人公益学习请在获取后24小时内删除

View File

@@ -1,100 +0,0 @@
# 📝 更新日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
## [未发布]
### 新增
- 自动转存调度功能
- 支持更多网盘平台
- 性能优化和监控
### 修复
- 修复已知问题
- 改进用户体验
## [1.0.0] - 2024-01-01
### 新增
- 🎉 首次发布
- ✨ 完整的网盘资源管理系统
- 🔐 JWT 用户认证系统
- 📁 多平台网盘支持
- 🔍 资源搜索和管理
- 🏷️ 分类和标签系统
- 📊 统计和监控功能
- 🐳 Docker 容器化部署
- 📱 响应式前端界面
- 🌙 深色模式支持
### 支持的网盘平台
- 百度网盘
- 阿里云盘
- 夸克网盘
- 天翼云盘
- 迅雷云盘
- UC网盘
- 123云盘
- 115网盘
### 技术特性
- **后端**: Go + Gin + GORM + PostgreSQL
- **前端**: Nuxt.js 3 + Vue 3 + TypeScript + Tailwind CSS
- **部署**: Docker + Docker Compose
- **认证**: JWT Token
- **架构**: 前后端分离
## [0.9.0] - 2024-12-15
### 新增
- 🚀 项目初始化
- 📋 基础功能开发
- 🏗️ 架构设计完成
- 🔧 开发环境搭建
### 技术栈确定
- 后端技术栈选型
- 前端技术栈选型
- 数据库设计
- API 接口设计
---
## 版本说明
### 版本号格式
- **主版本号**: 不兼容的 API 修改
- **次版本号**: 向下兼容的功能性新增
- **修订号**: 向下兼容的问题修正
### 更新类型
- 🎉 **重大更新**: 新版本发布
-**新增功能**: 新功能添加
- 🔧 **功能改进**: 现有功能优化
- 🐛 **问题修复**: Bug 修复
- 📝 **文档更新**: 文档改进
- 🚀 **性能优化**: 性能提升
- 🔒 **安全更新**: 安全相关更新
- 🎨 **界面优化**: UI/UX 改进
## 贡献指南
如果您想为项目做出贡献,请:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 反馈
如果您发现任何问题或有建议,请:
- 提交 [Issue](https://github.com/ctwj/urldb/issues)
- 发送邮件到 510199617@qq.com
- 在 [讨论区](https://github.com/ctwj/urldb/discussions) 交流
---
**注意**: 此更新日志记录了项目的重要变更。对于详细的开发日志,请查看 Git 提交历史。

View File

@@ -1,53 +0,0 @@
// docsify 配置文件
window.$docsify = {
name: '老九网盘链接数据库',
repo: 'https://github.com/ctwj/urldb',
loadSidebar: '_sidebar.md',
subMaxLevel: 3,
auto2top: true,
// 添加侧边栏配置
sidebarDisplayLevel: 1,
// 添加错误处理
notFoundPage: true,
search: {
maxAge: 86400000,
paths: 'auto',
placeholder: '搜索文档...',
noData: '找不到结果',
depth: 6
},
copyCode: {
buttonText: '复制',
errorText: '错误',
successText: '已复制'
},
pagination: {
previousText: '上一页',
nextText: '下一页',
crossChapter: true,
crossChapterText: true,
},
plugins: [
function(hook, vm) {
hook.beforeEach(function (html) {
// 添加页面标题
var url = '#' + vm.route.path;
var title = vm.route.path === '/' ? '首页' : vm.route.path.replace('/', '');
return html + '\n\n---\n\n' +
'<div style="text-align: center; color: #666; font-size: 14px;">' +
'最后更新: ' + new Date().toLocaleDateString('zh-CN') +
'</div>';
});
// 添加侧边栏加载调试
hook.doneEach(function() {
console.log('Docsify loaded, sidebar should be visible');
if (document.querySelector('.sidebar-nav')) {
console.log('Sidebar element found');
} else {
console.log('Sidebar element not found');
}
});
}
]
};

View File

@@ -1,26 +0,0 @@
# ❓ 常见问题
## 部署相关
### Q: 默认账号密码是多少?
**A:** 可以通过以下方式解决:
1. admin/password
### Q: 批量添加了资源,但是系统里面没有出现,也搜索不到?
**A:** 可以通过以下方式解决:
1. 需要先开启自动处理待处理任务的开关
2. 定时任务每5分钟执行一次可能需要等待
3. 如果添加的链接地址无效, 会被程序过滤
### Q: 没有自动转存?
**A:** 可以通过以下方式解决:
1. 需要先添加账号
2. 开启定时任务
3. 等待任务完成
4. 只要支持的网盘地址才会被自动转存并分享

View File

@@ -1,253 +0,0 @@
# GitHub版本管理指南
本项目使用GitHub进行版本管理支持自动创建Release和标签。
## 版本管理流程
### 1. 版本号规范
遵循[语义化版本](https://semver.org/lang/zh-CN/)规范:
- **主版本号** (Major): 不兼容的API修改
- **次版本号** (Minor): 向下兼容的功能性新增
- **修订号** (Patch): 向下兼容的问题修正
### 2. 版本管理命令
#### 显示版本信息
```bash
./scripts/version.sh show
```
#### 更新版本号
```bash
# 修订版本 (1.0.0 -> 1.0.1)
./scripts/version.sh patch
# 次版本 (1.0.0 -> 1.1.0)
./scripts/version.sh minor
# 主版本 (1.0.0 -> 2.0.0)
./scripts/version.sh major
```
#### 发布版本到GitHub
```bash
./scripts/version.sh release
```
### 3. 自动发布流程
当执行版本更新命令时,脚本会:
1. **更新版本号**: 修改 `VERSION` 文件
2. **同步文件**: 更新 `package.json``docker-compose.yml``README.md`
3. **创建Git标签**: 自动创建版本标签
4. **推送代码**: 推送代码和标签到GitHub
5. **创建Release**: 自动创建GitHub Release
### 4. 手动发布流程
如果自动发布失败,可以手动发布:
#### 步骤1: 更新版本号
```bash
./scripts/version.sh patch # 或 minor, major
```
#### 步骤2: 提交更改
```bash
git add .
git commit -m "chore: bump version to v1.0.1"
```
#### 步骤3: 创建标签
```bash
git tag v1.0.1
```
#### 步骤4: 推送到GitHub
```bash
git push origin main
git push origin v1.0.1
```
#### 步骤5: 创建Release
在GitHub网页上
1. 进入项目页面
2. 点击 "Releases"
3. 点击 "Create a new release"
4. 选择标签 `v1.0.1`
5. 填写Release说明
6. 发布
### 5. GitHub CLI工具
#### 安装GitHub CLI
```bash
# macOS
brew install gh
# Ubuntu/Debian
sudo apt install gh
# Windows
winget install GitHub.cli
```
#### 登录GitHub
```bash
gh auth login
```
#### 创建Release
```bash
gh release create v1.0.1 \
--title "Release v1.0.1" \
--notes "修复了一些bug" \
--draft=false \
--prerelease=false
```
### 6. 版本检查
#### API接口
- `GET /api/version/check-update` - 检查GitHub上的最新版本
#### 前端页面
- 访问 `/version` 页面查看版本信息和更新状态
### 7. 版本历史
#### 查看所有标签
```bash
git tag -l
```
#### 查看标签详情
```bash
git show v1.0.1
```
#### 查看版本历史
```bash
git log --oneline --decorate
```
### 8. 回滚版本
如果需要回滚到之前的版本:
#### 删除本地标签
```bash
git tag -d v1.0.1
```
#### 删除远程标签
```bash
git push origin :refs/tags/v1.0.1
```
#### 回滚代码
```bash
git reset --hard v1.0.0
git push --force origin main
```
### 9. 最佳实践
#### 提交信息规范
```bash
# 功能开发
git commit -m "feat: 添加新功能"
# Bug修复
git commit -m "fix: 修复某个bug"
# 文档更新
git commit -m "docs: 更新文档"
# 版本更新
git commit -m "chore: bump version to v1.0.1"
```
#### 分支管理
- `main`: 主分支,用于发布
- `develop`: 开发分支
- `feature/*`: 功能分支
- `hotfix/*`: 热修复分支
#### Release说明模板
```markdown
## Release v1.0.1
**发布日期**: 2024-01-15
### 更新内容
- 修复了某个bug
- 添加了新功能
- 优化了性能
### 下载
- [源码 (ZIP)](https://github.com/ctwj/urldb/archive/v1.0.1.zip)
- [源码 (TAR.GZ)](https://github.com/ctwj/urldb/archive/v1.0.1.tar.gz)
### 安装
```bash
# 克隆项目
git clone https://github.com/ctwj/urldb.git
cd urldb
# 切换到指定版本
git checkout v1.0.1
# 使用Docker部署
docker-compose up --build -d
```
### 更新日志
详细更新日志请查看 [CHANGELOG.md](https://github.com/ctwj/urldb/blob/v1.0.1/CHANGELOG.md)
```
### 10. 故障排除
#### 常见问题
1. **GitHub CLI未安装**
```bash
# 安装GitHub CLI
brew install gh # macOS
```
2. **GitHub CLI未登录**
```bash
# 登录GitHub
gh auth login
```
3. **标签已存在**
```bash
# 删除本地标签
git tag -d v1.0.1
# 删除远程标签
git push origin :refs/tags/v1.0.1
```
4. **推送失败**
```bash
# 检查远程仓库
git remote -v
# 重新设置远程仓库
git remote set-url origin https://github.com/ctwj/urldb.git
```
#### 获取帮助
```bash
./scripts/version.sh help
```

View File

@@ -1,352 +0,0 @@
# 🐳 Docker 部署
## 概述
urlDB 支持使用 Docker 进行容器化部署,提供了完整的前后端分离架构。
## 系统架构
| 服务 | 端口 | 说明 |
|------|------|------|
| frontend | 3000 | Nuxt.js 前端应用 |
| backend | 8080 | Go API 后端服务 |
| postgres | 5432 | PostgreSQL 数据库 |
## 快速部署
### 1. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
```
### 2. 使用启动脚本(推荐)
```bash
# 给脚本执行权限
chmod +x docker-start.sh
# 启动服务
./docker-start.sh
```
### 3. 手动启动
```bash
# 构建并启动所有服务
docker compose up --build -d
# 查看服务状态
docker compose ps
```
## 配置说明
### 环境变量
可以通过修改 `docker-compose.yml` 文件中的环境变量来配置服务:
后端 backend
```yaml
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: password
DB_NAME: url_db
PORT: 8080
```
前端 frontend
```yaml
environment:
API_BASE: /api
```
### 端口映射
如果需要修改端口映射,可以编辑 `docker-compose.yml`
```yaml
ports:
- "3001:3000" # 前端端口
- "8081:8080" # API端口
- "5433:5432" # 数据库端口
```
## 常用命令
### 服务管理
```bash
# 启动服务
docker compose up -d
# 停止服务
docker compose down
# 重启服务
docker compose restart
# 查看服务状态
docker compose ps
# 查看日志
docker compose logs -f [service_name]
```
### 数据管理
```bash
# 备份数据库
docker compose exec postgres pg_dump -U postgres url_db > backup.sql
# 恢复数据库
docker compose exec -T postgres psql -U postgres url_db < backup.sql
# 进入数据库
docker compose exec postgres psql -U postgres url_db
```
### 容器管理
```bash
# 进入容器
docker compose exec [service_name] sh
# 查看容器资源使用
docker stats
# 清理未使用的资源
docker system prune -a
```
## 生产环境部署
### 1. 环境准备
```bash
# 安装 Docker 和 Docker Compose
# 确保服务器有足够资源(建议 4GB+ 内存)
# 创建部署目录
mkdir -p /opt/urldb
cd /opt/urldb
```
### 2. 配置文件
创建生产环境配置文件:
```bash
# 复制项目文件
git clone https://github.com/ctwj/urldb.git .
# 创建环境变量文件
cp env.example .env.prod
# 编辑生产环境配置
vim .env.prod
```
### 3. 启动服务
```bash
# 使用生产环境配置启动
docker compose -f docker-compose.yml --env-file .env.prod up -d
# 检查服务状态
docker compose ps
```
### 4. 配置反向代理
#### Nginx 配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
# 前端代理
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API 代理
location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 5. SSL 配置
```bash
# 使用 Let's Encrypt 获取证书
sudo certbot --nginx -d your-domain.com
# 或使用自签名证书
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/ssl/private/urldb.key \
-out /etc/ssl/certs/urldb.crt
```
## 监控和维护
### 1. 日志管理
```bash
# 查看所有服务日志
docker compose logs -f
# 查看特定服务日志
docker compose logs -f backend
# 导出日志
docker compose logs > urldb.log
```
### 2. 性能监控
```bash
# 查看容器资源使用
docker stats
# 查看系统资源
htop
df -h
free -h
```
### 3. 备份策略
```bash
#!/bin/bash
# 创建备份脚本 backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/urldb"
# 创建备份目录
mkdir -p $BACKUP_DIR
# 备份数据库
docker compose exec -T postgres pg_dump -U postgres url_db > $BACKUP_DIR/db_$DATE.sql
# 备份上传文件
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz uploads/
# 删除7天前的备份
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
```
### 4. 自动更新
```bash
#!/bin/bash
# 创建更新脚本 update.sh
cd /opt/urldb
# 拉取最新代码
git pull origin main
# 重新构建并启动
docker compose down
docker compose up --build -d
# 检查服务状态
docker compose ps
```
## 故障排除
### 1. 服务启动失败
```bash
# 查看详细错误信息
docker compose logs [service_name]
# 检查端口占用
netstat -tulpn | grep :3000
netstat -tulpn | grep :8080
# 检查磁盘空间
df -h
```
### 2. 数据库连接问题
```bash
# 检查数据库状态
docker compose exec postgres pg_isready -U postgres
# 检查数据库日志
docker compose logs postgres
# 重启数据库服务
docker compose restart postgres
```
### 3. 前端无法访问后端
```bash
# 检查网络连接
docker compose exec frontend ping backend
# 检查 API 配置
docker compose exec frontend env | grep API_BASE
# 测试 API 连接
curl http://localhost:8080/api/health
```
### 4. 内存不足
```bash
# 查看内存使用
free -h
# 增加 swap 空间
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
## 安全建议
### 1. 网络安全
- 使用防火墙限制端口访问
- 配置 SSL/TLS 加密
- 定期更新系统和 Docker 版本
### 2. 数据安全
- 定期备份数据库
- 使用强密码
- 限制数据库访问权限
### 3. 容器安全
- 使用非 root 用户运行容器
- 定期更新镜像
- 扫描镜像漏洞
## 下一步
- [了解系统配置](../guide/configuration.md)
- [查看 API 文档](../api/overview.md)
- [学习监控和维护](../development/deployment.md)

View File

@@ -1,302 +0,0 @@
# 💻 本地开发
## 环境准备
### 1. 安装必需软件
#### Go 环境
```bash
# 下载并安装 Go 1.23+
# 访问 https://golang.org/dl/
# 或使用包管理器安装
# 验证安装
go version
```
#### Node.js 环境
```bash
# 下载并安装 Node.js 18+
# 访问 https://nodejs.org/
# 或使用 nvm 安装
# 验证安装
node --version
npm --version
```
#### PostgreSQL 数据库
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib
# macOS (使用 Homebrew)
brew install postgresql
# 启动服务
sudo systemctl start postgresql # Linux
brew services start postgresql # macOS
```
#### pnpm (推荐)
```bash
# 安装 pnpm
npm install -g pnpm
# 验证安装
pnpm --version
```
### 2. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
```
## 后端开发
### 1. 环境配置
```bash
# 复制环境变量文件
cp env.example .env
# 编辑环境变量
vim .env
```
### 2. 数据库设置
```sql
-- 登录 PostgreSQL
sudo -u postgres psql
-- 创建数据库
CREATE DATABASE url_db;
-- 创建用户(可选)
CREATE USER url_user WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE url_db TO url_user;
-- 退出
\q
```
### 3. 安装依赖
```bash
# 安装 Go 依赖
go mod tidy
# 验证依赖
go mod verify
```
### 4. 启动后端服务
```bash
# 开发模式启动
go run main.go
# 或使用 air 热重载(推荐)
go install github.com/cosmtrek/air@latest
air
```
## 前端开发
### 1. 进入前端目录
```bash
cd web
```
### 2. 安装依赖
```bash
# 使用 pnpm (推荐)
pnpm install
# 或使用 npm
npm install
```
### 3. 启动开发服务器
```bash
# 开发模式
pnpm dev
# 或使用 npm
npm run dev
```
### 4. 访问前端
前端服务启动后,访问 http://localhost:3000
## 开发工具
### 推荐的 IDE 和插件
#### VS Code
- **Go** - Go 语言支持
- **Vetur** 或 **Volar** - Vue.js 支持
- **PostgreSQL** - 数据库支持
- **Docker** - Docker 支持
- **GitLens** - Git 增强
#### GoLand / IntelliJ IDEA
- 内置 Go 和 Vue.js 支持
- 数据库工具
- Docker 集成
### 代码格式化
```bash
# Go 代码格式化
go fmt ./...
# 前端代码格式化
cd web
pnpm format
```
### 代码检查
```bash
# Go 代码检查
go vet ./...
# 前端代码检查
cd web
pnpm lint
```
## 调试技巧
### 后端调试
```bash
# 使用 delve 调试器
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug main.go
# 或使用 VS Code 调试配置
```
### 前端调试
```bash
# 启动开发服务器时开启调试
cd web
pnpm dev --inspect
```
### 数据库调试
```bash
# 连接数据库
psql -h localhost -U postgres -d url_db
# 查看表结构
\dt
# 查看数据
SELECT * FROM users LIMIT 5;
```
## 测试
### 后端测试
```bash
# 运行所有测试
go test ./...
# 运行特定测试
go test ./handlers
# 生成测试覆盖率报告
go test -cover ./...
```
### 前端测试
```bash
cd web
# 运行单元测试
pnpm test
# 运行 E2E 测试
pnpm test:e2e
```
## 构建
### 后端构建
```bash
# 构建二进制文件
go build -o urlDB main.go
# 交叉编译
GOOS=linux GOARCH=amd64 go build -o urlDB-linux main.go
```
### 前端构建
```bash
cd web
# 构建生产版本
pnpm build
# 预览构建结果
pnpm preview
```
## 常见问题
### 1. 端口冲突
如果遇到端口被占用的问题:
```bash
# 查看端口占用
lsof -i :8080
lsof -i :3000
# 杀死进程
kill -9 <PID>
```
### 2. 数据库连接失败
检查 `.env` 文件中的数据库配置:
```bash
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=url_db
```
### 3. 前端依赖安装失败
```bash
# 清除缓存
pnpm store prune
rm -rf node_modules
pnpm install
```
## 下一步
- [了解项目架构](../architecture/overview.md)
- [查看 API 文档](../api/overview.md)
- [学习代码规范](../development/coding-standards.md)

View File

@@ -1,36 +0,0 @@
# 🚀 快速开始
## 环境要求
在开始使用 urlDB 之前,请确保您的系统满足以下要求:
### 推荐配置
- **CPU**: 2核
- **内存**: 2GB+
- **存储**: 20GB+ 可用空间
## 🐳 Docker 部署
### 1. 克隆项目
```bash
git clone https://github.com/ctwj/urldb.git
cd urldb
docker compose up --build -d
```
### 2. 访问应用
启动成功后,您可以通过以下地址访问:
- **前端界面**: http://localhost:3030
默认用户密码: admin/password
## 🆘 遇到问题?
如果您在部署过程中遇到问题,请:
1. 查看 [常见问题](../faq.md)
2. 检查 [更新日志](../changelog.md)
3. 提交 [Issue](https://github.com/ctwj/urldb/issues)

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>urlDB - 老九网盘资源数据库</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="一个现代化的网盘资源数据库,支持多网盘自动化转存分享">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/dark.css" media="(prefers-color-scheme: dark)">
<link rel="icon" href="https://img.icons8.com/color/48/000000/database.png" type="image/x-icon">
</head>
<body>
<div id="app"></div>
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="docsify.config.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/copy-code.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/pagination.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-bash.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-javascript.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-typescript.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-sql.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-json.min.js"></script>
</body>
</html>

View File

@@ -1,84 +0,0 @@
# 许可证
## GNU General Public License v3.0
本项目采用 GNU General Public License v3.0 (GPL-3.0) 许可证。
### 许可证概述
GPL-3.0 是一个自由软件许可证,它确保软件保持自由和开放。该许可证的主要特点包括:
- **自由使用**: 您可以自由地运行、研究、修改和分发软件
- **源代码开放**: 修改后的代码必须同样开源
- **专利保护**: 包含专利授权条款
- **兼容性**: 与大多数开源许可证兼容
### 主要条款
1. **自由使用和分发**
- 您可以自由地使用、复制、分发和修改本软件
- 您可以商业使用本软件
2. **源代码要求**
- 如果您分发修改后的版本,必须同时提供源代码
- 源代码必须采用相同的许可证
3. **专利授权**
- 贡献者自动授予用户专利使用权
- 保护用户免受专利诉讼
4. **免责声明**
- 软件按"原样"提供,不提供任何保证
- 作者不承担任何责任
### 完整许可证文本
```
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
[... 完整许可证文本请访问 https://www.gnu.org/licenses/gpl-3.0.html ...]
```
### 如何遵守许可证
如果您使用或修改本项目:
1. **保留许可证信息**: 不要删除或修改许可证文件
2. **注明修改**: 在修改的代码中添加适当的注释
3. **分发源代码**: 如果分发修改版本,必须提供源代码
4. **使用相同许可证**: 修改版本必须使用相同的GPL-3.0许可证
### 贡献代码
当您向本项目贡献代码时,您同意:
- 您的贡献将采用GPL-3.0许可证
- 您拥有或有权许可您贡献的代码
- 您授予项目维护者使用您贡献代码的权利
### 联系方式
如果您对许可证有任何疑问,请联系项目维护者。
---
**注意**: 本许可证信息仅供参考,完整和权威的许可证文本请参考 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html)。

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

@@ -1,29 +0,0 @@
#!/bin/bash
# 启动 docsify 文档服务脚本
echo "🚀 启动 docsify 文档服务..."
# 检查是否安装了 docsify-cli
if ! command -v docsify &> /dev/null; then
echo "❌ 未检测到 docsify-cli正在安装..."
npm install -g docsify-cli
if [ $? -ne 0 ]; then
echo "❌ docsify-cli 安装失败,请手动安装:"
echo " npm install -g docsify-cli"
exit 1
fi
fi
# 获取当前脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "📖 文档目录: $SCRIPT_DIR"
echo "🌐 启动文档服务..."
# 启动 docsify 服务
docsify serve "$SCRIPT_DIR" --port 3000 --open
echo "✅ 文档服务已启动!"
echo "📱 访问地址: http://localhost:3000"
echo "🛑 按 Ctrl+C 停止服务"

View File

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

36
go.mod
View File

@@ -8,13 +8,39 @@ require (
github.com/gin-contrib/cors v1.4.0 github.com/gin-contrib/cors v1.4.0
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-resty/resty/v2 v2.16.5 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/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.40.0 github.com/meilisearch/meilisearch-go v0.33.1
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.41.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.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/fatih/structs v1.1.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // 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/silenceper/wechat/v2 v2.1.10 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/cast v1.4.1 // 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
)
require ( require (
github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
@@ -44,10 +70,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.19.0 // indirect golang.org/x/arch v0.19.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.43.0
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

146
go.sum
View File

@@ -1,8 +1,22 @@
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
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 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 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.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 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/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 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 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= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
@@ -10,6 +24,12 @@ 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
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 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= 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= github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
@@ -32,18 +52,40 @@ 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.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 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 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.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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
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 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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.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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -81,26 +123,60 @@ github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZ
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/meilisearch/meilisearch-go v0.33.1 h1:IWM8iJU7UyuIoRiTTLONvpbEgMhP/yTrnNfSnxj4wu0=
github.com/meilisearch/meilisearch-go v0.33.1/go.mod h1:dY4nxhVc0Ext8Kn7u2YohJCsEjirg80DdcOmfNezUYg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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/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/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/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.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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.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.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.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 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 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 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -108,47 +184,117 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= 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/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/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
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 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= 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.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 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.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
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/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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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-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-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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 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.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/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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 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/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-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-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-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.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 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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 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-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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/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/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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

@@ -2,11 +2,18 @@ package handlers
import ( import (
"github.com/ctwj/urldb/db/repo" "github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/services"
) )
var repoManager *repo.RepositoryManager var repoManager *repo.RepositoryManager
var meilisearchManager *services.MeilisearchManager
// SetRepositoryManager 设置Repository管理器 // SetRepositoryManager 设置Repository管理器
func SetRepositoryManager(rm *repo.RepositoryManager) { func SetRepositoryManager(manager *repo.RepositoryManager) {
repoManager = rm repoManager = manager
}
// SetMeilisearchManager 设置Meilisearch管理器
func SetMeilisearchManager(manager *services.MeilisearchManager) {
meilisearchManager = manager
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -21,7 +22,49 @@ func GetCks(c *gin.Context) {
return 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) SuccessResponse(c, responses)
} }
@@ -51,6 +94,8 @@ func CreateCks(c *gin.Context) {
serviceType = panutils.BaiduPan serviceType = panutils.BaiduPan
case "uc": case "uc":
serviceType = panutils.UC serviceType = panutils.UC
case "xunlei":
serviceType = panutils.Xunlei
default: default:
ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest) ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest)
return return
@@ -64,28 +109,61 @@ func CreateCks(c *gin.Context) {
return return
} }
// 获取用户信息 var cks *entity.Cks
userInfo, err := service.GetUserInfo(req.Ck) // 迅雷网盘,添加的时候 只获取token就好 然后刷新的时候, 再补充用户信息等
if err != nil { if serviceType == panutils.Xunlei {
ErrorResponse(c, "无法获取用户信息,账号创建失败: "+err.Error(), http.StatusBadRequest) xunleiService := service.(*panutils.XunleiPanService)
return tokenData, err := xunleiService.GetAccessTokenByRefreshToken(req.Ck)
} if err != nil {
ErrorResponse(c, "无法获取有效token: "+err.Error(), http.StatusBadRequest)
return
}
extra := panutils.XunleiExtraData{
Token: &tokenData,
Captcha: &panutils.CaptchaData{},
}
extraStr, _ := json.Marshal(extra)
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace // 创建Cks实体
cks = &entity.Cks{
PanID: req.PanID,
Idx: req.Idx,
Ck: tokenData.RefreshToken,
IsValid: true, // 根据VIP状态设置有效性
Space: 0,
LeftSpace: 0,
UsedSpace: 0,
Username: "-",
VipStatus: false,
ServiceType: "xunlei",
Extra: string(extraStr),
Remark: req.Remark,
}
} else {
// 获取用户信息
userInfo, err := service.GetUserInfo(&req.Ck)
if err != nil {
ErrorResponse(c, "无法获取用户信息,账号创建失败: "+err.Error(), http.StatusBadRequest)
return
}
// 创建Cks实体 leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
cks := &entity.Cks{
PanID: req.PanID, // 创建Cks实体
Idx: req.Idx, cks = &entity.Cks{
Ck: req.Ck, PanID: req.PanID,
IsValid: userInfo.VIPStatus, // 根据VIP状态设置有效性 Idx: req.Idx,
Space: userInfo.TotalSpace, Ck: req.Ck,
LeftSpace: leftSpaceBytes, IsValid: userInfo.VIPStatus, // 根据VIP状态设置有效性
UsedSpace: userInfo.UsedSpace, Space: userInfo.TotalSpace,
Username: userInfo.Username, LeftSpace: leftSpaceBytes,
VipStatus: userInfo.VIPStatus, UsedSpace: userInfo.UsedSpace,
ServiceType: userInfo.ServiceType, Username: userInfo.Username,
Remark: req.Remark, VipStatus: userInfo.VIPStatus,
ServiceType: userInfo.ServiceType,
Extra: userInfo.ExtraData,
Remark: req.Remark,
}
} }
err = repoManager.CksRepository.Create(cks) err = repoManager.CksRepository.Create(cks)
@@ -293,6 +371,8 @@ func RefreshCapacity(c *gin.Context) {
serviceType = panutils.BaiduPan serviceType = panutils.BaiduPan
case "uc": case "uc":
serviceType = panutils.UC serviceType = panutils.UC
case "xunlei":
serviceType = panutils.Xunlei
default: default:
ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest) ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest)
return return
@@ -306,13 +386,20 @@ func RefreshCapacity(c *gin.Context) {
return return
} }
// 获取最新的用户信息 var userInfo *panutils.UserInfo
userInfo, err := service.GetUserInfo(cks.Ck) service.SetCKSRepository(repoManager.CksRepository, *cks) // 迅雷需要初始化 token 后才能获取,
userInfo, err = service.GetUserInfo(&cks.Ck)
// switch s := service.(type) {
// case *panutils.XunleiPanService:
// userInfo, err = s.GetUserInfo(nil)
// default:
// userInfo, err = service.GetUserInfo(&cks.Ck)
// }
if err != nil { if err != nil {
ErrorResponse(c, "无法获取用户信息,刷新失败: "+err.Error(), http.StatusBadRequest) ErrorResponse(c, "无法获取用户信息,刷新失败: "+err.Error(), http.StatusBadRequest)
return return
} }
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
// 更新账号信息 // 更新账号信息
@@ -322,7 +409,7 @@ func RefreshCapacity(c *gin.Context) {
cks.Space = userInfo.TotalSpace cks.Space = userInfo.TotalSpace
cks.LeftSpace = leftSpaceBytes cks.LeftSpace = leftSpaceBytes
cks.UsedSpace = userInfo.UsedSpace cks.UsedSpace = userInfo.UsedSpace
cks.IsValid = userInfo.VIPStatus // 根据VIP状态更新有效性 // cks.IsValid = userInfo.VIPStatus // 根据VIP状态更新有效性
err = repoManager.CksRepository.UpdateWithAllFields(cks) err = repoManager.CksRepository.UpdateWithAllFields(cks)
if err != nil { if err != nil {
@@ -335,3 +422,25 @@ func RefreshCapacity(c *gin.Context) {
"cks": converter.ToCksResponse(cks), "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

@@ -246,33 +246,23 @@ func (h *FileHandler) GetFileList(c *gin.Context) {
utils.Info("文件列表请求参数: page=%d, pageSize=%d, search='%s', fileType='%s', status='%s', userID=%d", utils.Info("文件列表请求参数: page=%d, pageSize=%d, search='%s', fileType='%s', status='%s', userID=%d",
req.Page, req.PageSize, req.Search, req.FileType, req.Status, req.UserID) req.Page, req.PageSize, req.Search, req.FileType, req.Status, req.UserID)
// 获取当前用户ID和角色 // 获取当前用户ID和角色(现在总是有认证)
userIDInterface, exists := c.Get("user_id") userID := c.GetUint("user_id")
roleInterface, _ := c.Get("role") role := c.GetString("role")
var userID uint utils.Info("GetFileList - 用户ID: %d, 角色: %s", userID, role)
var role string
if exists {
userID = userIDInterface.(uint)
}
if roleInterface != nil {
role = roleInterface.(string)
}
// 根据用户角色决定查询范围 // 根据用户角色决定查询范围
var files []entity.File var files []entity.File
var total int64 var total int64
var err error var err error
if exists && role == "admin" { if role == "admin" {
// 管理员可以查看所有文件 // 管理员可以查看所有文件
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, req.UserID, req.Page, req.PageSize) files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, req.UserID, req.Page, req.PageSize)
} else if userID > 0 { } else {
// 普通用户只能查看自己的文件 // 普通用户只能查看自己的文件
files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, userID, req.Page, req.PageSize) files, total, err = h.fileRepo.SearchFiles(req.Search, req.FileType, req.Status, userID, req.Page, req.PageSize)
} else {
// 未登录用户只能查看公开文件
files, total, err = h.fileRepo.FindPublicFiles(req.Page, req.PageSize)
} }
if err != nil { if err != nil {
@@ -450,3 +440,80 @@ func (h *FileHandler) calculateFileHash(filePath string) (string, error) {
} }
return fmt.Sprintf("%x", hash.Sum(nil)), nil 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 package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"github.com/ctwj/urldb/db/converter" "github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/db/dto" "github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo" "github.com/ctwj/urldb/db/repo"
"github.com/go-resty/resty/v2"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -94,6 +98,87 @@ func CreateHotDrama(c *gin.Context) {
SuccessResponse(c, response) 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 更新热播剧记录 // UpdateHotDrama 更新热播剧记录
func UpdateHotDrama(c *gin.Context) { func UpdateHotDrama(c *gin.Context) {
idStr := c.Param("id") idStr := c.Param("id")
@@ -149,6 +234,7 @@ func GetHotDramaList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
category := c.Query("category") category := c.Query("category")
subType := c.Query("sub_type")
var dramas []entity.HotDrama var dramas []entity.HotDrama
var total int64 var total int64
@@ -156,13 +242,17 @@ func GetHotDramaList(c *gin.Context) {
// 如果page_size很大比如>=1000则获取所有数据 // 如果page_size很大比如>=1000则获取所有数据
if pageSize >= 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) dramas, total, err = repoManager.HotDramaRepository.FindByCategory(category, 1, 10000)
} else { } else {
dramas, total, err = repoManager.HotDramaRepository.FindAll(1, 10000) dramas, total, err = repoManager.HotDramaRepository.FindAll(1, 10000)
} }
} else { } 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) dramas, total, err = repoManager.HotDramaRepository.FindByCategory(category, page, pageSize)
} else { } else {
dramas, total, err = repoManager.HotDramaRepository.FindAll(page, pageSize) 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": "系统日志清理成功"})
}

View File

@@ -0,0 +1,270 @@
package handlers
import (
"net/http"
"strconv"
"github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
// MeilisearchHandler Meilisearch处理器
type MeilisearchHandler struct {
meilisearchManager *services.MeilisearchManager
}
// NewMeilisearchHandler 创建Meilisearch处理器
func NewMeilisearchHandler(meilisearchManager *services.MeilisearchManager) *MeilisearchHandler {
return &MeilisearchHandler{
meilisearchManager: meilisearchManager,
}
}
// TestConnection 测试Meilisearch连接
func (h *MeilisearchHandler) TestConnection(c *gin.Context) {
var req struct {
Host string `json:"host"`
Port interface{} `json:"port"` // 支持字符串或数字
MasterKey string `json:"masterKey"`
IndexName string `json:"indexName"` // 可选字段
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return
}
// 验证必要字段
if req.Host == "" {
ErrorResponse(c, "主机地址不能为空", http.StatusBadRequest)
return
}
// 转换port为字符串
var portStr string
switch v := req.Port.(type) {
case string:
portStr = v
case float64:
portStr = strconv.Itoa(int(v))
case int:
portStr = strconv.Itoa(v)
default:
portStr = "7700" // 默认端口
}
// 如果没有提供索引名称,使用默认值
indexName := req.IndexName
if indexName == "" {
indexName = "resources"
}
// 创建临时服务进行测试
service := services.NewMeilisearchService(req.Host, portStr, req.MasterKey, indexName, true)
if err := service.HealthCheck(); err != nil {
ErrorResponse(c, "连接测试失败: "+err.Error(), http.StatusBadRequest)
return
}
SuccessResponse(c, gin.H{"message": "连接测试成功"})
}
// GetStatus 获取Meilisearch状态
func (h *MeilisearchHandler) GetStatus(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"enabled": false,
"healthy": false,
"message": "Meilisearch未初始化",
})
return
}
status, err := h.meilisearchManager.GetStatusWithHealthCheck()
if err != nil {
ErrorResponse(c, "获取状态失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, status)
}
// GetUnsyncedCount 获取未同步资源数量
func (h *MeilisearchHandler) GetUnsyncedCount(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{"count": 0})
return
}
count, err := h.meilisearchManager.GetUnsyncedCount()
if err != nil {
ErrorResponse(c, "获取未同步数量失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"count": count})
}
// GetUnsyncedResources 获取未同步的资源
func (h *MeilisearchHandler) GetUnsyncedResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetUnsyncedResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取未同步资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetSyncedResources 获取已同步的资源
func (h *MeilisearchHandler) GetSyncedResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetSyncedResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取已同步资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetAllResources 获取所有资源
func (h *MeilisearchHandler) GetAllResources(c *gin.Context) {
if h.meilisearchManager == nil {
SuccessResponse(c, gin.H{
"resources": []interface{}{},
"total": 0,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
resources, total, err := h.meilisearchManager.GetAllResources(page, pageSize)
if err != nil {
ErrorResponse(c, "获取所有资源失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
})
}
// SyncAllResources 同步所有资源
func (h *MeilisearchHandler) SyncAllResources(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
utils.Info("开始同步所有资源到Meilisearch...")
_, err := h.meilisearchManager.SyncAllResources()
if err != nil {
ErrorResponse(c, "同步失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"message": "同步已开始,请查看进度",
})
}
// GetSyncProgress 获取同步进度
func (h *MeilisearchHandler) GetSyncProgress(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
progress := h.meilisearchManager.GetSyncProgress()
SuccessResponse(c, progress)
}
// StopSync 停止同步
func (h *MeilisearchHandler) StopSync(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
h.meilisearchManager.StopSync()
SuccessResponse(c, gin.H{
"message": "同步已停止",
})
}
// ClearIndex 清空索引
func (h *MeilisearchHandler) ClearIndex(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
if err := h.meilisearchManager.ClearIndex(); err != nil {
ErrorResponse(c, "清空索引失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "清空索引成功"})
}
// UpdateIndexSettings 更新索引设置
func (h *MeilisearchHandler) UpdateIndexSettings(c *gin.Context) {
if h.meilisearchManager == nil {
ErrorResponse(c, "Meilisearch未初始化", http.StatusInternalServerError)
return
}
service := h.meilisearchManager.GetService()
if service == nil {
ErrorResponse(c, "Meilisearch服务未初始化", http.StatusInternalServerError)
return
}
if err := service.UpdateIndexSettings(); err != nil {
ErrorResponse(c, "更新索引设置失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "索引设置更新成功"})
}

View File

@@ -3,10 +3,12 @@ package handlers
import ( import (
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/ctwj/urldb/db/dto" "github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -68,6 +70,53 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
return filteredResources, uniqueForbiddenWords return filteredResources, uniqueForbiddenWords
} }
// logAPIAccess 记录API访问日志
func (h *PublicAPIHandler) logAPIAccess(c *gin.Context, startTime time.Time, processCount int, responseData interface{}, errorMessage string) {
endpoint := c.Request.URL.Path
method := c.Request.Method
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 计算处理时间
processingTime := time.Since(startTime).Milliseconds()
// 获取查询参数
var requestParams interface{}
if method == "GET" {
requestParams = c.Request.URL.Query()
} else {
// 对于POST请求尝试从上下文中获取请求体如果之前已解析
if req, exists := c.Get("request_body"); exists {
requestParams = req
}
}
// 异步记录日志避免影响API响应时间
go func() {
defer func() {
if r := recover(); r != nil {
utils.Error("记录API访问日志时发生panic: %v", r)
}
}()
err := repoManager.APIAccessLogRepository.RecordAccess(
ip,
userAgent,
endpoint,
method,
requestParams,
c.Writer.Status(),
responseData,
processCount,
errorMessage,
processingTime,
)
if err != nil {
utils.Error("记录API访问日志失败: %v", err)
}
}()
}
// AddBatchResources godoc // AddBatchResources godoc
// @Summary 批量添加资源 // @Summary 批量添加资源
// @Description 通过公开API批量添加多个资源到待处理列表 // @Description 通过公开API批量添加多个资源到待处理列表
@@ -82,17 +131,28 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/public/resources/batch-add [post] // @Router /api/public/resources/batch-add [post]
func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) { func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
startTime := time.Now()
var req dto.BatchReadyResourceRequest var req dto.BatchReadyResourceRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
h.logAPIAccess(c, startTime, 0, nil, "请求参数错误: "+err.Error())
ErrorResponse(c, "请求参数错误: "+err.Error(), 400) ErrorResponse(c, "请求参数错误: "+err.Error(), 400)
return return
} }
// 存储请求体用于日志记录
c.Set("request_body", req)
if len(req.Resources) == 0 { if len(req.Resources) == 0 {
ErrorResponse(c, "资源列表不能为空", 400) ErrorResponse(c, "资源列表不能为空", 400)
return 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去重 // 收集所有待提交的URL去重
urlSet := make(map[string]struct{}) urlSet := make(map[string]struct{})
for _, resource := range req.Resources { for _, resource := range req.Resources {
@@ -124,6 +184,7 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
// 生成 key每组同一个 key // 生成 key每组同一个 key
key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey() key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey()
if err != nil { if err != nil {
h.logAPIAccess(c, startTime, len(createdResources), nil, "生成资源组标识失败: "+err.Error())
ErrorResponse(c, "生成资源组标识失败: "+err.Error(), 500) ErrorResponse(c, "生成资源组标识失败: "+err.Error(), 500)
return return
} }
@@ -155,10 +216,12 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
} }
} }
SuccessResponse(c, gin.H{ responseData := gin.H{
"created_count": len(createdResources), "created_count": len(createdResources),
"created_ids": createdResources, "created_ids": createdResources,
}) }
h.logAPIAccess(c, startTime, len(createdResources), responseData, "")
SuccessResponse(c, responseData)
} }
// SearchResources godoc // SearchResources godoc
@@ -178,13 +241,21 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/public/resources/search [get] // @Router /api/public/resources/search [get]
func (h *PublicAPIHandler) SearchResources(c *gin.Context) { func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
// 获取查询参数 startTime := time.Now()
// 记录API访问安全日志
clientIP := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
keyword := c.Query("keyword") keyword := c.Query("keyword")
tag := c.Query("tag") tag := c.Query("tag")
category := c.Query("category") category := c.Query("category")
panID := c.Query("pan_id")
pageStr := c.DefaultQuery("page", "1") pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20") 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) page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 { if err != nil || page < 1 {
page = 1 page = 1
@@ -195,67 +266,133 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
pageSize = 20 pageSize = 20
} }
// 构建搜索条件 var resources []entity.Resource
params := map[string]interface{}{ var total int64
"page": page,
"page_size": pageSize, // 如果启用了Meilisearch优先使用Meilisearch搜索
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
// 构建过滤器
filters := make(map[string]interface{})
if category != "" {
filters["category"] = category
}
if tag != "" {
filters["tags"] = tag
}
if panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
// 根据pan_id获取pan_name
pan, err := repoManager.PanRepository.FindByID(uint(id))
if err == nil && pan != nil {
filters["pan_name"] = pan.Name
}
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(keyword, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为Resource实体保持兼容性
for _, doc := range docs {
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,
Cover: doc.Cover,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
resources = append(resources, resource)
}
total = docTotal
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
} }
if keyword != "" { // 如果Meilisearch未启用或搜索失败使用数据库搜索
params["search"] = keyword if meilisearchManager == nil || !meilisearchManager.IsEnabled() || err != nil {
// 构建搜索条件
params := map[string]interface{}{
"page": page,
"page_size": pageSize,
}
if keyword != "" {
params["search"] = keyword
}
if tag != "" {
params["tag"] = tag
}
if category != "" {
params["category"] = category
}
if panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
params["pan_id"] = uint(id)
}
}
// 执行数据库搜索
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
h.logAPIAccess(c, startTime, 0, nil, "搜索失败: "+err.Error())
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
return
}
} }
if tag != "" { // 获取违禁词配置(只获取一次)
params["tag"] = tag cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
} return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if category != "" {
params["category"] = category
}
// 执行搜索
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil { if err != nil {
ErrorResponse(c, "搜索失败: "+err.Error(), 500) utils.Error("获取违禁词配置失败: %v", err)
return cleanWords = []string{} // 如果获取失败,使用空列表
} }
// 过滤违禁词 // 转换为响应格式并添加违禁词标记
filteredResources, foundForbiddenWords := h.filterForbiddenWords(resources)
// 计算过滤后的总数
filteredTotal := len(filteredResources)
// 转换为响应格式
var resourceResponses []gin.H var resourceResponses []gin.H
for _, resource := range filteredResources { for i, processedResource := range resources {
resourceResponses = append(resourceResponses, gin.H{ originalResource := resources[i]
"id": resource.ID, forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
"title": resource.Title,
"url": resource.URL, resourceResponse := gin.H{
"description": resource.Description, "id": processedResource.ID,
"view_count": resource.ViewCount, "title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"), "url": processedResource.URL,
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"), "description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
}) "view_count": processedResource.ViewCount,
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
"cover": processedResource.Cover, // 添加封面字段
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
resourceResponses = append(resourceResponses, resourceResponse)
} }
// 构建响应数据 // 构建响应数据
responseData := gin.H{ responseData := gin.H{
"list": resourceResponses, "list": resourceResponses,
"total": filteredTotal, "total": total,
"page": page, "page": page,
"limit": pageSize, "limit": pageSize,
} }
// 如果存在违禁词过滤,添加提醒字段 h.logAPIAccess(c, startTime, len(resourceResponses), responseData, "")
if len(foundForbiddenWords) > 0 {
responseData["forbidden_words_filtered"] = true
responseData["filtered_forbidden_words"] = foundForbiddenWords
responseData["original_total"] = total
responseData["filtered_count"] = total - int64(filteredTotal)
}
SuccessResponse(c, responseData) SuccessResponse(c, responseData)
} }
@@ -273,9 +410,16 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
// @Failure 500 {object} map[string]interface{} "服务器内部错误" // @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/public/hot-dramas [get] // @Router /api/public/hot-dramas [get]
func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) { func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
startTime := time.Now()
// 记录API访问安全日志
clientIP := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
pageStr := c.DefaultQuery("page", "1") pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20") pageSizeStr := c.DefaultQuery("page_size", "20")
utils.Info("PublicAPI.GetHotDramas - API访问 - IP: %s, UserAgent: %s", clientIP, userAgent)
page, err := strconv.Atoi(pageStr) page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 { if err != nil || page < 1 {
page = 1 page = 1
@@ -289,6 +433,7 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
// 获取热门剧 // 获取热门剧
hotDramas, total, err := repoManager.HotDramaRepository.FindAll(page, pageSize) hotDramas, total, err := repoManager.HotDramaRepository.FindAll(page, pageSize)
if err != nil { if err != nil {
h.logAPIAccess(c, startTime, 0, nil, "获取热门剧失败: "+err.Error())
ErrorResponse(c, "获取热门剧失败: "+err.Error(), 500) ErrorResponse(c, "获取热门剧失败: "+err.Error(), 500)
return return
} }
@@ -312,10 +457,12 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
}) })
} }
SuccessResponse(c, gin.H{ responseData := gin.H{
"hot_dramas": hotDramaResponses, "hot_dramas": hotDramaResponses,
"total": total, "total": total,
"page": page, "page": page,
"page_size": pageSize, "page_size": pageSize,
}) }
h.logAPIAccess(c, startTime, len(hotDramaResponses), responseData, "")
SuccessResponse(c, responseData)
} }

View File

@@ -64,19 +64,144 @@ func GetResources(c *gin.Context) {
params["pan_name"] = panName params["pan_name"] = panName
} }
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params) // 获取违禁词配置(只获取一次)
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
})
if err != nil {
utils.Error("获取违禁词配置失败: %v", err)
cleanWords = []string{} // 如果获取失败,使用空列表
}
var resources []entity.Resource
var total int64
// 如果有搜索关键词且启用了Meilisearch优先使用Meilisearch搜索
if search := c.Query("search"); search != "" && meilisearchManager != nil && meilisearchManager.IsEnabled() {
// 构建Meilisearch过滤器
filters := make(map[string]interface{})
if panID := c.Query("pan_id"); panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
// 直接使用pan_id进行过滤
filters["pan_id"] = id
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(search, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为ResourceResponse包含高亮信息并处理违禁词
var resourceResponses []dto.ResourceResponse
for _, doc := range docs {
resourceResponse := converter.ToResourceResponseFromMeilisearch(doc)
// 处理违禁词Meilisearch场景需要处理高亮标记
if len(cleanWords) > 0 {
forbiddenInfo := utils.CheckResourceForbiddenWords(resourceResponse.Title, resourceResponse.Description, cleanWords)
if forbiddenInfo.HasForbiddenWords {
resourceResponse.Title = forbiddenInfo.ProcessedTitle
resourceResponse.Description = forbiddenInfo.ProcessedDesc
resourceResponse.TitleHighlight = forbiddenInfo.ProcessedTitle
resourceResponse.DescriptionHighlight = forbiddenInfo.ProcessedDesc
}
resourceResponse.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
resourceResponse.ForbiddenWords = forbiddenInfo.ForbiddenWords
}
resourceResponses = append(resourceResponses, resourceResponse)
}
// 返回Meilisearch搜索结果包含高亮信息
SuccessResponse(c, gin.H{
"data": resourceResponses,
"total": docTotal,
"page": page,
"page_size": pageSize,
"source": "meilisearch",
})
return
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
}
// 如果Meilisearch未启用、搜索失败或没有搜索关键词使用数据库搜索
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || len(resources) == 0 {
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
}
if err != nil { if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
SuccessResponse(c, gin.H{ // 处理违禁词替换和标记
"data": converter.ToResourceResponseList(resources), var processedResources []entity.Resource
if len(cleanWords) > 0 {
processedResources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
// 复制标签数据到处理后的资源
for i := range processedResources {
if i < len(resources) {
processedResources[i].Tags = resources[i].Tags
}
}
} else {
processedResources = resources
}
// 转换为响应格式并添加违禁词标记
var resourceResponses []gin.H
for i, processedResource := range processedResources {
// 使用原始资源进行检查违禁词(数据库搜索场景,使用普通处理)
originalResource := resources[i]
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
resourceResponse := gin.H{
"id": processedResource.ID,
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
"url": processedResource.URL,
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
"pan_id": processedResource.PanID,
"view_count": processedResource.ViewCount,
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
}
// 添加违禁词标记
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
// 添加标签信息(需要预加载)
var tagResponses []gin.H
if len(processedResource.Tags) > 0 {
for _, tag := range processedResource.Tags {
tagResponse := gin.H{
"id": tag.ID,
"name": tag.Name,
"description": tag.Description,
}
tagResponses = append(tagResponses, tagResponse)
}
}
resourceResponse["tags"] = tagResponses
resourceResponse["cover"] = originalResource.Cover
resourceResponses = append(resourceResponses, resourceResponse)
}
// 构建响应数据
responseData := gin.H{
"data": resourceResponses,
"total": total, "total": total,
"page": page, "page": page,
"page_size": pageSize, "page_size": pageSize,
}) }
SuccessResponse(c, responseData)
} }
// GetResourceByID 根据ID获取资源 // GetResourceByID 根据ID获取资源
@@ -164,6 +289,15 @@ func CreateResource(c *gin.Context) {
} }
} }
// 同步到Meilisearch
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
go func() {
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
}
}()
}
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "资源创建成功", "message": "资源创建成功",
"resource": converter.ToResourceResponse(resource), "resource": converter.ToResourceResponse(resource),
@@ -240,6 +374,15 @@ func UpdateResource(c *gin.Context) {
} }
} }
// 同步到Meilisearch
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
go func() {
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
utils.Error("同步资源到Meilisearch失败: %v", err)
}
}()
}
SuccessResponse(c, gin.H{"message": "资源更新成功"}) SuccessResponse(c, gin.H{"message": "资源更新成功"})
} }
@@ -252,12 +395,29 @@ func DeleteResource(c *gin.Context) {
return return
} }
// 先从数据库中删除资源
err = repoManager.ResourceRepository.Delete(uint(id)) err = repoManager.ResourceRepository.Delete(uint(id))
if err != nil { if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return 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": "资源删除成功"}) SuccessResponse(c, gin.H{"message": "资源删除成功"})
} }
@@ -271,12 +431,53 @@ func SearchResources(c *gin.Context) {
var total int64 var total int64
var err error var err error
if query == "" { // 如果启用了Meilisearch优先使用Meilisearch搜索
// 搜索关键词为空时,返回最新记录(分页) if meilisearchManager != nil && meilisearchManager.IsEnabled() {
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize) // 构建过滤器
} else { filters := make(map[string]interface{})
// 有搜索关键词时,执行搜索 if categoryID := c.Query("category_id"); categoryID != "" {
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize) if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
filters["category"] = uint(id)
}
}
// 使用Meilisearch搜索
service := meilisearchManager.GetService()
if service != nil {
docs, docTotal, err := service.Search(query, filters, page, pageSize)
if err == nil {
// 将Meilisearch文档转换为Resource实体
for _, doc := range docs {
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,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}
resources = append(resources, resource)
}
total = docTotal
} else {
utils.Error("Meilisearch搜索失败回退到数据库搜索: %v", err)
}
}
}
// 如果Meilisearch未启用或搜索失败使用数据库搜索
if meilisearchManager == nil || !meilisearchManager.IsEnabled() || err != nil {
if query == "" {
// 搜索关键词为空时,返回最新记录(分页)
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize)
} else {
// 有搜索关键词时,执行搜索
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize)
}
} }
if err != nil { if err != nil {
@@ -329,12 +530,39 @@ func BatchDeleteResources(c *gin.Context) {
ErrorResponse(c, "参数错误", 400) ErrorResponse(c, "参数错误", 400)
return return
} }
count := 0 count := 0
var deletedIDs []uint
// 先删除数据库中的资源
for _, id := range req.IDs { for _, id := range req.IDs {
if err := repoManager.ResourceRepository.Delete(id); err == nil { if err := repoManager.ResourceRepository.Delete(id); err == nil {
count++ 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": "批量删除成功"}) SuccessResponse(c, gin.H{"deleted": count, "message": "批量删除成功"})
} }
@@ -386,8 +614,8 @@ func GetResourceLink(c *gin.Context) {
} }
// 如果不是夸克网盘,直接返回原链接 // 如果不是夸克网盘,直接返回原链接
if panInfo.Name != "quark" { if panInfo.Name != "quark" && panInfo.Name != "xunlei" {
utils.Info("非夸克资源,直接返回原链接") utils.Info("非夸克和迅雷资源,直接返回原链接")
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"url": resource.URL, "url": resource.URL,
"type": "original", "type": "original",
@@ -397,9 +625,6 @@ func GetResourceLink(c *gin.Context) {
return return
} }
// 夸克资源处理逻辑
utils.Info("夸克资源处理开始")
// 如果已存在转存链接,直接返回 // 如果已存在转存链接,直接返回
if resource.SaveURL != "" { if resource.SaveURL != "" {
utils.Info("已存在转存链接,直接返回: %s", resource.SaveURL) utils.Info("已存在转存链接,直接返回: %s", resource.SaveURL)
@@ -467,6 +692,7 @@ func GetResourceLink(c *gin.Context) {
// TransferResult 转存结果 // TransferResult 转存结果
type TransferResult struct { type TransferResult struct {
Success bool `json:"success"` Success bool `json:"success"`
Fid string `json:"fid"`
SaveURL string `json:"save_url"` SaveURL string `json:"save_url"`
ErrorMsg string `json:"error_msg"` ErrorMsg string `json:"error_msg"`
} }
@@ -475,18 +701,11 @@ type TransferResult struct {
func performAutoTransfer(resource *entity.Resource) TransferResult { func performAutoTransfer(resource *entity.Resource) TransferResult {
utils.Info("开始执行资源转存 - ID: %d, URL: %s", resource.ID, resource.URL) utils.Info("开始执行资源转存 - ID: %d, URL: %s", resource.ID, resource.URL)
// 获取夸克平台ID // 平台ID
quarkPanID, err := getQuarkPanID() panID := resource.PanID
if err != nil {
utils.Error("获取夸克平台ID失败: %v", err)
return TransferResult{
Success: false,
ErrorMsg: fmt.Sprintf("获取夸克平台ID失败: %v", err),
}
}
// 获取可用的夸克账号 // 获取可用的夸克账号
accounts, err := repoManager.CksRepository.FindAll() accounts, err := repoManager.CksRepository.FindByPanID(*panID)
if err != nil { if err != nil {
utils.Error("获取网盘账号失败: %v", err) utils.Error("获取网盘账号失败: %v", err)
return TransferResult{ return TransferResult{
@@ -495,6 +714,7 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
} }
} }
// 测试阶段,移除最小限制
// 获取最小存储空间配置 // 获取最小存储空间配置
autoTransferMinSpace, err := repoManager.SystemConfigRepository.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace) autoTransferMinSpace, err := repoManager.SystemConfigRepository.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil { if err != nil {
@@ -506,23 +726,24 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024 minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
var validAccounts []entity.Cks var validAccounts []entity.Cks
for _, acc := range accounts { 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) validAccounts = append(validAccounts, acc)
} }
} }
if len(validAccounts) == 0 { if len(validAccounts) == 0 {
utils.Info("没有可用的夸克网盘账号") utils.Info("没有可用的网盘账号")
return TransferResult{ return TransferResult{
Success: false, Success: false,
ErrorMsg: "没有可用的夸克网盘账号", ErrorMsg: "没有可用的网盘账号",
} }
} }
utils.Info("找到 %d 个可用夸克网盘账号,开始转存处理...", len(validAccounts)) utils.Info("找到 %d 个可用网盘账号,开始转存处理...", len(validAccounts))
// 使用第一个可用账号进行转存 // 使用第一个可用账号进行转存
account := validAccounts[0] account := validAccounts[0]
// account := accounts[0]
// 创建网盘服务工厂 // 创建网盘服务工厂
factory := pan.NewPanFactory() factory := pan.NewPanFactory()
@@ -533,6 +754,8 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
if result.Success { if result.Success {
// 更新资源的转存信息 // 更新资源的转存信息
resource.SaveURL = result.SaveURL resource.SaveURL = result.SaveURL
resource.Fid = result.Fid
resource.CkID = &account.ID
resource.ErrorMsg = "" resource.ErrorMsg = ""
if err := repoManager.ResourceRepository.Update(resource); err != nil { if err := repoManager.ResourceRepository.Update(resource); err != nil {
utils.Error("更新资源转存信息失败: %v", err) utils.Error("更新资源转存信息失败: %v", err)
@@ -566,6 +789,9 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
} }
} }
// 设置账号信息
service.SetCKSRepository(repoManager.CksRepository, account)
// 提取分享ID // 提取分享ID
shareID, _ := commonutils.ExtractShareIdString(resource.URL) shareID, _ := commonutils.ExtractShareIdString(resource.URL)
if shareID == "" { if shareID == "" {
@@ -576,7 +802,7 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
} }
// 执行转存 // 执行转存
transferResult, err := service.Transfer(shareID) transferResult, err := service.Transfer(shareID) // 有些链接还需要其他信息从 url 中自行解析
if err != nil { if err != nil {
utils.Error("转存失败: %v", err) utils.Error("转存失败: %v", err)
return TransferResult{ return TransferResult{
@@ -599,10 +825,15 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
// 提取转存链接 // 提取转存链接
var saveURL string var saveURL string
var fid string
if data, ok := transferResult.Data.(map[string]interface{}); ok { if data, ok := transferResult.Data.(map[string]interface{}); ok {
if v, ok := data["shareUrl"]; ok { if v, ok := data["shareUrl"]; ok {
saveURL, _ = v.(string) saveURL, _ = v.(string)
} }
if v, ok := data["fid"]; ok {
fid, _ = v.(string)
}
} }
if saveURL == "" { if saveURL == "" {
saveURL = transferResult.ShareURL saveURL = transferResult.ShareURL
@@ -620,6 +851,7 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
return TransferResult{ return TransferResult{
Success: true, Success: true,
SaveURL: saveURL, SaveURL: saveURL,
Fid: fid,
} }
} }

View File

@@ -23,7 +23,6 @@ func GetSchedulerStatus(c *gin.Context) {
status := gin.H{ status := gin.H{
"hot_drama_scheduler_running": scheduler.IsHotDramaSchedulerRunning(), "hot_drama_scheduler_running": scheduler.IsHotDramaSchedulerRunning(),
"ready_resource_scheduler_running": scheduler.IsReadyResourceRunning(), "ready_resource_scheduler_running": scheduler.IsReadyResourceRunning(),
"auto_transfer_scheduler_running": scheduler.IsAutoTransferRunning(),
} }
SuccessResponse(c, status) SuccessResponse(c, status)
@@ -160,59 +159,3 @@ func TriggerReadyResourceScheduler(c *gin.Context) {
scheduler.StartReadyResourceScheduler() // 直接启动一次 scheduler.StartReadyResourceScheduler() // 直接启动一次
SuccessResponse(c, gin.H{"message": "手动触发待处理资源自动处理任务成功"}) 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": "手动触发自动转存定时任务成功"})
}

View File

@@ -22,16 +22,20 @@ func GetStats(c *gin.Context) {
db.DB.Model(&entity.Tag{}).Count(&totalTags) db.DB.Model(&entity.Tag{}).Count(&totalTags)
db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews) db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews)
// 获取今日数据 // 获取今日数据在UTC+8时区的0点开始统计
today := utils.GetTodayString() 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 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 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 var todayViews int64
@@ -41,9 +45,9 @@ func GetStats(c *gin.Context) {
todayViews = 0 todayViews = 0
} }
// 今日搜索量 // 今日搜索量从0点开始
var todaySearches int64 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", utils.Info("统计数据 - 总资源: %d, 总分类: %d, 总标签: %d, 总浏览量: %d",

View File

@@ -125,10 +125,15 @@ func GetSystemConfig(c *gin.Context) {
func UpdateSystemConfig(c *gin.Context) { func UpdateSystemConfig(c *gin.Context) {
var req dto.SystemConfigRequest var req dto.SystemConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
utils.Error("JSON绑定失败: %v", err)
ErrorResponse(c, "请求参数错误", http.StatusBadRequest) ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return return
} }
adminUsername, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
utils.Info("UpdateSystemConfig - 管理员更新系统配置 - 管理员: %s, IP: %s", adminUsername, clientIP)
// 调试信息 // 调试信息
utils.Info("接收到的配置请求: %+v", req) utils.Info("接收到的配置请求: %+v", req)
@@ -141,30 +146,52 @@ func UpdateSystemConfig(c *gin.Context) {
} }
// 验证参数 - 只验证提交的字段 // 验证参数 - 只验证提交的字段
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) { utils.Info("开始验证参数")
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest) if req.SiteTitle != nil {
return utils.Info("验证SiteTitle: '%s', 长度: %d", *req.SiteTitle, len(*req.SiteTitle))
if len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100 {
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
return
}
} }
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) { if req.AutoProcessInterval != nil {
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest) utils.Info("验证AutoProcessInterval: %d", *req.AutoProcessInterval)
return if *req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440 {
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
return
}
} }
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) { if req.PageSize != nil {
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest) utils.Info("验证PageSize: %d", *req.PageSize)
return if *req.PageSize < 10 || *req.PageSize > 500 {
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
return
}
} }
// 验证自动转存配置 // 验证自动转存配置
if req.AutoTransferLimitDays != nil && (*req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365) { if req.AutoTransferLimitDays != nil {
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest) utils.Info("验证AutoTransferLimitDays: %d", *req.AutoTransferLimitDays)
return if *req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365 {
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
return
}
} }
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) { if req.AutoTransferMinSpace != nil {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest) utils.Info("验证AutoTransferMinSpace: %d", *req.AutoTransferMinSpace)
return if *req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024 {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
return
}
}
// 验证公告相关字段
if req.Announcements != nil {
utils.Info("验证Announcements: '%s'", *req.Announcements)
// 可以在这里添加更详细的验证逻辑
} }
// 转换为实体 // 转换为实体
@@ -195,6 +222,17 @@ func UpdateSystemConfig(c *gin.Context) {
// 刷新系统配置缓存 // 刷新系统配置缓存
pan.RefreshSystemConfigCache() pan.RefreshSystemConfigCache()
// 重新加载Meilisearch配置如果Meilisearch配置有变更
if req.MeilisearchEnabled != nil || req.MeilisearchHost != nil || req.MeilisearchPort != nil || req.MeilisearchMasterKey != nil || req.MeilisearchIndexName != nil {
if meilisearchManager != nil {
if err := meilisearchManager.ReloadConfig(); err != nil {
utils.Error("重新加载Meilisearch配置失败: %v", err)
} else {
utils.Debug("Meilisearch配置重新加载成功")
}
}
}
// 根据配置更新定时任务状态(错误不影响配置保存) // 根据配置更新定时任务状态(错误不影响配置保存)
scheduler := scheduler.GetGlobalScheduler( scheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository, repoManager.HotDramaRepository,
@@ -286,6 +324,10 @@ func ToggleAutoProcess(c *gin.Context) {
return 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() configs, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
if err != nil { if err != nil {
@@ -311,6 +353,12 @@ func ToggleAutoProcess(c *gin.Context) {
return return
} }
// 确保配置缓存已刷新
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
utils.Error("刷新配置缓存失败: %v", err)
// 不返回错误,因为配置已经保存成功
}
// 更新定时任务状态 // 更新定时任务状态
scheduler := scheduler.GetGlobalScheduler( scheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository, repoManager.HotDramaRepository,

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -50,7 +51,11 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
return return
} }
utils.Info("创建批量转存任务: %s资源数量: %d选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts)) 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))
// 构建任务配置 // 构建任务配置
taskConfig := map[string]interface{}{ taskConfig := map[string]interface{}{
@@ -105,7 +110,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
} }
} }
utils.Info("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources)) utils.Debug("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"task_id": newTask.ID, "task_id": newTask.ID,
@@ -123,7 +128,9 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
return return
} }
utils.Info("启动任务: %d", taskID) 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)) err = h.taskManager.StartTask(uint(taskID))
if err != nil { if err != nil {
@@ -132,6 +139,8 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
return return
} }
utils.Debug("启动任务: %d", taskID)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "任务启动成功", "message": "任务启动成功",
}) })
@@ -146,7 +155,9 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
return return
} }
utils.Info("停止任务: %d", taskID) 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)) err = h.taskManager.StopTask(uint(taskID))
if err != nil { if err != nil {
@@ -155,6 +166,8 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
return return
} }
utils.Debug("停止任务: %d", taskID)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "任务停止成功", "message": "任务停止成功",
}) })
@@ -169,7 +182,9 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
return return
} }
utils.Info("暂停任务: %d", taskID) 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)) err = h.taskManager.PauseTask(uint(taskID))
if err != nil { if err != nil {
@@ -178,6 +193,8 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
return return
} }
utils.Debug("暂停任务: %d", taskID)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "任务暂停成功", "message": "任务暂停成功",
}) })
@@ -234,13 +251,25 @@ func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
// GetTasks 获取任务列表 // GetTasks 获取任务列表
func (h *TaskHandler) GetTasks(c *gin.Context) { func (h *TaskHandler) GetTasks(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) // 获取查询参数
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) pageStr := c.DefaultQuery("page", "1")
taskType := c.Query("task_type") pageSizeStr := c.DefaultQuery("pageSize", "10")
taskType := c.Query("taskType")
status := c.Query("status") status := c.Query("status")
utils.Info("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status) page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 10
}
utils.Debug("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
// 获取任务列表
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status) tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
if err != nil { if err != nil {
utils.Error("获取任务列表失败: %v", err) utils.Error("获取任务列表失败: %v", err)
@@ -248,19 +277,19 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
return return
} }
utils.Info("GetTasks: 从数据库获取到 %d 个任务", len(tasks)) utils.Debug("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
// 为每个任务添加运行状态 // 获取任务运行状态
var result []gin.H var taskList []gin.H
for _, task := range tasks { for _, task := range tasks {
isRunning := h.taskManager.IsTaskRunning(task.ID) isRunning := h.taskManager.IsTaskRunning(task.ID)
utils.Info("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning) utils.Debug("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
result = append(result, gin.H{ taskList = append(taskList, gin.H{
"id": task.ID, "id": task.ID,
"title": task.Title, "title": task.Title,
"description": task.Description, "description": task.Description,
"task_type": task.Type, "type": task.Type,
"status": task.Status, "status": task.Status,
"total_items": task.TotalItems, "total_items": task.TotalItems,
"processed_items": task.ProcessedItems, "processed_items": task.ProcessedItems,
@@ -273,10 +302,11 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
} }
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"items": result, "tasks": taskList,
"total": total, "total": total,
"page": page, "page": page,
"size": pageSize, "page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
}) })
} }
@@ -346,9 +376,14 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
return 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)) { if h.taskManager.IsTaskRunning(uint(taskID)) {
ErrorResponse(c, "任务正在运行,请先停止任务", http.StatusBadRequest) utils.Warn("DeleteTask - 尝试删除正在运行的任务 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
ErrorResponse(c, "任务正在运行中,无法删除", http.StatusBadRequest)
return return
} }
@@ -368,8 +403,243 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
return return
} }
utils.Info("任务删除成功: %d", taskID) utils.Debug("任务删除成功: %d", taskID)
utils.Info("DeleteTask - 任务删除成功 - 用户: %s, 任务ID: %d, IP: %s", username, taskID, clientIP)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "任务删除成功", "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,540 @@
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,
})
}
// 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 package handlers
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -8,6 +9,7 @@ import (
"github.com/ctwj/urldb/db/dto" "github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity" "github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/middleware" "github.com/ctwj/urldb/middleware"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -20,18 +22,24 @@ func Login(c *gin.Context) {
return return
} }
clientIP, _ := c.Get("client_ip")
utils.Info("Login - 尝试登录 - 用户名: %s, IP: %s", req.Username, clientIP)
user, err := repoManager.UserRepository.FindByUsername(req.Username) user, err := repoManager.UserRepository.FindByUsername(req.Username)
if err != nil { if err != nil {
utils.Warn("Login - 用户不存在或密码错误 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized) ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized)
return return
} }
if !user.IsActive { if !user.IsActive {
utils.Warn("Login - 账户已被禁用 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "账户已被禁用", http.StatusUnauthorized) ErrorResponse(c, "账户已被禁用", http.StatusUnauthorized)
return return
} }
if !middleware.CheckPassword(req.Password, user.Password) { if !middleware.CheckPassword(req.Password, user.Password) {
utils.Warn("Login - 密码错误 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized) ErrorResponse(c, "用户名或密码错误", http.StatusUnauthorized)
return return
} }
@@ -42,10 +50,13 @@ func Login(c *gin.Context) {
// 生成JWT令牌 // 生成JWT令牌
token, err := middleware.GenerateToken(user) token, err := middleware.GenerateToken(user)
if err != nil { if err != nil {
utils.Error("Login - 生成令牌失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, "生成令牌失败", http.StatusInternalServerError) ErrorResponse(c, "生成令牌失败", http.StatusInternalServerError)
return return
} }
utils.Info("Login - 登录成功 - 用户名: %s(ID:%d), IP: %s", req.Username, user.ID, clientIP)
response := dto.LoginResponse{ response := dto.LoginResponse{
Token: token, Token: token,
User: converter.ToUserResponse(user), User: converter.ToUserResponse(user),
@@ -62,9 +73,13 @@ func Register(c *gin.Context) {
return return
} }
clientIP, _ := c.Get("client_ip")
utils.Info("Register - 尝试注册 - 用户名: %s, 邮箱: %s, IP: %s", req.Username, req.Email, clientIP)
// 检查用户名是否已存在 // 检查用户名是否已存在
existingUser, _ := repoManager.UserRepository.FindByUsername(req.Username) existingUser, _ := repoManager.UserRepository.FindByUsername(req.Username)
if existingUser != nil { if existingUser != nil {
utils.Warn("Register - 用户名已存在 - 用户名: %s, IP: %s", req.Username, clientIP)
ErrorResponse(c, "用户名已存在", http.StatusBadRequest) ErrorResponse(c, "用户名已存在", http.StatusBadRequest)
return return
} }
@@ -72,6 +87,7 @@ func Register(c *gin.Context) {
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email) existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email)
if existingEmail != nil { if existingEmail != nil {
utils.Warn("Register - 邮箱已存在 - 邮箱: %s, IP: %s", req.Email, clientIP)
ErrorResponse(c, "邮箱已存在", http.StatusBadRequest) ErrorResponse(c, "邮箱已存在", http.StatusBadRequest)
return return
} }
@@ -79,6 +95,7 @@ func Register(c *gin.Context) {
// 哈希密码 // 哈希密码
hashedPassword, err := middleware.HashPassword(req.Password) hashedPassword, err := middleware.HashPassword(req.Password)
if err != nil { if err != nil {
utils.Error("Register - 密码加密失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError) ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return return
} }
@@ -93,10 +110,13 @@ func Register(c *gin.Context) {
err = repoManager.UserRepository.Create(user) err = repoManager.UserRepository.Create(user)
if err != nil { if err != nil {
utils.Error("Register - 创建用户失败 - 用户名: %s, IP: %s, Error: %v", req.Username, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
utils.Info("Register - 注册成功 - 用户名: %s(ID:%d), 邮箱: %s, IP: %s", req.Username, user.ID, req.Email, clientIP)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "注册成功", "message": "注册成功",
"user": converter.ToUserResponse(user), "user": converter.ToUserResponse(user),
@@ -123,9 +143,14 @@ func CreateUser(c *gin.Context) {
return 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) existingUser, _ := repoManager.UserRepository.FindByUsername(req.Username)
if existingUser != nil { if existingUser != nil {
utils.Warn("CreateUser - 用户名已存在 - 管理员: %s, 用户名: %s, IP: %s", adminUsername, req.Username, clientIP)
ErrorResponse(c, "用户名已存在", http.StatusBadRequest) ErrorResponse(c, "用户名已存在", http.StatusBadRequest)
return return
} }
@@ -133,6 +158,7 @@ func CreateUser(c *gin.Context) {
// 检查邮箱是否已存在 // 检查邮箱是否已存在
existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email) existingEmail, _ := repoManager.UserRepository.FindByEmail(req.Email)
if existingEmail != nil { if existingEmail != nil {
utils.Warn("CreateUser - 邮箱已存在 - 管理员: %s, 邮箱: %s, IP: %s", adminUsername, req.Email, clientIP)
ErrorResponse(c, "邮箱已存在", http.StatusBadRequest) ErrorResponse(c, "邮箱已存在", http.StatusBadRequest)
return return
} }
@@ -140,6 +166,7 @@ func CreateUser(c *gin.Context) {
// 哈希密码 // 哈希密码
hashedPassword, err := middleware.HashPassword(req.Password) hashedPassword, err := middleware.HashPassword(req.Password)
if err != nil { if err != nil {
utils.Error("CreateUser - 密码加密失败 - 管理员: %s, 用户名: %s, IP: %s, Error: %v", adminUsername, req.Username, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError) ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return return
} }
@@ -154,10 +181,13 @@ func CreateUser(c *gin.Context) {
err = repoManager.UserRepository.Create(user) err = repoManager.UserRepository.Create(user)
if err != nil { if err != nil {
utils.Error("CreateUser - 创建用户失败 - 管理员: %s, 用户名: %s, IP: %s, Error: %v", adminUsername, req.Username, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
utils.Info("CreateUser - 用户创建成功 - 管理员: %s, 用户名: %s(ID:%d), 角色: %s, IP: %s", adminUsername, req.Username, user.ID, req.Role, clientIP)
SuccessResponse(c, gin.H{ SuccessResponse(c, gin.H{
"message": "用户创建成功", "message": "用户创建成功",
"user": converter.ToUserResponse(user), "user": converter.ToUserResponse(user),
@@ -179,12 +209,21 @@ func UpdateUser(c *gin.Context) {
return 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)) user, err := repoManager.UserRepository.FindByID(uint(id))
if err != nil { if err != nil {
utils.Warn("UpdateUser - 目标用户不存在 - 管理员: %s, 用户ID: %d, IP: %s", adminUsername, id, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound) ErrorResponse(c, "用户不存在", http.StatusNotFound)
return 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 != "" { if req.Username != "" {
user.Username = req.Username user.Username = req.Username
} }
@@ -198,10 +237,15 @@ func UpdateUser(c *gin.Context) {
err = repoManager.UserRepository.Update(user) err = repoManager.UserRepository.Update(user)
if err != nil { if err != nil {
utils.Error("UpdateUser - 更新用户失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return 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": "用户更新成功"}) SuccessResponse(c, gin.H{"message": "用户更新成功"})
} }
@@ -220,8 +264,13 @@ func ChangePassword(c *gin.Context) {
return 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)) user, err := repoManager.UserRepository.FindByID(uint(id))
if err != nil { if err != nil {
utils.Warn("ChangePassword - 目标用户不存在 - 管理员: %s, 用户ID: %d, IP: %s", adminUsername, id, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound) ErrorResponse(c, "用户不存在", http.StatusNotFound)
return return
} }
@@ -229,6 +278,7 @@ func ChangePassword(c *gin.Context) {
// 哈希新密码 // 哈希新密码
hashedPassword, err := middleware.HashPassword(req.NewPassword) hashedPassword, err := middleware.HashPassword(req.NewPassword)
if err != nil { if err != nil {
utils.Error("ChangePassword - 密码加密失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, "密码加密失败", http.StatusInternalServerError) ErrorResponse(c, "密码加密失败", http.StatusInternalServerError)
return return
} }
@@ -236,10 +286,13 @@ func ChangePassword(c *gin.Context) {
user.Password = hashedPassword user.Password = hashedPassword
err = repoManager.UserRepository.Update(user) err = repoManager.UserRepository.Update(user)
if err != nil { if err != nil {
utils.Error("ChangePassword - 更新密码失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
utils.Info("ChangePassword - 密码修改成功 - 管理员: %s, 用户名: %s(ID:%d), IP: %s", adminUsername, user.Username, id, clientIP)
SuccessResponse(c, gin.H{"message": "密码修改成功"}) SuccessResponse(c, gin.H{"message": "密码修改成功"})
} }
@@ -252,12 +305,27 @@ func DeleteUser(c *gin.Context) {
return 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)) err = repoManager.UserRepository.Delete(uint(id))
if err != nil { if err != nil {
utils.Error("DeleteUser - 删除用户失败 - 管理员: %s, 用户ID: %d, IP: %s, Error: %v", adminUsername, id, clientIP, err)
ErrorResponse(c, err.Error(), http.StatusInternalServerError) ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return return
} }
utils.Info("DeleteUser - 用户删除成功 - 管理员: %s, 用户名: %s(ID:%d), IP: %s", adminUsername, user.Username, id, clientIP)
SuccessResponse(c, gin.H{"message": "用户删除成功"}) SuccessResponse(c, gin.H{"message": "用户删除成功"})
} }
@@ -269,12 +337,18 @@ func GetProfile(c *gin.Context) {
return 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)) user, err := repoManager.UserRepository.FindByID(userID.(uint))
if err != nil { if err != nil {
utils.Warn("GetProfile - 用户不存在 - 用户名: %s(ID:%d), IP: %s", username, userID, clientIP)
ErrorResponse(c, "用户不存在", http.StatusNotFound) ErrorResponse(c, "用户不存在", http.StatusNotFound)
return return
} }
response := converter.ToUserResponse(user) response := converter.ToUserResponse(user)
utils.Debug("GetProfile - 成功获取个人资料 - 用户名: %s(ID:%d), IP: %s", username, userID, clientIP)
SuccessResponse(c, response) 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
}
}

202
main.go
View File

@@ -1,14 +1,21 @@
package main package main
import ( import (
"fmt"
"log" "log"
"os" "os"
"strings" "strings"
"time"
"github.com/ctwj/urldb/config"
"github.com/ctwj/urldb/db" "github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo" "github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/handlers" "github.com/ctwj/urldb/handlers"
"github.com/ctwj/urldb/middleware" "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" "github.com/ctwj/urldb/task"
"github.com/ctwj/urldb/utils" "github.com/ctwj/urldb/utils"
@@ -18,11 +25,22 @@ import (
) )
func main() { func main() {
// 检查命令行参数
if len(os.Args) > 1 && os.Args[1] == "version" {
versionInfo := utils.GetVersionInfo()
fmt.Printf("版本: v%s\n", versionInfo.Version)
fmt.Printf("构建时间: %s\n", versionInfo.BuildTime.Format("2006-01-02 15:04:05"))
fmt.Printf("Git提交: %s\n", versionInfo.GitCommit)
fmt.Printf("Git分支: %s\n", versionInfo.GitBranch)
fmt.Printf("Go版本: %s\n", versionInfo.GoVersion)
fmt.Printf("平台: %s/%s\n", versionInfo.Platform, versionInfo.Arch)
return
}
// 初始化日志系统 // 初始化日志系统
if err := utils.InitLogger(nil); err != nil { if err := utils.InitLogger(nil); err != nil {
log.Fatal("初始化日志系统失败:", err) log.Fatal("初始化日志系统失败:", err)
} }
defer utils.GetLogger().Close()
// 加载环境变量 // 加载环境变量
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
@@ -69,6 +87,17 @@ func main() {
// 创建Repository管理器 // 创建Repository管理器
repoManager := repo.NewRepositoryManager(db.DB) 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) taskManager := task.NewTaskManager(repoManager)
@@ -76,6 +105,16 @@ func main() {
transferProcessor := task.NewTransferProcessor(repoManager) transferProcessor := task.NewTransferProcessor(repoManager)
taskManager.RegisterProcessor(transferProcessor) taskManager.RegisterProcessor(transferProcessor)
// 注册扩容任务处理器
expansionProcessor := task.NewExpansionProcessor(repoManager)
taskManager.RegisterProcessor(expansionProcessor)
// 初始化Meilisearch管理器
meilisearchManager := services.NewMeilisearchManager(repoManager)
if err := meilisearchManager.Initialize(); err != nil {
utils.Error("初始化Meilisearch管理器失败: %v", err)
}
// 恢复运行中的任务(服务器重启后) // 恢复运行中的任务(服务器重启后)
if err := taskManager.RecoverRunningTasks(); err != nil { if err := taskManager.RecoverRunningTasks(); err != nil {
utils.Error("恢复运行中任务失败: %v", err) utils.Error("恢复运行中任务失败: %v", err)
@@ -86,7 +125,22 @@ func main() {
utils.Info("任务管理器初始化完成") utils.Info("任务管理器初始化完成")
// 创建Gin实例 // 创建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 // 配置CORS
config := cors.DefaultConfig() config := cors.DefaultConfig()
@@ -98,6 +152,43 @@ func main() {
// 将Repository管理器注入到handlers中 // 将Repository管理器注入到handlers中
handlers.SetRepositoryManager(repoManager) handlers.SetRepositoryManager(repoManager)
// 将Repository管理器注入到services中
services.SetRepositoryManager(repoManager)
// 设置Meilisearch管理器到handlers中
handlers.SetMeilisearchManager(meilisearchManager)
// 设置Meilisearch管理器到services中
services.SetMeilisearchManager(meilisearchManager)
// 设置全局调度器的Meilisearch管理器
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
// 初始化并启动调度器
globalScheduler := scheduler.GetGlobalScheduler(
repoManager.HotDramaRepository,
repoManager.ReadyResourceRepository,
repoManager.ResourceRepository,
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 根据系统配置启动相应的调度任务
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
autoFetchHotDrama,
autoProcessReadyResources,
autoTransferEnabled,
)
utils.Info("调度器初始化完成")
// 设置公开API中间件的Repository管理器 // 设置公开API中间件的Repository管理器
middleware.SetRepositoryManager(repoManager) middleware.SetRepositoryManager(repoManager)
@@ -110,6 +201,9 @@ func main() {
// 创建文件处理器 // 创建文件处理器
fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository) fileHandler := handlers.NewFileHandler(repoManager.FileRepository, repoManager.SystemConfigRepository, repoManager.UserRepository)
// 创建Meilisearch处理器
meilisearchHandler := handlers.NewMeilisearchHandler(meilisearchManager)
// API路由 // API路由
api := r.Group("/api") api := r.Group("/api")
{ {
@@ -171,6 +265,7 @@ func main() {
api.DELETE("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteCks) api.DELETE("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteCks)
api.GET("/cks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetCksByID) 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/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) api.GET("/tags", handlers.GetTags)
@@ -212,6 +307,18 @@ func main() {
api.POST("/search-stats/record", handlers.RecordSearch) api.POST("/search-stats/record", handlers.RecordSearch)
api.GET("/search-stats/summary", handlers.GetSearchStatsSummary) 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.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig) api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
@@ -225,9 +332,12 @@ func main() {
api.POST("/hot-dramas", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateHotDrama) api.POST("/hot-dramas", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateHotDrama)
api.PUT("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateHotDrama) api.PUT("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateHotDrama)
api.DELETE("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteHotDrama) 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/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", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTasks)
api.GET("/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTaskStatus) api.GET("/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTaskStatus)
api.POST("/tasks/:id/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.StartTask) api.POST("/tasks/:id/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.StartTask)
@@ -242,13 +352,99 @@ func main() {
api.GET("/version/full", handlers.GetFullVersionInfo) api.GET("/version/full", handlers.GetFullVersionInfo)
api.GET("/version/check-update", handlers.CheckUpdate) api.GET("/version/check-update", handlers.CheckUpdate)
// Meilisearch管理路由
api.GET("/meilisearch/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetStatus)
api.GET("/meilisearch/unsynced-count", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetUnsyncedCount)
api.GET("/meilisearch/unsynced", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetUnsyncedResources)
api.GET("/meilisearch/synced", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetSyncedResources)
api.GET("/meilisearch/resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetAllResources)
api.POST("/meilisearch/sync-all", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.SyncAllResources)
api.GET("/meilisearch/sync-progress", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.GetSyncProgress)
api.POST("/meilisearch/stop-sync", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.StopSync)
api.POST("/meilisearch/clear-index", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.ClearIndex)
api.POST("/meilisearch/test-connection", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.TestConnection)
api.POST("/meilisearch/update-settings", middleware.AuthMiddleware(), middleware.AdminMiddleware(), meilisearchHandler.UpdateIndexSettings)
// 文件上传相关路由 // 文件上传相关路由
api.POST("/files/upload", middleware.AuthMiddleware(), fileHandler.UploadFile) api.POST("/files/upload", middleware.AuthMiddleware(), fileHandler.UploadFile)
api.GET("/files", fileHandler.GetFileList) api.GET("/files", middleware.AuthMiddleware(), fileHandler.GetFileList)
api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles) api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles)
api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile) 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)
// 微信公众号相关路由
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)
} }
// 设置监控系统
monitor.SetupMonitoring(r)
// 启动监控服务器
metricsConfig := &monitor.MetricsConfig{
Enabled: true,
ListenAddress: ":9090",
MetricsPath: "/metrics",
Namespace: "urldb",
Subsystem: "api",
}
metrics.StartMetricsServer(metricsConfig)
// 静态文件服务 // 静态文件服务
r.Static("/uploads", "./uploads") r.Static("/uploads", "./uploads")

View File

@@ -27,11 +27,14 @@ type Claims struct {
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path) clientIP := c.ClientIP()
utils.Info("AuthMiddleware - Authorization头: %s", authHeader) userAgent := c.Request.UserAgent()
utils.Debug("AuthMiddleware - 认证请求: %s %s, IP: %s, UserAgent: %s",
c.Request.Method, c.Request.URL.Path, clientIP, userAgent)
if authHeader == "" { 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.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证令牌"})
c.Abort() c.Abort()
return return
@@ -39,29 +42,31 @@ func AuthMiddleware() gin.HandlerFunc {
// 检查Bearer前缀 // 检查Bearer前缀
if !strings.HasPrefix(authHeader, "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.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
c.Abort() c.Abort()
return return
} }
tokenString := strings.TrimPrefix(authHeader, "Bearer ") 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) claims, err := parseToken(tokenString)
if err != nil { 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.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
c.Abort() c.Abort()
return 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("user_id", claims.UserID)
c.Set("username", claims.Username) c.Set("username", claims.Username)
c.Set("role", claims.Role) c.Set("role", claims.Role)
c.Set("client_ip", clientIP)
c.Next() c.Next()
} }
@@ -71,18 +76,23 @@ func AuthMiddleware() gin.HandlerFunc {
func AdminMiddleware() gin.HandlerFunc { func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
role, exists := c.Get("role") role, exists := c.Get("role")
username, _ := c.Get("username")
clientIP, _ := c.Get("client_ip")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"}) utils.Warn("AdminMiddleware - 未认证访问管理员接口 - IP: %s, Path: %s", clientIP, c.Request.URL.Path)
c.Abort() c.Abort()
return return
} }
if role != "admin" { 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() c.Abort()
return return
} }
utils.Debug("AdminMiddleware - 管理员访问接口 - 用户: %s, IP: %s, Path: %s", username, clientIP, c.Request.URL.Path)
c.Next() c.Next()
} }
} }
@@ -106,23 +116,23 @@ func GenerateToken(user *entity.User) (string, error) {
// parseToken 解析JWT令牌 // parseToken 解析JWT令牌
func parseToken(tokenString string) (*Claims, error) { func parseToken(tokenString string) (*Claims, error) {
utils.Info("parseToken - 开始解析令牌") // utils.Info("parseToken - 开始解析令牌")
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil return jwtSecret, nil
}) })
if err != nil { if err != nil {
utils.Error("parseToken - JWT解析失败: %v", err) // utils.Error("parseToken - JWT解析失败: %v", err)
return nil, err return nil, err
} }
if claims, ok := token.Claims.(*Claims); ok && token.Valid { if claims, ok := token.Claims.(*Claims); ok && token.Valid {
utils.Info("parseToken - 令牌解析成功用户ID: %d", claims.UserID) // utils.Info("parseToken - 令牌解析成功用户ID: %d", claims.UserID)
return claims, nil return claims, nil
} }
utils.Error("parseToken - 令牌无效或签名错误") // utils.Error("parseToken - 令牌无效或签名错误")
return nil, jwt.ErrSignatureInvalid return nil, jwt.ErrSignatureInvalid
} }

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

14
migrations/v1.x.x.sql Normal file
View File

@@ -0,0 +1,14 @@
-- 添加文件哈希字段
ALTER TABLE files ADD COLUMN file_hash VARCHAR(64) COMMENT '文件哈希值';
CREATE UNIQUE INDEX idx_files_hash ON files(file_hash);
-- 添加同步状态字段
ALTER TABLE resources ADD COLUMN synced_to_meilisearch BOOLEAN DEFAULT FALSE;
ALTER TABLE resources ADD COLUMN synced_at TIMESTAMP NULL;
-- 创建索引以提高查询性能
CREATE INDEX idx_resources_synced ON resources(synced_to_meilisearch, synced_at);
-- 添加注释
COMMENT ON COLUMN resources.synced_to_meilisearch IS '是否已同步到Meilisearch';
COMMENT ON COLUMN resources.synced_at IS '同步时间';

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

@@ -60,17 +60,38 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# 缓存设置 # 缓存设置
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
# 允许跨域访问 # 允许跨域访问
add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS"; add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept"; add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept";
} }
# 微信公众号验证文件路由 - 根目录的TXT文件直接访问后端uploads目录
location ~ ^/[^/]+\.txt$ {
# 检查文件是否存在于uploads目录
set $uploads_path /uploads$uri;
if (-f $uploads_path) {
proxy_pass http://backend;
# 缓存设置
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;
}
# 健康检查 # 健康检查
location /health { location /health {
proxy_pass http://backend/health; proxy_pass http://backend/health;

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("存在违禁词: %s", strings.Join(matchedWords, ", "))
forbiddenResources = append(forbiddenResources, resource)
utils.Info(fmt.Sprintf("标记违禁词资源: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
} else {
filteredResources = append(filteredResources, resource)
}
}
utils.Info(fmt.Sprintf("违禁词过滤后,剩余 %d 个资源需要转存,违禁词资源 %d 个", len(filteredResources), len(forbiddenResources)))
} else {
filteredResources = resources
}
// 注意:资源数量已在数据库查询时限制,无需再次限制
// 保存违禁词资源的错误信息
for _, resource := range forbiddenResources {
if err := a.resourceRepo.Update(resource); err != nil {
utils.Error(fmt.Sprintf("保存违禁词错误信息失败 (ID: %d): %v", resource.ID, err))
}
}
// 并发自动转存
resourceCh := make(chan *entity.Resource, len(filteredResources))
for _, res := range filteredResources {
resourceCh <- res
}
close(resourceCh)
var wg sync.WaitGroup
for _, account := range validAccounts {
wg.Add(1)
go func(acc entity.Cks) {
defer wg.Done()
factory := panutils.GetInstance() // 使用单例模式
for res := range resourceCh {
if err := a.transferResource(res, []entity.Cks{acc}, factory); err != nil {
utils.Error(fmt.Sprintf("转存资源失败 (ID: %d): %v", res.ID, err))
} else {
utils.Info(fmt.Sprintf("成功转存资源: %s", res.Title))
rand.Seed(utils.GetCurrentTime().UnixNano())
sleepSec := rand.Intn(3) + 1 // 1,2,3
time.Sleep(time.Duration(sleepSec) * time.Second)
}
}
}(account)
}
wg.Wait()
utils.Info(fmt.Sprintf("自动转存处理完成,账号数: %d处理资源数: %d违禁词资源数: %d",
len(validAccounts), len(filteredResources), len(forbiddenResources)))
}
// getQuarkPanID 获取夸克网盘ID
func (a *AutoTransferScheduler) getQuarkPanID() (uint, error) {
// 获取panRepo的实现以便访问数据库
panRepoImpl, ok := a.panRepo.(interface{ GetDB() *gorm.DB })
if !ok {
return 0, fmt.Errorf("panRepo不支持GetDB方法")
}
var quarkPan entity.Pan
err := panRepoImpl.GetDB().Where("name = ?", "quark").First(&quarkPan).Error
if err != nil {
return 0, fmt.Errorf("未找到quark平台: %v", err)
}
return quarkPan.ID, nil
}
// getResourcesForTransfer 获取需要转存的资源
func (a *AutoTransferScheduler) getResourcesForTransfer(quarkPanID uint, limit int) ([]*entity.Resource, error) {
// 获取最近24小时内的资源
sinceTime := utils.GetCurrentTime().Add(-24 * time.Hour)
// 使用资源仓库的方法获取需要转存的资源
repoImpl, ok := a.resourceRepo.(*repo.ResourceRepositoryImpl)
if !ok {
return nil, fmt.Errorf("资源仓库类型错误")
}
return repoImpl.GetResourcesForTransfer(quarkPanID, sinceTime, limit)
}
// transferResource 转存单个资源
func (a *AutoTransferScheduler) transferResource(resource *entity.Resource, accounts []entity.Cks, factory *panutils.PanFactory) error {
if len(accounts) == 0 {
return fmt.Errorf("没有可用的网盘账号")
}
account := accounts[0]
service, err := factory.CreatePanService(resource.URL, &panutils.PanConfig{
URL: resource.URL,
ExpiredType: 0,
IsType: 0,
Cookie: account.Ck,
})
if err != nil {
return fmt.Errorf("创建网盘服务失败: %v", err)
}
// 获取最小存储空间配置
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
return err
}
// 检查账号剩余空间
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
if account.LeftSpace < minSpaceBytes {
return fmt.Errorf("账号剩余空间不足,需要 %d GB当前剩余 %d GB", autoTransferMinSpace, account.LeftSpace/1024/1024/1024)
}
// 提取分享ID
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
// 转存资源
result, err := service.Transfer(shareID)
if err != nil {
// 更新错误信息
resource.ErrorMsg = err.Error()
a.resourceRepo.Update(resource)
return fmt.Errorf("转存失败: %v", err)
}
if result == nil || !result.Success {
errMsg := "转存失败"
if result != nil && result.Message != "" {
errMsg = result.Message
}
// 更新错误信息
resource.ErrorMsg = errMsg
a.resourceRepo.Update(resource)
return fmt.Errorf("转存失败: %s", errMsg)
}
// 提取转存链接、fid等
var saveURL, fid string
if data, ok := result.Data.(map[string]interface{}); ok {
if v, ok := data["shareUrl"]; ok {
saveURL, _ = v.(string)
}
if v, ok := data["fid"]; ok {
fid, _ = v.(string)
}
}
if saveURL == "" {
saveURL = result.ShareURL
}
// 更新资源信息
resource.SaveURL = saveURL
resource.CkID = &account.ID
resource.Fid = fid
resource.ErrorMsg = ""
// 保存更新
err = a.resourceRepo.Update(resource)
if err != nil {
return fmt.Errorf("保存转存结果失败: %v", err)
}
return nil
}
// selectBestAccount 选择最佳账号
func (a *AutoTransferScheduler) selectBestAccount(accounts []entity.Cks) *entity.Cks {
if len(accounts) == 0 {
return nil
}
// 获取最小存储空间配置
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
return &accounts[0] // 返回第一个账号
}
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
var bestAccount *entity.Cks
var bestScore int64 = -1
for i := range accounts {
account := &accounts[i]
if account.LeftSpace < minSpaceBytes {
continue // 跳过空间不足的账号
}
score := a.calculateAccountScore(account)
if score > bestScore {
bestScore = score
bestAccount = account
}
}
return bestAccount
}
// calculateAccountScore 计算账号评分
func (a *AutoTransferScheduler) calculateAccountScore(account *entity.Cks) int64 {
// TODO: 实现账号评分算法
// 1. VIP账号加分
// 2. 剩余空间大的账号加分
// 3. 使用率低的账号加分
// 4. 可以根据历史使用情况调整评分
score := int64(0)
// VIP账号加分
if account.VipStatus {
score += 1000
}
// 剩余空间加分每GB加1分
score += account.LeftSpace / (1024 * 1024 * 1024)
// 使用率加分(使用率越低分数越高)
if account.Space > 0 {
usageRate := float64(account.UsedSpace) / float64(account.Space)
score += int64((1 - usageRate) * 500) // 使用率越低,加分越多
}
return score
}

View File

@@ -4,6 +4,7 @@ import (
"sync" "sync"
"github.com/ctwj/urldb/db/repo" "github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/services"
"github.com/ctwj/urldb/utils" "github.com/ctwj/urldb/utils"
) )
@@ -16,8 +17,20 @@ type GlobalScheduler struct {
var ( var (
globalScheduler *GlobalScheduler globalScheduler *GlobalScheduler
once sync.Once once sync.Once
// 全局Meilisearch管理器
globalMeilisearchManager *services.MeilisearchManager
) )
// SetGlobalMeilisearchManager 设置全局Meilisearch管理器
func SetGlobalMeilisearchManager(manager *services.MeilisearchManager) {
globalMeilisearchManager = manager
}
// GetGlobalMeilisearchManager 获取全局Meilisearch管理器
func GetGlobalMeilisearchManager() *services.MeilisearchManager {
return globalMeilisearchManager
}
// GetGlobalScheduler 获取全局调度器实例(单例模式) // GetGlobalScheduler 获取全局调度器实例(单例模式)
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler { func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
once.Do(func() { once.Do(func() {
@@ -34,12 +47,12 @@ func (gs *GlobalScheduler) StartHotDramaScheduler() {
defer gs.mutex.Unlock() defer gs.mutex.Unlock()
if gs.manager.IsHotDramaRunning() { if gs.manager.IsHotDramaRunning() {
utils.Info("热播剧定时任务已在运行中") utils.Debug("热播剧定时任务已在运行中")
return return
} }
gs.manager.StartHotDramaScheduler() gs.manager.StartHotDramaScheduler()
utils.Info("全局调度器已启动热播剧定时任务") utils.Debug("全局调度器已启动热播剧定时任务")
} }
// StopHotDramaScheduler 停止热播剧定时任务 // StopHotDramaScheduler 停止热播剧定时任务
@@ -48,12 +61,12 @@ func (gs *GlobalScheduler) StopHotDramaScheduler() {
defer gs.mutex.Unlock() defer gs.mutex.Unlock()
if !gs.manager.IsHotDramaRunning() { if !gs.manager.IsHotDramaRunning() {
utils.Info("热播剧定时任务未在运行") utils.Debug("热播剧定时任务未在运行")
return return
} }
gs.manager.StopHotDramaScheduler() gs.manager.StopHotDramaScheduler()
utils.Info("全局调度器已停止热播剧定时任务") utils.Debug("全局调度器已停止热播剧定时任务")
} }
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行 // IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
@@ -74,12 +87,12 @@ func (gs *GlobalScheduler) StartReadyResourceScheduler() {
defer gs.mutex.Unlock() defer gs.mutex.Unlock()
if gs.manager.IsReadyResourceRunning() { if gs.manager.IsReadyResourceRunning() {
utils.Info("待处理资源自动处理任务已在运行中") utils.Debug("待处理资源自动处理任务已在运行中")
return return
} }
gs.manager.StartReadyResourceScheduler() gs.manager.StartReadyResourceScheduler()
utils.Info("全局调度器已启动待处理资源自动处理任务") utils.Debug("全局调度器已启动待处理资源自动处理任务")
} }
// StopReadyResourceScheduler 停止待处理资源自动处理任务 // StopReadyResourceScheduler 停止待处理资源自动处理任务
@@ -88,12 +101,12 @@ func (gs *GlobalScheduler) StopReadyResourceScheduler() {
defer gs.mutex.Unlock() defer gs.mutex.Unlock()
if !gs.manager.IsReadyResourceRunning() { if !gs.manager.IsReadyResourceRunning() {
utils.Info("待处理资源自动处理任务未在运行") utils.Debug("待处理资源自动处理任务未在运行")
return return
} }
gs.manager.StopReadyResourceScheduler() gs.manager.StopReadyResourceScheduler()
utils.Info("全局调度器已停止待处理资源自动处理任务") utils.Debug("全局调度器已停止待处理资源自动处理任务")
} }
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行 // IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
@@ -103,41 +116,6 @@ func (gs *GlobalScheduler) IsReadyResourceRunning() bool {
return gs.manager.IsReadyResourceRunning() return gs.manager.IsReadyResourceRunning()
} }
// StartAutoTransferScheduler 启动自动转存定时任务
func (gs *GlobalScheduler) StartAutoTransferScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if gs.manager.IsAutoTransferRunning() {
utils.Info("自动转存定时任务已在运行中")
return
}
gs.manager.StartAutoTransferScheduler()
utils.Info("全局调度器已启动自动转存定时任务")
}
// StopAutoTransferScheduler 停止自动转存定时任务
func (gs *GlobalScheduler) StopAutoTransferScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if !gs.manager.IsAutoTransferRunning() {
utils.Info("自动转存定时任务未在运行")
return
}
gs.manager.StopAutoTransferScheduler()
utils.Info("全局调度器已停止自动转存定时任务")
}
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
func (gs *GlobalScheduler) IsAutoTransferRunning() bool {
gs.mutex.RLock()
defer gs.mutex.RUnlock()
return gs.manager.IsAutoTransferRunning()
}
// UpdateSchedulerStatusWithAutoTransfer 根据系统配置更新调度器状态(包含自动转存) // UpdateSchedulerStatusWithAutoTransfer 根据系统配置更新调度器状态(包含自动转存)
func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool, autoTransferEnabled bool) { func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool, autoTransferEnabled bool) {
gs.mutex.Lock() gs.mutex.Lock()
@@ -169,16 +147,4 @@ func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDra
} }
} }
// 处理自动转存功能
if autoTransferEnabled {
if !gs.manager.IsAutoTransferRunning() {
utils.Info("系统配置启用自动转存,启动定时任务")
gs.manager.StartAutoTransferScheduler()
}
} else {
if gs.manager.IsAutoTransferRunning() {
utils.Info("系统配置禁用自动转存,停止定时任务")
gs.manager.StopAutoTransferScheduler()
}
}
} }

View File

@@ -90,13 +90,30 @@ func (h *HotDramaScheduler) processHotDramaNames(dramaNames []string) {
// 收集所有数据 // 收集所有数据
var allDramas []*entity.HotDrama var allDramas []*entity.HotDrama
// 获取电影数据 // 获取最近热门电影数据
movieDramas := h.processMovieData() recentMovieDramas := h.processRecentMovies()
allDramas = append(allDramas, movieDramas...) allDramas = append(allDramas, recentMovieDramas...)
// 获取电视剧数据 // 获取最近热门剧集数据
tvDramas := h.processTvData() recentTVDramas := h.processRecentTVs()
allDramas = append(allDramas, tvDramas...) 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)) utils.Info("准备清空数据库,当前共有 %d 条数据", len(allDramas))
@@ -121,111 +138,201 @@ func (h *HotDramaScheduler) processHotDramaNames(dramaNames []string) {
utils.Info("热播剧数据处理完成") utils.Info("热播剧数据处理完成")
} }
// processMovieData 处理电影数据 // processRecentMovies 处理最近热门电影数据
func (h *HotDramaScheduler) processMovieData() []*entity.HotDrama { func (h *HotDramaScheduler) processRecentMovies() []*entity.HotDrama {
utils.Info("开始处理电影数据...") utils.Info("开始处理最近热门电影数据...")
var movieDramas []*entity.HotDrama var recentMovies []*entity.HotDrama
// 使用GetTypePage方法获取电影数据 items, err := h.doubanService.GetRecentHotMovies()
movieResult, err := h.doubanService.GetTypePage("热门", "全部")
if err != nil { if err != nil {
utils.Error(fmt.Sprintf("获取电影榜单失败: %v", err)) utils.Error(fmt.Sprintf("获取最近热门电影失败: %v", err))
return movieDramas return recentMovies
} }
if movieResult.Success && movieResult.Data != nil { utils.Info("最近热门电影获取到 %d 个数据", len(items))
utils.Info("电影获取到 %d 个数据", len(movieResult.Data.Items))
for _, item := range movieResult.Data.Items { for _, item := range items {
drama := &entity.HotDrama{ drama := h.convertDoubanItemToHotDrama(item, "电影", "热门")
Title: item.Title, recentMovies = append(recentMovies, drama)
CardSubtitle: item.CardSubtitle, utils.Info("收集最近热门电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
EpisodesInfo: item.EpisodesInfo, item.Title, item.Rating.Value, item.Year, item.Region)
IsNew: item.IsNew,
Rating: item.Rating.Value,
RatingCount: item.Rating.Count,
Year: item.Year,
Region: item.Region,
Genres: strings.Join(item.Genres, ", "),
Directors: strings.Join(item.Directors, ", "),
Actors: strings.Join(item.Actors, ", "),
PosterURL: item.Pic.Normal,
Category: "电影",
SubType: "热门",
Source: "douban",
DoubanID: item.ID,
DoubanURI: item.URI,
}
movieDramas = append(movieDramas, drama)
utils.Info("收集电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
item.Title, item.Rating.Value, item.Year, item.Region)
}
} else {
utils.Warn("电影获取数据失败或为空")
} }
utils.Info("电影数据处理完成,共收集 %d 条数据", len(movieDramas)) utils.Info("最近热门电影数据处理完成,共收集 %d 条数据", len(recentMovies))
return movieDramas return recentMovies
} }
// processTvData 处理电视剧数据 // processRecentTVs 处理最近热门剧集数据
func (h *HotDramaScheduler) processTvData() []*entity.HotDrama { func (h *HotDramaScheduler) processRecentTVs() []*entity.HotDrama {
utils.Info("开始处理电视剧数据...") utils.Info("开始处理最近热门剧集数据...")
var tvDramas []*entity.HotDrama var recentTVs []*entity.HotDrama
// 获取所有tv类型 items, err := h.doubanService.GetRecentHotTVs()
tvTypes := h.doubanService.GetAllTvTypes() if err != nil {
utils.Info("获取到 %d 个tv类型: %v", len(tvTypes), tvTypes) utils.Error(fmt.Sprintf("获取最近热门剧集失败: %v", err))
return recentTVs
}
// 遍历每个type分别请求数据 utils.Info("最近热门剧集获取到 %d 个数据", len(items))
for _, tvType := range tvTypes {
utils.Info("正在处理tv类型: %s", tvType)
// 使用GetTypePage方法请求数据 for _, item := range items {
tvResult, err := h.doubanService.GetTypePage("tv", tvType) 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 { if err != nil {
utils.Error(fmt.Sprintf("获取tv类型 %s 数据失败: %v", tvType, err)) utils.Error(fmt.Sprintf("获取%s-%s数据失败: %v", config.category, "排行", err))
continue continue
} }
if tvResult.Success && tvResult.Data != nil { utils.Info("子类别%s-%s获取到%d个数据", config.category, "排行", len(items))
utils.Info("tv类型 %s 获取到 %d 个数据", tvType, len(tvResult.Data.Items))
for _, item := range tvResult.Data.Items { // 转换每个item
drama := &entity.HotDrama{ for _, item := range items {
Title: item.Title, drama := h.convertDoubanItemToHotDrama(item, config.category, "排行")
CardSubtitle: item.CardSubtitle, allDramas = append(allDramas, drama)
EpisodesInfo: item.EpisodesInfo, utils.Info("收集子类别%s-%s: %s (评分: %.1f, 年份: %s, 地区: %s)",
IsNew: item.IsNew, config.category, config.subType, item.Title, item.Rating.Value, item.Year, item.Region)
Rating: item.Rating.Value,
RatingCount: item.Rating.Count,
Year: item.Year,
Region: item.Region,
Genres: strings.Join(item.Genres, ", "),
Directors: strings.Join(item.Directors, ", "),
Actors: strings.Join(item.Actors, ", "),
PosterURL: item.Pic.Normal,
Category: "电视剧",
SubType: tvType, // 使用具体的tv类型
Source: "douban",
DoubanID: item.ID,
DoubanURI: item.URI,
}
tvDramas = append(tvDramas, drama)
utils.Info("收集tv类型 %s: %s (评分: %.1f, 年份: %s, 地区: %s)",
tvType, item.Title, item.Rating.Value, item.Year, item.Region)
}
} else {
utils.Warn("tv类型 %s 获取数据失败或为空", tvType)
} }
} }
utils.Info("电视剧数据处理完成,共收集 %d 条数据", len(tvDramas)) // 根据DoubanID去重
return tvDramas seen := make(map[string]bool)
uniqueDramas := make([]*entity.HotDrama, 0)
for _, drama := range allDramas {
if !seen[drama.DoubanID] {
seen[drama.DoubanID] = true
uniqueDramas = append(uniqueDramas, drama)
}
}
utils.Info("子类别排名数据处理完成,去重后共收集%d条数据", len(uniqueDramas))
return uniqueDramas
}
// convertDoubanItemToHotDrama 转换DoubanItem为HotDrama实体
func (h *HotDramaScheduler) convertDoubanItemToHotDrama(item utils.DoubanItem, category, subType string) *entity.HotDrama {
return &entity.HotDrama{
Title: item.Title,
CardSubtitle: item.CardSubtitle,
EpisodesInfo: item.EpisodesInfo,
IsNew: item.IsNew,
Rating: item.Rating.Value,
RatingCount: item.Rating.Count,
Year: item.Year,
Region: item.Region,
Genres: strings.Join(item.Genres, ", "),
Directors: strings.Join(item.Directors, ", "),
Actors: strings.Join(item.Actors, ", "),
PosterURL: item.Pic.Normal,
Category: category,
SubType: subType,
Source: "douban",
DoubanID: item.ID,
DoubanURI: item.URI,
}
} }
// GetHotDramaNames 获取热播剧名称列表(公共方法) // GetHotDramaNames 获取热播剧名称列表(公共方法)

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