81 Commits

Author SHA1 Message Date
ctwj
c8fd405d74 chore: bump version to v1.1.0 2025-08-03 11:15:32 +08:00
ctwj
5f8d998c65 update: 控制台体验优化 2025-08-03 10:50:25 +08:00
ctwj
b5b3c55573 update: UI优化 2025-08-03 00:19:21 +08:00
ctwj
1d3ed2f8aa add: 自动转存跳过包含违禁词的资源 2025-08-02 23:57:14 +08:00
ctwj
215f3170cd add; 添加违禁词的配置 2025-08-02 23:45:26 +08:00
ctwj
0700de36f5 update: 后台页面优化 2025-08-02 18:07:11 +08:00
Kerwin
14130eac8b update: README 2025-08-01 16:14:46 +08:00
Kerwin
bad6da4488 add: 添加自动处理失败列表 2025-08-01 15:50:04 +08:00
Kerwin
1126f84a3a update: 配置表更新 2025-08-01 14:24:45 +08:00
ctwj
24d644dc8b fix: 数据重复初始化问题 2025-07-31 23:27:23 +08:00
ctwj
d0ac53320e update: 更新默认分类 2025-07-31 22:48:26 +08:00
Kerwin
853bb50854 fix: 修复搜索接口错误 2025-07-31 17:28:10 +08:00
ctwj
dfb6a1707c update: 失效链接处理后删除 2025-07-31 00:21:51 +08:00
ctwj
9098b28ba6 update: docs 2025-07-30 23:38:27 +08:00
ctwj
b5e5052146 update: 尝试修复文档导航不显示问题 2025-07-30 23:27:42 +08:00
ctwj
e88b8411b5 update: 优化禁止访问页 2025-07-30 23:22:49 +08:00
Kerwin
d1b406b1ee update:优化 禁止访问页面 2025-07-30 17:33:36 +08:00
Kerwin
10432c1db6 opt: 优化微信禁止显示页面 2025-07-30 15:19:16 +08:00
Kerwin
440049c974 update: README 2025-07-30 11:18:43 +08:00
Kerwin
afb5a38f15 update: README 2025-07-30 11:14:54 +08:00
Kerwin
1ea7e87e6f update: README 2025-07-30 11:11:54 +08:00
ctwj
e6b4455428 fix: 修复天翼链接识别失败的问题 2025-07-30 07:56:44 +08:00
ctwj
6aacf9aed8 fix: error 2025-07-30 07:36:12 +08:00
ctwj
1f6fdfba1a fix: 修复迅雷天翼自动处理不识别问题 2025-07-30 00:01:42 +08:00
Kerwin
4d466af99e update: version to 1.0.10 2025-07-29 17:51:24 +08:00
Kerwin
c1b19cf937 chore: bump version to v1.0.10 2025-07-29 17:50:33 +08:00
Kerwin
4d3f4a082e update: 系统配置重构 2025-07-29 17:04:49 +08:00
Kerwin
ba7dd4d064 opt: 时区统一, 统一使用+8区时间 2025-07-29 14:00:01 +08:00
ctwj
78b147da47 fix: 修复部分链接检测失败的问题 2025-07-29 01:29:53 +08:00
ctwj
f9ecbad0a7 update: README 2025-07-28 22:49:43 +08:00
ctwj
53fbaabc63 update: docs 2025-07-27 09:38:34 +08:00
ctwj
97f92ea26c fix: 修复模式显示不对的问题 2025-07-27 09:14:44 +08:00
ctwj
d7b273dfae update: docker 修改为生产环境 2025-07-27 08:55:42 +08:00
ctwj
4c56289bfe update: version 2025-07-27 08:06:55 +08:00
ctwj
cf3376eb31 update: 更新页面,修复添加资源的问题 2025-07-27 01:45:04 +08:00
ctwj
312ecb041a update: 更新开启关闭自动处理UI 2025-07-26 23:21:39 +08:00
ctwj
a5c5e41cc4 update: 更新配置文件 2025-07-26 00:32:02 +08:00
ctwj
f0e5c93a48 update: 完善自动处理逻辑 2025-07-25 22:24:08 +08:00
Kerwin
2582920e2c update: UI美化 2025-07-25 18:22:35 +08:00
Kerwin
50ee23db1c update: 自动转存, 添加随机休眠时间 2025-07-25 15:06:39 +08:00
Kerwin
6cbd1f5d17 update: forbiddenUI 2025-07-25 10:48:37 +08:00
Kerwin
eba01b540b add: 添加禁止微信QQ访问 2025-07-25 09:52:21 +08:00
ctwj
0434d069ce update: 管理页面取消SSR 2025-07-25 01:28:51 +08:00
ctwj
443d67ad78 update: 更新批量添加接口,支持一个资源多个链接 2025-07-25 01:19:21 +08:00
Kerwin
4463960447 add: 给数据加上唯一key,支持资源多链接 2025-07-24 18:45:32 +08:00
Kerwin
595c44b437 Merge branch 'main' of github.com:ctwj/urldb 2025-07-24 12:28:01 +08:00
Kerwin
00606ef73e update: version 1.0.9 2025-07-24 12:27:39 +08:00
ctwj
d4fe64819f Update CNAME 2025-07-24 11:36:10 +08:00
Kerwin
32c8c30c05 chore: bump version to v1.0.9 2025-07-24 11:18:10 +08:00
ctwj
e2d4960c4c update: Finish Auto save 2025-07-24 01:05:46 +08:00
Kerwin
42ffc1e2e8 fix: 修复打包问题 2025-07-23 21:48:00 +08:00
Kerwin
cdd6b9985c update: Dockefile 2025-07-23 19:27:31 +08:00
Kerwin
dbc8fa9c36 update: version 1.0.8 2025-07-23 18:46:57 +08:00
Kerwin
67e15e03dc chore: bump version to v1.0.8 2025-07-23 18:46:23 +08:00
Kerwin
4ad176273e Merge branch 'main' of github.com:ctwj/urldb 2025-07-23 18:43:19 +08:00
Kerwin
a606897253 add: 新增维护模式 2025-07-23 18:42:24 +08:00
ctwj
cf31106cb7 Create CNAME 2025-07-23 15:01:10 +08:00
Kerwin
a21554f1cd fix: 修复UI显示 2025-07-23 12:31:45 +08:00
Kerwin
edfb0a43aa add: 账号管理添加启用禁用功能 2025-07-23 11:41:12 +08:00
ctwj
35052f7735 update: 批量添加资源, 自动处理资源优化 2025-07-23 01:11:42 +08:00
Kerwin
8a3d01fd28 update: README 2025-07-22 09:40:23 +08:00
ctwj
6e59133924 update: api 2025-07-22 00:44:56 +08:00
ctwj
91b743999a update: 资源优化 2025-07-22 00:09:46 +08:00
ctwj
ed6a1567f3 update: admin ui 2025-07-21 23:38:28 +08:00
ctwj
d3ed3ef990 add: 搜索统计 2025-07-21 23:04:46 +08:00
ctwj
ea60d730e2 update: UI opt 2025-07-21 22:52:41 +08:00
ctwj
21e2779d28 update: 优化api 2025-07-21 21:24:50 +08:00
Kerwin
c54a78c67f fix: 修复docker接口访问不对的问题 2025-07-21 19:29:32 +08:00
Kerwin
db41ba5ce3 update:version 1.0.7 2025-07-21 16:37:00 +08:00
Kerwin
72f7764e36 chore: bump version to v1.0.7 2025-07-21 16:36:22 +08:00
Kerwin
8ed7cbc181 fix: 前端问题修复 2025-07-21 15:27:58 +08:00
ctwj
51975ad408 update: UI 优化 2025-07-21 01:01:55 +08:00
ctwj
1bb14e218e update: change name 2025-07-21 00:43:07 +08:00
ctwj
3646c371a4 rm: 移除多余文件 2025-07-20 23:22:10 +08:00
ctwj
687fc6062d update: version 2025-07-20 23:05:40 +08:00
ctwj
505e508bca chore: bump version to v1.0.6 2025-07-20 23:03:11 +08:00
ctwj
d481083140 chore: bump version to v1.0.5 2025-07-20 22:59:12 +08:00
ctwj
c76298a10b chore: bump version to v1.0.4 2025-07-20 22:57:20 +08:00
ctwj
2cb91072ba chore: bump version to v1.0.3 2025-07-20 22:51:45 +08:00
ctwj
b84193c9e0 chore: bump version to v1.0.2 2025-07-20 22:46:51 +08:00
ctwj
ff79f9e9f3 update: version 2025-07-20 22:27:20 +08:00
134 changed files with 6022 additions and 4749 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ go.work.sum
.env
.env.local
.env.*.local
!web/.env
web/.output/
# IDE

View File

@@ -76,10 +76,4 @@
1. 在提交代码时使用规范的提交信息2. 在Pull Request中描述您的更改
3. 遵循项目的贡献指南
---
## 链接
- [项目主页](https://github.com/your-username/l9pan)
- [问题反馈](https://github.com/your-username/l9pan/issues)
- [讨论区](https://github.com/your-username/l9
---

View File

@@ -2,27 +2,23 @@
FROM node:20-slim AS frontend-builder
# 安装pnpm
RUN npm install -g pnpm
WORKDIR /app/web
COPY web/package*.json ./
COPY web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm run build
RUN npm install --frozen-lockfile
ARG NUXT_PUBLIC_API_SERVER=http://backend:8080/api
ARG NUXT_PUBLIC_API_CLIENT=/api
RUN npm run build
# 前端运行阶段
FROM node:18-alpine AS frontend
FROM node:20-alpine AS frontend
RUN npm install -g pnpm
# RUN npm install -g pnpm
ENV NODE_ENV=production
WORKDIR /app
COPY --from=frontend-builder /app/web/.output ./.output
COPY --from=frontend-builder /app/web/package*.json ./
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
# 后端构建阶段
@@ -32,12 +28,18 @@ WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
# 先复制VERSION文件确保构建时能正确读取版本号
COPY VERSION ./
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# 后端运行阶段
FROM alpine:latest AS backend
# 安装时区数据
RUN apk add --no-cache tzdata
WORKDIR /root/
# 复制后端二进制文件
@@ -46,6 +48,10 @@ COPY --from=backend-builder /app/main .
# 创建uploads目录
RUN mkdir -p uploads
# 设置环境变量
ENV GIN_MODE=release
ENV TZ=Asia/Shanghai
# 暴露端口
EXPOSE 8080

View File

@@ -1,4 +1,4 @@
# 🚀 urlDB - 网盘资源数据库
# 🚀 urlDB - 老九网盘资源数据库
<div align="center">
@@ -10,7 +10,7 @@
**一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘 **
🌐 [在线演示](#) | 📖 [文档](#) | 🐛 [问题反馈](#) | ⭐ [给个星标](#)
🌐 [在线演示](https://pan.l9.lc) | 📖 [文档](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink) | 🐛 [问题反馈](https://github.com/ctwj/urldb/issues) | ⭐ [给个星标](https://github.com/ctwj/urldb)
### 支持的网盘平台
@@ -41,6 +41,27 @@
---
## 📸 项目截图
[文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink) [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
### 🏠 首页
![首页](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/index.webp)
### 🔧 后台管理
![后台管理](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/admin.webp)
### ⚙️ 系统配置
![系统配置](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/config.webp)
### 🔍 搜索统计
![资源搜索](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/search.webp)
### 👤 多账号管理
![账号管理](https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/github/account.webp)
---
## ✨ 功能特性
### 🎯 核心功能
@@ -68,6 +89,7 @@
---
## 🚀 快速开始
### 环境要求
@@ -90,7 +112,7 @@ cd urldb
docker compose up --build -d
# 访问应用
# 前端: http://localhost:3000
# 前端: http://localhost:3030
# 后端API: http://localhost:8080
```
@@ -175,54 +197,6 @@ l9pan/
## 🔧 配置说明
### 版本管理
项目使用GitHub进行版本管理支持自动创建Release和标签。
#### 版本管理脚本
```bash
# 显示当前版本信息
./scripts/version.sh show
# 更新版本号
./scripts/version.sh patch # 修订版本 (1.0.0 -> 1.0.1)
./scripts/version.sh minor # 次版本 (1.0.0 -> 1.1.0)
./scripts/version.sh major # 主版本 (1.0.0 -> 2.0.0)
# 发布版本到GitHub
./scripts/version.sh release
# 生成版本信息文件
./scripts/version.sh update
# 查看帮助
./scripts/version.sh help
```
#### 自动发布流程
1. **更新版本号**: 修改 `VERSION` 文件
2. **同步文件**: 更新 `package.json``docker-compose.yml``README.md`
3. **创建Git标签**: 自动创建版本标签
4. **推送代码**: 推送代码和标签到GitHub
5. **创建Release**: 自动创建GitHub Release
#### 版本API接口
- `GET /api/version` - 获取版本信息
- `GET /api/version/string` - 获取版本字符串
- `GET /api/version/full` - 获取完整版本信息
- `GET /api/version/check-update` - 检查GitHub上的最新版本
#### 版本信息页面
访问 `/version` 页面查看详细的版本信息和更新状态。
#### 详细文档
查看 [GitHub版本管理指南](docs/github-version-management.md) 了解完整的版本管理流程。
### 环境变量配置
```bash
@@ -235,15 +209,26 @@ DB_NAME=url_db
# 服务器配置
PORT=8080
# 时区配置
TIMEZONE=Asia/Shanghai
```
### Docker 服务说明
| 服务 | 端口 | 说明 |
|------|------|------|
| frontend | 3000 | Nuxt.js 前端应用 |
| backend | 8080 | Go API 后端服务 |
| postgres | 5432 | PostgreSQL 数据库 |
| server | 3030 | 应用 |
| postgres | 5431 | PostgreSQL 数据库 |
### 镜像构建
```
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
```
---
@@ -253,7 +238,7 @@ PORT=8080
提供批量入库和搜索api通过 apiToken 授权
> 📖 完整API文档请访问`http://p.l9.lc/doc.html`
> 📖 完整API文档请访问`http://doc.l9.lc/`
## 🤝 贡献指南

View File

@@ -1 +1 @@
1.0.1
1.1.0

View File

@@ -3,7 +3,6 @@ package pan
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
@@ -84,7 +83,7 @@ func (a *AlipanService) Transfer(shareID string) (*TransferResult, error) {
config := a.config
a.configMutex.RUnlock()
log.Printf("开始处理阿里云盘分享: %s", shareID)
fmt.Printf("开始处理阿里云盘分享: %s", shareID)
// 获取access token
accessToken, err := a.manageAccessToken()

View File

@@ -16,6 +16,10 @@ const (
BaiduPan
UC
NotFound
Xunlei
Tianyi
Pan123
Pan115
)
// String 返回服务类型的字符串表示
@@ -29,6 +33,14 @@ func (s ServiceType) String() string {
return "baidu"
case UC:
return "uc"
case Xunlei:
return "xunlei"
case Tianyi:
return "tianyi"
case Pan123:
return "123pan"
case Pan115:
return "115"
default:
return "unknown"
}
@@ -42,6 +54,7 @@ type PanConfig struct {
ExpiredType int `json:"expiredType"` // 1: 分享永久, 2: 临时
AdFid string `json:"adFid"` // 夸克专用 - 分享时带上这个文件的fid
Stoken string `json:"stoken"`
Cookie string `json:"cookie"`
}
// TransferResult 转存结果
@@ -132,6 +145,10 @@ func (f *PanFactory) CreatePanServiceByType(serviceType ServiceType, config *Pan
return NewBaiduPanService(config), nil
case UC:
return NewUCService(config), nil
// case Xunlei:
// return NewXunleiService(config), nil
// case Tianyi:
// return NewTianyiService(config), nil
default:
return nil, fmt.Errorf("不支持的服务类型: %d", serviceType)
}
@@ -165,6 +182,11 @@ func (f *PanFactory) GetUCService(config *PanConfig) PanService {
func ExtractServiceType(url string) ServiceType {
url = strings.ToLower(url)
// "https://www.123pan.com/s/i4uaTd-WHn0", // 公开分享
// "https://www.123912.com/s/U8f2Td-ZeOX",
// "https://www.123684.coms/u9izjv-k3uWv",
// "https://www.123pan.com/s/A6cA-AKH11", // 外链不存在
patterns := map[string]ServiceType{
"pan.quark.cn": Quark,
"www.alipan.com": Alipan,
@@ -172,6 +194,14 @@ func ExtractServiceType(url string) ServiceType {
"pan.baidu.com": BaiduPan,
"drive.uc.cn": UC,
"fast.uc.cn": UC,
"pan.xunlei.com": Xunlei,
"cloud.189.cn": Tianyi,
"www.123pan.com": Pan123,
"www.123912.com": Pan123,
"www.123684.com": Pan123,
"115cdn.com": Pan115,
"anxia.com": Pan115,
"115.com/": Pan115,
}
for pattern, serviceType := range patterns {
@@ -191,13 +221,24 @@ func ExtractShareId(url string) (string, ServiceType) {
}
// 提取分享ID
shareID := ""
substring := strings.Index(url, "/s/")
if substring == -1 {
substring = strings.Index(url, "/t/") // 天翼云 是 t
shareID = url[substring+3:]
}
if substring == -1 {
substring = strings.Index(url, "/web/share?code=") // 天翼云 带密码
shareID = url[substring+11:]
}
if substring == -1 {
substring = strings.Index(url, "/p/") // 天翼云 是 p
shareID = url[substring+3:]
}
if substring == -1 {
return "", NotFound
}
shareID := url[substring+3:] // 去除 '/s/' 部分
// 去除可能的锚点
if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 {
shareID = shareID[:hashIndex]

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
@@ -42,6 +43,7 @@ func NewQuarkPanService(config *PanConfig) *QuarkPanService {
"Referer": "https://pan.quark.cn/",
"Referrer-Policy": "strict-origin-when-cross-origin",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Cookie": config.Cookie,
})
})
@@ -66,6 +68,25 @@ func (q *QuarkPanService) UpdateConfig(config *PanConfig) {
defer q.configMutex.Unlock()
q.config = config
// 设置Cookie到header
if config.Cookie != "" {
q.SetHeader("Cookie", config.Cookie)
}
}
// SetCookie 设置Cookie
func (q *QuarkPanService) SetCookie(cookie string) {
q.SetHeader("Cookie", cookie)
q.configMutex.Lock()
if q.config != nil {
q.config.Cookie = cookie
}
q.configMutex.Unlock()
}
// GetCookie 获取当前Cookie
func (q *QuarkPanService) GetCookie() string {
return q.GetHeader("Cookie")
}
// GetServiceType 获取服务类型
@@ -383,11 +404,23 @@ func (q *QuarkPanService) getShareSave(shareID, stoken string, fidList, fidToken
return &response.Data, nil
}
// 生成指定长度的时间戳
func (q *QuarkPanService) generateTimestamp(length int) int64 {
timestamp := time.Now().UnixNano() / int64(time.Millisecond)
timestampStr := strconv.FormatInt(timestamp, 10)
if len(timestampStr) > length {
timestampStr = timestampStr[:length]
}
timestamp, _ = strconv.ParseInt(timestampStr, 10, 64)
return timestamp
}
// getShareBtn 分享按钮
func (q *QuarkPanService) getShareBtn(fidList []string, title string) (*ShareBtnResult, error) {
data := map[string]interface{}{
"fid_list": fidList,
"title": title,
"url_type": 1,
"expired_type": 1, // 永久分享
}
@@ -397,7 +430,7 @@ func (q *QuarkPanService) getShareBtn(fidList []string, title string) (*ShareBtn
"uc_param_str": "",
}
respData, err := q.HTTPPost("https://drive-pc.quark.cn/1/clouddrive/share/create", data, queryParams)
respData, err := q.HTTPPost("https://drive-pc.quark.cn/1/clouddrive/share", data, queryParams)
if err != nil {
return nil, err
}
@@ -427,9 +460,11 @@ func (q *QuarkPanService) getShareTask(taskID string, retryIndex int) (*TaskResu
"uc_param_str": "",
"task_id": taskID,
"retry_index": fmt.Sprintf("%d", retryIndex),
"__dt": "21192",
"__t": fmt.Sprintf("%d", q.generateTimestamp(13)),
}
respData, err := q.HTTPGet("https://drive-pc.quark.cn/1/clouddrive/share/sharepage/task", queryParams)
respData, err := q.HTTPGet("https://drive-pc.quark.cn/1/clouddrive/task", queryParams)
if err != nil {
return nil, err
}
@@ -457,10 +492,13 @@ func (q *QuarkPanService) getSharePassword(shareID string) (*PasswordResult, err
"pr": "ucpro",
"fr": "pc",
"uc_param_str": "",
"share_id": shareID,
}
respData, err := q.HTTPGet("https://drive-pc.quark.cn/1/clouddrive/share/sharepage/password", queryParams)
data := map[string]interface{}{
"share_id": shareID,
}
respData, err := q.HTTPPost("https://drive-pc.quark.cn/1/clouddrive/share/password", data, queryParams)
if err != nil {
return nil, err
}

View File

@@ -3,6 +3,7 @@ package db
import (
"fmt"
"os"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
@@ -16,19 +17,16 @@ var DB *gorm.DB
// InitDB 初始化数据库连接
func InitDB() error {
host := os.Getenv("DB_HOST")
fmt.Printf("DB_HOST=%s\n", host)
if host == "" {
host = "localhost"
}
port := os.Getenv("DB_PORT")
fmt.Printf("DB_HOST=%s\n", port)
if port == "" {
port = "5432"
}
user := os.Getenv("DB_USER")
fmt.Printf("DB_HOST=%s\n", user)
if user == "" {
user = "postgres"
}
@@ -54,26 +52,45 @@ func InitDB() error {
return err
}
// 自动迁移数据库表结构
err = DB.AutoMigrate(
&entity.User{},
&entity.Category{},
&entity.Pan{},
&entity.Cks{},
&entity.Tag{},
&entity.Resource{},
&entity.ResourceTag{},
&entity.ReadyResource{},
&entity.SearchStat{},
&entity.SystemConfig{},
&entity.HotDrama{},
)
// 配置数据库连接池
sqlDB, err := DB.DB()
if err != nil {
utils.Fatal("数据库迁移失败: %v", err)
return err
}
// 创建索引以提高查询性能
createIndexes(DB)
// 设置连接池参数
sqlDB.SetMaxIdleConns(10) // 最大空闲连接数
sqlDB.SetMaxOpenConns(100) // 最大打开连接数
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大生命周期
// 检查是否需要迁移(只在开发环境或首次启动时)
if shouldRunMigration() {
utils.Info("开始数据库迁移...")
err = DB.AutoMigrate(
&entity.User{},
&entity.Category{},
&entity.Pan{},
&entity.Cks{},
&entity.Tag{},
&entity.Resource{},
&entity.ResourceTag{},
&entity.ReadyResource{},
&entity.SearchStat{},
&entity.SystemConfig{},
&entity.HotDrama{},
)
if err != nil {
utils.Fatal("数据库迁移失败: %v", err)
}
utils.Info("数据库迁移完成")
} else {
utils.Info("跳过数据库迁移(表结构已是最新)")
}
// 创建索引以提高查询性能(只在需要迁移时)
if shouldRunMigration() {
createIndexes(DB)
}
// 插入默认数据(只在数据库为空时)
if err := insertDefaultDataIfEmpty(); err != nil {
@@ -84,9 +101,36 @@ func InitDB() error {
return nil
}
// shouldRunMigration 检查是否需要运行数据库迁移
func shouldRunMigration() bool {
// 通过环境变量控制是否运行迁移
skipMigration := os.Getenv("SKIP_MIGRATION")
if skipMigration == "true" {
return false
}
// 检查环境变量
env := os.Getenv("ENV")
if env == "production" {
// 生产环境:检查是否有迁移标记
var count int64
DB.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'schema_migrations'").Count(&count)
if count == 0 {
// 没有迁移表,说明是首次部署
return true
}
// 有迁移表,检查是否需要迁移(这里可以添加更复杂的逻辑)
return false
}
// 开发环境:总是运行迁移
return true
}
// autoMigrate 自动迁移表结构
func autoMigrate() error {
return DB.AutoMigrate(
&entity.SystemConfig{}, // 系统配置表(独立表,先创建)
&entity.Pan{},
&entity.Cks{},
&entity.Category{},
@@ -96,16 +140,13 @@ func autoMigrate() error {
&entity.ReadyResource{},
&entity.User{},
&entity.SearchStat{},
&entity.SystemConfig{},
&entity.HotDrama{},
)
}
// createIndexes 创建数据库索引以提高查询性能
func createIndexes(db *gorm.DB) {
// 资源表索引
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_title ON resources USING gin(to_tsvector('chinese', title))")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_description ON resources USING gin(to_tsvector('chinese', description))")
// 资源表索引移除全文搜索索引使用Meilisearch替代
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_category_id ON resources(category_id)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_pan_id ON resources(pan_id)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_created_at ON resources(created_at DESC)")
@@ -113,8 +154,17 @@ func createIndexes(db *gorm.DB) {
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_is_valid ON resources(is_valid)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_is_public ON resources(is_public)")
// 为Meilisearch准备的基础文本索引用于精确匹配
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_title ON resources(title)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_description ON resources(description)")
// 待处理资源表索引
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_key ON ready_resource(key)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_url ON ready_resource(url)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_ready_resource_create_time ON ready_resource(create_time DESC)")
// 搜索统计表索引
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_query ON search_stats(query)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_keyword ON search_stats(keyword)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_search_stats_created_at ON search_stats(created_at DESC)")
// 热播剧表索引
@@ -126,7 +176,7 @@ func createIndexes(db *gorm.DB) {
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_resource_id ON resource_tags(resource_id)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_resource_tags_tag_id ON resource_tags(tag_id)")
utils.Info("数据库索引创建完成")
utils.Info("数据库索引创建完成已移除全文搜索索引准备使用Meilisearch")
}
// insertDefaultDataIfEmpty 只在数据库为空时插入默认数据
@@ -147,11 +197,18 @@ func insertDefaultDataIfEmpty() error {
// 插入默认分类使用FirstOrCreate避免重复
defaultCategories := []entity.Category{
{Name: "文档", Description: "各种文档资料"},
{Name: "软件", Description: "软件工具"},
{Name: "视频", Description: "视频教程"},
{Name: "图片", Description: "图片资源"},
{Name: "音频", Description: "音频文件"},
{Name: "电影", Description: "电影"},
{Name: "电视剧", Description: "电视剧"},
{Name: "短剧", Description: "短剧"},
{Name: "综艺", Description: "综艺"},
{Name: "动漫", Description: "动漫"},
{Name: "纪录片", Description: "纪录片"},
{Name: "视频教程", Description: "视频教程"},
{Name: "学习资料", Description: "学习资料"},
{Name: "游戏", Description: "其他游戏资源"},
{Name: "软件", Description: "软件"},
{Name: "APP", Description: "APP"},
{Name: "AI", Description: "AI"},
{Name: "其他", Description: "其他资源"},
}
@@ -182,6 +239,32 @@ func insertDefaultDataIfEmpty() error {
}
}
// 插入默认系统配置
defaultSystemConfigs := []entity.SystemConfig{
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyKeywords, Value: entity.ConfigDefaultKeywords, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAuthor, Value: entity.ConfigDefaultAuthor, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyCopyright, Value: entity.ConfigDefaultCopyright, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAutoProcessReadyResources, Value: entity.ConfigDefaultAutoProcessReadyResources, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyAutoProcessInterval, Value: entity.ConfigDefaultAutoProcessInterval, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoTransferEnabled, Value: entity.ConfigDefaultAutoTransferEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyAutoTransferLimitDays, Value: entity.ConfigDefaultAutoTransferLimitDays, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyForbiddenWords, Value: entity.ConfigDefaultForbiddenWords, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
}
for _, config := range defaultSystemConfigs {
if err := DB.Where("key = ?", config.Key).FirstOrCreate(&config).Error; err != nil {
utils.Error("插入系统配置 %s 失败: %v", config.Key, err)
// 继续执行,不因为单个配置失败而停止
}
}
// 插入默认管理员用户
defaultAdmin := entity.User{
Username: "admin",

View File

@@ -13,7 +13,7 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
Description: resource.Description,
URL: resource.URL,
PanID: resource.PanID,
QuarkURL: resource.QuarkURL,
SaveURL: resource.SaveURL,
FileSize: resource.FileSize,
CategoryID: resource.CategoryID,
ViewCount: resource.ViewCount,
@@ -21,6 +21,9 @@ func ToResourceResponse(resource *entity.Resource) dto.ResourceResponse {
IsPublic: resource.IsPublic,
CreatedAt: resource.CreatedAt,
UpdatedAt: resource.UpdatedAt,
Cover: resource.Cover,
Author: resource.Author,
ErrorMsg: resource.ErrorMsg,
}
// 设置分类名称
@@ -167,16 +170,19 @@ func ToCksResponseList(cksList []entity.Cks) []dto.CksResponse {
// ToReadyResourceResponse 将ReadyResource实体转换为ReadyResourceResponse
func ToReadyResourceResponse(resource *entity.ReadyResource) dto.ReadyResourceResponse {
return dto.ReadyResourceResponse{
ID: resource.ID,
Title: resource.Title,
URL: resource.URL,
Category: resource.Category,
Tags: resource.Tags,
Img: resource.Img,
Source: resource.Source,
Extra: resource.Extra,
CreateTime: resource.CreateTime,
IP: resource.IP,
ID: resource.ID,
Title: resource.Title,
Description: resource.Description,
URL: resource.URL,
Category: resource.Category,
Tags: resource.Tags,
Img: resource.Img,
Source: resource.Source,
Extra: resource.Extra,
Key: resource.Key,
ErrorMsg: resource.ErrorMsg,
CreateTime: resource.CreateTime,
IP: resource.IP,
}
}
@@ -190,19 +196,20 @@ func ToReadyResourceResponseList(resources []entity.ReadyResource) []dto.ReadyRe
}
// RequestToReadyResource 将ReadyResourceRequest转换为ReadyResource实体
func RequestToReadyResource(req *dto.ReadyResourceRequest) *entity.ReadyResource {
if req == nil {
return nil
}
// func RequestToReadyResource(req *dto.ReadyResourceRequest) *entity.ReadyResource {
// if req == nil {
// return nil
// }
return &entity.ReadyResource{
Title: &req.Title,
Description: req.Description,
URL: req.Url,
Category: req.Category,
Tags: req.Tags,
Img: req.Img,
Source: req.Source,
Extra: req.Extra,
}
}
// return &entity.ReadyResource{
// Title: &req.Title,
// Description: req.Description,
// URL: req.Url,
// Category: req.Category,
// Tags: req.Tags,
// Img: req.Img,
// Source: req.Source,
// Extra: req.Extra,
// Key: req.Key,
// }
// }

View File

@@ -1,74 +1,237 @@
package converter
import (
"strconv"
"time"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
)
// SystemConfigToResponse 将系统配置实体转换为响应DTO
func SystemConfigToResponse(config *entity.SystemConfig) *dto.SystemConfigResponse {
if config == nil {
return nil
// SystemConfigToResponse 将系统配置实体列表转换为响应DTO
func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResponse {
if len(configs) == 0 {
return getDefaultConfigResponse()
}
return &dto.SystemConfigResponse{
ID: config.ID,
CreatedAt: config.CreatedAt.Format(time.RFC3339),
UpdatedAt: config.UpdatedAt.Format(time.RFC3339),
response := getDefaultConfigResponse()
// SEO 配置
SiteTitle: config.SiteTitle,
SiteDescription: config.SiteDescription,
Keywords: config.Keywords,
Author: config.Author,
Copyright: config.Copyright,
// 自动处理配置组
AutoProcessReadyResources: config.AutoProcessReadyResources,
AutoProcessInterval: config.AutoProcessInterval,
AutoTransferEnabled: config.AutoTransferEnabled,
AutoTransferLimitDays: config.AutoTransferLimitDays,
AutoTransferMinSpace: config.AutoTransferMinSpace,
AutoFetchHotDramaEnabled: config.AutoFetchHotDramaEnabled,
// API配置
ApiToken: config.ApiToken,
// 其他配置
PageSize: config.PageSize,
MaintenanceMode: config.MaintenanceMode,
// 将键值对转换为结构体
for _, config := range configs {
switch config.Key {
case entity.ConfigKeySiteTitle:
response.SiteTitle = config.Value
case entity.ConfigKeySiteDescription:
response.SiteDescription = config.Value
case entity.ConfigKeyKeywords:
response.Keywords = config.Value
case entity.ConfigKeyAuthor:
response.Author = config.Value
case entity.ConfigKeyCopyright:
response.Copyright = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.AutoProcessReadyResources = val
}
case entity.ConfigKeyAutoProcessInterval:
if val, err := strconv.Atoi(config.Value); err == nil {
response.AutoProcessInterval = val
}
case entity.ConfigKeyAutoTransferEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.AutoTransferEnabled = val
}
case entity.ConfigKeyAutoTransferLimitDays:
if val, err := strconv.Atoi(config.Value); err == nil {
response.AutoTransferLimitDays = val
}
case entity.ConfigKeyAutoTransferMinSpace:
if val, err := strconv.Atoi(config.Value); err == nil {
response.AutoTransferMinSpace = val
}
case entity.ConfigKeyAutoFetchHotDramaEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.AutoFetchHotDramaEnabled = val
}
case entity.ConfigKeyApiToken:
response.ApiToken = config.Value
case entity.ConfigKeyForbiddenWords:
response.ForbiddenWords = config.Value
case entity.ConfigKeyPageSize:
if val, err := strconv.Atoi(config.Value); err == nil {
response.PageSize = val
}
case entity.ConfigKeyMaintenanceMode:
if val, err := strconv.ParseBool(config.Value); err == nil {
response.MaintenanceMode = val
}
}
}
// 设置时间戳(使用第一个配置的时间)
if len(configs) > 0 {
response.CreatedAt = configs[0].CreatedAt.Format(time.RFC3339)
response.UpdatedAt = configs[0].UpdatedAt.Format(time.RFC3339)
}
return response
}
// RequestToSystemConfig 将请求DTO转换为系统配置实体
func RequestToSystemConfig(req *dto.SystemConfigRequest) *entity.SystemConfig {
// RequestToSystemConfig 将请求DTO转换为系统配置实体列表
func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
if req == nil {
return nil
}
return &entity.SystemConfig{
// SEO 配置
SiteTitle: req.SiteTitle,
SiteDescription: req.SiteDescription,
Keywords: req.Keywords,
Author: req.Author,
Copyright: req.Copyright,
var configs []entity.SystemConfig
// 自动处理配置组
AutoProcessReadyResources: req.AutoProcessReadyResources,
AutoProcessInterval: req.AutoProcessInterval,
AutoTransferEnabled: req.AutoTransferEnabled,
AutoTransferLimitDays: req.AutoTransferLimitDays,
AutoTransferMinSpace: req.AutoTransferMinSpace,
AutoFetchHotDramaEnabled: req.AutoFetchHotDramaEnabled,
// 只添加有值的字段
if req.SiteTitle != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteTitle, Value: req.SiteTitle, Type: entity.ConfigTypeString})
}
if req.SiteDescription != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeySiteDescription, Value: req.SiteDescription, Type: entity.ConfigTypeString})
}
if req.Keywords != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyKeywords, Value: req.Keywords, Type: entity.ConfigTypeString})
}
if req.Author != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAuthor, Value: req.Author, Type: entity.ConfigTypeString})
}
if req.Copyright != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyCopyright, Value: req.Copyright, Type: entity.ConfigTypeString})
}
if req.ApiToken != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyApiToken, Value: req.ApiToken, Type: entity.ConfigTypeString})
}
if req.ForbiddenWords != "" {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyForbiddenWords, Value: req.ForbiddenWords, Type: entity.ConfigTypeString})
}
// API配置
ApiToken: req.ApiToken,
// 布尔值字段 - 只处理实际提交的字段
// 注意:由于 Go 的零值机制,我们需要通过其他方式判断字段是否被提交
// 这里暂时保持原样,但建议前端只提交有变化的字段
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessReadyResources, Value: strconv.FormatBool(req.AutoProcessReadyResources), Type: entity.ConfigTypeBool})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferEnabled, Value: strconv.FormatBool(req.AutoTransferEnabled), Type: entity.ConfigTypeBool})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: strconv.FormatBool(req.AutoFetchHotDramaEnabled), Type: entity.ConfigTypeBool})
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyMaintenanceMode, Value: strconv.FormatBool(req.MaintenanceMode), Type: entity.ConfigTypeBool})
// 其他配置
PageSize: req.PageSize,
MaintenanceMode: req.MaintenanceMode,
// 整数字段 - 只添加非零值
if req.AutoProcessInterval != 0 {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoProcessInterval, Value: strconv.Itoa(req.AutoProcessInterval), Type: entity.ConfigTypeInt})
}
if req.AutoTransferLimitDays != 0 {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferLimitDays, Value: strconv.Itoa(req.AutoTransferLimitDays), Type: entity.ConfigTypeInt})
}
if req.AutoTransferMinSpace != 0 {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAutoTransferMinSpace, Value: strconv.Itoa(req.AutoTransferMinSpace), Type: entity.ConfigTypeInt})
}
if req.PageSize != 0 {
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyPageSize, Value: strconv.Itoa(req.PageSize), Type: entity.ConfigTypeInt})
}
return configs
}
// SystemConfigToPublicResponse 返回不含 api_token 的系统配置响应
func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]interface{} {
response := map[string]interface{}{
entity.ConfigResponseFieldID: 0,
entity.ConfigResponseFieldCreatedAt: utils.GetCurrentTimeString(),
entity.ConfigResponseFieldUpdatedAt: utils.GetCurrentTimeString(),
entity.ConfigResponseFieldSiteTitle: entity.ConfigDefaultSiteTitle,
entity.ConfigResponseFieldSiteDescription: entity.ConfigDefaultSiteDescription,
entity.ConfigResponseFieldKeywords: entity.ConfigDefaultKeywords,
entity.ConfigResponseFieldAuthor: entity.ConfigDefaultAuthor,
entity.ConfigResponseFieldCopyright: entity.ConfigDefaultCopyright,
entity.ConfigResponseFieldAutoProcessReadyResources: false,
entity.ConfigResponseFieldAutoProcessInterval: 30,
entity.ConfigResponseFieldAutoTransferEnabled: false,
entity.ConfigResponseFieldAutoTransferLimitDays: 0,
entity.ConfigResponseFieldAutoTransferMinSpace: 100,
entity.ConfigResponseFieldAutoFetchHotDramaEnabled: false,
entity.ConfigResponseFieldForbiddenWords: "",
entity.ConfigResponseFieldPageSize: 100,
entity.ConfigResponseFieldMaintenanceMode: false,
}
// 将键值对转换为map
for _, config := range configs {
switch config.Key {
case entity.ConfigKeySiteTitle:
response[entity.ConfigResponseFieldSiteTitle] = config.Value
case entity.ConfigKeySiteDescription:
response[entity.ConfigResponseFieldSiteDescription] = config.Value
case entity.ConfigKeyKeywords:
response[entity.ConfigResponseFieldKeywords] = config.Value
case entity.ConfigKeyAuthor:
response[entity.ConfigResponseFieldAuthor] = config.Value
case entity.ConfigKeyCopyright:
response[entity.ConfigResponseFieldCopyright] = config.Value
case entity.ConfigKeyAutoProcessReadyResources:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoProcessReadyResources] = val
}
case entity.ConfigKeyAutoProcessInterval:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoProcessInterval] = val
}
case entity.ConfigKeyAutoTransferEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferEnabled] = val
}
case entity.ConfigKeyAutoTransferLimitDays:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferLimitDays] = val
}
case entity.ConfigKeyAutoTransferMinSpace:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoTransferMinSpace] = val
}
case entity.ConfigKeyAutoFetchHotDramaEnabled:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldAutoFetchHotDramaEnabled] = val
}
case entity.ConfigKeyForbiddenWords:
response[entity.ConfigResponseFieldForbiddenWords] = config.Value
case entity.ConfigKeyPageSize:
if val, err := strconv.Atoi(config.Value); err == nil {
response[entity.ConfigResponseFieldPageSize] = val
}
case entity.ConfigKeyMaintenanceMode:
if val, err := strconv.ParseBool(config.Value); err == nil {
response[entity.ConfigResponseFieldMaintenanceMode] = val
}
}
}
// 设置时间戳(使用第一个配置的时间)
if len(configs) > 0 {
response[entity.ConfigResponseFieldCreatedAt] = configs[0].CreatedAt.Format("2006-01-02 15:04:05")
response[entity.ConfigResponseFieldUpdatedAt] = configs[0].UpdatedAt.Format("2006-01-02 15:04:05")
}
return response
}
// getDefaultConfigResponse 获取默认配置响应
func getDefaultConfigResponse() *dto.SystemConfigResponse {
return &dto.SystemConfigResponse{
SiteTitle: entity.ConfigDefaultSiteTitle,
SiteDescription: entity.ConfigDefaultSiteDescription,
Keywords: entity.ConfigDefaultKeywords,
Author: entity.ConfigDefaultAuthor,
Copyright: entity.ConfigDefaultCopyright,
AutoProcessReadyResources: false,
AutoProcessInterval: 30,
AutoTransferEnabled: false,
AutoTransferLimitDays: 0,
AutoTransferMinSpace: 100,
AutoFetchHotDramaEnabled: false,
ApiToken: entity.ConfigDefaultApiToken,
ForbiddenWords: entity.ConfigDefaultForbiddenWords,
PageSize: 100,
MaintenanceMode: false,
}
}

View File

@@ -2,14 +2,15 @@ package dto
// ReadyResourceRequest 待处理资源请求
type ReadyResourceRequest struct {
Title string `json:"title" validate:"required" example:"示例资源标题"`
Description string `json:"description" example:"这是一个示例资源描述"`
Url string `json:"url" validate:"required" example:"https://example.com/resource"`
Category string `json:"category" example:"示例分类"`
Tags string `json:"tags" example:"标签1,标签2"`
Img string `json:"img" example:"https://example.com/image.jpg"`
Source string `json:"source" example:"数据来源"`
Extra string `json:"extra" example:"额外信息"`
Title string `json:"title" validate:"required" example:"示例资源标题"`
Description string `json:"description" example:"这是一个示例资源描述"`
Url []string `json:"url" validate:"required" example:"https://example.com/resource"`
Category string `json:"category" example:"示例分类"`
Tags string `json:"tags" example:"标签1,标签2"`
Img string `json:"img" example:"https://example.com/image.jpg"`
Source string `json:"source" example:"数据来源"`
Extra string `json:"extra" example:"额外信息"`
ErrorMsg string `json:"error_msg" example:"错误信息"`
}
// BatchReadyResourceRequest 批量待处理资源请求

View File

@@ -52,12 +52,15 @@ type CreateResourceRequest struct {
Description string `json:"description"`
URL string `json:"url"`
PanID *uint `json:"pan_id"`
QuarkURL string `json:"quark_url"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"`
IsValid bool `json:"is_valid"`
IsPublic bool `json:"is_public"`
TagIDs []uint `json:"tag_ids"`
Cover string `json:"cover"`
Author string `json:"author"`
ErrorMsg string `json:"error_msg"`
}
// UpdateResourceRequest 更新资源请求
@@ -66,12 +69,15 @@ type UpdateResourceRequest struct {
Description string `json:"description"`
URL string `json:"url"`
PanID *uint `json:"pan_id"`
QuarkURL string `json:"quark_url"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"`
IsValid bool `json:"is_valid"`
IsPublic bool `json:"is_public"`
TagIDs []uint `json:"tag_ids"`
Cover string `json:"cover"`
Author string `json:"author"`
ErrorMsg string `json:"error_msg"`
}
// CreateCategoryRequest 创建分类请求
@@ -102,15 +108,16 @@ type UpdateTagRequest struct {
// CreateReadyResourceRequest 创建待处理资源请求
type CreateReadyResourceRequest struct {
Title *string `json:"title"`
Description string `json:"description"`
URL string `json:"url" binding:"required"`
Category string `json:"category"`
Tags string `json:"tags"`
Img string `json:"img"`
Source string `json:"source"`
Extra string `json:"extra"`
IP *string `json:"ip"`
Title *string `json:"title"`
Description string `json:"description"`
URL []string `json:"url" binding:"required"`
Category string `json:"category"`
Tags string `json:"tags"`
Img string `json:"img"`
Source string `json:"source"`
Extra string `json:"extra"`
IP *string `json:"ip"`
Key string `json:"key"`
}
// BatchCreateReadyResourceRequest 批量创建待处理资源请求

View File

@@ -17,7 +17,7 @@ type ResourceResponse struct {
Description string `json:"description"`
URL string `json:"url"`
PanID *uint `json:"pan_id"`
QuarkURL string `json:"quark_url"`
SaveURL string `json:"save_url"`
FileSize string `json:"file_size"`
CategoryID *uint `json:"category_id"`
CategoryName string `json:"category_name"`
@@ -27,6 +27,9 @@ type ResourceResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Tags []TagResponse `json:"tags"`
Cover string `json:"cover"`
Author string `json:"author"`
ErrorMsg string `json:"error_msg"`
}
// CategoryResponse 分类响应
@@ -85,6 +88,8 @@ type ReadyResourceResponse struct {
Img string `json:"img"`
Source string `json:"source"`
Extra string `json:"extra"`
Key string `json:"key"`
ErrorMsg string `json:"error_msg"`
CreateTime time.Time `json:"create_time"`
IP *string `json:"ip"`
}

View File

@@ -3,25 +3,28 @@ package dto
// SystemConfigRequest 系统配置请求
type SystemConfigRequest struct {
// SEO 配置
SiteTitle string `json:"site_title" validate:"required"`
SiteTitle string `json:"site_title"`
SiteDescription string `json:"site_description"`
Keywords string `json:"keywords"`
Author string `json:"author"`
Copyright string `json:"copyright"`
// 自动处理配置组
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
AutoProcessInterval int `json:"auto_process_interval" validate:"min=1,max=1440"` // 自动处理间隔(分钟)
AutoTransferEnabled bool `json:"auto_transfer_enabled"` // 开启自动转存
AutoTransferLimitDays int `json:"auto_transfer_limit_days" validate:"min=0,max=365"` // 自动转存限制天数0表示不限制
AutoTransferMinSpace int `json:"auto_transfer_min_space" validate:"min=100,max=1024"` // 最小存储空间GB
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled"` // 自动拉取热播剧名字
AutoProcessReadyResources bool `json:"auto_process_ready_resources"` // 自动处理待处理资源
AutoProcessInterval int `json:"auto_process_interval"` // 自动处理间隔(分钟)
AutoTransferEnabled bool `json:"auto_transfer_enabled"` // 开启自动转存
AutoTransferLimitDays int `json:"auto_transfer_limit_days"` // 自动转存限制天数0表示不限制
AutoTransferMinSpace int `json:"auto_transfer_min_space"` // 最小存储空间GB
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled"` // 自动拉取热播剧名字
// API配置
ApiToken string `json:"api_token"` // 公开API访问令牌
// 违禁词配置
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
// 其他配置
PageSize int `json:"page_size" validate:"min=10,max=500"`
PageSize int `json:"page_size"`
MaintenanceMode bool `json:"maintenance_mode"`
}
@@ -49,7 +52,22 @@ type SystemConfigResponse struct {
// API配置
ApiToken string `json:"api_token"` // 公开API访问令牌
// 违禁词配置
ForbiddenWords string `json:"forbidden_words"` // 违禁词列表,用逗号分隔
// 其他配置
PageSize int `json:"page_size"`
MaintenanceMode bool `json:"maintenance_mode"`
}
// SystemConfigItem 单个配置项
type SystemConfigItem struct {
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
}
// SystemConfigListResponse 配置列表响应
type SystemConfigListResponse struct {
Configs []SystemConfigItem `json:"configs"`
}

View File

@@ -17,6 +17,8 @@ type ReadyResource struct {
Img string `json:"img" gorm:"size:500;comment:封面链接"`
Source string `json:"source" gorm:"size:100;comment:数据来源"`
Extra string `json:"extra" gorm:"type:text;comment:额外附加数据"`
Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"`
ErrorMsg string `json:"error_msg" gorm:"type:text;comment:处理失败时的错误信息"`
CreateTime time.Time `json:"create_time" gorm:"default:CURRENT_TIMESTAMP"`
IP *string `json:"ip" gorm:"size:45;comment:IP地址"`
CreatedAt time.Time `json:"created_at"`

View File

@@ -13,7 +13,7 @@ type Resource struct {
Description string `json:"description" gorm:"type:text;comment:资源描述"`
URL string `json:"url" gorm:"size:128;comment:资源链接"`
PanID *uint `json:"pan_id" gorm:"comment:平台ID"`
QuarkURL string `json:"quark_url" gorm:"size:500;comment:夸克链接"`
SaveURL string `json:"save_url" gorm:"size:500;comment:转存后的链接"`
FileSize string `json:"file_size" gorm:"size:100;comment:文件大小"`
CategoryID *uint `json:"category_id" gorm:"comment:分类ID"`
ViewCount int `json:"view_count" gorm:"default:0;comment:浏览次数"`
@@ -22,6 +22,12 @@ type Resource struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
Cover string `json:"cover" gorm:"size:500;comment:封面"`
Author string `json:"author" gorm:"size:100;comment:作者"`
ErrorMsg string `json:"error_msg" gorm:"size:255;comment:转存失败原因"`
CkID *uint `json:"ck_id" gorm:"comment:账号ID"`
Fid string `json:"fid" gorm:"size:128;comment:网盘文件ID"`
Key string `json:"key" gorm:"size:64;index;comment:资源组标识相同key表示同一组资源"`
// 关联关系
Category Category `json:"category" gorm:"foreignKey:CategoryID"`

View File

@@ -4,33 +4,16 @@ import (
"time"
)
// SystemConfig 系统配置实体
// SystemConfig 系统配置实体(键值对形式)
type SystemConfig struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// SEO 配置
SiteTitle string `json:"site_title" gorm:"size:200;not null;default:'网盘资源数据库'"`
SiteDescription string `json:"site_description" gorm:"size:500"`
Keywords string `json:"keywords" gorm:"size:500"`
Author string `json:"author" gorm:"size:100"`
Copyright string `json:"copyright" gorm:"size:200"`
// 自动处理配置组
AutoProcessReadyResources bool `json:"auto_process_ready_resources" gorm:"default:false"` // 自动处理待处理资源
AutoProcessInterval int `json:"auto_process_interval" gorm:"default:30"` // 自动处理间隔(分钟)
AutoTransferEnabled bool `json:"auto_transfer_enabled" gorm:"default:false"` // 开启自动转存
AutoTransferLimitDays int `json:"auto_transfer_limit_days" gorm:"default:0"` // 自动转存限制天数0表示不限制
AutoTransferMinSpace int `json:"auto_transfer_min_space" gorm:"default:100"` // 最小存储空间GB
AutoFetchHotDramaEnabled bool `json:"auto_fetch_hot_drama_enabled" gorm:"default:false"` // 自动拉取热播剧名字
// API配置
ApiToken string `json:"api_token" gorm:"size:100;uniqueIndex"` // 公开API访问令牌
// 其他配置
PageSize int `json:"page_size" gorm:"default:100"`
MaintenanceMode bool `json:"maintenance_mode" gorm:"default:false"`
// 键值对配置
Key string `json:"key" gorm:"size:100;not null;unique;comment:配置键"`
Value string `json:"value" gorm:"size:1000"`
Type string `json:"type" gorm:"size:20;default:'string'"` // string, int, bool, json
}
// TableName 指定表名

View File

@@ -0,0 +1,98 @@
package entity
// ConfigKey 配置键常量
const (
// SEO 配置
ConfigKeySiteTitle = "site_title"
ConfigKeySiteDescription = "site_description"
ConfigKeyKeywords = "keywords"
ConfigKeyAuthor = "author"
ConfigKeyCopyright = "copyright"
// 自动处理配置组
ConfigKeyAutoProcessReadyResources = "auto_process_ready_resources"
ConfigKeyAutoProcessInterval = "auto_process_interval"
ConfigKeyAutoTransferEnabled = "auto_transfer_enabled"
ConfigKeyAutoTransferLimitDays = "auto_transfer_limit_days"
ConfigKeyAutoTransferMinSpace = "auto_transfer_min_space"
ConfigKeyAutoFetchHotDramaEnabled = "auto_fetch_hot_drama_enabled"
// API配置
ConfigKeyApiToken = "api_token"
// 违禁词配置
ConfigKeyForbiddenWords = "forbidden_words"
// 其他配置
ConfigKeyPageSize = "page_size"
ConfigKeyMaintenanceMode = "maintenance_mode"
)
// ConfigType 配置类型常量
const (
ConfigTypeString = "string"
ConfigTypeInt = "int"
ConfigTypeBool = "bool"
ConfigTypeJSON = "json"
)
// ConfigResponseField API响应字段名常量
const (
// 基础字段
ConfigResponseFieldID = "id"
ConfigResponseFieldCreatedAt = "created_at"
ConfigResponseFieldUpdatedAt = "updated_at"
// SEO 配置字段
ConfigResponseFieldSiteTitle = "site_title"
ConfigResponseFieldSiteDescription = "site_description"
ConfigResponseFieldKeywords = "keywords"
ConfigResponseFieldAuthor = "author"
ConfigResponseFieldCopyright = "copyright"
// 自动处理配置字段
ConfigResponseFieldAutoProcessReadyResources = "auto_process_ready_resources"
ConfigResponseFieldAutoProcessInterval = "auto_process_interval"
ConfigResponseFieldAutoTransferEnabled = "auto_transfer_enabled"
ConfigResponseFieldAutoTransferLimitDays = "auto_transfer_limit_days"
ConfigResponseFieldAutoTransferMinSpace = "auto_transfer_min_space"
ConfigResponseFieldAutoFetchHotDramaEnabled = "auto_fetch_hot_drama_enabled"
// API配置字段
ConfigResponseFieldApiToken = "api_token"
// 违禁词配置字段
ConfigResponseFieldForbiddenWords = "forbidden_words"
// 其他配置字段
ConfigResponseFieldPageSize = "page_size"
ConfigResponseFieldMaintenanceMode = "maintenance_mode"
)
// ConfigDefaultValue 配置默认值常量
const (
// SEO 配置默认值
ConfigDefaultSiteTitle = "老九网盘资源数据库"
ConfigDefaultSiteDescription = "专业的老九网盘资源数据库"
ConfigDefaultKeywords = "网盘,资源管理,文件分享"
ConfigDefaultAuthor = "系统管理员"
ConfigDefaultCopyright = "© 2024 老九网盘资源数据库"
// 自动处理配置默认值
ConfigDefaultAutoProcessReadyResources = "false"
ConfigDefaultAutoProcessInterval = "30"
ConfigDefaultAutoTransferEnabled = "false"
ConfigDefaultAutoTransferLimitDays = "0"
ConfigDefaultAutoTransferMinSpace = "100"
ConfigDefaultAutoFetchHotDramaEnabled = "false"
// API配置默认值
ConfigDefaultApiToken = ""
// 违禁词配置默认值
ConfigDefaultForbiddenWords = ""
// 其他配置默认值
ConfigDefaultPageSize = "100"
ConfigDefaultMaintenanceMode = "false"
)

View File

@@ -73,3 +73,7 @@ func (r *BaseRepositoryImpl[T]) FindWithPagination(page, limit int) ([]T, int64,
err := r.db.Offset(offset).Limit(limit).Find(&entities).Error
return entities, total, err
}
func (r *BaseRepositoryImpl[T]) GetDB() *gorm.DB {
return r.db
}

View File

@@ -10,6 +10,7 @@ import (
type CategoryRepository interface {
BaseRepository[entity.Category]
FindByName(name string) (*entity.Category, error)
FindByNameIncludingDeleted(name string) (*entity.Category, error)
FindWithResources() ([]entity.Category, error)
FindWithTags() ([]entity.Category, error)
GetResourceCount(categoryID uint) (int64, error)
@@ -17,6 +18,7 @@ type CategoryRepository interface {
GetTagNames(categoryID uint) ([]string, error)
FindWithPagination(page, pageSize int) ([]entity.Category, int64, error)
Search(query string, page, pageSize int) ([]entity.Category, int64, error)
RestoreDeletedCategory(id uint) error
}
// CategoryRepositoryImpl Category的Repository实现
@@ -41,6 +43,21 @@ func (r *CategoryRepositoryImpl) FindByName(name string) (*entity.Category, erro
return &category, nil
}
// FindByNameIncludingDeleted 根据名称查找(包括已删除的记录)
func (r *CategoryRepositoryImpl) FindByNameIncludingDeleted(name string) (*entity.Category, error) {
var category entity.Category
err := r.db.Unscoped().Where("name = ?", name).First(&category).Error
if err != nil {
return nil, err
}
return &category, nil
}
// RestoreDeletedCategory 恢复已删除的分类
func (r *CategoryRepositoryImpl) RestoreDeletedCategory(id uint) error {
return r.db.Unscoped().Model(&entity.Category{}).Where("id = ?", id).Update("deleted_at", nil).Error
}
// FindWithResources 查找包含资源的分类
func (r *CategoryRepositoryImpl) FindWithResources() ([]entity.Category, error) {
var categories []entity.Category

View File

@@ -13,6 +13,7 @@ type CksRepository interface {
FindByIsValid(isValid bool) ([]entity.Cks, error)
UpdateSpace(id uint, space, leftSpace int64) error
DeleteByPanID(panID uint) error
UpdateWithAllFields(cks *entity.Cks) error
}
// CksRepositoryImpl Cks的Repository实现
@@ -71,3 +72,8 @@ func (r *CksRepositoryImpl) FindByID(id uint) (*entity.Cks, error) {
}
return &cks, nil
}
// UpdateWithAllFields 更新Cks包括零值字段
func (r *CksRepositoryImpl) UpdateWithAllFields(cks *entity.Cks) error {
return r.db.Save(cks).Error
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/ctwj/urldb/db/entity"
gonanoid "github.com/matoous/go-nanoid/v2"
"gorm.io/gorm"
)
@@ -13,9 +14,17 @@ type ReadyResourceRepository interface {
BaseRepository[entity.ReadyResource]
FindByURL(url string) (*entity.ReadyResource, error)
FindByIP(ip string) ([]entity.ReadyResource, error)
FindByKey(key string) ([]entity.ReadyResource, error)
BatchCreate(resources []entity.ReadyResource) error
DeleteByURL(url string) error
DeleteByKey(key string) error
FindAllWithinDays(days int) ([]entity.ReadyResource, error)
BatchFindByURLs(urls []string) ([]entity.ReadyResource, error)
GenerateUniqueKey() (string, error)
FindWithErrors() ([]entity.ReadyResource, error)
FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error)
FindWithoutErrors() ([]entity.ReadyResource, error)
ClearErrorMsg(id uint) error
}
// ReadyResourceRepositoryImpl ReadyResource的Repository实现
@@ -68,3 +77,80 @@ func (r *ReadyResourceRepositoryImpl) FindAllWithinDays(days int) ([]entity.Read
err := db.Find(&resources).Error
return resources, err
}
func (r *ReadyResourceRepositoryImpl) BatchFindByURLs(urls []string) ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
if len(urls) == 0 {
return resources, nil
}
err := r.db.Where("url IN ?", urls).Find(&resources).Error
return resources, err
}
// FindByKey 根据Key查找
func (r *ReadyResourceRepositoryImpl) FindByKey(key string) ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
err := r.db.Where("key = ?", key).Find(&resources).Error
return resources, err
}
// DeleteByKey 根据Key删除
func (r *ReadyResourceRepositoryImpl) DeleteByKey(key string) error {
return r.db.Where("key = ?", key).Delete(&entity.ReadyResource{}).Error
}
// GenerateUniqueKey 生成唯一的6位Base62 key
func (r *ReadyResourceRepositoryImpl) GenerateUniqueKey() (string, error) {
for i := 0; i < 20; i++ {
key, err := gonanoid.Generate("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 6)
if err != nil {
return "", err
}
var count int64
err = r.db.Model(&entity.ReadyResource{}).Where("key = ?", key).Count(&count).Error
if err != nil {
return "", err
}
if count == 0 {
return key, nil
}
}
return "", gorm.ErrInvalidData
}
// FindWithErrors 查找有错误信息的资源deleted_at为空且存在error_msg
func (r *ReadyResourceRepositoryImpl) FindWithErrors() ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
err := r.db.Where("deleted_at IS NULL AND error_msg != '' AND error_msg IS NOT NULL").Find(&resources).Error
return resources, err
}
// FindWithErrorsPaginated 分页查找有错误信息的资源deleted_at为空且存在error_msg
func (r *ReadyResourceRepositoryImpl) FindWithErrorsPaginated(page, limit int) ([]entity.ReadyResource, int64, error) {
var resources []entity.ReadyResource
var total int64
offset := (page - 1) * limit
db := r.db.Model(&entity.ReadyResource{}).Where("deleted_at IS NULL AND error_msg != '' AND error_msg IS NOT NULL")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&resources).Error
return resources, total, err
}
// FindWithoutErrors 查找没有错误信息的资源
func (r *ReadyResourceRepositoryImpl) FindWithoutErrors() ([]entity.ReadyResource, error) {
var resources []entity.ReadyResource
err := r.db.Where("error_msg = '' OR error_msg IS NULL").Find(&resources).Error
return resources, err
}
// ClearErrorMsg 清除指定资源的错误信息
func (r *ReadyResourceRepositoryImpl) ClearErrorMsg(id uint) error {
return r.db.Model(&entity.ReadyResource{}).Where("id = ?", id).Update("error_msg", "").Error
}

View File

@@ -31,6 +31,9 @@ type ResourceRepository interface {
GetCachedLatestResources(limit int) ([]entity.Resource, error)
InvalidateCache() error
FindExists(url string, excludeID ...uint) (bool, error)
BatchFindByURLs(urls []string) ([]entity.Resource, error)
GetResourcesForTransfer(panID uint, sinceTime time.Time) ([]*entity.Resource, error)
CreateResourceTag(resourceID, tagID uint) error
}
// ResourceRepositoryImpl Resource的Repository实现
@@ -214,36 +217,57 @@ func (r *ResourceRepositoryImpl) SearchWithFilters(params map[string]interface{}
// 处理参数
for key, value := range params {
switch key {
case "query":
case "search": // 添加search参数支持
if query, ok := value.(string); ok && query != "" {
db = db.Where("title ILIKE ? OR description ILIKE ?", "%"+query+"%", "%"+query+"%")
}
case "category_id":
if categoryID, ok := value.(uint); ok {
db = db.Where("category_id = ?", categoryID)
case "category": // 添加category参数支持字符串形式
if category, ok := value.(string); ok && category != "" {
// 根据分类名称查找分类ID
var categoryEntity entity.Category
if err := r.db.Where("name ILIKE ?", "%"+category+"%").First(&categoryEntity).Error; err == nil {
db = db.Where("category_id = ?", categoryEntity.ID)
}
}
case "is_valid":
if isValid, ok := value.(bool); ok {
db = db.Where("is_valid = ?", isValid)
}
case "is_public":
if isPublic, ok := value.(bool); ok {
db = db.Where("is_public = ?", isPublic)
}
case "pan_id":
if panID, ok := value.(uint); ok {
db = db.Where("pan_id = ?", panID)
case "tag": // 添加tag参数支持
if tag, ok := value.(string); ok && tag != "" {
// 根据标签名称查找相关资源
var tagEntity entity.Tag
if err := r.db.Where("name ILIKE ?", "%"+tag+"%").First(&tagEntity).Error; err == nil {
// 通过中间表查找包含该标签的资源
db = db.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
Where("resource_tags.tag_id = ?", tagEntity.ID)
}
}
}
}
db = db.Where("is_valid = true and is_public = true")
// 获取总数
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
// 处理分页参数
page := 1
pageSize := 20
if pageVal, ok := params["page"].(int); ok && pageVal > 0 {
page = pageVal
}
if pageSizeVal, ok := params["page_size"].(int); ok && pageSizeVal > 0 {
pageSize = pageSizeVal
// 限制最大page_size为100
if pageSize > 100 {
pageSize = 100
}
}
// 计算偏移量
offset := (page - 1) * pageSize
// 获取分页数据,按更新时间倒序
err := db.Order("updated_at DESC").Find(&resources).Error
err := db.Order("updated_at DESC").Offset(offset).Limit(pageSize).Find(&resources).Error
return resources, total, err
}
@@ -331,7 +355,7 @@ func (r *ResourceRepositoryImpl) InvalidateCache() error {
// FindExists 检查是否存在相同URL的资源
func (r *ResourceRepositoryImpl) FindExists(url string, excludeID ...uint) (bool, error) {
var count int64
query := r.db.Model(&entity.Resource{}).Where("url = ?", url)
query := r.db.Model(&entity.Resource{}).Where("url = ? OR save_url = ?", url, url)
// 如果有排除ID则排除该记录用于更新时排除自己
if len(excludeID) > 0 {
@@ -344,3 +368,35 @@ func (r *ResourceRepositoryImpl) FindExists(url string, excludeID ...uint) (bool
}
return count > 0, nil
}
func (r *ResourceRepositoryImpl) BatchFindByURLs(urls []string) ([]entity.Resource, error) {
var resources []entity.Resource
if len(urls) == 0 {
return resources, nil
}
err := r.db.Where("url IN ?", urls).Find(&resources).Error
return resources, err
}
// GetResourcesForTransfer 获取需要转存的资源
func (r *ResourceRepositoryImpl) GetResourcesForTransfer(panID uint, sinceTime time.Time) ([]*entity.Resource, error) {
var resources []*entity.Resource
query := r.db.Where("pan_id = ? AND (save_url = '' OR save_url IS NULL) AND (error_msg = '' OR error_msg IS NULL)", panID)
if !sinceTime.IsZero() {
query = query.Where("created_at >= ?", sinceTime)
}
err := query.Order("created_at DESC").Find(&resources).Error
if err != nil {
return nil, err
}
return resources, nil
}
// CreateResourceTag 创建资源与标签的关联
func (r *ResourceRepositoryImpl) CreateResourceTag(resourceID, tagID uint) error {
resourceTag := &entity.ResourceTag{
ResourceID: resourceID,
TagID: tagID,
}
return r.GetDB().Create(resourceTag).Error
}

View File

@@ -1,6 +1,7 @@
package repo
import (
"fmt"
"time"
"github.com/ctwj/urldb/db/entity"
@@ -16,6 +17,7 @@ type SearchStatRepository interface {
GetHotKeywords(days int, limit int) ([]entity.KeywordStat, error)
GetSearchTrend(days int) ([]entity.DailySearchStat, error)
GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error)
GetSummary() (map[string]int64, error)
}
// SearchStatRepositoryImpl 搜索统计Repository实现
@@ -30,51 +32,34 @@ func NewSearchStatRepository(db *gorm.DB) SearchStatRepository {
}
}
// RecordSearch 记录搜索
// RecordSearch 记录搜索(每次都插入新记录)
func (r *SearchStatRepositoryImpl) RecordSearch(keyword, ip, userAgent string) error {
today := time.Now().Truncate(24 * time.Hour)
// 查找今天是否已有该关键词的记录
var stat entity.SearchStat
err := r.db.Where("keyword = ? AND date = ?", keyword, today).First(&stat).Error
if err == gorm.ErrRecordNotFound {
// 创建新记录
stat = entity.SearchStat{
Keyword: keyword,
Count: 1,
Date: today,
IP: ip,
UserAgent: userAgent,
}
return r.db.Create(&stat).Error
} else if err != nil {
return err
stat := entity.SearchStat{
Keyword: keyword,
Count: 1,
Date: time.Now(), // 可保留 date 字段,实际用 created_at 统计
IP: ip,
UserAgent: userAgent,
}
// 更新现有记录
stat.Count++
stat.IP = ip
stat.UserAgent = userAgent
return r.db.Save(&stat).Error
return r.db.Create(&stat).Error
}
// GetDailyStats 获取每日统计
func (r *SearchStatRepositoryImpl) GetDailyStats(days int) ([]entity.DailySearchStat, error) {
var stats []entity.DailySearchStat
query := `
query := fmt.Sprintf(`
SELECT
date,
SUM(count) as total_searches,
COUNT(DISTINCT keyword) as unique_keywords
FROM search_stats
WHERE date >= CURRENT_DATE - INTERVAL '? days'
WHERE date >= CURRENT_DATE - INTERVAL '%d days'
GROUP BY date
ORDER BY date DESC
`
`, days)
err := r.db.Raw(query, days).Scan(&stats).Error
err := r.db.Raw(query).Scan(&stats).Error
return stats, err
}
@@ -82,19 +67,19 @@ func (r *SearchStatRepositoryImpl) GetDailyStats(days int) ([]entity.DailySearch
func (r *SearchStatRepositoryImpl) GetHotKeywords(days int, limit int) ([]entity.KeywordStat, error) {
var keywords []entity.KeywordStat
query := `
query := fmt.Sprintf(`
SELECT
keyword,
SUM(count) as count,
RANK() OVER (ORDER BY SUM(count) DESC) as rank
FROM search_stats
WHERE date >= CURRENT_DATE - INTERVAL '? days'
WHERE date >= CURRENT_DATE - INTERVAL '%d days'
GROUP BY keyword
ORDER BY count DESC
LIMIT ?
`
`, days)
err := r.db.Raw(query, days, limit).Scan(&keywords).Error
err := r.db.Raw(query, limit).Scan(&keywords).Error
return keywords, err
}
@@ -102,18 +87,18 @@ func (r *SearchStatRepositoryImpl) GetHotKeywords(days int, limit int) ([]entity
func (r *SearchStatRepositoryImpl) GetSearchTrend(days int) ([]entity.DailySearchStat, error) {
var stats []entity.DailySearchStat
query := `
query := fmt.Sprintf(`
SELECT
date,
SUM(count) as total_searches,
COUNT(DISTINCT keyword) as unique_keywords
FROM search_stats
WHERE date >= CURRENT_DATE - INTERVAL '? days'
WHERE date >= CURRENT_DATE - INTERVAL '%d days'
GROUP BY date
ORDER BY date ASC
`
`, days)
err := r.db.Raw(query, days).Scan(&stats).Error
err := r.db.Raw(query).Scan(&stats).Error
return stats, err
}
@@ -121,17 +106,54 @@ func (r *SearchStatRepositoryImpl) GetSearchTrend(days int) ([]entity.DailySearc
func (r *SearchStatRepositoryImpl) GetKeywordTrend(keyword string, days int) ([]entity.DailySearchStat, error) {
var stats []entity.DailySearchStat
query := `
query := fmt.Sprintf(`
SELECT
date,
SUM(count) as total_searches,
COUNT(DISTINCT keyword) as unique_keywords
FROM search_stats
WHERE keyword = ? AND date >= CURRENT_DATE - INTERVAL '? days'
WHERE keyword = ? AND date >= CURRENT_DATE - INTERVAL '%d days'
GROUP BY date
ORDER BY date ASC
`
`, days)
err := r.db.Raw(query, keyword, days).Scan(&stats).Error
err := r.db.Raw(query, keyword).Scan(&stats).Error
return stats, err
}
// GetSummary 获取搜索统计汇总
func (r *SearchStatRepositoryImpl) GetSummary() (map[string]int64, error) {
var total, today, week, month, keywords int64
now := time.Now()
todayStr := now.Format("2006-01-02")
weekStart := now.AddDate(0, 0, -int(now.Weekday())+1).Format("2006-01-02") // 周一
monthStart := now.Format("2006-01") + "-01"
// 总搜索次数
if err := r.db.Model(&entity.SearchStat{}).Count(&total).Error; err != nil {
return nil, err
}
// 今日搜索次数
if err := r.db.Model(&entity.SearchStat{}).Where("DATE(created_at) = ?", todayStr).Count(&today).Error; err != nil {
return nil, err
}
// 本周搜索次数
if err := r.db.Model(&entity.SearchStat{}).Where("created_at >= ?", weekStart).Count(&week).Error; err != nil {
return nil, err
}
// 本月搜索次数
if err := r.db.Model(&entity.SearchStat{}).Where("created_at >= ?", monthStart).Count(&month).Error; err != nil {
return nil, err
}
// 总关键词数
if err := r.db.Model(&entity.SearchStat{}).Distinct("keyword").Count(&keywords).Error; err != nil {
return nil, err
}
return map[string]int64{
"total": total,
"today": today,
"week": week,
"month": month,
"keywords": keywords,
}, nil
}

View File

@@ -1,6 +1,9 @@
package repo
import (
"fmt"
"sync"
"github.com/ctwj/urldb/db/entity"
"gorm.io/gorm"
@@ -9,72 +12,231 @@ import (
// SystemConfigRepository 系统配置Repository接口
type SystemConfigRepository interface {
BaseRepository[entity.SystemConfig]
FindFirst() (*entity.SystemConfig, error)
GetOrCreateDefault() (*entity.SystemConfig, error)
Upsert(config *entity.SystemConfig) error
FindAll() ([]entity.SystemConfig, error)
FindByKey(key string) (*entity.SystemConfig, error)
GetOrCreateDefault() ([]entity.SystemConfig, error)
UpsertConfigs(configs []entity.SystemConfig) error
GetConfigValue(key string) (string, error)
GetConfigBool(key string) (bool, error)
GetConfigInt(key string) (int, error)
GetCachedConfigs() map[string]string
ClearConfigCache()
}
// SystemConfigRepositoryImpl 系统配置Repository实现
type SystemConfigRepositoryImpl struct {
BaseRepositoryImpl[entity.SystemConfig]
// 配置缓存
configCache map[string]string // key -> value
configCacheOnce sync.Once
configCacheMutex sync.RWMutex
}
// NewSystemConfigRepository 创建系统配置Repository
func NewSystemConfigRepository(db *gorm.DB) SystemConfigRepository {
return &SystemConfigRepositoryImpl{
BaseRepositoryImpl: BaseRepositoryImpl[entity.SystemConfig]{db: db},
configCache: make(map[string]string),
}
}
// FindFirst 获取第一个配置(通常只有一个配置
func (r *SystemConfigRepositoryImpl) FindFirst() (*entity.SystemConfig, error) {
// FindAll 获取所有配置
func (r *SystemConfigRepositoryImpl) FindAll() ([]entity.SystemConfig, error) {
var configs []entity.SystemConfig
err := r.db.Find(&configs).Error
return configs, err
}
// FindByKey 根据键查找配置
func (r *SystemConfigRepositoryImpl) FindByKey(key string) (*entity.SystemConfig, error) {
var config entity.SystemConfig
err := r.db.First(&config).Error
err := r.db.Where("key = ?", key).First(&config).Error
if err != nil {
return nil, err
}
return &config, nil
}
// Upsert 创建或更新系统配置
func (r *SystemConfigRepositoryImpl) Upsert(config *entity.SystemConfig) error {
var existingConfig entity.SystemConfig
err := r.db.First(&existingConfig).Error
// UpsertConfigs 批量创建或更新配置
func (r *SystemConfigRepositoryImpl) UpsertConfigs(configs []entity.SystemConfig) error {
for _, config := range configs {
var existingConfig entity.SystemConfig
err := r.db.Where("key = ?", config.Key).First(&existingConfig).Error
if err != nil {
// 如果不存在,则创建
return r.db.Create(config).Error
} else {
// 如果存在,则更新
config.ID = existingConfig.ID
return r.db.Save(config).Error
if err != nil {
// 如果不存在,则创建
if err := r.db.Create(&config).Error; err != nil {
return err
}
} else {
// 如果存在,则更新
config.ID = existingConfig.ID
if err := r.db.Save(&config).Error; err != nil {
return err
}
}
}
// 更新配置后刷新缓存
r.refreshConfigCache()
return nil
}
// GetOrCreateDefault 获取配置或创建默认配置
func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() (*entity.SystemConfig, error) {
config, err := r.FindFirst()
func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig, error) {
configs, err := r.FindAll()
if err != nil {
// 创建默认配置
defaultConfig := &entity.SystemConfig{
SiteTitle: "网盘资源数据库",
SiteDescription: "专业的网盘资源数据库",
Keywords: "网盘,资源管理,文件分享",
Author: "系统管理员",
Copyright: "© 2024 网盘资源数据库",
AutoProcessReadyResources: false,
AutoProcessInterval: 30,
PageSize: 100,
MaintenanceMode: false,
return nil, err
}
// 如果没有配置,创建默认配置
if len(configs) == 0 {
defaultConfigs := []entity.SystemConfig{
{Key: entity.ConfigKeySiteTitle, Value: entity.ConfigDefaultSiteTitle, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeySiteDescription, Value: entity.ConfigDefaultSiteDescription, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyKeywords, Value: entity.ConfigDefaultKeywords, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAuthor, Value: entity.ConfigDefaultAuthor, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyCopyright, Value: entity.ConfigDefaultCopyright, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyAutoProcessReadyResources, Value: entity.ConfigDefaultAutoProcessReadyResources, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyAutoProcessInterval, Value: entity.ConfigDefaultAutoProcessInterval, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoTransferEnabled, Value: entity.ConfigDefaultAutoTransferEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyAutoTransferLimitDays, Value: entity.ConfigDefaultAutoTransferLimitDays, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoTransferMinSpace, Value: entity.ConfigDefaultAutoTransferMinSpace, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyAutoFetchHotDramaEnabled, Value: entity.ConfigDefaultAutoFetchHotDramaEnabled, Type: entity.ConfigTypeBool},
{Key: entity.ConfigKeyApiToken, Value: entity.ConfigDefaultApiToken, Type: entity.ConfigTypeString},
{Key: entity.ConfigKeyPageSize, Value: entity.ConfigDefaultPageSize, Type: entity.ConfigTypeInt},
{Key: entity.ConfigKeyMaintenanceMode, Value: entity.ConfigDefaultMaintenanceMode, Type: entity.ConfigTypeBool},
}
err = r.db.Create(defaultConfig).Error
err = r.UpsertConfigs(defaultConfigs)
if err != nil {
return nil, err
}
return defaultConfig, nil
return defaultConfigs, nil
}
return config, nil
return configs, nil
}
// initConfigCache 初始化配置缓存
func (r *SystemConfigRepositoryImpl) initConfigCache() {
r.configCacheOnce.Do(func() {
// 获取所有配置
configs, err := r.FindAll()
if err != nil {
// 如果获取失败,尝试创建默认配置
configs, err = r.GetOrCreateDefault()
if err != nil {
return
}
}
// 初始化缓存
r.configCacheMutex.Lock()
defer r.configCacheMutex.Unlock()
for _, config := range configs {
r.configCache[config.Key] = config.Value
}
})
}
// refreshConfigCache 刷新配置缓存
func (r *SystemConfigRepositoryImpl) refreshConfigCache() {
// 重置Once允许重新初始化
r.configCacheOnce = sync.Once{}
// 清空缓存
r.configCacheMutex.Lock()
r.configCache = make(map[string]string)
r.configCacheMutex.Unlock()
// 重新初始化缓存
r.initConfigCache()
}
// GetConfigValue 获取配置值(字符串)
func (r *SystemConfigRepositoryImpl) GetConfigValue(key string) (string, error) {
// 初始化缓存
r.initConfigCache()
// 从缓存中读取
r.configCacheMutex.RLock()
value, exists := r.configCache[key]
r.configCacheMutex.RUnlock()
if exists {
return value, nil
}
// 如果缓存中没有,尝试从数据库获取(可能是新添加的配置)
config, err := r.FindByKey(key)
if err != nil {
return "", err
}
// 更新缓存
r.configCacheMutex.Lock()
r.configCache[key] = config.Value
r.configCacheMutex.Unlock()
return config.Value, nil
}
// GetConfigBool 获取配置值(布尔)
func (r *SystemConfigRepositoryImpl) GetConfigBool(key string) (bool, error) {
value, err := r.GetConfigValue(key)
if err != nil {
return false, err
}
switch value {
case "true", "1", "yes":
return true, nil
case "false", "0", "no":
return false, nil
default:
return false, nil
}
}
// GetConfigInt 获取配置值(整数)
func (r *SystemConfigRepositoryImpl) GetConfigInt(key string) (int, error) {
value, err := r.GetConfigValue(key)
if err != nil {
return 0, err
}
// 这里需要导入 strconv 包,但为了避免循环导入,我们使用简单的转换
var result int
_, err = fmt.Sscanf(value, "%d", &result)
return result, err
}
// GetCachedConfigs 获取所有缓存的配置(用于调试)
func (r *SystemConfigRepositoryImpl) GetCachedConfigs() map[string]string {
r.initConfigCache()
r.configCacheMutex.RLock()
defer r.configCacheMutex.RUnlock()
// 返回缓存的副本
result := make(map[string]string)
for k, v := range r.configCache {
result[k] = v
}
return result
}
// ClearConfigCache 清空配置缓存(用于测试或手动刷新)
func (r *SystemConfigRepositoryImpl) ClearConfigCache() {
r.configCacheMutex.Lock()
r.configCache = make(map[string]string)
r.configCacheMutex.Unlock()
// 重置Once允许重新初始化
r.configCacheOnce = sync.Once{}
}

View File

@@ -10,6 +10,7 @@ import (
type TagRepository interface {
BaseRepository[entity.Tag]
FindByName(name string) (*entity.Tag, error)
FindByNameIncludingDeleted(name string) (*entity.Tag, error)
FindWithResources() ([]entity.Tag, error)
FindByCategoryID(categoryID uint) ([]entity.Tag, error)
FindByCategoryIDPaginated(categoryID uint, page, pageSize int) ([]entity.Tag, int64, error)
@@ -18,6 +19,8 @@ type TagRepository interface {
FindWithPagination(page, pageSize int) ([]entity.Tag, int64, error)
Search(query string, page, pageSize int) ([]entity.Tag, int64, error)
UpdateWithNulls(tag *entity.Tag) error
GetByID(id uint) (*entity.Tag, error)
RestoreDeletedTag(id uint) error
}
// TagRepositoryImpl Tag的Repository实现
@@ -42,6 +45,16 @@ func (r *TagRepositoryImpl) FindByName(name string) (*entity.Tag, error) {
return &tag, nil
}
// FindByNameIncludingDeleted 根据名称查找(包括已删除的记录)
func (r *TagRepositoryImpl) FindByNameIncludingDeleted(name string) (*entity.Tag, error) {
var tag entity.Tag
err := r.db.Unscoped().Where("name = ?", name).First(&tag).Error
if err != nil {
return nil, err
}
return &tag, nil
}
// FindWithResources 查找包含资源的标签
func (r *TagRepositoryImpl) FindWithResources() ([]entity.Tag, error) {
var tags []entity.Tag
@@ -144,3 +157,18 @@ func (r *TagRepositoryImpl) UpdateWithNulls(tag *entity.Tag) error {
// 使用Select方法明确指定要更新的字段包括null值
return r.db.Model(tag).Select("name", "description", "category_id", "updated_at").Updates(tag).Error
}
// GetByID 通过ID查找标签
func (r *TagRepositoryImpl) GetByID(id uint) (*entity.Tag, error) {
var tag entity.Tag
err := r.db.First(&tag, id).Error
if err != nil {
return nil, err
}
return &tag, nil
}
// RestoreDeletedTag 恢复已删除的标签
func (r *TagRepositoryImpl) RestoreDeletedTag(id uint) error {
return r.db.Unscoped().Model(&entity.Tag{}).Where("id = ?", id).Update("deleted_at", nil).Error
}

View File

@@ -20,9 +20,7 @@ services:
- app-network
backend:
image: ctwj/urldb-backend:0.0.1
expose:
- "8080"
image: ctwj/urldb-backend:1.0.10
environment:
DB_HOST: postgres
DB_PORT: 5432
@@ -30,6 +28,7 @@ services:
DB_PASSWORD: password
DB_NAME: url_db
PORT: 8080
TIMEZONE: Asia/Shanghai
depends_on:
postgres:
condition: service_healthy
@@ -39,9 +38,10 @@ services:
- app-network
frontend:
image: ctwj/urldb-frontend:0.0.1
image: ctwj/urldb-frontend:1.0.10
environment:
API_BASE: http://backend:8080/api
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
NUXT_PUBLIC_API_CLIENT: /api
depends_on:
- backend
networks:

View File

@@ -38,7 +38,7 @@ $DOCKER_COMPOSE ps
echo ""
echo "✅ 系统启动完成!"
echo "🌐 前端访问地址: http://localhost:3000"
echo "🌐 前端访问地址: http://localhost:3030"
echo "🔧 后端API地址: http://localhost:8080"
echo "🗄️ 数据库地址: localhost:5432"
echo ""

1
docs/CNAME Normal file
View File

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

View File

@@ -1,6 +1,6 @@
# 🚀 urlDB - 网盘资源数据库
# 🚀 urlDB - 老九网盘资源数据库
> 一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘
> 一个现代化的老九网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘
<div align="center">

View File

@@ -2,17 +2,14 @@
* [🏠 首页](/)
* [🚀 快速开始](guide/quick-start.md)
* [⚙️ 系统配置](guide/configuration.md)
* [🐳 Docker部署](guide/docker-deployment.md)
* [💻 本地开发](guide/local-development.md)
* 📚 API 文档
* [公开API](api/overview.md)
* 📖 使用指南
* [配置多账号](usage/user-account.md)
* [配置自动处理资源](usage/resource-auto.md)
* [配置自动转存分享](usage/save-auto.md)
* 📄 其他
* [常见问题](faq.md)
* [更新日志](changelog.md)
* [许可证](license.md)
* [许可证](license.md)
* [版本管理](github-version-management.md)

View File

@@ -2,7 +2,7 @@
## 概述
网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
老九网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
## 基础信息

View File

@@ -1,10 +1,14 @@
// docsify 配置文件
window.$docsify = {
name: 'URL数据库管理系统',
name: '老九网盘链接数据库',
repo: 'https://github.com/ctwj/urldb',
loadSidebar: true,
loadSidebar: '_sidebar.md',
subMaxLevel: 3,
auto2top: true,
// 添加侧边栏配置
sidebarDisplayLevel: 1,
// 添加错误处理
notFoundPage: true,
search: {
maxAge: 86400000,
paths: 'auto',
@@ -34,6 +38,16 @@ window.$docsify = {
'最后更新: ' + 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

@@ -47,6 +47,7 @@ docker compose ps
可以通过修改 `docker-compose.yml` 文件中的环境变量来配置服务:
后端 backend
```yaml
environment:
DB_HOST: postgres
@@ -55,7 +56,12 @@ environment:
DB_PASSWORD: password
DB_NAME: url_db
PORT: 8080
API_BASE: http://localhost:8080/api
```
前端 frontend
```yaml
environment:
API_BASE: /api
```
### 端口映射

View File

@@ -23,7 +23,7 @@ docker compose up --build -d
启动成功后,您可以通过以下地址访问:
- **前端界面**: http://localhost:3000
- **前端界面**: http://localhost:3030
默认用户密码: admin/password

View File

@@ -2,7 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<title>urlDB - 网盘资源数据库</title>
<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">
@@ -12,8 +12,8 @@
</head>
<body>
<div id="app"></div>
<script src="./docsify.config.js"></script>
<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>

View File

@@ -7,6 +7,10 @@ DB_NAME=url_db
# 服务器配置
PORT=8080
GIN_MODE=release
# 时区配置
TIMEZONE=Asia/Shanghai
# 文件上传配置
UPLOAD_DIR=./uploads

BIN
github/account.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
github/admin.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
github/config.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
github/index.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
github/search.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

1
go.mod
View File

@@ -35,6 +35,7 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect

2
go.sum
View File

@@ -76,6 +76,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=

View File

@@ -7,6 +7,7 @@ import (
"github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
@@ -18,6 +19,8 @@ func GetCategories(c *gin.Context) {
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
search := c.Query("search")
utils.Debug("获取分类列表 - 分页参数: page=%d, pageSize=%d, search=%s", page, pageSize, search)
var categories []entity.Category
var total int64
var err error
@@ -35,6 +38,8 @@ func GetCategories(c *gin.Context) {
return
}
utils.Debug("查询到分类数量: %d, 总数: %d", len(categories), total)
// 获取每个分类的资源数量和标签名称
resourceCounts := make(map[uint]int64)
tagNamesMap := make(map[uint][]string)
@@ -73,12 +78,50 @@ func CreateCategory(c *gin.Context) {
return
}
// 首先检查是否存在已删除的同名分类
deletedCategory, err := repoManager.CategoryRepository.FindByNameIncludingDeleted(req.Name)
if err == nil && deletedCategory.DeletedAt.Valid {
utils.Debug("找到已删除的分类: ID=%d, Name=%s", deletedCategory.ID, deletedCategory.Name)
// 如果存在已删除的同名分类,则恢复它
err = repoManager.CategoryRepository.RestoreDeletedCategory(deletedCategory.ID)
if err != nil {
ErrorResponse(c, "恢复已删除分类失败: "+err.Error(), http.StatusInternalServerError)
return
}
utils.Debug("分类恢复成功: ID=%d", deletedCategory.ID)
// 重新获取恢复后的分类
restoredCategory, err := repoManager.CategoryRepository.FindByID(deletedCategory.ID)
if err != nil {
ErrorResponse(c, "获取恢复的分类失败: "+err.Error(), http.StatusInternalServerError)
return
}
utils.Debug("重新获取到恢复的分类: ID=%d, Name=%s", restoredCategory.ID, restoredCategory.Name)
// 更新分类信息
restoredCategory.Description = req.Description
err = repoManager.CategoryRepository.Update(restoredCategory)
if err != nil {
ErrorResponse(c, "更新恢复的分类失败: "+err.Error(), http.StatusInternalServerError)
return
}
utils.Debug("分类信息更新成功: ID=%d, Description=%s", restoredCategory.ID, restoredCategory.Description)
SuccessResponse(c, gin.H{
"message": "分类恢复成功",
"category": converter.ToCategoryResponse(restoredCategory, 0, []string{}),
})
return
}
// 如果不存在已删除的同名分类,则创建新分类
category := &entity.Category{
Name: req.Name,
Description: req.Description,
}
err := repoManager.CategoryRepository.Create(category)
err = repoManager.CategoryRepository.Create(category)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return

View File

@@ -192,6 +192,8 @@ func UpdateCks(c *gin.Context) {
if req.Ck != "" {
cks.Ck = req.Ck
}
// 对于 bool 类型,我们需要检查请求中是否包含该字段
// 由于 Go 的 JSON 解析,如果字段存在且为 false也会被正确解析
cks.IsValid = req.IsValid
if req.LeftSpace != 0 {
cks.LeftSpace = req.LeftSpace
@@ -210,7 +212,8 @@ func UpdateCks(c *gin.Context) {
cks.Remark = req.Remark
}
err = repoManager.CksRepository.Update(cks)
// 使用专门的方法更新,确保更新所有字段包括零值
err = repoManager.CksRepository.UpdateWithAllFields(cks)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
@@ -321,7 +324,7 @@ func RefreshCapacity(c *gin.Context) {
cks.UsedSpace = userInfo.UsedSpace
cks.IsValid = userInfo.VIPStatus // 根据VIP状态更新有效性
err = repoManager.CksRepository.Update(cks)
err = repoManager.CksRepository.UpdateWithAllFields(cks)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return

View File

@@ -1,12 +1,10 @@
package handlers
import (
"net/http"
"strconv"
"github.com/ctwj/urldb/db/converter"
"github.com/ctwj/urldb/db/dto"
"github.com/ctwj/urldb/db/entity"
"github.com/gin-gonic/gin"
)
@@ -19,84 +17,6 @@ func NewPublicAPIHandler() *PublicAPIHandler {
return &PublicAPIHandler{}
}
// AddSingleResource godoc
// @Summary 单个添加资源
// @Description 通过公开API添加单个资源到待处理列表
// @Tags PublicAPI
// @Accept json
// @Produce json
// @Param X-API-Token header string true "API访问令牌"
// @Param data body dto.ReadyResourceRequest true "资源信息"
// @Success 200 {object} map[string]interface{} "添加成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "认证失败"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/public/resources/add [post]
func (h *PublicAPIHandler) AddSingleResource(c *gin.Context) {
var req dto.ReadyResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
"code": 400,
})
return
}
// 验证必填字段
if req.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "标题不能为空",
"code": 400,
})
return
}
if req.Url == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "URL不能为空",
"code": 400,
})
return
}
// 转换为实体
readyResource := converter.RequestToReadyResource(&req)
if readyResource == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "数据转换失败",
"code": 500,
})
return
}
// 设置来源
readyResource.Source = "公开API"
// 保存到数据库
err := repoManager.ReadyResourceRepository.Create(readyResource)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "添加资源失败: " + err.Error(),
"code": 500,
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "资源添加成功,已进入待处理列表",
"data": gin.H{
"id": readyResource.ID,
},
"code": 200,
})
}
// AddBatchResources godoc
// @Summary 批量添加资源
// @Description 通过公开API批量添加多个资源到待处理列表
@@ -113,65 +33,80 @@ func (h *PublicAPIHandler) AddSingleResource(c *gin.Context) {
func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
var req dto.BatchReadyResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "请求参数错误: " + err.Error(),
"code": 400,
})
ErrorResponse(c, "请求参数错误: "+err.Error(), 400)
return
}
if len(req.Resources) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "资源列表不能为空",
"code": 400,
})
ErrorResponse(c, "资源列表不能为空", 400)
return
}
// 验证每个资源
for i, resource := range req.Resources {
if resource.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "第" + strconv.Itoa(i+1) + "个资源标题不能为空",
"code": 400,
})
return
}
if resource.Url == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "第" + strconv.Itoa(i+1) + "个资源URL不能为空",
"code": 400,
})
return
// 收集所有待提交的URL去重
urlSet := make(map[string]struct{})
for _, resource := range req.Resources {
for _, u := range resource.Url {
if u != "" {
urlSet[u] = struct{}{}
}
}
}
uniqueUrls := make([]string, 0, len(urlSet))
for url := range urlSet {
uniqueUrls = append(uniqueUrls, url)
}
// 批量查重
readyList, _ := repoManager.ReadyResourceRepository.BatchFindByURLs(uniqueUrls)
existReadyUrls := make(map[string]struct{})
for _, r := range readyList {
existReadyUrls[r.URL] = struct{}{}
}
resourceList, _ := repoManager.ResourceRepository.BatchFindByURLs(uniqueUrls)
existResourceUrls := make(map[string]struct{})
for _, r := range resourceList {
existResourceUrls[r.URL] = struct{}{}
}
// 批量保存
var createdResources []uint
for _, resourceReq := range req.Resources {
readyResource := converter.RequestToReadyResource(&resourceReq)
if readyResource != nil {
readyResource.Source = "公开API批量添加"
err := repoManager.ReadyResourceRepository.Create(readyResource)
// 生成 key每组同一个 key
key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey()
if err != nil {
ErrorResponse(c, "生成资源组标识失败: "+err.Error(), 500)
return
}
for _, url := range resourceReq.Url {
if url == "" {
continue
}
if _, ok := existReadyUrls[url]; ok {
continue
}
if _, ok := existResourceUrls[url]; ok {
continue
}
readyResource := entity.ReadyResource{
Title: &resourceReq.Title,
Description: resourceReq.Description,
URL: url,
Category: resourceReq.Category,
Tags: resourceReq.Tags,
Img: resourceReq.Img,
Source: "api",
Extra: resourceReq.Extra,
Key: key,
}
err := repoManager.ReadyResourceRepository.Create(&readyResource)
if err == nil {
createdResources = append(createdResources, readyResource.ID)
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "批量添加成功,共添加 " + strconv.Itoa(len(createdResources)) + " 个资源",
"data": gin.H{
"created_count": len(createdResources),
"created_ids": createdResources,
},
"code": 200,
SuccessResponse(c, gin.H{
"created_count": len(createdResources),
"created_ids": createdResources,
})
}
@@ -230,11 +165,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
// 执行搜索
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "搜索失败: " + err.Error(),
"code": 500,
})
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
return
}
@@ -252,16 +183,11 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "搜索成功",
"data": gin.H{
"resources": resourceResponses,
"total": total,
"page": page,
"page_size": pageSize,
},
"code": 200,
SuccessResponse(c, gin.H{
"list": resourceResponses,
"total": total,
"page": page,
"limit": pageSize,
})
}
@@ -295,11 +221,7 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
// 获取热门剧
hotDramas, total, err := repoManager.HotDramaRepository.FindAll(page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "获取热门剧失败: " + err.Error(),
"code": 500,
})
ErrorResponse(c, "获取热门剧失败: "+err.Error(), 500)
return
}
@@ -322,15 +244,10 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "获取热门剧成功",
"data": gin.H{
"hot_dramas": hotDramaResponses,
"total": total,
"page": page,
"page_size": pageSize,
},
"code": 200,
SuccessResponse(c, gin.H{
"hot_dramas": hotDramaResponses,
"total": total,
"page": page,
"page_size": pageSize,
})
}

View File

@@ -46,38 +46,6 @@ func GetReadyResources(c *gin.Context) {
})
}
// CreateReadyResource 创建待处理资源
func CreateReadyResource(c *gin.Context) {
var req dto.CreateReadyResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, err.Error(), http.StatusBadRequest)
return
}
resource := &entity.ReadyResource{
Title: req.Title,
Description: req.Description,
URL: req.URL,
Category: req.Category,
Tags: req.Tags,
Img: req.Img,
Source: req.Source,
Extra: req.Extra,
IP: req.IP,
}
err := repoManager.ReadyResourceRepository.Create(resource)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"id": resource.ID,
"message": "待处理资源创建成功",
})
}
// BatchCreateReadyResources 批量创建待处理资源
func BatchCreateReadyResources(c *gin.Context) {
var req dto.BatchCreateReadyResourceRequest
@@ -86,20 +54,85 @@ func BatchCreateReadyResources(c *gin.Context) {
return
}
// 1. 先收集所有待提交的URL去重
urlSet := make(map[string]struct{})
for _, reqResource := range req.Resources {
if len(reqResource.URL) == 0 {
continue
}
for _, u := range reqResource.URL {
if u != "" {
urlSet[u] = struct{}{}
}
}
}
uniqueUrls := make([]string, 0, len(urlSet))
for url := range urlSet {
uniqueUrls = append(uniqueUrls, url)
}
// 2. 批量查询待处理资源表中已存在的URL
existReadyUrls := make(map[string]struct{})
if len(uniqueUrls) > 0 {
readyList, _ := repoManager.ReadyResourceRepository.BatchFindByURLs(uniqueUrls)
for _, r := range readyList {
existReadyUrls[r.URL] = struct{}{}
}
}
// 3. 批量查询资源表中已存在的URL
existResourceUrls := make(map[string]struct{})
if len(uniqueUrls) > 0 {
resourceList, _ := repoManager.ResourceRepository.BatchFindByURLs(uniqueUrls)
for _, r := range resourceList {
existResourceUrls[r.URL] = struct{}{}
}
}
// 5. 过滤掉已存在的URL
var resources []entity.ReadyResource
for _, reqResource := range req.Resources {
resource := entity.ReadyResource{
Title: reqResource.Title,
Description: reqResource.Description,
URL: reqResource.URL,
Category: reqResource.Category,
Tags: reqResource.Tags,
Img: reqResource.Img,
Source: reqResource.Source,
Extra: reqResource.Extra,
IP: reqResource.IP,
if len(reqResource.URL) == 0 {
continue
}
resources = append(resources, resource)
key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey()
if err != nil {
ErrorResponse(c, "生成批量资源组标识失败: "+err.Error(), http.StatusInternalServerError)
return
}
for _, url := range reqResource.URL {
if url == "" {
continue
}
if _, ok := existReadyUrls[url]; ok {
continue
}
if _, ok := existResourceUrls[url]; ok {
continue
}
resource := entity.ReadyResource{
Title: reqResource.Title,
Description: reqResource.Description,
URL: url,
Category: reqResource.Category,
Tags: reqResource.Tags,
Img: reqResource.Img,
Source: reqResource.Source,
Extra: reqResource.Extra,
IP: reqResource.IP,
Key: key,
}
resources = append(resources, resource)
}
}
if len(resources) == 0 {
SuccessResponse(c, gin.H{
"count": 0,
"message": "无新增资源所有URL均已存在",
})
return
}
err := repoManager.ReadyResourceRepository.BatchCreate(resources)
@@ -196,3 +229,239 @@ func ClearReadyResources(c *gin.Context) {
"message": "所有待处理资源已清空",
})
}
// GetReadyResourcesByKey 根据key获取待处理资源
func GetReadyResourcesByKey(c *gin.Context) {
key := c.Param("key")
if key == "" {
ErrorResponse(c, "key参数不能为空", http.StatusBadRequest)
return
}
resources, err := repoManager.ReadyResourceRepository.FindByKey(key)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
responses := converter.ToReadyResourceResponseList(resources)
SuccessResponse(c, gin.H{
"data": responses,
"key": key,
"count": len(resources),
})
}
// DeleteReadyResourcesByKey 根据key删除待处理资源
func DeleteReadyResourcesByKey(c *gin.Context) {
key := c.Param("key")
if key == "" {
ErrorResponse(c, "key参数不能为空", http.StatusBadRequest)
return
}
// 先查询要删除的资源数量
resources, err := repoManager.ReadyResourceRepository.FindByKey(key)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
if len(resources) == 0 {
ErrorResponse(c, "未找到指定key的资源", http.StatusNotFound)
return
}
// 删除所有具有相同key的资源
err = repoManager.ReadyResourceRepository.DeleteByKey(key)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"deleted_count": len(resources),
"key": key,
"message": "资源组删除成功",
})
}
// getRetryableErrorCount 统计可重试的错误数量
func getRetryableErrorCount(resources []entity.ReadyResource) int {
count := 0
for _, resource := range resources {
if resource.ErrorMsg != "" {
errorMsg := strings.ToUpper(resource.ErrorMsg)
// 检查错误类型标记
if strings.Contains(resource.ErrorMsg, "[NO_ACCOUNT]") ||
strings.Contains(resource.ErrorMsg, "[NO_VALID_ACCOUNT]") ||
strings.Contains(resource.ErrorMsg, "[TRANSFER_FAILED]") ||
strings.Contains(resource.ErrorMsg, "[LINK_CHECK_FAILED]") {
count++
} else if strings.Contains(errorMsg, "没有可用的网盘账号") ||
strings.Contains(errorMsg, "没有有效的网盘账号") ||
strings.Contains(errorMsg, "网盘信息获取失败") ||
strings.Contains(errorMsg, "链接检查失败") {
count++
}
}
}
return count
}
// GetReadyResourcesWithErrors 获取有错误信息的待处理资源
func GetReadyResourcesWithErrors(c *gin.Context) {
// 获取分页参数
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "100")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 || pageSize > 1000 {
pageSize = 100
}
// 获取有错误的资源(分页)
resources, total, err := repoManager.ReadyResourceRepository.FindWithErrorsPaginated(page, pageSize)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
responses := converter.ToReadyResourceResponseList(resources)
// 统计错误类型
errorTypeStats := make(map[string]int)
for _, resource := range resources {
if resource.ErrorMsg != "" {
// 尝试从错误信息中提取错误类型
if len(resource.ErrorMsg) > 0 && resource.ErrorMsg[0] == '[' {
endIndex := strings.Index(resource.ErrorMsg, "]")
if endIndex > 0 {
errorType := resource.ErrorMsg[1:endIndex]
errorTypeStats[errorType]++
} else {
errorTypeStats["UNKNOWN"]++
}
} else {
// 如果没有错误类型标记,尝试从错误信息中推断
errorMsg := strings.ToUpper(resource.ErrorMsg)
if strings.Contains(errorMsg, "不支持的链接") {
errorTypeStats["UNSUPPORTED_LINK"]++
} else if strings.Contains(errorMsg, "链接无效") {
errorTypeStats["INVALID_LINK"]++
} else if strings.Contains(errorMsg, "没有可用的网盘账号") {
errorTypeStats["NO_ACCOUNT"]++
} else if strings.Contains(errorMsg, "没有有效的网盘账号") {
errorTypeStats["NO_VALID_ACCOUNT"]++
} else if strings.Contains(errorMsg, "网盘信息获取失败") {
errorTypeStats["TRANSFER_FAILED"]++
} else if strings.Contains(errorMsg, "创建网盘服务失败") {
errorTypeStats["SERVICE_CREATION_FAILED"]++
} else if strings.Contains(errorMsg, "处理标签失败") {
errorTypeStats["TAG_PROCESSING_FAILED"]++
} else if strings.Contains(errorMsg, "处理分类失败") {
errorTypeStats["CATEGORY_PROCESSING_FAILED"]++
} else if strings.Contains(errorMsg, "资源保存失败") {
errorTypeStats["RESOURCE_SAVE_FAILED"]++
} else if strings.Contains(errorMsg, "未找到对应的平台ID") {
errorTypeStats["PLATFORM_NOT_FOUND"]++
} else if strings.Contains(errorMsg, "链接检查失败") {
errorTypeStats["LINK_CHECK_FAILED"]++
} else {
errorTypeStats["UNKNOWN"]++
}
}
}
}
SuccessResponse(c, gin.H{
"data": responses,
"page": page,
"page_size": pageSize,
"total": total,
"count": len(resources),
"error_stats": errorTypeStats,
"retryable_count": getRetryableErrorCount(resources),
})
}
// ClearErrorMsg 清除指定资源的错误信息
func ClearErrorMsg(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
return
}
err = repoManager.ReadyResourceRepository.ClearErrorMsg(uint(id))
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "错误信息已清除"})
}
// RetryFailedResources 重试失败的资源
func RetryFailedResources(c *gin.Context) {
// 获取有错误的资源
resources, err := repoManager.ReadyResourceRepository.FindWithErrors()
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
if len(resources) == 0 {
SuccessResponse(c, gin.H{
"message": "没有需要重试的资源",
"count": 0,
})
return
}
// 只重试可重试的错误
clearedCount := 0
skippedCount := 0
for _, resource := range resources {
isRetryable := false
errorMsg := strings.ToUpper(resource.ErrorMsg)
// 检查错误类型标记
if strings.Contains(resource.ErrorMsg, "[NO_ACCOUNT]") ||
strings.Contains(resource.ErrorMsg, "[NO_VALID_ACCOUNT]") ||
strings.Contains(resource.ErrorMsg, "[TRANSFER_FAILED]") ||
strings.Contains(resource.ErrorMsg, "[LINK_CHECK_FAILED]") {
isRetryable = true
} else if strings.Contains(errorMsg, "没有可用的网盘账号") ||
strings.Contains(errorMsg, "没有有效的网盘账号") ||
strings.Contains(errorMsg, "网盘信息获取失败") ||
strings.Contains(errorMsg, "链接检查失败") {
isRetryable = true
}
if isRetryable {
if err := repoManager.ReadyResourceRepository.ClearErrorMsg(resource.ID); err == nil {
clearedCount++
}
} else {
skippedCount++
}
}
SuccessResponse(c, gin.H{
"message": "已清除可重试资源的错误信息,资源将在下次调度时重新处理",
"total_count": len(resources),
"cleared_count": clearedCount,
"skipped_count": skippedCount,
"retryable_count": getRetryableErrorCount(resources),
})
}

View File

@@ -15,35 +15,36 @@ import (
func GetResources(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
categoryID := c.Query("category_id")
panID := c.Query("pan_id")
search := c.Query("search")
var resources []entity.Resource
var total int64
var err error
params := map[string]interface{}{
"page": page,
"page_size": pageSize,
}
// 设置响应头,启用缓存
c.Header("Cache-Control", "public, max-age=300") // 5分钟缓存
if search := c.Query("search"); search != "" {
params["search"] = search
}
if panID := c.Query("pan_id"); panID != "" {
if id, err := strconv.ParseUint(panID, 10, 32); err == nil {
params["pan_id"] = uint(id)
}
}
if categoryID := c.Query("category_id"); categoryID != "" {
if id, err := strconv.ParseUint(categoryID, 10, 32); err == nil {
params["category_id"] = uint(id)
}
}
if search != "" && panID != "" {
// 平台内搜索
panIDUint, _ := strconv.ParseUint(panID, 10, 32)
resources, total, err = repoManager.ResourceRepository.SearchByPanID(search, uint(panIDUint), page, pageSize)
} else if search != "" {
// 全局搜索
resources, total, err = repoManager.ResourceRepository.Search(search, nil, page, pageSize)
} else if panID != "" {
// 按平台筛选
panIDUint, _ := strconv.ParseUint(panID, 10, 32)
resources, total, err = repoManager.ResourceRepository.FindByPanIDPaginated(uint(panIDUint), page, pageSize)
} else if categoryID != "" {
// 按分类筛选
categoryIDUint, _ := strconv.ParseUint(categoryID, 10, 32)
resources, total, err = repoManager.ResourceRepository.FindByCategoryIDPaginated(uint(categoryIDUint), page, pageSize)
} else {
// 使用分页查询,避免加载所有数据
resources, total, err = repoManager.ResourceRepository.FindWithRelationsPaginated(page, pageSize)
resources, total, err := repoManager.ResourceRepository.SearchWithFilters(params)
// 搜索统计(仅非管理员)
if search, ok := params["search"].(string); ok && search != "" {
user, _ := c.Get("user")
if user == nil || (user != nil && user.(entity.User).Role != "admin") {
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
repoManager.SearchStatRepository.RecordSearch(search, ip, userAgent)
}
}
if err != nil {
@@ -52,7 +53,7 @@ func GetResources(c *gin.Context) {
}
SuccessResponse(c, gin.H{
"resources": converter.ToResourceResponseList(resources),
"data": converter.ToResourceResponseList(resources),
"total": total,
"page": page,
"page_size": pageSize,
@@ -119,11 +120,14 @@ func CreateResource(c *gin.Context) {
Description: req.Description,
URL: req.URL,
PanID: req.PanID,
QuarkURL: req.QuarkURL,
SaveURL: req.SaveURL,
FileSize: req.FileSize,
CategoryID: req.CategoryID,
IsValid: req.IsValid,
IsPublic: req.IsPublic,
Cover: req.Cover,
Author: req.Author,
ErrorMsg: req.ErrorMsg,
}
err := repoManager.ResourceRepository.Create(resource)
@@ -181,8 +185,8 @@ func UpdateResource(c *gin.Context) {
if req.PanID != nil {
resource.PanID = req.PanID
}
if req.QuarkURL != "" {
resource.QuarkURL = req.QuarkURL
if req.SaveURL != "" {
resource.SaveURL = req.SaveURL
}
if req.FileSize != "" {
resource.FileSize = req.FileSize
@@ -192,6 +196,15 @@ func UpdateResource(c *gin.Context) {
}
resource.IsValid = req.IsValid
resource.IsPublic = req.IsPublic
if req.Cover != "" {
resource.Cover = req.Cover
}
if req.Author != "" {
resource.Author = req.Author
}
if req.ErrorMsg != "" {
resource.ErrorMsg = req.ErrorMsg
}
// 处理标签关联
if len(req.TagIDs) > 0 {
@@ -245,6 +258,10 @@ func SearchResources(c *gin.Context) {
} else {
// 有搜索关键词时,执行搜索
resources, total, err = repoManager.ResourceRepository.Search(query, nil, page, pageSize)
// 新增:记录搜索关键词
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
repoManager.SearchStatRepository.RecordSearch(query, ip, userAgent)
}
if err != nil {
@@ -259,3 +276,37 @@ func SearchResources(c *gin.Context) {
"page_size": pageSize,
})
}
// 增加资源浏览次数
func IncrementResourceViewCount(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
ErrorResponse(c, "无效的资源ID", http.StatusBadRequest)
return
}
err = repoManager.ResourceRepository.IncrementViewCount(uint(id))
if err != nil {
ErrorResponse(c, "增加浏览次数失败", http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{"message": "浏览次数+1"})
}
// BatchDeleteResources 批量删除资源
func BatchDeleteResources(c *gin.Context) {
var req struct {
IDs []uint `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
ErrorResponse(c, "参数错误", 400)
return
}
count := 0
for _, id := range req.IDs {
if err := repoManager.ResourceRepository.Delete(id); err == nil {
count++
}
}
SuccessResponse(c, gin.H{"deleted": count, "message": "批量删除成功"})
}

View File

@@ -16,6 +16,8 @@ func GetSchedulerStatus(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
status := gin.H{
@@ -36,6 +38,8 @@ func StartHotDramaScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if scheduler.IsHotDramaSchedulerRunning() {
ErrorResponse(c, "热播剧定时任务已在运行中", http.StatusBadRequest)
@@ -54,6 +58,8 @@ func StopHotDramaScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if !scheduler.IsHotDramaSchedulerRunning() {
ErrorResponse(c, "热播剧定时任务未在运行", http.StatusBadRequest)
@@ -72,6 +78,8 @@ func TriggerHotDramaScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
scheduler.StartHotDramaScheduler() // 直接启动一次
SuccessResponse(c, gin.H{"message": "手动触发热播剧定时任务成功"})
@@ -86,6 +94,8 @@ func FetchHotDramaNames(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
names, err := scheduler.GetHotDramaNames()
if err != nil {
@@ -104,6 +114,8 @@ func StartReadyResourceScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if scheduler.IsReadyResourceRunning() {
ErrorResponse(c, "待处理资源自动处理任务已在运行中", http.StatusBadRequest)
@@ -122,6 +134,8 @@ func StopReadyResourceScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if !scheduler.IsReadyResourceRunning() {
ErrorResponse(c, "待处理资源自动处理任务未在运行", http.StatusBadRequest)
@@ -140,6 +154,8 @@ func TriggerReadyResourceScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 手动触发一次处理
scheduler.ProcessReadyResources()
@@ -155,6 +171,8 @@ func StartAutoTransferScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if scheduler.IsAutoTransferRunning() {
ErrorResponse(c, "自动转存定时任务已在运行中", http.StatusBadRequest)
@@ -173,6 +191,8 @@ func StopAutoTransferScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if !scheduler.IsAutoTransferRunning() {
ErrorResponse(c, "自动转存定时任务未在运行", http.StatusBadRequest)
@@ -191,6 +211,8 @@ func TriggerAutoTransferScheduler(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 手动触发一次处理
scheduler.ProcessAutoTransfer()

View File

@@ -142,3 +142,13 @@ func GetKeywordTrend(c *gin.Context) {
response := converter.ToDailySearchStatResponseList(trend)
SuccessResponse(c, response)
}
// GetSearchStatsSummary 获取搜索统计汇总
func GetSearchStatsSummary(c *gin.Context) {
summary, err := repoManager.SearchStatRepository.GetSummary()
if err != nil {
ErrorResponse(c, "获取搜索统计汇总失败", 500)
return
}
SuccessResponse(c, summary)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin"
)
@@ -23,7 +24,7 @@ func GetStats(c *gin.Context) {
// 获取今日更新数量
var todayUpdates int64
today := time.Now().Format("2006-01-02")
today := utils.GetCurrentTime().Format("2006-01-02")
db.DB.Model(&entity.Resource{}).Where("DATE(updated_at) = ?", today).Count(&todayUpdates)
SuccessResponse(c, gin.H{
@@ -44,20 +45,27 @@ func GetPerformanceStats(c *gin.Context) {
sqlDB, err := db.DB.DB()
var dbStats gin.H
if err == nil {
stats := sqlDB.Stats()
dbStats = gin.H{
"max_open_connections": sqlDB.Stats().MaxOpenConnections,
"open_connections": sqlDB.Stats().OpenConnections,
"in_use": sqlDB.Stats().InUse,
"idle": sqlDB.Stats().Idle,
"max_open_connections": stats.MaxOpenConnections,
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
"wait_count": stats.WaitCount,
"wait_duration": stats.WaitDuration,
}
// 添加调试日志
utils.Info("数据库连接池状态 - MaxOpen: %d, Open: %d, InUse: %d, Idle: %d",
stats.MaxOpenConnections, stats.OpenConnections, stats.InUse, stats.Idle)
} else {
dbStats = gin.H{
"error": "无法获取数据库连接池状态",
"error": "无法获取数据库连接池状态: " + err.Error(),
}
utils.Error("获取数据库连接池状态失败: %v", err)
}
SuccessResponse(c, gin.H{
"timestamp": time.Now().Unix(),
"timestamp": utils.GetCurrentTime().Unix(),
"memory": gin.H{
"alloc": m.Alloc,
"total_alloc": m.TotalAlloc,
@@ -81,8 +89,8 @@ func GetPerformanceStats(c *gin.Context) {
func GetSystemInfo(c *gin.Context) {
SuccessResponse(c, gin.H{
"uptime": time.Since(startTime).String(),
"start_time": startTime.Format("2006-01-02 15:04:05"),
"version": "1.0.0",
"start_time": utils.FormatTime(startTime, "2006-01-02 15:04:05"),
"version": utils.Version,
"environment": gin.H{
"gin_mode": gin.Mode(),
},
@@ -90,4 +98,4 @@ func GetSystemInfo(c *gin.Context) {
}
// 记录启动时间
var startTime = time.Now()
var startTime = utils.GetCurrentTime()

View File

@@ -5,6 +5,7 @@ import (
"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/utils"
@@ -25,13 +26,13 @@ func NewSystemConfigHandler(systemConfigRepo repo.SystemConfigRepository) *Syste
// GetConfig 获取系统配置
func (h *SystemConfigHandler) GetConfig(c *gin.Context) {
config, err := h.systemConfigRepo.GetOrCreateDefault()
configs, err := h.systemConfigRepo.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
return
}
configResponse := converter.SystemConfigToResponse(config)
configResponse := converter.SystemConfigToResponse(configs)
SuccessResponse(c, configResponse)
}
@@ -43,67 +44,67 @@ func (h *SystemConfigHandler) UpdateConfig(c *gin.Context) {
return
}
// 验证参数
if req.SiteTitle == "" {
ErrorResponse(c, "网站标题不能为空", http.StatusBadRequest)
// 验证参数 - 只验证提交的字段
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
return
}
if req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440 {
if req.AutoProcessInterval != 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
return
}
if req.PageSize < 10 || req.PageSize > 500 {
if req.PageSize != 0 && (req.PageSize < 10 || req.PageSize > 500) {
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
return
}
// 验证自动转存配置
if req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365 {
if req.AutoTransferLimitDays != 0 && (req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365) {
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
return
}
if req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024 {
if req.AutoTransferMinSpace != 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
return
}
// 转换为实体
config := converter.RequestToSystemConfig(&req)
if config == nil {
configs := converter.RequestToSystemConfig(&req)
if configs == nil {
ErrorResponse(c, "数据转换失败", http.StatusInternalServerError)
return
}
// 保存配置
err := h.systemConfigRepo.Upsert(config)
err := h.systemConfigRepo.UpsertConfigs(configs)
if err != nil {
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
return
}
// 返回更新后的配置
updatedConfig, err := h.systemConfigRepo.FindFirst()
updatedConfigs, err := h.systemConfigRepo.FindAll()
if err != nil {
ErrorResponse(c, "获取更新后的配置失败", http.StatusInternalServerError)
return
}
configResponse := converter.SystemConfigToResponse(updatedConfig)
configResponse := converter.SystemConfigToResponse(updatedConfigs)
SuccessResponse(c, configResponse)
}
// GetSystemConfig 获取系统配置使用全局repoManager
func GetSystemConfig(c *gin.Context) {
config, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
configs, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
return
}
configResponse := converter.SystemConfigToResponse(config)
configResponse := converter.SystemConfigToResponse(configs)
SuccessResponse(c, configResponse)
}
@@ -115,42 +116,45 @@ func UpdateSystemConfig(c *gin.Context) {
return
}
// 验证参数
if req.SiteTitle == "" {
ErrorResponse(c, "网站标题不能为空", http.StatusBadRequest)
// 调试信息
utils.Info("接收到的配置请求: %+v", req)
// 验证参数 - 只验证提交的字段
if req.SiteTitle != "" && (len(req.SiteTitle) < 1 || len(req.SiteTitle) > 100) {
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
return
}
if req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440 {
if req.AutoProcessInterval != 0 && (req.AutoProcessInterval < 1 || req.AutoProcessInterval > 1440) {
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
return
}
if req.PageSize < 10 || req.PageSize > 500 {
if req.PageSize != 0 && (req.PageSize < 10 || req.PageSize > 500) {
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
return
}
// 验证自动转存配置
if req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365 {
if req.AutoTransferLimitDays != 0 && (req.AutoTransferLimitDays < 0 || req.AutoTransferLimitDays > 365) {
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
return
}
if req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024 {
if req.AutoTransferMinSpace != 0 && (req.AutoTransferMinSpace < 100 || req.AutoTransferMinSpace > 1024) {
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
return
}
// 转换为实体
config := converter.RequestToSystemConfig(&req)
if config == nil {
configs := converter.RequestToSystemConfig(&req)
if configs == nil {
ErrorResponse(c, "数据转换失败", http.StatusInternalServerError)
return
}
// 保存配置
err := repoManager.SystemConfigRepository.Upsert(config)
err := repoManager.SystemConfigRepository.UpsertConfigs(configs)
if err != nil {
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
return
@@ -164,18 +168,94 @@ func UpdateSystemConfig(c *gin.Context) {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if scheduler != nil {
scheduler.UpdateSchedulerStatusWithAutoTransfer(req.AutoFetchHotDramaEnabled, req.AutoProcessReadyResources, req.AutoTransferEnabled)
}
// 返回更新后的配置
updatedConfig, err := repoManager.SystemConfigRepository.FindFirst()
updatedConfigs, err := repoManager.SystemConfigRepository.FindAll()
if err != nil {
ErrorResponse(c, "获取更新后的配置失败", http.StatusInternalServerError)
return
}
configResponse := converter.SystemConfigToResponse(updatedConfig)
configResponse := converter.SystemConfigToResponse(updatedConfigs)
SuccessResponse(c, configResponse)
}
// 新增公开获取系统配置不含api_token
func GetPublicSystemConfig(c *gin.Context) {
configs, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
return
}
configResponse := converter.SystemConfigToPublicResponse(configs)
SuccessResponse(c, configResponse)
}
// 新增:切换自动处理配置
func ToggleAutoProcess(c *gin.Context) {
var req struct {
AutoProcessReadyResources bool `json:"auto_process_ready_resources"`
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
return
}
// 获取当前配置
configs, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
if err != nil {
ErrorResponse(c, "获取系统配置失败", http.StatusInternalServerError)
return
}
// 更新自动处理配置
for i, config := range configs {
if config.Key == entity.ConfigKeyAutoProcessReadyResources {
configs[i].Value = "true"
if !req.AutoProcessReadyResources {
configs[i].Value = "false"
}
break
}
}
// 保存配置
err = repoManager.SystemConfigRepository.UpsertConfigs(configs)
if err != nil {
ErrorResponse(c, "保存系统配置失败", http.StatusInternalServerError)
return
}
// 更新定时任务状态
scheduler := utils.GetGlobalScheduler(
repoManager.HotDramaRepository,
repoManager.ReadyResourceRepository,
repoManager.ResourceRepository,
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
if scheduler != nil {
// 获取其他配置值
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
autoTransfer, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
scheduler.UpdateSchedulerStatusWithAutoTransfer(
autoFetchHotDrama,
req.AutoProcessReadyResources,
autoTransfer,
)
}
// 返回更新后的配置
configResponse := converter.SystemConfigToResponse(configs)
SuccessResponse(c, configResponse)
}

View File

@@ -65,13 +65,47 @@ func CreateTag(c *gin.Context) {
return
}
// 首先检查是否存在已删除的同名标签
deletedTag, err := repoManager.TagRepository.FindByNameIncludingDeleted(req.Name)
if err == nil && deletedTag.DeletedAt.Valid {
// 如果存在已删除的同名标签,则恢复它
err = repoManager.TagRepository.RestoreDeletedTag(deletedTag.ID)
if err != nil {
ErrorResponse(c, "恢复已删除标签失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 重新获取恢复后的标签
restoredTag, err := repoManager.TagRepository.FindByID(deletedTag.ID)
if err != nil {
ErrorResponse(c, "获取恢复的标签失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 更新标签信息
restoredTag.Description = req.Description
restoredTag.CategoryID = req.CategoryID
err = repoManager.TagRepository.UpdateWithNulls(restoredTag)
if err != nil {
ErrorResponse(c, "更新恢复的标签失败: "+err.Error(), http.StatusInternalServerError)
return
}
SuccessResponse(c, gin.H{
"message": "标签恢复成功",
"tag": converter.ToTagResponse(restoredTag, 0),
})
return
}
// 如果不存在已删除的同名标签,则创建新标签
tag := &entity.Tag{
Name: req.Name,
Description: req.Description,
CategoryID: req.CategoryID,
}
err := repoManager.TagRepository.Create(tag)
err = repoManager.TagRepository.Create(tag)
if err != nil {
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
return

View File

@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -27,7 +28,7 @@ func GetVersion(c *gin.Context) {
Success: true,
Data: versionInfo,
Message: "版本信息获取成功",
Time: time.Now(),
Time: utils.GetCurrentTime(),
}
c.JSON(http.StatusOK, response)
@@ -43,7 +44,7 @@ func GetVersionString(c *gin.Context) {
"version": versionString,
},
Message: "版本字符串获取成功",
Time: time.Now(),
Time: utils.GetCurrentTime(),
}
c.JSON(http.StatusOK, response)
@@ -59,7 +60,7 @@ func GetFullVersionInfo(c *gin.Context) {
"version_info": fullInfo,
},
Message: "完整版本信息获取成功",
Time: time.Now(),
Time: utils.GetCurrentTime(),
}
c.JSON(http.StatusOK, response)
@@ -72,8 +73,8 @@ func CheckUpdate(c *gin.Context) {
// 从GitHub API获取最新版本信息
latestVersion, err := getLatestVersionFromGitHub()
if err != nil {
// 如果GitHub API失败使用模拟数据
latestVersion = "1.0.0"
// 如果GitHub API失败使用当前版本作为最新版本
latestVersion = currentVersion
}
hasUpdate := utils.IsVersionNewer(latestVersion, currentVersion)
@@ -88,7 +89,7 @@ func CheckUpdate(c *gin.Context) {
"update_url": "https://github.com/ctwj/urldb/releases/latest",
},
Message: "更新检查完成",
Time: time.Now(),
Time: utils.GetCurrentTime(),
}
c.JSON(http.StatusOK, response)
@@ -96,10 +97,25 @@ func CheckUpdate(c *gin.Context) {
// getLatestVersionFromGitHub 从GitHub获取最新版本
func getLatestVersionFromGitHub() (string, error) {
// 使用GitHub API获取最新Release
// 首先尝试从VERSION文件URL获取最新版本
versionURL := "https://raw.githubusercontent.com/ctwj/urldb/refs/heads/main/VERSION"
resp, err := http.Get(versionURL)
if err == nil && resp.StatusCode == http.StatusOK {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err == nil {
version := strings.TrimSpace(string(body))
if version != "" {
return version, nil
}
}
}
// 如果VERSION文件获取失败尝试GitHub API获取最新Release
url := "https://api.github.com/repos/ctwj/urldb/releases/latest"
resp, err := http.Get(url)
resp, err = http.Get(url)
if err != nil {
return "", err
}

106
main.go
View File

@@ -7,6 +7,7 @@ import (
"github.com/ctwj/urldb/utils"
"github.com/ctwj/urldb/db"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/ctwj/urldb/handlers"
"github.com/ctwj/urldb/middleware"
@@ -28,6 +29,38 @@ func main() {
utils.Info("未找到.env文件使用默认配置")
}
// 初始化时区设置
utils.InitTimezone()
// 设置Gin运行模式
ginMode := os.Getenv("GIN_MODE")
if ginMode == "" {
// 如果没有设置GIN_MODE根据环境判断
if os.Getenv("ENV") == "production" {
gin.SetMode(gin.ReleaseMode)
utils.Info("设置Gin为Release模式")
} else {
gin.SetMode(gin.DebugMode)
utils.Info("设置Gin为Debug模式")
}
} else {
// 如果已经设置了GIN_MODE根据值设置模式
switch ginMode {
case "release":
gin.SetMode(gin.ReleaseMode)
utils.Info("设置Gin为Release模式 (来自环境变量)")
case "debug":
gin.SetMode(gin.DebugMode)
utils.Info("设置Gin为Debug模式 (来自环境变量)")
case "test":
gin.SetMode(gin.TestMode)
utils.Info("设置Gin为Test模式 (来自环境变量)")
default:
gin.SetMode(gin.DebugMode)
utils.Info("未知的GIN_MODE值: %s使用Debug模式", ginMode)
}
}
// 初始化数据库
if err := db.InitDB(); err != nil {
utils.Fatal("数据库连接失败: %v", err)
@@ -44,36 +77,47 @@ func main() {
repoManager.SystemConfigRepository,
repoManager.PanRepository,
repoManager.CksRepository,
repoManager.TagRepository,
repoManager.CategoryRepository,
)
// 确保默认配置存在
// _, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
// if err != nil {
// utils.Error("初始化默认配置失败: %v", err)
// } else {
// utils.Info("默认配置初始化完成")
// }
// 检查系统配置,决定是否启动各种自动任务
systemConfig, err := repoManager.SystemConfigRepository.GetOrCreateDefault()
autoProcessReadyResources, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
if err != nil {
utils.Error("获取系统配置失败: %v", err)
utils.Error("获取自动处理待处理资源配置失败: %v", err)
} else if autoProcessReadyResources {
scheduler.StartReadyResourceScheduler()
utils.Info("已启动待处理资源自动处理任务")
} else {
// 检查是否启动待处理资源自动处理任务
if systemConfig.AutoProcessReadyResources {
scheduler.StartReadyResourceScheduler()
utils.Info("已启动待处理资源自动处理任务")
} else {
utils.Info("系统配置中自动处理待处理资源功能已禁用,跳过启动定时任务")
}
utils.Info("系统配置中自动处理待处理资源功能已禁用,跳过启动定时任务")
}
// 检查是否启动热播剧自动拉取任务
if systemConfig.AutoFetchHotDramaEnabled {
scheduler.StartHotDramaScheduler()
utils.Info("已启动热播剧自动拉取任务")
} else {
utils.Info("系统配置中自动拉取热播剧功能已禁用,跳过启动定时任务")
}
autoFetchHotDramaEnabled, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
if err != nil {
utils.Error("获取自动拉取热播剧配置失败: %v", err)
} else if autoFetchHotDramaEnabled {
scheduler.StartHotDramaScheduler()
utils.Info("已启动热播剧自动拉取任务")
} else {
utils.Info("系统配置中自动拉取热播剧功能已禁用,跳过启动定时任务")
}
// 检查是否启动自动转存任务
if systemConfig.AutoTransferEnabled {
scheduler.StartAutoTransferScheduler()
utils.Info("已启动自动转存任务")
} else {
utils.Info("系统配置中自动转存功能已禁用,跳过启动定时任务")
}
autoTransferEnabled, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
if err != nil {
utils.Error("获取自动转存配置失败: %v", err)
} else if autoTransferEnabled {
scheduler.StartAutoTransferScheduler()
utils.Info("已启动自动转存任务")
} else {
utils.Info("系统配置中自动转存功能已禁用,跳过启动定时任务")
}
// 创建Gin实例
@@ -102,8 +146,6 @@ func main() {
publicAPI := api.Group("/public")
publicAPI.Use(middleware.PublicAPIAuth())
{
// 单个添加资源
publicAPI.POST("/resources/add", publicAPIHandler.AddSingleResource)
// 批量添加资源
publicAPI.POST("/resources/batch-add", publicAPIHandler.AddBatchResources)
// 资源搜索
@@ -124,6 +166,8 @@ func main() {
api.DELETE("/resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteResource)
api.GET("/resources/:id", handlers.GetResourceByID)
api.GET("/resources/check-exists", handlers.CheckResourceExists)
api.POST("/resources/:id/view", handlers.IncrementResourceViewCount)
api.DELETE("/resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchDeleteResources)
// 分类管理
api.GET("/categories", handlers.GetCategories)
@@ -164,11 +208,15 @@ func main() {
// 待处理资源管理
api.GET("/ready-resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetReadyResources)
api.POST("/ready-resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateReadyResource)
api.POST("/ready-resources/batch", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.BatchCreateReadyResources)
api.POST("/ready-resources/text", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateReadyResourcesFromText)
api.DELETE("/ready-resources/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteReadyResource)
api.DELETE("/ready-resources", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearReadyResources)
api.GET("/ready-resources/key/:key", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetReadyResourcesByKey)
api.DELETE("/ready-resources/key/:key", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteReadyResourcesByKey)
api.GET("/ready-resources/errors", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetReadyResourcesWithErrors)
api.POST("/ready-resources/:id/clear-error", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearErrorMsg)
api.POST("/ready-resources/retry-failed", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.RetryFailedResources)
// 用户管理(仅管理员)
api.GET("/users", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetUsers)
@@ -183,11 +231,15 @@ func main() {
api.GET("/search-stats/daily", handlers.GetDailyStats)
api.GET("/search-stats/trend", handlers.GetSearchTrend)
api.GET("/search-stats/keyword/:keyword/trend", handlers.GetKeywordTrend)
api.POST("/search-stats", handlers.RecordSearch)
api.POST("/search-stats/record", handlers.RecordSearch)
api.GET("/search-stats/summary", handlers.GetSearchStatsSummary)
// 系统配置路由
api.GET("/system/config", 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/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess)
api.GET("/public/system-config", handlers.GetPublicSystemConfig)
// 热播剧管理路由(查询接口无需认证)
api.GET("/hot-dramas", handlers.GetHotDramaList)

View File

@@ -94,9 +94,9 @@ func GenerateToken(user *entity.User) (string, error) {
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(utils.GetCurrentTime().Add(30 * 24 * time.Hour)), // 30天有效期
IssuedAt: jwt.NewNumericDate(utils.GetCurrentTime()),
NotBefore: jwt.NewNumericDate(utils.GetCurrentTime()),
},
}

View File

@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"github.com/gin-gonic/gin"
)
@@ -45,7 +46,8 @@ func PublicAPIAuth() gin.HandlerFunc {
return
}
config, err := repoManager.SystemConfigRepository.FindFirst()
// 验证API Token
apiTokenConfig, err := repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyApiToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -56,7 +58,7 @@ func PublicAPIAuth() gin.HandlerFunc {
return
}
if config.ApiToken == "" {
if apiTokenConfig == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "API Token未配置",
@@ -66,7 +68,7 @@ func PublicAPIAuth() gin.HandlerFunc {
return
}
if config.ApiToken != apiToken {
if apiTokenConfig != apiToken {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "API Token无效",
@@ -77,7 +79,18 @@ func PublicAPIAuth() gin.HandlerFunc {
}
// 检查维护模式
if config.MaintenanceMode {
maintenanceMode, err := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyMaintenanceMode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "系统配置获取失败",
"code": 500,
})
c.Abort()
return
}
if maintenanceMode {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"message": "系统维护中,请稍后再试",

View File

@@ -95,11 +95,37 @@ update_version_in_files() {
echo -e " ✅ 更新 web/package.json"
fi
# 更新useVersion.ts中的默认版本
if [ -f "web/composables/useVersion.ts" ]; then
# 使用更简单的模式匹配,先获取当前版本号
current_use_version=$(grep -o "version: '[0-9]\+\.[0-9]\+\.[0-9]\+'" web/composables/useVersion.ts | head -1)
if [ -n "$current_use_version" ]; then
sed -i.bak "s/$current_use_version/version: '${new_version}'/" web/composables/useVersion.ts
rm -f web/composables/useVersion.ts.bak
echo -e " ✅ 更新 web/composables/useVersion.ts"
else
echo -e " ⚠️ 未找到useVersion.ts中的版本号"
fi
fi
# 更新Docker镜像标签
if [ -f "docker-compose.yml" ]; then
sed -i.bak "s/image:.*:.*/image: urldb:${new_version}/" docker-compose.yml
# 获取当前镜像版本
current_backend_version=$(grep -o "ctwj/urldb-backend:[0-9]\+\.[0-9]\+\.[0-9]\+" docker-compose.yml | head -1)
current_frontend_version=$(grep -o "ctwj/urldb-frontend:[0-9]\+\.[0-9]\+\.[0-9]\+" docker-compose.yml | head -1)
if [ -n "$current_backend_version" ]; then
sed -i.bak "s|$current_backend_version|ctwj/urldb-backend:${new_version}|" docker-compose.yml
echo -e " ✅ 更新 backend 镜像: ${current_backend_version} -> ctwj/urldb-backend:${new_version}"
fi
if [ -n "$current_frontend_version" ]; then
sed -i.bak "s|$current_frontend_version|ctwj/urldb-frontend:${new_version}|" docker-compose.yml
echo -e " ✅ 更新 frontend 镜像: ${current_frontend_version} -> ctwj/urldb-frontend:${new_version}"
fi
rm -f docker-compose.yml.bak
echo -e " ✅ 更新 docker-compose.yml"
echo -e " ✅ 更新 docker-compose.yml 完成"
fi
# 更新README中的版本信息

View File

@@ -1,173 +0,0 @@
// 测试新的AdminHeader样式是否与首页完全对齐
const testAdminHeaderStyle = async () => {
console.log('测试新的AdminHeader样式是否与首页完全对齐...')
// 测试前端页面AdminHeader
console.log('\n1. 测试前端页面AdminHeader:')
const adminPages = [
{ name: '管理后台', url: 'http://localhost:3000/admin' },
{ name: '用户管理', url: 'http://localhost:3000/users' },
{ name: '分类管理', url: 'http://localhost:3000/categories' },
{ name: '标签管理', url: 'http://localhost:3000/tags' },
{ name: '系统配置', url: 'http://localhost:3000/system-config' },
{ name: '资源管理', url: 'http://localhost:3000/resources' }
]
for (const page of adminPages) {
try {
const response = await fetch(page.url)
const html = await response.text()
console.log(`\n${page.name}页面:`)
console.log(`状态码: ${response.status}`)
// 检查是否包含AdminHeader组件
if (html.includes('AdminHeader')) {
console.log('✅ 包含AdminHeader组件')
} else {
console.log('❌ 未找到AdminHeader组件')
}
// 检查是否包含首页样式(深色背景)
if (html.includes('bg-slate-800') && html.includes('dark:bg-gray-800')) {
console.log('✅ 包含首页样式(深色背景)')
} else {
console.log('❌ 未找到首页样式')
}
// 检查是否包含首页标题样式
if (html.includes('text-2xl sm:text-3xl font-bold mb-4')) {
console.log('✅ 包含首页标题样式')
} else {
console.log('❌ 未找到首页标题样式')
}
// 检查是否包含n-button组件与首页一致
if (html.includes('n-button') && html.includes('size="tiny"') && html.includes('type="tertiary"')) {
console.log('✅ 包含n-button组件与首页一致')
} else {
console.log('❌ 未找到n-button组件')
}
// 检查是否包含右上角绝对定位的按钮
if (html.includes('absolute right-4 top-4')) {
console.log('✅ 包含右上角绝对定位的按钮')
} else {
console.log('❌ 未找到右上角绝对定位的按钮')
}
// 检查是否包含首页、添加、退出按钮
if (html.includes('fa-home') && html.includes('fa-plus') && html.includes('fa-sign-out-alt')) {
console.log('✅ 包含首页、添加、退出按钮')
} else {
console.log('❌ 未找到完整的按钮组')
}
// 检查是否包含用户信息
if (html.includes('欢迎') && html.includes('管理员')) {
console.log('✅ 包含用户信息')
} else {
console.log('❌ 未找到用户信息')
}
// 检查是否包含移动端适配
if (html.includes('sm:hidden') && html.includes('hidden sm:flex')) {
console.log('✅ 包含移动端适配')
} else {
console.log('❌ 未找到移动端适配')
}
// 检查是否不包含导航链接(除了首页和添加资源)
if (!html.includes('用户管理') && !html.includes('分类管理') && !html.includes('标签管理')) {
console.log('✅ 不包含导航链接(符合预期)')
} else {
console.log('❌ 包含导航链接(不符合预期)')
}
} catch (error) {
console.error(`${page.name}页面测试失败:`, error.message)
}
}
// 测试首页样式对比
console.log('\n2. 测试首页样式对比:')
try {
const response = await fetch('http://localhost:3000/')
const html = await response.text()
console.log('首页页面:')
console.log(`状态码: ${response.status}`)
// 检查首页是否包含相同的样式
if (html.includes('bg-slate-800') && html.includes('dark:bg-gray-800')) {
console.log('✅ 首页包含相同的深色背景样式')
} else {
console.log('❌ 首页不包含相同的深色背景样式')
}
// 检查首页是否包含相同的布局结构
if (html.includes('text-2xl sm:text-3xl font-bold mb-4')) {
console.log('✅ 首页包含相同的标题样式')
} else {
console.log('❌ 首页不包含相同的标题样式')
}
// 检查首页是否包含相同的n-button样式
if (html.includes('n-button') && html.includes('size="tiny"') && html.includes('type="tertiary"')) {
console.log('✅ 首页包含相同的n-button样式')
} else {
console.log('❌ 首页不包含相同的n-button样式')
}
// 检查首页是否包含相同的绝对定位
if (html.includes('absolute right-4 top-0')) {
console.log('✅ 首页包含相同的绝对定位')
} else {
console.log('❌ 首页不包含相同的绝对定位')
}
} catch (error) {
console.error('❌ 首页测试失败:', error.message)
}
// 测试系统配置API
console.log('\n3. 测试系统配置API:')
try {
const response = await fetch('http://localhost:8080/api/system-config')
const data = await response.json()
console.log('系统配置API响应:')
console.log(`状态: ${data.success ? '✅ 成功' : '❌ 失败'}`)
if (data.success) {
console.log(`网站标题: ${data.data?.site_title || 'N/A'}`)
console.log(`版权信息: ${data.data?.copyright || 'N/A'}`)
}
if (data.success) {
console.log('✅ 系统配置API测试通过')
} else {
console.log('❌ 系统配置API测试失败')
}
} catch (error) {
console.error('❌ 系统配置API测试失败:', error.message)
}
console.log('\n✅ AdminHeader样式测试完成')
console.log('\n总结:')
console.log('- ✅ AdminHeader样式与首页完全一致')
console.log('- ✅ 使用相同的深色背景和圆角设计')
console.log('- ✅ 使用相同的n-button组件样式')
console.log('- ✅ 按钮位于右上角绝对定位')
console.log('- ✅ 包含首页、添加、退出按钮')
console.log('- ✅ 包含用户信息和角色显示')
console.log('- ✅ 响应式设计,适配移动端')
console.log('- ✅ 移除了导航链接,只保留必要操作')
console.log('- ✅ 系统配置集成正常')
}
// 运行测试
testAdminHeaderStyle()

View File

@@ -1,188 +0,0 @@
// 测试AdminHeader组件和版本显示功能
const testAdminHeader = async () => {
console.log('测试AdminHeader组件和版本显示功能...')
const { exec } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)
// 测试后端版本接口
console.log('\n1. 测试后端版本接口:')
try {
const { stdout: versionOutput } = await execAsync('curl -s http://localhost:8080/api/version')
const versionData = JSON.parse(versionOutput)
console.log('版本接口响应:')
console.log(`状态: ${versionData.success ? '✅ 成功' : '❌ 失败'}`)
console.log(`版本号: ${versionData.data.version}`)
console.log(`Git提交: ${versionData.data.git_commit}`)
console.log(`构建时间: ${versionData.data.build_time}`)
if (versionData.success) {
console.log('✅ 后端版本接口测试通过')
} else {
console.log('❌ 后端版本接口测试失败')
}
} catch (error) {
console.error('❌ 后端版本接口测试失败:', error.message)
}
// 测试版本字符串接口
console.log('\n2. 测试版本字符串接口:')
try {
const { stdout: versionStringOutput } = await execAsync('curl -s http://localhost:8080/api/version/string')
const versionStringData = JSON.parse(versionStringOutput)
console.log('版本字符串接口响应:')
console.log(`状态: ${versionStringData.success ? '✅ 成功' : '❌ 失败'}`)
console.log(`版本字符串: ${versionStringData.data.version}`)
if (versionStringData.success) {
console.log('✅ 版本字符串接口测试通过')
} else {
console.log('❌ 版本字符串接口测试失败')
}
} catch (error) {
console.error('❌ 版本字符串接口测试失败:', error.message)
}
// 测试完整版本信息接口
console.log('\n3. 测试完整版本信息接口:')
try {
const { stdout: fullVersionOutput } = await execAsync('curl -s http://localhost:8080/api/version/full')
const fullVersionData = JSON.parse(fullVersionOutput)
console.log('完整版本信息接口响应:')
console.log(`状态: ${fullVersionData.success ? '✅ 成功' : '❌ 失败'}`)
if (fullVersionData.success) {
console.log(`版本信息:`, JSON.stringify(fullVersionData.data.version_info, null, 2))
}
if (fullVersionData.success) {
console.log('✅ 完整版本信息接口测试通过')
} else {
console.log('❌ 完整版本信息接口测试失败')
}
} catch (error) {
console.error('❌ 完整版本信息接口测试失败:', error.message)
}
// 测试版本更新检查接口
console.log('\n4. 测试版本更新检查接口:')
try {
const { stdout: updateCheckOutput } = await execAsync('curl -s http://localhost:8080/api/version/check-update')
const updateCheckData = JSON.parse(updateCheckOutput)
console.log('版本更新检查接口响应:')
console.log(`状态: ${updateCheckData.success ? '✅ 成功' : '❌ 失败'}`)
if (updateCheckData.success) {
console.log(`当前版本: ${updateCheckData.data.current_version}`)
console.log(`最新版本: ${updateCheckData.data.latest_version}`)
console.log(`有更新: ${updateCheckData.data.has_update}`)
console.log(`下载链接: ${updateCheckData.data.download_url || 'N/A'}`)
}
if (updateCheckData.success) {
console.log('✅ 版本更新检查接口测试通过')
} else {
console.log('❌ 版本更新检查接口测试失败')
}
} catch (error) {
console.error('❌ 版本更新检查接口测试失败:', error.message)
}
// 测试前端页面
console.log('\n5. 测试前端页面:')
const testPages = [
{ name: '管理后台', url: 'http://localhost:3000/admin' },
{ name: '用户管理', url: 'http://localhost:3000/users' },
{ name: '分类管理', url: 'http://localhost:3000/categories' },
{ name: '标签管理', url: 'http://localhost:3000/tags' },
{ name: '系统配置', url: 'http://localhost:3000/system-config' },
{ name: '资源管理', url: 'http://localhost:3000/resources' }
]
for (const page of testPages) {
try {
const response = await fetch(page.url)
const html = await response.text()
console.log(`\n${page.name}页面:`)
console.log(`状态码: ${response.status}`)
// 检查是否包含AdminHeader组件
if (html.includes('AdminHeader') || html.includes('版本管理')) {
console.log('✅ 包含AdminHeader组件')
} else {
console.log('❌ 未找到AdminHeader组件')
}
// 检查是否包含版本信息
if (html.includes('版本') || html.includes('version')) {
console.log('✅ 包含版本信息')
} else {
console.log('❌ 未找到版本信息')
}
} catch (error) {
console.error(`${page.name}页面测试失败:`, error.message)
}
}
// 测试版本管理脚本
console.log('\n6. 测试版本管理脚本:')
try {
const { stdout: scriptHelp } = await execAsync('./scripts/version.sh help')
console.log('版本管理脚本帮助信息:')
console.log(scriptHelp)
const { stdout: scriptShow } = await execAsync('./scripts/version.sh show')
console.log('当前版本信息:')
console.log(scriptShow)
console.log('✅ 版本管理脚本测试通过')
} catch (error) {
console.error('❌ 版本管理脚本测试失败:', error.message)
}
// 测试Git标签
console.log('\n7. 测试Git标签:')
try {
const { stdout: tagOutput } = await execAsync('git tag -l')
console.log('当前Git标签:')
console.log(tagOutput || '暂无标签')
const { stdout: logOutput } = await execAsync('git log --oneline -3')
console.log('最近3次提交:')
console.log(logOutput)
console.log('✅ Git标签测试通过')
} catch (error) {
console.error('❌ Git标签测试失败:', error.message)
}
console.log('\n✅ AdminHeader组件和版本显示功能测试完成')
console.log('\n总结:')
console.log('- ✅ 后端版本接口正常工作')
console.log('- ✅ 前端AdminHeader组件已集成')
console.log('- ✅ 版本信息在管理页面右下角显示')
console.log('- ✅ 首页已移除版本显示')
console.log('- ✅ 版本管理脚本功能完整')
console.log('- ✅ Git标签管理正常')
}
// 运行测试
testAdminHeader()

View File

@@ -1,155 +0,0 @@
// 测试admin layout功能
const testAdminLayout = async () => {
console.log('测试admin layout功能...')
// 测试前端页面admin layout
console.log('\n1. 测试前端页面admin layout:')
const adminPages = [
{ name: '管理后台', url: 'http://localhost:3000/admin' },
{ name: '用户管理', url: 'http://localhost:3000/users' },
{ name: '分类管理', url: 'http://localhost:3000/categories' },
{ name: '标签管理', url: 'http://localhost:3000/tags' },
{ name: '系统配置', url: 'http://localhost:3000/system-config' },
{ name: '资源管理', url: 'http://localhost:3000/resources' }
]
for (const page of adminPages) {
try {
const response = await fetch(page.url)
const html = await response.text()
console.log(`\n${page.name}页面:`)
console.log(`状态码: ${response.status}`)
// 检查是否包含AdminHeader组件
if (html.includes('AdminHeader')) {
console.log('✅ 包含AdminHeader组件')
} else {
console.log('❌ 未找到AdminHeader组件')
}
// 检查是否包含AppFooter组件
if (html.includes('AppFooter')) {
console.log('✅ 包含AppFooter组件')
} else {
console.log('❌ 未找到AppFooter组件')
}
// 检查是否包含admin layout的样式
if (html.includes('bg-gray-50 dark:bg-gray-900')) {
console.log('✅ 包含admin layout样式')
} else {
console.log('❌ 未找到admin layout样式')
}
// 检查是否包含页面加载状态
if (html.includes('正在加载') || html.includes('初始化管理后台')) {
console.log('✅ 包含页面加载状态')
} else {
console.log('❌ 未找到页面加载状态')
}
// 检查是否包含max-w-7xl mx-auto容器
if (html.includes('max-w-7xl mx-auto')) {
console.log('✅ 包含标准容器布局')
} else {
console.log('❌ 未找到标准容器布局')
}
// 检查是否不包含重复的布局代码
const adminHeaderCount = (html.match(/AdminHeader/g) || []).length
if (adminHeaderCount === 1) {
console.log('✅ AdminHeader组件只出现一次无重复')
} else {
console.log(`❌ AdminHeader组件出现${adminHeaderCount}次(可能有重复)`)
}
} catch (error) {
console.error(`${page.name}页面测试失败:`, error.message)
}
}
// 测试admin layout文件是否存在
console.log('\n2. 测试admin layout文件:')
try {
const response = await fetch('http://localhost:3000/layouts/admin.vue')
console.log('admin layout文件状态:', response.status)
if (response.status === 200) {
console.log('✅ admin layout文件存在')
} else {
console.log('❌ admin layout文件不存在或无法访问')
}
} catch (error) {
console.error('❌ admin layout文件测试失败:', error.message)
}
// 测试definePageMeta是否正确设置
console.log('\n3. 测试definePageMeta设置:')
const pagesWithLayout = [
{ name: '管理后台', file: 'web/pages/admin.vue' },
{ name: '用户管理', file: 'web/pages/users.vue' },
{ name: '分类管理', file: 'web/pages/categories.vue' }
]
for (const page of pagesWithLayout) {
try {
const fs = require('fs')
const content = fs.readFileSync(page.file, 'utf8')
if (content.includes("definePageMeta({") && content.includes("layout: 'admin'")) {
console.log(`${page.name}页面正确设置了admin layout`)
} else {
console.log(`${page.name}页面未正确设置admin layout`)
}
} catch (error) {
console.error(`${page.name}页面文件读取失败:`, error.message)
}
}
// 测试首页不使用admin layout
console.log('\n4. 测试首页不使用admin layout:')
try {
const response = await fetch('http://localhost:3000/')
const html = await response.text()
console.log('首页页面:')
console.log(`状态码: ${response.status}`)
// 检查首页是否不包含AdminHeader
if (!html.includes('AdminHeader')) {
console.log('✅ 首页不包含AdminHeader符合预期')
} else {
console.log('❌ 首页包含AdminHeader不符合预期')
}
// 检查首页是否使用默认layout
if (html.includes('bg-gray-50 dark:bg-gray-900') && html.includes('AppFooter')) {
console.log('✅ 首页使用默认layout')
} else {
console.log('❌ 首页可能使用了错误的layout')
}
} catch (error) {
console.error('❌ 首页测试失败:', error.message)
}
console.log('\n✅ admin layout测试完成')
console.log('\n总结:')
console.log('- ✅ 创建了admin layout文件')
console.log('- ✅ 管理页面使用admin layout')
console.log('- ✅ 移除了重复的布局代码')
console.log('- ✅ 统一了管理页面的样式和结构')
console.log('- ✅ 首页继续使用默认layout')
console.log('- ✅ 页面加载状态和错误处理统一')
console.log('- ✅ 响应式设计和容器布局统一')
}
// 运行测试
testAdminLayout()

View File

@@ -1,140 +0,0 @@
// 测试Footer中的版本信息显示
const testFooterVersion = async () => {
console.log('测试Footer中的版本信息显示...')
const { exec } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)
// 测试后端版本接口
console.log('\n1. 测试后端版本接口:')
try {
const { stdout: versionOutput } = await execAsync('curl -s http://localhost:8080/api/version')
const versionData = JSON.parse(versionOutput)
console.log('版本接口响应:')
console.log(`状态: ${versionData.success ? '✅ 成功' : '❌ 失败'}`)
console.log(`版本号: ${versionData.data.version}`)
console.log(`Git提交: ${versionData.data.git_commit}`)
console.log(`构建时间: ${versionData.data.build_time}`)
if (versionData.success) {
console.log('✅ 后端版本接口测试通过')
} else {
console.log('❌ 后端版本接口测试失败')
}
} catch (error) {
console.error('❌ 后端版本接口测试失败:', error.message)
}
// 测试前端页面Footer
console.log('\n2. 测试前端页面Footer:')
const testPages = [
{ name: '首页', url: 'http://localhost:3000/' },
{ name: '热播剧', url: 'http://localhost:3000/hot-dramas' },
{ name: '系统监控', url: 'http://localhost:3000/monitor' },
{ name: 'API文档', url: 'http://localhost:3000/api-docs' }
]
for (const page of testPages) {
try {
const response = await fetch(page.url)
const html = await response.text()
console.log(`\n${page.name}页面:`)
console.log(`状态码: ${response.status}`)
// 检查是否包含AppFooter组件
if (html.includes('AppFooter')) {
console.log('✅ 包含AppFooter组件')
} else {
console.log('❌ 未找到AppFooter组件')
}
// 检查是否包含版本信息
if (html.includes('v1.0.0') || html.includes('version')) {
console.log('✅ 包含版本信息')
} else {
console.log('❌ 未找到版本信息')
}
// 检查是否包含版权信息
if (html.includes('© 2025') || html.includes('网盘资源数据库')) {
console.log('✅ 包含版权信息')
} else {
console.log('❌ 未找到版权信息')
}
} catch (error) {
console.error(`${page.name}页面测试失败:`, error.message)
}
}
// 测试管理页面(应该没有版本信息)
console.log('\n3. 测试管理页面(应该没有版本信息):')
const adminPages = [
{ name: '管理后台', url: 'http://localhost:3000/admin' },
{ name: '用户管理', url: 'http://localhost:3000/users' },
{ name: '分类管理', url: 'http://localhost:3000/categories' },
{ name: '标签管理', url: 'http://localhost:3000/tags' },
{ name: '系统配置', url: 'http://localhost:3000/system-config' },
{ name: '资源管理', url: 'http://localhost:3000/resources' }
]
for (const page of adminPages) {
try {
const response = await fetch(page.url)
const html = await response.text()
console.log(`\n${page.name}页面:`)
console.log(`状态码: ${response.status}`)
// 检查是否包含AdminHeader组件
if (html.includes('AdminHeader')) {
console.log('✅ 包含AdminHeader组件')
} else {
console.log('❌ 未找到AdminHeader组件')
}
// 检查是否不包含版本信息(管理页面应该没有版本显示)
if (!html.includes('v1.0.0') && !html.includes('version')) {
console.log('✅ 不包含版本信息(符合预期)')
} else {
console.log('❌ 包含版本信息(不符合预期)')
}
} catch (error) {
console.error(`${page.name}页面测试失败:`, error.message)
}
}
// 测试版本管理脚本
console.log('\n4. 测试版本管理脚本:')
try {
const { stdout: scriptShow } = await execAsync('./scripts/version.sh show')
console.log('当前版本信息:')
console.log(scriptShow)
console.log('✅ 版本管理脚本测试通过')
} catch (error) {
console.error('❌ 版本管理脚本测试失败:', error.message)
}
console.log('\n✅ Footer版本信息显示测试完成')
console.log('\n总结:')
console.log('- ✅ 后端版本接口正常工作')
console.log('- ✅ 前端AppFooter组件已集成')
console.log('- ✅ 版本信息在Footer中显示')
console.log('- ✅ 管理页面已移除版本显示')
console.log('- ✅ 版本信息显示格式:版权信息 | v版本号')
console.log('- ✅ 版本管理脚本功能完整')
}
// 运行测试
testFooterVersion()

View File

@@ -1,123 +0,0 @@
// 测试GitHub版本系统
const testGitHubVersion = async () => {
console.log('测试GitHub版本系统...')
const { exec } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)
// 测试版本管理脚本
console.log('\n1. 测试版本管理脚本:')
try {
// 显示版本信息
const { stdout: showOutput } = await execAsync('./scripts/version.sh show')
console.log('版本信息:')
console.log(showOutput)
// 显示帮助信息
const { stdout: helpOutput } = await execAsync('./scripts/version.sh help')
console.log('帮助信息:')
console.log(helpOutput)
console.log('✅ 版本管理脚本测试通过')
} catch (error) {
console.error('❌ 版本管理脚本测试失败:', error.message)
}
// 测试版本API接口
console.log('\n2. 测试版本API接口:')
const baseUrl = 'http://localhost:8080'
const testEndpoints = [
'/api/version',
'/api/version/string',
'/api/version/full',
'/api/version/check-update'
]
for (const endpoint of testEndpoints) {
try {
const response = await fetch(`${baseUrl}${endpoint}`)
const data = await response.json()
console.log(`\n接口: ${endpoint}`)
console.log(`状态码: ${response.status}`)
console.log(`响应:`, JSON.stringify(data, null, 2))
if (data.success) {
console.log('✅ 接口测试通过')
} else {
console.log('❌ 接口测试失败')
}
} catch (error) {
console.error(`❌ 接口 ${endpoint} 测试失败:`, error.message)
}
}
// 测试GitHub版本检查
console.log('\n3. 测试GitHub版本检查:')
try {
const response = await fetch('https://api.github.com/repos/ctwj/urldb/releases/latest')
const data = await response.json()
console.log('GitHub API响应:')
console.log(`状态码: ${response.status}`)
console.log(`最新版本: ${data.tag_name || 'N/A'}`)
console.log(`发布日期: ${data.published_at || 'N/A'}`)
if (data.tag_name) {
console.log('✅ GitHub版本检查测试通过')
} else {
console.log('⚠️ GitHub上暂无Release')
}
} catch (error) {
console.error('❌ GitHub版本检查测试失败:', error.message)
}
// 测试前端版本页面
console.log('\n4. 测试前端版本页面:')
try {
const response = await fetch('http://localhost:3000/version')
const html = await response.text()
console.log(`状态码: ${response.status}`)
if (html.includes('版本信息') && html.includes('VersionInfo')) {
console.log('✅ 前端版本页面测试通过')
} else {
console.log('❌ 前端版本页面测试失败')
}
} catch (error) {
console.error('❌ 前端版本页面测试失败:', error.message)
}
// 测试Git标签
console.log('\n5. 测试Git标签:')
try {
const { stdout: tagOutput } = await execAsync('git tag -l')
console.log('当前Git标签:')
console.log(tagOutput || '暂无标签')
const { stdout: logOutput } = await execAsync('git log --oneline -5')
console.log('最近5次提交:')
console.log(logOutput)
console.log('✅ Git标签测试通过')
} catch (error) {
console.error('❌ Git标签测试失败:', error.message)
}
console.log('\n✅ GitHub版本系统测试完成')
}
// 运行测试
testGitHubVersion()

View File

@@ -1,83 +0,0 @@
// 测试版本系统
const testVersionSystem = async () => {
console.log('测试版本系统...')
const baseUrl = 'http://localhost:8080'
// 测试版本API接口
const testEndpoints = [
'/api/version',
'/api/version/string',
'/api/version/full',
'/api/version/check-update'
]
for (const endpoint of testEndpoints) {
console.log(`\n测试接口: ${endpoint}`)
try {
const response = await fetch(`${baseUrl}${endpoint}`)
const data = await response.json()
console.log(`状态码: ${response.status}`)
console.log(`响应:`, JSON.stringify(data, null, 2))
if (data.success) {
console.log('✅ 接口测试通过')
} else {
console.log('❌ 接口测试失败')
}
} catch (error) {
console.error(`❌ 请求失败:`, error.message)
}
}
// 测试版本管理脚本
console.log('\n测试版本管理脚本...')
const { exec } = require('child_process')
const { promisify } = require('util')
const execAsync = promisify(exec)
try {
// 显示版本信息
const { stdout: showOutput } = await execAsync('./scripts/version.sh show')
console.log('版本信息:')
console.log(showOutput)
// 生成版本信息文件
const { stdout: updateOutput } = await execAsync('./scripts/version.sh update')
console.log('生成版本信息文件:')
console.log(updateOutput)
console.log('✅ 版本管理脚本测试通过')
} catch (error) {
console.error('❌ 版本管理脚本测试失败:', error.message)
}
// 测试前端版本页面
console.log('\n测试前端版本页面...')
try {
const response = await fetch('http://localhost:3000/version')
const html = await response.text()
console.log(`状态码: ${response.status}`)
if (html.includes('版本信息') && html.includes('VersionInfo')) {
console.log('✅ 前端版本页面测试通过')
} else {
console.log('❌ 前端版本页面测试失败')
}
} catch (error) {
console.error('❌ 前端版本页面测试失败:', error.message)
}
console.log('\n✅ 版本系统测试完成')
}
// 运行测试
testVersionSystem()

153
utils/errors.go Normal file
View File

@@ -0,0 +1,153 @@
package utils
import "fmt"
// ErrorType 错误类型枚举
type ErrorType string
const (
// ErrorTypeUnsupportedLink 不支持的链接
ErrorTypeUnsupportedLink ErrorType = "UNSUPPORTED_LINK"
// ErrorTypeInvalidLink 无效链接
ErrorTypeInvalidLink ErrorType = "INVALID_LINK"
// ErrorTypeNoAccount 没有可用账号
ErrorTypeNoAccount ErrorType = "NO_ACCOUNT"
// ErrorTypeNoValidAccount 没有有效账号
ErrorTypeNoValidAccount ErrorType = "NO_VALID_ACCOUNT"
// ErrorTypeServiceCreation 服务创建失败
ErrorTypeServiceCreation ErrorType = "SERVICE_CREATION_FAILED"
// ErrorTypeTransferFailed 转存失败
ErrorTypeTransferFailed ErrorType = "TRANSFER_FAILED"
// ErrorTypeTagProcessing 标签处理失败
ErrorTypeTagProcessing ErrorType = "TAG_PROCESSING_FAILED"
// ErrorTypeCategoryProcessing 分类处理失败
ErrorTypeCategoryProcessing ErrorType = "CATEGORY_PROCESSING_FAILED"
// ErrorTypeResourceSave 资源保存失败
ErrorTypeResourceSave ErrorType = "RESOURCE_SAVE_FAILED"
// ErrorTypePlatformNotFound 平台未找到
ErrorTypePlatformNotFound ErrorType = "PLATFORM_NOT_FOUND"
// ErrorTypeLinkCheckFailed 链接检查失败
ErrorTypeLinkCheckFailed ErrorType = "LINK_CHECK_FAILED"
)
// ResourceError 资源处理错误
type ResourceError struct {
Type ErrorType `json:"type"`
Message string `json:"message"`
URL string `json:"url,omitempty"`
Details string `json:"details,omitempty"`
}
// Error 实现error接口
func (e *ResourceError) Error() string {
if e.Details != "" {
return fmt.Sprintf("[%s] %s: %s", e.Type, e.Message, e.Details)
}
return fmt.Sprintf("[%s] %s", e.Type, e.Message)
}
// NewResourceError 创建新的资源错误
func NewResourceError(errorType ErrorType, message string, url string, details string) *ResourceError {
return &ResourceError{
Type: errorType,
Message: message,
URL: url,
Details: details,
}
}
// NewUnsupportedLinkError 创建不支持的链接错误
func NewUnsupportedLinkError(url string) *ResourceError {
return NewResourceError(ErrorTypeUnsupportedLink, "不支持的链接地址", url, "")
}
// NewInvalidLinkError 创建无效链接错误
func NewInvalidLinkError(url string, details string) *ResourceError {
return NewResourceError(ErrorTypeInvalidLink, "链接无效", url, details)
}
// NewNoAccountError 创建没有账号错误
func NewNoAccountError(platform string) *ResourceError {
return NewResourceError(ErrorTypeNoAccount, "没有可用的网盘账号", "", fmt.Sprintf("平台: %s", platform))
}
// NewNoValidAccountError 创建没有有效账号错误
func NewNoValidAccountError(platform string) *ResourceError {
return NewResourceError(ErrorTypeNoValidAccount, "没有有效的网盘账号", "", fmt.Sprintf("平台: %s", platform))
}
// NewServiceCreationError 创建服务创建失败错误
func NewServiceCreationError(url string, details string) *ResourceError {
return NewResourceError(ErrorTypeServiceCreation, "创建网盘服务失败", url, details)
}
// NewTransferFailedError 创建转存失败错误
func NewTransferFailedError(url string, details string) *ResourceError {
return NewResourceError(ErrorTypeTransferFailed, "网盘信息获取失败", url, details)
}
// NewTagProcessingError 创建标签处理失败错误
func NewTagProcessingError(details string) *ResourceError {
return NewResourceError(ErrorTypeTagProcessing, "处理标签失败", "", details)
}
// NewCategoryProcessingError 创建分类处理失败错误
func NewCategoryProcessingError(details string) *ResourceError {
return NewResourceError(ErrorTypeCategoryProcessing, "处理分类失败", "", details)
}
// NewResourceSaveError 创建资源保存失败错误
func NewResourceSaveError(url string, details string) *ResourceError {
return NewResourceError(ErrorTypeResourceSave, "资源保存失败", url, details)
}
// NewPlatformNotFoundError 创建平台未找到错误
func NewPlatformNotFoundError(platform string) *ResourceError {
return NewResourceError(ErrorTypePlatformNotFound, "未找到对应的平台ID", "", fmt.Sprintf("平台: %s", platform))
}
// NewLinkCheckError 创建链接检查失败错误
func NewLinkCheckError(url string, details string) *ResourceError {
return NewResourceError(ErrorTypeLinkCheckFailed, "链接检查失败", url, details)
}
// IsResourceError 检查是否为资源错误
func IsResourceError(err error) bool {
_, ok := err.(*ResourceError)
return ok
}
// GetResourceError 获取资源错误
func GetResourceError(err error) *ResourceError {
if resourceErr, ok := err.(*ResourceError); ok {
return resourceErr
}
return nil
}
// GetErrorType 获取错误类型
func GetErrorType(err error) ErrorType {
if resourceErr := GetResourceError(err); resourceErr != nil {
return resourceErr.Type
}
return ""
}
// IsRetryableError 检查是否为可重试的错误
func IsRetryableError(err error) bool {
errorType := GetErrorType(err)
switch errorType {
case ErrorTypeNoAccount, ErrorTypeNoValidAccount, ErrorTypeTransferFailed, ErrorTypeLinkCheckFailed:
return true
default:
return false
}
}
// GetErrorSummary 获取错误摘要
func GetErrorSummary(err error) string {
if resourceErr := GetResourceError(err); resourceErr != nil {
return fmt.Sprintf("%s: %s", resourceErr.Type, resourceErr.Message)
}
return err.Error()
}

View File

@@ -18,10 +18,10 @@ var (
)
// GetGlobalScheduler 获取全局调度器实例(单例模式)
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository) *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() {
globalScheduler = &GlobalScheduler{
scheduler: NewScheduler(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo),
scheduler: NewScheduler(hotDramaRepo, readyResourceRepo, resourceRepo, systemConfigRepo, panRepo, cksRepo, tagRepo, categoryRepo),
}
})
return globalScheduler

View File

@@ -269,7 +269,7 @@ func (lv *LogViewer) CleanOldLogs(days int) error {
return err
}
cutoffTime := time.Now().AddDate(0, 0, -days)
cutoffTime := GetCurrentTime().AddDate(0, 0, -days)
deletedCount := 0
for _, file := range files {

View File

@@ -153,7 +153,7 @@ func (l *Logger) initLogFile() error {
}
// 创建新的日志文件
logFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", time.Now().Format("2006-01-02")))
logFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", GetCurrentTime().Format("2006-01-02")))
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return fmt.Errorf("创建日志文件失败: %v", err)
@@ -291,8 +291,8 @@ func (l *Logger) rotateLog() {
}
// 重命名当前日志文件
currentLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", time.Now().Format("2006-01-02")))
backupLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s_%s.log", time.Now().Format("2006-01-02"), time.Now().Format("15-04-05")))
currentLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s.log", GetCurrentTime().Format("2006-01-02")))
backupLogFile := filepath.Join(l.config.LogDir, fmt.Sprintf("app_%s_%s.log", GetCurrentTime().Format("2006-01-02"), GetCurrentTime().Format("15-04-05")))
if _, err := os.Stat(currentLogFile); err == nil {
os.Rename(currentLogFile, backupLogFile)
@@ -314,7 +314,7 @@ func (l *Logger) cleanOldLogs() {
return
}
cutoffTime := time.Now().AddDate(0, 0, -l.config.MaxAge)
cutoffTime := GetCurrentTime().AddDate(0, 0, -l.config.MaxAge)
for _, file := range files {
fileInfo, err := os.Stat(file)

View File

@@ -2,6 +2,7 @@ package utils
import (
"fmt"
"math/rand"
"strings"
"sync"
"time"
@@ -10,17 +11,21 @@ import (
commonutils "github.com/ctwj/urldb/common/utils"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/db/repo"
"gorm.io/gorm"
)
// Scheduler 定时任务管理器
type Scheduler struct {
doubanService *DoubanService
hotDramaRepo repo.HotDramaRepository
readyResourceRepo repo.ReadyResourceRepository
resourceRepo repo.ResourceRepository
systemConfigRepo repo.SystemConfigRepository
panRepo repo.PanRepository
cksRepo repo.CksRepository
doubanService *DoubanService
hotDramaRepo repo.HotDramaRepository
readyResourceRepo repo.ReadyResourceRepository
resourceRepo repo.ResourceRepository
systemConfigRepo repo.SystemConfigRepository
panRepo repo.PanRepository
cksRepo repo.CksRepository
// 新增
tagRepo repo.TagRepository
categoryRepo repo.CategoryRepository
stopChan chan bool
isRunning bool
readyResourceRunning bool
@@ -35,7 +40,7 @@ type Scheduler struct {
}
// NewScheduler 创建新的定时任务管理器
func NewScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository) *Scheduler {
func NewScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *Scheduler {
return &Scheduler{
doubanService: NewDoubanService(),
hotDramaRepo: hotDramaRepo,
@@ -44,6 +49,8 @@ func NewScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.R
systemConfigRepo: systemConfigRepo,
panRepo: panRepo,
cksRepo: cksRepo,
tagRepo: tagRepo,
categoryRepo: categoryRepo,
stopChan: make(chan bool),
isRunning: false,
readyResourceRunning: false,
@@ -283,10 +290,9 @@ func (s *Scheduler) StartReadyResourceScheduler() {
go func() {
// 获取系统配置中的间隔时间
config, err := s.systemConfigRepo.GetOrCreateDefault()
interval := 5 * time.Minute // 默认5分钟
if err == nil && config.AutoProcessInterval > 0 {
interval = time.Duration(config.AutoProcessInterval) * time.Minute
interval := 3 * time.Minute // 默认3分钟
if autoProcessInterval, err := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
interval = time.Duration(autoProcessInterval) * time.Minute
}
ticker := time.NewTicker(interval)
@@ -334,19 +340,20 @@ func (s *Scheduler) processReadyResources() {
Info("开始处理待处理资源...")
// 检查系统配置,确认是否启用自动处理
config, err := s.systemConfigRepo.GetOrCreateDefault()
autoProcess, err := s.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
if err != nil {
Error("获取系统配置失败: %v", err)
return
}
if !config.AutoProcessReadyResources {
if !autoProcess {
Info("自动处理待处理资源功能已禁用")
return
}
// 获取所有待处理资源
// 获取所有没有错误的待处理资源
readyResources, err := s.readyResourceRepo.FindAll()
// readyResources, err := s.readyResourceRepo.FindWithoutErrors()
if err != nil {
Error("获取待处理资源失败: %v", err)
return
@@ -377,10 +384,24 @@ func (s *Scheduler) processReadyResources() {
if err := s.convertReadyResourceToResource(readyResource, factory); err != nil {
Error("处理资源失败 (ID: %d): %v", readyResource.ID, err)
// 保存完整的错误信息
readyResource.ErrorMsg = err.Error()
if updateErr := s.readyResourceRepo.Update(&readyResource); updateErr != nil {
Error("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr)
} else {
Info("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error())
}
// 处理失败后删除资源,避免重复处理
s.readyResourceRepo.Delete(readyResource.ID)
} else {
// 处理成功删除readyResource
s.readyResourceRepo.Delete(readyResource.ID)
processedCount++
Info("成功处理资源: %s", readyResource.URL)
}
s.readyResourceRepo.Delete(readyResource.ID)
processedCount++
Info("成功处理资源: %s", readyResource.URL)
}
Info("待处理资源处理完成,共处理 %d 个资源", processedCount)
@@ -394,121 +415,152 @@ func (s *Scheduler) convertReadyResourceToResource(readyResource entity.ReadyRes
shareID, serviceType := panutils.ExtractShareId(readyResource.URL)
if serviceType == panutils.NotFound {
Warn("不支持的链接地址: %s", readyResource.URL)
return nil
return NewUnsupportedLinkError(readyResource.URL)
}
Debug("检测到服务类型: %s, 分享ID: %s", serviceType.String(), shareID)
resource := &entity.Resource{
Title: derefString(readyResource.Title),
Description: readyResource.Description,
URL: readyResource.URL,
Cover: readyResource.Img,
IsValid: true,
IsPublic: true,
Key: readyResource.Key,
PanID: s.getPanIDByServiceType(serviceType),
}
// 不是夸克,直接保存,
if serviceType != panutils.Quark {
// 检测是否有效
checkResult, _ := commonutils.CheckURL(readyResource.URL)
checkResult, err := commonutils.CheckURL(readyResource.URL)
if err != nil {
Error("链接检查失败: %v", err)
return NewLinkCheckError(readyResource.URL, err.Error())
}
if !checkResult.Status {
Warn("链接无效: %s", readyResource.URL)
return nil
return NewInvalidLinkError(readyResource.URL, "链接状态检查失败")
}
} else {
// 获取夸克网盘账号的 cookie
panID := s.getPanIDByServiceType(serviceType)
if panID == nil {
Error("未找到对应的平台ID")
return NewPlatformNotFoundError(serviceType.String())
}
// 入库
}
// 准备配置
config := &panutils.PanConfig{
URL: readyResource.URL,
Code: "", // 可以从readyResource中获取
IsType: 1, // 转存并分享后的资源信息 0 转存后分享, 1 只获取基本信息
ExpiredType: 1, // 永久分享
AdFid: "",
Stoken: "",
}
// 通过工厂获取对应的网盘服务单例
panService, err := factory.CreatePanService(readyResource.URL, config)
if err != nil {
Error("获取网盘服务失败: %v", err)
return err
}
// 阿里云盘特殊处理检查URL有效性
// if serviceType == panutils.Alipan {
// checkResult, _ := CheckURL(readyResource.URL)
// if !checkResult.Status {
// log.Printf("阿里云盘链接无效: %s", readyResource.URL)
// return nil
// }
// // 如果有标题,直接创建资源
// if readyResource.Title != nil && *readyResource.Title != "" {
// resource := &entity.Resource{
// Title: *readyResource.Title,
// Description: readyResource.Description,
// URL: readyResource.URL,
// PanID: s.determinePanID(readyResource.URL),
// IsValid: true,
// IsPublic: true,
// }
// // 如果有分类信息,尝试查找或创建分类
// if readyResource.Category != "" {
// categoryID, err := s.getOrCreateCategory(readyResource.Category)
// if err == nil {
// resource.CategoryID = &categoryID
// }
// }
// return s.resourceRepo.Create(resource)
// }
// }
// 统一处理:尝试转存获取标题
result, err := panService.Transfer(shareID)
if err != nil {
Error("网盘信息获取失败: %v", err)
return err
}
if !result.Success {
Error("网盘信息获取失败: %s", result.Message)
return nil
}
// 提取转存结果
if resultData, ok := result.Data.(map[string]interface{}); ok {
title := resultData["title"].(string)
shareURL := resultData["shareUrl"].(string)
// fid := resultData["fid"].(string) // 暂时未使用
// 创建资源记录
resource := &entity.Resource{
Title: title,
Description: readyResource.Description,
URL: shareURL,
PanID: s.getPanIDByServiceType(serviceType),
IsValid: true,
IsPublic: true,
accounts, err := s.cksRepo.FindByPanID(*panID)
if err != nil {
Error("获取夸克网盘账号失败: %v", err)
return NewServiceCreationError(readyResource.URL, fmt.Sprintf("获取网盘账号失败: %v", err))
}
// 如果有分类信息,尝试查找或创建分类
if readyResource.Category != "" {
categoryID, err := s.getOrCreateCategory(readyResource.Category)
if err == nil {
resource.CategoryID = &categoryID
if len(accounts) == 0 {
Error("没有可用的夸克网盘账号")
return NewNoAccountError(serviceType.String())
}
// 选择第一个有效的账号
var selectedAccount *entity.Cks
for _, account := range accounts {
if account.IsValid {
selectedAccount = &account
break
}
}
return s.resourceRepo.Create(resource)
if selectedAccount == nil {
Error("没有有效的夸克网盘账号")
return NewNoValidAccountError(serviceType.String())
}
Debug("使用夸克网盘账号: %d, Cookie: %s", selectedAccount.ID, selectedAccount.Ck[:20]+"...")
// 准备配置
config := &panutils.PanConfig{
URL: readyResource.URL,
Code: "", // 可以从readyResource中获取
IsType: 1, // 转存并分享后的资源信息 0 转存后分享, 1 只获取基本信息
ExpiredType: 1, // 永久分享
AdFid: "",
Stoken: "",
Cookie: selectedAccount.Ck, // 添加 cookie
}
// 通过工厂获取对应的网盘服务单例
panService, err := factory.CreatePanService(readyResource.URL, config)
if err != nil {
Error("获取网盘服务失败: %v", err)
return NewServiceCreationError(readyResource.URL, err.Error())
}
// 统一处理:尝试转存获取标题
result, err := panService.Transfer(shareID)
if err != nil {
Error("网盘信息获取失败: %v", err)
return NewTransferFailedError(readyResource.URL, err.Error())
}
if !result.Success {
Error("网盘信息获取失败: %s", result.Message)
return NewTransferFailedError(readyResource.URL, result.Message)
}
// 如果获取到了标题,更新资源标题
// if result.Title != "" {
// resource.Title = result.Title
// }
}
Error("转存结果格式异常")
// 处理标签
tagIDs, err := s.handleTags(readyResource.Tags)
if err != nil {
Error("处理标签失败: %v", err)
return NewTagProcessingError(err.Error())
}
// 如果没有标签tagIDs 可能为 nil这是正常的
if tagIDs == nil {
tagIDs = []uint{} // 初始化为空数组
}
// 处理分类
categoryID, err := s.resolveCategory(readyResource.Category, tagIDs)
if err != nil {
Error("处理分类失败: %v", err)
return NewCategoryProcessingError(err.Error())
}
if categoryID != nil {
resource.CategoryID = categoryID
}
// 保存资源
err = s.resourceRepo.Create(resource)
if err != nil {
Error("资源保存失败: %v", err)
return NewResourceSaveError(readyResource.URL, err.Error())
}
// 插入 resource_tags 关联
if len(tagIDs) > 0 {
for _, tagID := range tagIDs {
err := s.resourceRepo.CreateResourceTag(resource.ID, tagID)
if err != nil {
Error("插入资源标签关联失败: %v", err)
// 这里不返回错误,因为资源已经保存成功,标签关联失败不影响主要功能
}
}
} else {
Debug("没有标签,跳过插入资源标签关联")
}
return nil
}
// getOrCreateCategory 获取或创建分类
func (s *Scheduler) getOrCreateCategory(categoryName string) (uint, error) {
// 这里需要实现分类的查找和创建逻辑
// 由于没有CategoryRepository的注入这里先返回0
// 你可以根据需要添加CategoryRepository的依赖
return 0, nil
}
// // getOrCreateCategory 获取或创建分类
// func (s *Scheduler) getOrCreateCategory(categoryName string) (uint, error) {
// // 这里需要实现分类的查找和创建逻辑
// // 由于没有CategoryRepository的注入这里先返回0
// // 你可以根据需要添加CategoryRepository的依赖
// return 0, nil
// }
// initPanCache 初始化平台映射缓存
func (s *Scheduler) initPanCache() {
@@ -526,6 +578,10 @@ func (s *Scheduler) initPanCache() {
"alipan": "aliyun", // 阿里云盘在数据库中的名称是 aliyun
"baidu": "baidu",
"uc": "uc",
"xunlei": "xunlei",
"tianyi": "tianyi",
"123pan": "123pan",
"115": "115",
"unknown": "other",
}
@@ -591,10 +647,9 @@ func (s *Scheduler) StartAutoTransferScheduler() {
go func() {
// 获取系统配置中的间隔时间
config, err := s.systemConfigRepo.GetOrCreateDefault()
interval := 5 * time.Minute // 默认5分钟
if err == nil && config.AutoProcessInterval > 0 {
interval = time.Duration(config.AutoProcessInterval) * time.Minute
if autoProcessInterval, err := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
interval = time.Duration(autoProcessInterval) * time.Minute
}
ticker := time.NewTicker(interval)
@@ -647,33 +702,63 @@ func (s *Scheduler) processAutoTransfer() {
Info("开始处理自动转存...")
// 检查系统配置,确认是否启用自动转存
config, err := s.systemConfigRepo.GetOrCreateDefault()
autoTransferEnabled, err := s.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
if err != nil {
Error("获取系统配置失败: %v", err)
return
}
if !config.AutoTransferEnabled {
if !autoTransferEnabled {
Info("自动转存功能已禁用")
return
}
// 获取所有有效的网盘账号
// 获取quark平台ID
panRepoImpl, ok := s.panRepo.(interface{ GetDB() *gorm.DB })
if !ok {
Error("panRepo不支持GetDB方法")
return
}
var quarkPan entity.Pan
err = panRepoImpl.GetDB().Where("name = ?", "quark").First(&quarkPan).Error
if err != nil {
Error("未找到quark平台: %v", err)
return
}
quarkPanID := quarkPan.ID
// 获取所有账号
accounts, err := s.cksRepo.FindAll()
if err != nil {
Error("获取网盘账号失败: %v", err)
return
}
if len(accounts) == 0 {
Info("没有可用的网盘账号")
// 获取最小存储空间配置
autoTransferMinSpace, err := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
Error("获取最小存储空间配置失败: %v", err)
return
}
Info("找到 %d 个网盘账号,开始自动转存处理...", len(accounts))
// 过滤只保留已激活、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 {
Info("没有可用的quark网盘账号")
return
}
Info("找到 %d 个可用quark网盘账号开始自动转存处理...", len(validAccounts))
// 获取需要转存的资源
resources, err := s.getResourcesForTransfer(config)
resources, err := s.getResourcesForTransfer(quarkPanID)
if err != nil {
Error("获取需要转存的资源失败: %v", err)
return
@@ -686,88 +771,212 @@ func (s *Scheduler) processAutoTransfer() {
Info("找到 %d 个需要转存的资源", len(resources))
// 执行自动转存
transferCount := 0
for _, resource := range resources {
if err := s.transferResource(resource, accounts, config); err != nil {
Error("转存资源失败 (ID: %d): %v", resource.ID, err)
} else {
transferCount++
Info("成功转存资源: %s", resource.Title)
}
// 获取违禁词配置
forbiddenWords, err := s.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
if err != nil {
Error("获取违禁词配置失败: %v", err)
forbiddenWords = "" // 如果获取失败,使用空字符串
}
Info("自动转存处理完成,共转存 %d 个资源", transferCount)
// 过滤包含违禁词的资源
var filteredResources []*entity.Resource
if forbiddenWords != "" {
words := strings.Split(forbiddenWords, ",")
for _, resource := range resources {
shouldSkip := false
title := strings.ToLower(resource.Title)
description := strings.ToLower(resource.Description)
for _, word := range words {
word = strings.TrimSpace(word)
if word != "" && (strings.Contains(title, strings.ToLower(word)) || strings.Contains(description, strings.ToLower(word))) {
Info("跳过包含违禁词 '%s' 的资源: %s", word, resource.Title)
shouldSkip = true
break
}
}
if !shouldSkip {
filteredResources = append(filteredResources, resource)
}
}
Info("违禁词过滤后,剩余 %d 个资源需要转存", len(filteredResources))
} else {
filteredResources = resources
}
// 并发自动转存
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 := s.transferResource(res, []entity.Cks{acc}, factory); err != nil {
Error("转存资源失败 (ID: %d): %v", res.ID, err)
} else {
Info("成功转存资源: %s", res.Title)
rand.Seed(GetCurrentTime().UnixNano())
sleepSec := rand.Intn(3) + 1 // 1,2,3
time.Sleep(time.Duration(sleepSec) * time.Second)
}
}
}(account)
}
wg.Wait()
Info("自动转存处理完成,账号数: %d资源数: %d", len(validAccounts), len(filteredResources))
}
// getResourcesForTransfer 获取需要转存的资源
func (s *Scheduler) getResourcesForTransfer(config *entity.SystemConfig) ([]*entity.Resource, error) {
// TODO: 实现获取需要转存的资源逻辑
// 1. 获取所有有效的资源
// 2. 根据配置的转存限制天数过滤资源
// 3. 排除已经转存过的资源
// 4. 按优先级排序(可以根据浏览次数、创建时间等)
Info("获取需要转存的资源 - 限制天数: %d", config.AutoTransferLimitDays)
// 临时返回空数组,等待具体实现
return []*entity.Resource{}, nil
}
// transferResource 转存单个资源
func (s *Scheduler) transferResource(resource *entity.Resource, accounts []entity.Cks, config *entity.SystemConfig) error {
// TODO: 实现单个资源的转存逻辑
// 1. 选择合适的网盘账号根据剩余空间、VIP状态等
// 2. 检查账号剩余空间是否满足最小空间要求
// 3. 调用网盘API进行转存
// 4. 更新资源状态和转存记录
// 5. 更新账号使用空间
Info("开始转存资源: %s (ID: %d)", resource.Title, resource.ID)
// 选择最佳账号
selectedAccount := s.selectBestAccount(accounts, config)
if selectedAccount == nil {
return fmt.Errorf("没有合适的网盘账号")
func (s *Scheduler) getResourcesForTransfer(quarkPanID uint) ([]*entity.Resource, error) {
// 获取自动转存限制天数配置
autoTransferLimitDays, err := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferLimitDays)
if err != nil {
Error("获取自动转存限制天数配置失败: %v", err)
return nil, err
}
Info("选择账号: %s (剩余空间: %d GB)", selectedAccount.Username, selectedAccount.LeftSpace/1024/1024/1024)
days := autoTransferLimitDays
var sinceTime time.Time
if days > 0 {
sinceTime = GetCurrentTime().AddDate(0, 0, -days)
} else {
sinceTime = time.Time{}
}
// TODO: 执行实际的转存操作
// 这里需要调用网盘API进行转存
repoImpl, ok := s.resourceRepo.(*repo.ResourceRepositoryImpl)
if !ok {
return nil, fmt.Errorf("resourceRepo不是ResourceRepositoryImpl类型")
}
return repoImpl.GetResourcesForTransfer(quarkPanID, sinceTime)
}
var resourceUpdateMutex sync.Mutex // 全局互斥锁,保证多协程安全
// transferResource 转存单个资源
func (s *Scheduler) 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 := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
Error("获取最小存储空间配置失败: %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 {
resourceUpdateMutex.Lock()
defer resourceUpdateMutex.Unlock()
s.resourceRepo.Update(&entity.Resource{
ID: resource.ID,
ErrorMsg: err.Error(),
})
return fmt.Errorf("转存失败: %v", err)
}
if result == nil || !result.Success {
errMsg := "转存失败"
if result != nil && result.Message != "" {
errMsg = result.Message
}
resourceUpdateMutex.Lock()
defer resourceUpdateMutex.Unlock()
s.resourceRepo.Update(&entity.Resource{
ID: resource.ID,
ErrorMsg: errMsg,
})
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
}
resourceUpdateMutex.Lock()
defer resourceUpdateMutex.Unlock()
err = s.resourceRepo.Update(&entity.Resource{
ID: resource.ID,
SaveURL: saveURL,
CkID: &account.ID,
Fid: fid,
ErrorMsg: "",
})
if err != nil {
return fmt.Errorf("保存转存结果失败: %v", err)
}
return nil
}
// selectBestAccount 选择最佳网盘账号
func (s *Scheduler) selectBestAccount(accounts []entity.Cks, config *entity.SystemConfig) *entity.Cks {
// TODO: 实现账号选择逻辑
// 1. 过滤出有效的账号
// 2. 检查剩余空间是否满足最小要求
// 3. 优先选择VIP账号
// 4. 优先选择剩余空间大的账号
// 5. 考虑账号的使用频率(避免单个账号过度使用)
// selectBestAccount 选择最佳账号
func (s *Scheduler) selectBestAccount(accounts []entity.Cks) *entity.Cks {
if len(accounts) == 0 {
return nil
}
minSpaceBytes := int64(config.AutoTransferMinSpace) * 1024 * 1024 * 1024 // 转换为字节
// 获取最小存储空间配置
autoTransferMinSpace, err := s.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
if err != nil {
Error("获取最小存储空间配置失败: %v", err)
return &accounts[0] // 返回第一个账号
}
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
var bestAccount *entity.Cks
var maxScore int64 = -1
var bestScore int64 = -1
for _, account := range accounts {
if !account.IsValid {
continue
}
// 检查剩余空间
for i := range accounts {
account := &accounts[i]
if account.LeftSpace < minSpaceBytes {
continue
continue // 跳过空间不足的账号
}
// 计算账号评分
score := s.calculateAccountScore(&account)
if score > maxScore {
maxScore = score
bestAccount = &account
score := s.calculateAccountScore(account)
if score > bestScore {
bestScore = score
bestAccount = account
}
}
@@ -800,3 +1009,128 @@ func (s *Scheduler) calculateAccountScore(account *entity.Cks) int64 {
return score
}
// 分割标签,支持中英文逗号
func splitTags(tagStr string) []string {
tagStr = strings.ReplaceAll(tagStr, "", ",")
return strings.Split(tagStr, ",")
}
// 处理标签返回所有标签ID
func (s *Scheduler) handleTags(tagStr string) ([]uint, error) {
if tagStr == "" {
Debug("标签字符串为空,返回空数组")
return []uint{}, nil // 返回空数组而不是 nil
}
Debug("开始处理标签字符串: %s", tagStr)
tagNames := splitTags(tagStr)
Debug("分割后的标签名称: %v", tagNames)
var tagIDs []uint
for _, name := range tagNames {
name = strings.TrimSpace(name)
if name == "" {
Debug("跳过空标签名称")
continue
}
Debug("查找标签: %s", name)
tag, err := s.tagRepo.FindByName(name)
if err != nil {
// 检查是否存在已删除的同名标签
Debug("标签 %s 不存在,检查是否有已删除的同名标签", name)
deletedTag, err2 := s.tagRepo.FindByNameIncludingDeleted(name)
if err2 == nil && deletedTag.DeletedAt.Valid {
// 如果存在已删除的同名标签,则恢复它
Debug("找到已删除的同名标签 %s正在恢复", name)
err2 = s.tagRepo.RestoreDeletedTag(deletedTag.ID)
if err2 != nil {
Error("恢复已删除标签 %s 失败: %v", name, err2)
return nil, fmt.Errorf("恢复已删除标签 %s 失败: %v", name, err2)
}
tag = deletedTag
Debug("成功恢复标签: %s (ID: %d)", name, tag.ID)
} else {
// 如果不存在已删除的同名标签,则创建新标签
Debug("标签 %s 不存在,创建新标签", name)
tag = &entity.Tag{Name: name}
err2 = s.tagRepo.Create(tag)
if err2 != nil {
Error("创建标签 %s 失败: %v", name, err2)
return nil, fmt.Errorf("创建标签 %s 失败: %v", name, err2)
}
Debug("成功创建标签: %s (ID: %d)", name, tag.ID)
}
} else {
Debug("找到已存在的标签: %s (ID: %d)", name, tag.ID)
}
tagIDs = append(tagIDs, tag.ID)
}
Debug("处理完成标签ID列表: %v", tagIDs)
return tagIDs, nil
}
// 分类处理逻辑
func (s *Scheduler) resolveCategory(categoryName string, tagIDs []uint) (*uint, error) {
Debug("开始处理分类,分类名称: %s, 标签ID列表: %v", categoryName, tagIDs)
if categoryName != "" {
Debug("查找分类: %s", categoryName)
cat, err := s.categoryRepo.FindByName(categoryName)
if err != nil {
// 检查是否存在已删除的同名分类
Debug("分类 %s 不存在,检查是否有已删除的同名分类", categoryName)
deletedCat, err2 := s.categoryRepo.FindByNameIncludingDeleted(categoryName)
if err2 == nil && deletedCat.DeletedAt.Valid {
// 如果存在已删除的同名分类,则恢复它
Debug("找到已删除的同名分类 %s正在恢复", categoryName)
err2 = s.categoryRepo.RestoreDeletedCategory(deletedCat.ID)
if err2 != nil {
Error("恢复已删除分类 %s 失败: %v", categoryName, err2)
return nil, fmt.Errorf("恢复已删除分类 %s 失败: %v", categoryName, err2)
}
cat = deletedCat
Debug("成功恢复分类: %s (ID: %d)", categoryName, cat.ID)
} else {
Debug("分类 %s 不存在: %v", categoryName, err)
}
}
if cat != nil {
Debug("找到分类: %s (ID: %d)", categoryName, cat.ID)
return &cat.ID, nil
}
}
// 没有分类,尝试用标签反查
if len(tagIDs) == 0 {
Debug("没有标签,无法通过标签反查分类")
return nil, nil
}
Debug("尝试通过标签反查分类")
for _, tagID := range tagIDs {
Debug("查找标签ID: %d", tagID)
tag, err := s.tagRepo.GetByID(tagID)
if err != nil {
Debug("查找标签ID %d 失败: %v", tagID, err)
continue
}
if tag != nil && tag.CategoryID != nil {
Debug("通过标签 %s (ID: %d) 找到分类ID: %d", tag.Name, tagID, *tag.CategoryID)
return tag.CategoryID, nil
}
}
Debug("未找到分类返回nil")
return nil, nil
}
// 工具函数解引用string指针
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}

55
utils/timezone.go Normal file
View File

@@ -0,0 +1,55 @@
package utils
import (
"os"
"time"
)
// InitTimezone 初始化时区设置
func InitTimezone() {
// 从环境变量获取时区配置
timezone := os.Getenv("TIMEZONE")
if timezone == "" {
// 默认使用上海时间
timezone = "Asia/Shanghai"
Info("未配置时区,使用默认时区: %s", timezone)
} else {
Info("使用配置的时区: %s", timezone)
}
// 设置时区
loc, err := time.LoadLocation(timezone)
if err != nil {
Error("加载时区失败: %v使用系统默认时区", err)
return
}
// 设置全局时区
time.Local = loc
Info("时区设置成功: %s", timezone)
}
// GetCurrentTime 获取当前时间(使用配置的时区)
func GetCurrentTime() time.Time {
return time.Now()
}
// GetCurrentTimeString 获取当前时间字符串(使用配置的时区)
func GetCurrentTimeString() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// GetCurrentTimeRFC3339 获取当前时间RFC3339格式使用配置的时区
func GetCurrentTimeRFC3339() string {
return time.Now().Format(time.RFC3339)
}
// ParseTime 解析时间字符串(使用配置的时区)
func ParseTime(timeStr string) (time.Time, error) {
return time.Parse("2006-01-02 15:04:05", timeStr)
}
// FormatTime 格式化时间(使用配置的时区)
func FormatTime(t time.Time, layout string) string {
return t.Format(layout)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"runtime"
"strings"
"time"
)
@@ -22,15 +23,24 @@ type VersionInfo struct {
// 编译时注入的版本信息
var (
Version = "1.0.0"
BuildTime = time.Now().Format("2006-01-02 15:04:05")
Version = getVersionFromFile()
BuildTime = GetCurrentTimeString()
GitCommit = "unknown"
GitBranch = "unknown"
)
// getVersionFromFile 从VERSION文件读取版本号
func getVersionFromFile() string {
data, err := os.ReadFile("VERSION")
if err != nil {
return "1.0.0" // 默认版本
}
return strings.TrimSpace(string(data))
}
// GetVersionInfo 获取版本信息
func GetVersionInfo() *VersionInfo {
buildTime, _ := time.Parse("2006-01-02 15:04:05", BuildTime)
buildTime, _ := ParseTime(BuildTime)
return &VersionInfo{
Version: Version,
@@ -62,7 +72,7 @@ func GetFullVersionInfo() string {
Node版本: %s
平台: %s/%s`,
info.Version,
info.BuildTime.Format("2006-01-02 15:04:05"),
FormatTime(info.BuildTime, "2006-01-02 15:04:05"),
info.GitCommit,
info.GitBranch,
info.GoVersion,

2
web/.env Normal file
View File

@@ -0,0 +1,2 @@
NUXT_PUBLIC_API_CLIENT=http://localhost:8080/api
NUXT_PUBLIC_API_SERVER=http://localhost:8080/api

BIN
web/assets/images/3dian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

8
web/components.d.ts vendored
View File

@@ -8,9 +8,17 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NInput: typeof import('naive-ui')['NInput']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NSelect: typeof import('naive-ui')['NSelect']
NSwitch: typeof import('naive-ui')['NSwitch']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -1,10 +1,10 @@
<template>
<div class="bg-slate-800 dark:bg-gray-800 text-white dark:text-gray-100 rounded-lg shadow-lg p-4 sm:p-8 text-center relative">
<div class="header-container bg-slate-800 dark:bg-gray-800 text-white dark:text-gray-100 rounded-lg shadow-lg p-4 sm:p-8 text-center relative">
<!-- 页面标题和面包屑 -->
<div class="mb-4">
<h1 class="text-2xl sm:text-3xl font-bold mb-2">
<NuxtLink to="/" class="text-white hover:text-gray-200 dark:hover:text-gray-300 no-underline">
{{ systemConfig?.site_title || '网盘资源数据库' }}
{{ systemConfig?.site_title || '老九网盘资源数据库' }}
</NuxtLink>
</h1>
<!-- 面包屑导航 -->
@@ -59,10 +59,43 @@
<span>欢迎{{ userStore.user?.username || '管理员' }}</span>
<span class="ml-2 px-2 py-1 bg-blue-600/80 rounded text-xs text-white">{{ userStore.user?.role || 'admin' }}</span>
</div>
<!-- 自动转存状态提示 -->
<ClientOnly>
<div
class="absolute right-4 bottom-4 flex items-center gap-2 rounded-lg px-3 py-2"
>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_process_ready_resources,
'bg-green-400': systemConfig?.auto_process_ready_resources
}"></div>
<span class="text-xs text-white font-medium">
自动处理已<span>{{ systemConfig?.auto_process_ready_resources ? '开启' : '关闭' }}</span>
</span>
</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full animate-pulse" :class="{
'bg-red-400': !systemConfig?.auto_transfer_enabled,
'bg-green-400': systemConfig?.auto_transfer_enabled
}"></div>
<span class="text-xs text-white font-medium">
自动转存已<span>{{ systemConfig?.auto_transfer_enabled ? '开启' : '关闭' }}</span>
</span>
</div>
</div>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import { useApiFetch } from '~/composables/useApiFetch'
import { parseApiResponse } from '~/composables/useApi'
import { ref, onMounted } from 'vue'
import { useSystemConfigStore } from '~/stores/systemConfig'
interface Props {
title?: string
}
@@ -90,7 +123,7 @@ const pageConfig = computed(() => {
'/admin/search-stats': { title: '搜索统计', icon: 'fas fa-chart-bar', description: '搜索数据分析' },
'/admin/hot-dramas': { title: '热播剧管理', icon: 'fas fa-film', description: '管理热门剧集' },
'/monitor': { title: '系统监控', icon: 'fas fa-desktop', description: '系统性能监控' },
'/add-resource': { title: '添加资源', icon: 'fas fa-plus', description: '添加新资源' },
'/admin/add-resource': { title: '添加资源', icon: 'fas fa-plus', description: '添加新资源' },
'/api-docs': { title: 'API文档', icon: 'fas fa-book', description: '接口文档说明' },
'/admin/version': { title: '版本信息', icon: 'fas fa-code-branch', description: '系统版本详情' }
}
@@ -101,12 +134,12 @@ const currentPageTitle = computed(() => pageConfig.value.title)
const currentPageIcon = computed(() => pageConfig.value.icon)
const currentPageDescription = computed(() => pageConfig.value.description)
// 获取系统配置
const { data: systemConfigData } = await useAsyncData('systemConfig',
() => $fetch('/api/system-config')
)
const systemConfigStore = useSystemConfigStore()
const systemConfig = computed(() => systemConfigStore.config)
const systemConfig = computed(() => (systemConfigData.value as any)?.data || { site_title: '网盘资源数据库' })
onMounted(() => {
systemConfigStore.initConfig()
})
// 退出登录
const logout = async () => {
@@ -117,4 +150,12 @@ const logout = async () => {
<style scoped>
/* 确保样式与首页完全一致 */
.header-container {
background: url(/assets/images/banner.webp) center top/cover no-repeat,
linear-gradient(
to bottom,
rgba(0,0,0,0.1) 0%,
rgba(0,0,0,0.25) 100%
);
}
</style>

View File

@@ -1,23 +1,49 @@
<template>
<footer class="mt-auto py-6 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto text-center text-gray-600 dark:text-gray-400 text-sm px-3 sm:px-5">
<footer class="footer-container mt-auto py-6 border-t border-gray-700 bg-white dark:bg-gray-800">
<div class="max-w-7xl mx-auto text-center text-gray-400 text-sm px-3 sm:px-5">
<p class="mb-2">本站内容由网络爬虫自动抓取本站不储存复制传播任何文件仅作个人公益学习请在获取后24小内删除!!!</p>
<p class="flex items-center justify-center gap-2">
<span>{{ systemConfig?.copyright || '© 2025 网盘资源数据库 By 老九' }}</span>
<span v-if="versionInfo.version" class="text-gray-400 dark:text-gray-500">| v{{ versionInfo.version }}</span>
<span>{{ systemConfig?.copyright || '© 2025 老九网盘资源数据库 By 老九' }}</span>
<span v-if="versionInfo && versionInfo.version" class="text-gray-400 dark:text-gray-500">| v <n-a
href="https://github.com/ctwj/urldb"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
aria-label=" GitHub 上查看版本信息"
class="github-link"
><span>{{ versionInfo.version }}</span></n-a>
</span>
</p>
</div>
</footer>
</template>
<script setup lang="ts">
import { useApiFetch } from '~/composables/useApiFetch'
import { parseApiResponse } from '~/composables/useApi'
// 使用版本信息组合式函数
const { versionInfo } = useVersion()
const { versionInfo, fetchVersionInfo } = useVersion()
// 获取系统配置
const { data: systemConfigData } = await useAsyncData('systemConfig',
() => $fetch('/api/system-config')
() => useApiFetch('/system/config').then(parseApiResponse)
)
const systemConfig = computed(() => (systemConfigData.value as any)?.data || { copyright: '© 2025 网盘资源数据库 By 老九' })
</script>
const systemConfig = computed(() => (systemConfigData.value as any) || { copyright: '© 2025 老九网盘资源数据库 By 老九' })
// 组件挂载时获取版本信息
onMounted(() => {
fetchVersionInfo()
})
</script>
<style scoped>
.footer-container{
background: url(/assets/images/footer-banner.webp) center top/cover no-repeat,
linear-gradient(
to bottom,
rgba(0,0,0,0.1) 0%,
rgba(0,0,0,0.25) 100%
);
}
</style>

View File

@@ -1,32 +1,33 @@
<template>
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">输入格式说明</label>
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded text-sm text-gray-600 dark:text-gray-300 mb-4">
<p class="mb-2"><strong>格式要求</strong>标题和URL两行为一组标题为必填项</p>
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
电影标题1
<div class="flex justify-between mb-4">
<div class="mb-4 flex-1 w-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">输入格式说明</label>
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded text-sm text-gray-600 dark:text-gray-300 mb-4">
<p class="mb-2"><strong>格式要求</strong>标题和URL为一组标题必填, 同一标题URL支持多行</p>
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
电影1
https://pan.baidu.com/s/123456
https://pan.quark.com/s/123456
电影标题2
https://pan.baidu.com/s/789012
电视剧标题3
https://pan.quark.cn/s/345678</pre>
<p class="mt-2 text-xs text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
注意标题为必填项不能为空
</p>
<p class="mt-2 text-xs text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-1"></i>
注意标题为必填项不能为空
</p>
</div>
</div>
<div class="mb-4 flex-1 w-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">资源内容</label>
<n-input v-model="batchInput" type="textarea"
:autosize="{ minRows: 10, maxRows: 15 }"
placeholder="请输入资源内容格式标题和URL为一组..." />
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">资源内容</label>
<textarea
v-model="batchInput"
rows="15"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
placeholder="请输入资源内容格式标题和URL两行为一组..."
></textarea>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" @click="$emit('cancel')" class="btn-secondary">取消</button>
<button type="button" @click="handleSubmit" class="btn-primary" :disabled="loading">
@@ -52,37 +53,18 @@ const validateInput = () => {
if (!batchInput.value.trim()) {
throw new Error('请输入资源内容')
}
const lines = batchInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
if (lines.length === 0) {
throw new Error('请输入有效的资源内容')
}
// 检查是否为偶数行(标题+URL为一组
if (lines.length % 2 !== 0) {
throw new Error('资源格式错误标题和URL必须成对出现请检查是否缺少标题或URL')
}
// 检查每组的标题是否为空
for (let i = 0; i < lines.length; i += 2) {
const title = lines[i]
const url = lines[i + 1]
if (!title) {
throw new Error(`${i + 1}行标题不能为空`)
}
if (!url) {
throw new Error(`${i + 2}行URL不能为空`)
}
// 验证URL格式
try {
new URL(url)
} catch {
throw new Error(`${i + 2}行URL格式无效: ${url}`)
}
// 首行必须为标题
if (/^https?:\/\//i.test(lines[0])) {
// 你可以用 alert、ElMessage 或其它方式提示
alert('首行必须为标题,不能为链接!')
return
}
}
@@ -91,25 +73,42 @@ const handleSubmit = async () => {
loading.value = true
try {
validateInput()
// 解析输入内容
const lines = batchInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
const resources = []
for (let i = 0; i < lines.length; i += 2) {
const title = lines[i]
const url = lines[i + 1]
let currentTitle = ''
let currentUrls = []
for (const line of lines) {
// 判断是否为 url以 http/https 开头)
if (/^https?:\/\//i.test(line)) {
currentUrls.push(line)
} else {
// 新标题,先保存上一个
if (currentTitle && currentUrls.length) {
resources.push({
title: currentTitle,
url: currentUrls.slice()
})
}
currentTitle = line
currentUrls = []
}
}
// 处理最后一组
if (currentTitle && currentUrls.length) {
resources.push({
title: title,
url: url,
source: '批量添加'
title: currentTitle,
url: currentUrls.slice()
})
}
// 调用API添加资源
const res: any = await readyResourceApi.batchCreateReadyResources(resources)
emit('success', `成功添加 ${res.count || resources.length} 个资源,资源已进入待处理列表,处理完成后会自动入库`)
const res: any = await readyResourceApi.batchCreateReadyResources({resources})
console.log(res)
emit('success', res.message)
batchInput.value = ''
} catch (e: any) {
emit('error', e.message || '批量添加失败')
@@ -127,4 +126,4 @@ const handleSubmit = async () => {
.btn-secondary {
@apply px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors;
}
</style>
</style>

View File

@@ -1,58 +0,0 @@
<template>
<div v-if="show" class="fixed top-4 right-4 z-50">
<div class="bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm">
<div class="flex items-start">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-red-400"></i>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-red-800">错误</h3>
<div class="mt-1 text-sm text-red-700">
{{ message }}
</div>
</div>
<div class="ml-4 flex-shrink-0">
<button
@click="close"
class="inline-flex text-red-400 hover:text-red-600 focus:outline-none"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Props {
message: string
duration?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 5000
})
const emit = defineEmits<{
close: []
}>()
const show = ref(false)
const close = () => {
show.value = false
emit('close')
}
onMounted(() => {
show.value = true
if (props.duration > 0) {
setTimeout(() => {
close()
}, props.duration)
}
})
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="forbidden-page">
<div class="top-bar-guidance">
<p class="top-bar-guidance-text">请按提示在手机 浏览器 打开<img src="/assets/images/3dian.png" class="icon-safari"></p>
<p class="top-bar-guidance-text">苹果设备<img src="/assets/images/iphone.png" class="icon-safari"></p>
<p class="top-bar-guidance-text">安卓设备<img src="/assets/images/android.png" class="icon-safari"></p>
</div>
<div id="contens">
<p><br/><br/></p>
<p>1.本站不支持 微信,QQ等APP 内访问</p>
<p><br/></p>
<p>2.请按提示在手机 浏览器 打开</p>
<p id="device-tip"><br/>3.请在浏览器中打开</p>
</div>
<p><br/><br/></p>
<div class="app-download-tip">
<span class="guidance-desc" id="current-url"></span>
</div>
<p><br/></p>
<div class="app-download-tip">
<span class="guidance-desc">点击右上角···图标 or 复制网址自行打开</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
// 组件属性
interface Props {
currentUrl?: string
isIOS?: boolean
}
const props = withDefaults(defineProps<Props>(), {
currentUrl: '',
isIOS: false
})
// 在组件挂载时设置内容
onMounted(() => {
const currentUrlElement = document.getElementById('current-url')
const deviceTipElement = document.getElementById('device-tip')
if (currentUrlElement) {
currentUrlElement.textContent = props.currentUrl || window.location.href
}
if (deviceTipElement) {
const deviceText = props.isIOS ? '苹果设备请在Safari浏览器中打开' : '安卓设备请在Chrome或其他浏览器中打开'
deviceTipElement.innerHTML = `<br/>3.${deviceText}`
}
})
</script>
<style scoped>
.forbidden-page {
min-height: 100vh;
}
.top-bar-guidance {
font-size: 15px;
color: #fff;
height: 70%;
line-height: 1.2;
padding-left: 20px;
padding-top: 20px;
background: url('/assets/images/banner.png') center top/cover no-repeat;
}
.top-bar-guidance p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar-guidance .icon-safari {
width: 25px;
height: 25px;
vertical-align: middle;
margin: 0 .2em;
}
.top-bar-guidance-text {
display: flex;
justify-items: center;
word-wrap: nowrap;
}
.top-bar-guidance-text img {
display: inline-block;
width: 25px;
height: 25px;
vertical-align: middle;
margin: 0 .2em;
}
#contens {
font-weight: bold;
color: #2466f4;
text-align: center;
font-size: 20px;
margin-bottom: 125px;
}
.app-download-tip {
margin: 0 auto;
width: 290px;
text-align: center;
font-size: 15px;
color: #2466f4;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAcAQMAAACak0ePAAAABlBMVEUAAAAdYfh+GakkAAAAAXRSTlMAQObYZgAAAA5JREFUCNdjwA8acEkAAAy4AIE4hQq/AAAAAElFTkSuQmCC) left center/auto 15px repeat-x;
}
.app-download-tip .guidance-desc {
background-color: #fff;
padding: 0 5px;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="loading-container">
<div class="loading-spinner"></div>
<p class="loading-text">{{ message }}</p>
</div>
</template>
<script setup lang="ts">
// 组件属性
interface Props {
message?: string
}
const props = withDefaults(defineProps<Props>(), {
message: '正在检测访问环境...'
})
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: url('/assets/images/banner.webp') center / cover no-repeat;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
color: #ffffff;
font-size: 16px;
font-weight: 500;
text-align: center;
margin: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -96,14 +96,16 @@ import QRCode from 'qrcode'
interface Props {
visible: boolean
url: string
url?: string
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
url: ''
})
const emit = defineEmits<Emits>()
const qrCanvas = ref<HTMLCanvasElement>()
@@ -168,7 +170,9 @@ const copyUrl = async () => {
// 跳转到链接
const openLink = () => {
window.open(props.url, '_blank')
if (process.client) {
window.open(props.url, '_blank')
}
}
// 下载二维码

View File

@@ -1,273 +0,0 @@
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full mx-4" style="height:600px;">
<div class="p-6 h-full flex flex-col text-gray-900 dark:text-gray-100">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
添加资源
</h2>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Tab 切换 -->
<div class="flex mb-6 border-b flex-shrink-0">
<button
v-for="tab in tabs"
:key="tab.value"
:class="['px-4 py-2 -mb-px border-b-2', mode === tab.value ? 'border-blue-500 text-blue-600 font-bold' : 'border-transparent text-gray-500']"
@click="mode = tab.value"
>
{{ tab.label }}
</button>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto">
<!-- 批量添加 -->
<div v-if="mode === 'batch'">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">输入格式说明</label>
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded text-sm text-gray-600 dark:text-gray-300 mb-4">
<p class="mb-2"><strong>格式1</strong>标题和URL两行一组</p>
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
电影标题1
https://pan.baidu.com/s/123456
电影标题2
https://pan.baidu.com/s/789012</pre>
<p class="mt-2 mb-2"><strong>格式2</strong>只有URL系统自动判断</p>
<pre class="bg-white dark:bg-gray-800 p-2 rounded border text-xs">
https://pan.baidu.com/s/123456
https://pan.baidu.com/s/789012
https://pan.baidu.com/s/345678</pre>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">资源内容</label>
<textarea
v-model="batchInput"
rows="15"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-900 dark:text-gray-100"
placeholder="请输入资源内容,支持两种格式..."
></textarea>
</div>
</div>
<!-- 单个添加 -->
<div v-else-if="mode === 'single'" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">标题</label>
<input v-model="form.title" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入标题" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">描述</label>
<textarea v-model="form.description" rows="3" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入资源描述"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">类型</label>
<select v-model="form.file_type" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700">
<option value="">选择类型</option>
<option value="pan">网盘</option>
<option value="link">直链</option>
<option value="other">其他</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">标签</label>
<div class="flex flex-wrap gap-2 mb-2">
<span v-for="tag in form.tags" :key="tag" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs flex items-center">
{{ tag }}
<button type="button" class="ml-1 text-xs" @click="removeTag(tag)">×</button>
</span>
</div>
<input v-model="newTag" @keyup.enter.prevent="addTag" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="输入标签后回车添加" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">链接可多行每行一个链接</label>
<textarea v-model="form.url" rows="3" class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700" placeholder="https://a.com&#10;https://b.com"></textarea>
</div>
</div>
<!-- API说明 -->
<div v-else class="space-y-4">
<div class="text-gray-700 dark:text-gray-300 text-sm">
<p>你可以通过API批量添加资源</p>
<pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs overflow-x-auto mt-2">
POST /api/resources/batch
Content-Type: application/json
Body:
[
{ "title": "资源A", "url": "https://a.com", "file_type": "pan", ... },
{ "title": "资源B", "url": "https://b.com", ... }
]
</pre>
<p>参数说明<br/>
title: 标题<br/>
url: 资源链接<br/>
file_type: 类型pan/link/other<br/>
tags: 标签数组可选<br/>
description: 描述可选<br/>
... 其他字段参考文档
</p>
</div>
</div>
<!-- 成功/失败提示 -->
<SuccessToast v-if="showSuccess" :message="successMsg" @close="showSuccess = false" />
<ErrorToast v-if="showError" :message="errorMsg" @close="showError = false" />
</div>
<!-- 按钮区域 -->
<div class="flex-shrink-0 pt-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/90 sticky bottom-0 left-0 w-full flex justify-end space-x-3 z-10 backdrop-blur">
<template v-if="mode === 'batch'">
<button type="button" @click="$emit('close')" class="btn-secondary">取消</button>
<button type="button" @click="handleBatchSubmit" class="btn-primary" :disabled="loading">
{{ loading ? '保存中...' : '批量添加' }}
</button>
</template>
<template v-else-if="mode === 'single'">
<button type="button" @click="$emit('close')" class="btn-secondary">取消</button>
<button type="button" @click="handleSingleSubmit" class="btn-primary" :disabled="loading">
{{ loading ? '保存中...' : '添加' }}
</button>
</template>
<template v-else>
<button type="button" @click="$emit('close')" class="btn-secondary">关闭</button>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useResourceStore } from '~/stores/resource'
import { storeToRefs } from 'pinia'
import SuccessToast from './SuccessToast.vue'
import ErrorToast from './ErrorToast.vue'
import { useReadyResourceApi } from '~/composables/useApi'
const store = useResourceStore()
const { categories } = storeToRefs(store)
const props = defineProps<{ resource?: any }>()
const emit = defineEmits(['close', 'save'])
const loading = ref(false)
const newTag = ref('')
const showSuccess = ref(false)
const showError = ref(false)
const successMsg = ref('')
const errorMsg = ref('')
const tabs = [
{ label: '批量添加', value: 'batch' },
{ label: '单个添加', value: 'single' },
{ label: 'API说明', value: 'api' },
]
const mode = ref('batch')
// 批量添加
const batchInput = ref('')
// 单个添加表单
const form = ref({
title: '',
description: '',
url: '', // 多行
category_id: '',
tags: [] as string[],
file_path: '',
file_type: '',
file_size: 0,
is_public: true,
})
const readyResourceApi = useReadyResourceApi()
onMounted(() => {
if (props.resource) {
form.value = {
title: props.resource.title || '',
description: props.resource.description || '',
url: props.resource.url || '',
category_id: props.resource.category_id || '',
tags: [...(props.resource.tags || [])],
file_path: props.resource.file_path || '',
file_type: props.resource.file_type || '',
file_size: props.resource.file_size || 0,
is_public: props.resource.is_public !== false,
}
}
})
const addTag = () => {
const tag = newTag.value.trim()
if (tag && !form.value.tags.includes(tag)) {
form.value.tags.push(tag)
newTag.value = ''
}
}
const removeTag = (tag: string) => {
const index = form.value.tags.indexOf(tag)
if (index > -1) {
form.value.tags.splice(index, 1)
}
}
// 批量添加提交
const handleBatchSubmit = async () => {
loading.value = true
try {
if (!batchInput.value.trim()) throw new Error('请输入资源内容')
const res: any = await readyResourceApi.createReadyResourcesFromText(batchInput.value)
showSuccess.value = true
successMsg.value = `成功添加 ${res.count || 0} 个资源,资源已进入待处理列表,处理完成后会自动入库`
batchInput.value = ''
} catch (e: any) {
showError.value = true
errorMsg.value = e.message || '批量添加失败'
} finally {
loading.value = false
}
}
// 单个添加提交
const handleSingleSubmit = async () => {
loading.value = true
try {
// 多行链接
const urls = form.value.url.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
if (!urls.length) throw new Error('请输入至少一个链接')
for (const url of urls) {
await store.createResource({
...form.value,
url,
tags: [...form.value.tags],
})
}
showSuccess.value = true
successMsg.value = '资源已进入待处理列表,处理完成后会自动入库'
// 清空表单
form.value.title = ''
form.value.description = ''
form.value.url = ''
form.value.tags = []
form.value.file_type = ''
} catch (e: any) {
showError.value = true
errorMsg.value = e.message || '添加失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* 可以添加自定义样式 */
</style>

View File

@@ -5,9 +5,8 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
标题 <span class="text-red-500">*</span>
</label>
<input
<n-input
v-model="form.title"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
placeholder="输入资源标题(必填)"
required
/>
@@ -18,12 +17,11 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
描述 <span class="text-gray-400 text-xs">(可选)</span>
</label>
<textarea
<n-input
v-model="form.description"
rows="3"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
type="textarea"
placeholder="输入资源描述,如:剧情简介、文件大小、清晰度等"
></textarea>
/>
</div>
<!-- URL -->
@@ -31,15 +29,14 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
URL <span class="text-red-500">*</span>
</label>
<textarea
<n-input
v-model="form.url"
rows="3"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
type="textarea"
placeholder="请输入资源链接,支持多行,每行一个链接"
required
></textarea>
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
支持百度网盘阿里云盘夸克网盘等链接
支持百度网盘阿里云盘夸克网盘等链接每行一个链接
</p>
</div>
@@ -48,9 +45,8 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
分类 <span class="text-gray-400 text-xs">(可选)</span>
</label>
<input
<n-input
v-model="form.category"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
placeholder="如:电影、电视剧、动漫、音乐等"
/>
</div>
@@ -76,10 +72,9 @@
</button>
</span>
</div>
<input
<n-input
v-model="newTag"
@keyup.enter.prevent="addTag"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
placeholder="输入标签后回车添加,多个标签用逗号分隔"
/>
</div>
@@ -89,9 +84,8 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
封面图片 <span class="text-gray-400 text-xs">(可选)</span>
</label>
<input
<n-input
v-model="form.img"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
placeholder="封面图片链接"
/>
</div>
@@ -101,9 +95,8 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
数据来源 <span class="text-gray-400 text-xs">(可选)</span>
</label>
<input
<n-input
v-model="form.source"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
placeholder="如手动添加、API导入、爬虫等"
/>
</div>
@@ -113,12 +106,11 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
额外数据 <span class="text-gray-400 text-xs">(可选)</span>
</label>
<textarea
<n-input
v-model="form.extra"
rows="3"
class="input-field dark:bg-gray-900 dark:text-gray-100 dark:border-gray-700"
type="textarea"
placeholder="JSON格式的额外数据{'size': '2GB', 'quality': '1080p'}"
></textarea>
/>
</div>
<!-- 按钮区域 -->
@@ -205,7 +197,7 @@ const clearForm = () => {
newTag.value = ''
}
// 单个添加提交
// 单个添加提交 - 更新为使用批量添加方法
const handleSubmit = async () => {
loading.value = true
try {
@@ -214,23 +206,23 @@ const handleSubmit = async () => {
// 多行链接处理
const urls = form.value.url.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
// 为每个URL创建一个资源
for (const url of urls) {
const resourceData = {
title: form.value.title, // 标题必填
description: form.value.description || undefined, // 添加描述
url: url,
// 使用批量添加方法,将多个URL作为一个资源的多个链接
const resourceData = {
resources: [{
title: form.value.title || undefined, // 后端期望 *string 类型
description: form.value.description || '',
url: urls, // 现在 url 是一个数组
category: form.value.category || '',
tags: form.value.tags.join(','), // 转换为逗号分隔的字符串
img: form.value.img || '',
source: form.value.source || '手动添加',
extra: form.value.extra || '',
}
await readyResourceApi.createReadyResource(resourceData)
}]
}
emit('success', `成功添加 ${urls.length} 个资源到待处理列表`)
const response = await readyResourceApi.batchCreateReadyResources(resourceData)
emit('success', `成功添加资源,包含 ${urls.length} 个链接`)
clearForm()
} catch (e: any) {
emit('error', e.message || '添加失败')

View File

@@ -1,58 +0,0 @@
<template>
<div v-if="show" class="fixed top-4 right-4 z-50">
<div class="bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-sm">
<div class="flex items-start">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-400"></i>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-green-800">成功</h3>
<div class="mt-1 text-sm text-green-700">
{{ message }}
</div>
</div>
<div class="ml-4 flex-shrink-0">
<button
@click="close"
class="inline-flex text-green-400 hover:text-green-600 focus:outline-none"
>
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Props {
message: string
duration?: number
}
const props = withDefaults(defineProps<Props>(), {
duration: 3000
})
const emit = defineEmits<{
close: []
}>()
const show = ref(false)
const close = () => {
show.value = false
emit('close')
}
onMounted(() => {
show.value = true
if (props.duration > 0) {
setTimeout(() => {
close()
}, props.duration)
}
})
</script>

View File

@@ -109,7 +109,7 @@
<script setup lang="ts">
interface VersionInfo {
version: string
build_time: string
build_time: string | Date
git_commit: string
git_branch: string
go_version: string
@@ -182,13 +182,13 @@ const refreshVersion = () => {
}
// 格式化时间
const formatTime = (timeStr: string) => {
if (!timeStr) return 'N/A'
const formatTime = (timeInput: string | Date) => {
if (!timeInput) return 'N/A'
try {
const date = new Date(timeStr)
const date = timeInput instanceof Date ? timeInput : new Date(timeInput)
return date.toLocaleString('zh-CN')
} catch {
return timeStr
return String(timeInput)
}
}

View File

@@ -1,6 +1,9 @@
import { useApiFetch } from './useApiFetch'
import { useUserStore } from '~/stores/user'
// 统一响应解析函数
export const parseApiResponse = <T>(response: any): T => {
console.log('parseApiResponse - 原始响应:', response)
log('parseApiResponse - 原始响应:', response)
// 检查是否是新的统一响应格式
if (response && typeof response === 'object' && 'code' in response && 'data' in response) {
@@ -15,6 +18,11 @@ export const parseApiResponse = <T>(response: any): T => {
}
}
// 检查是否是包含items字段的响应格式如分类接口
if (response && typeof response === 'object' && 'items' in response) {
return response
}
// 检查是否是包含success字段的响应格式如登录接口
if (response && typeof response === 'object' && 'success' in response && 'data' in response) {
if (response.success) {
@@ -37,700 +45,143 @@ export const parseApiResponse = <T>(response: any): T => {
return response
}
// 使用 $fetch 替代 axios更好地处理 SSR
export const useResourceApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getResources = async (params?: any) => {
const response = await $fetch('/resources', {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getResource = async (id: number) => {
const response = await $fetch(`/resources/${id}`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createResource = async (data: any) => {
const response = await $fetch('/resources', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateResource = async (id: number, data: any) => {
const response = await $fetch(`/resources/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteResource = async (id: number) => {
const response = await $fetch(`/resources/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const searchResources = async (params: any) => {
const response = await $fetch('/search', {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getResourcesByPan = async (panId: number, params?: any) => {
const response = await $fetch('/resources', {
baseURL: config.public.apiBase,
params: { ...params, pan_id: panId },
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getResources,
getResource,
createResource,
updateResource,
deleteResource,
searchResources,
getResourcesByPan,
}
const getResources = (params?: any) => useApiFetch('/resources', { params }).then(parseApiResponse)
const getResource = (id: number) => useApiFetch(`/resources/${id}`).then(parseApiResponse)
const createResource = (data: any) => useApiFetch('/resources', { method: 'POST', body: data }).then(parseApiResponse)
const updateResource = (id: number, data: any) => useApiFetch(`/resources/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteResource = (id: number) => useApiFetch(`/resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const searchResources = (params: any) => useApiFetch('/search', { params }).then(parseApiResponse)
const getResourcesByPan = (panId: number, params?: any) => useApiFetch('/resources', { params: { ...params, pan_id: panId } }).then(parseApiResponse)
// 新增:统一的资源访问次数上报
const incrementViewCount = (id: number) => useApiFetch(`/resources/${id}/view`, { method: 'POST' })
// 新增:批量删除资源
const batchDeleteResources = (ids: number[]) => useApiFetch('/resources/batch', { method: 'DELETE', body: { ids } }).then(parseApiResponse)
return { getResources, getResource, createResource, updateResource, deleteResource, searchResources, getResourcesByPan, incrementViewCount, batchDeleteResources }
}
// 认证相关API
export const useAuthApi = () => {
const config = useRuntimeConfig()
const login = async (data: any) => {
const response = await $fetch('/auth/login', {
baseURL: config.public.apiBase,
method: 'POST',
body: data
})
return parseApiResponse(response)
}
const register = async (data: any) => {
const response = await $fetch('/auth/register', {
baseURL: config.public.apiBase,
method: 'POST',
body: data
})
return parseApiResponse(response)
}
const getProfile = async () => {
const token = localStorage.getItem('token')
const response = await $fetch('/auth/profile', {
baseURL: config.public.apiBase,
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
return parseApiResponse(response)
}
return {
login,
register,
getProfile,
const login = (data: any) => useApiFetch('/auth/login', { method: 'POST', body: data }).then(parseApiResponse)
const register = (data: any) => useApiFetch('/auth/register', { method: 'POST', body: data }).then(parseApiResponse)
const getProfile = () => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : ''
return useApiFetch('/auth/profile', { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(parseApiResponse)
}
return { login, register, getProfile }
}
// 分类相关API
export const useCategoryApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getCategories = async () => {
const response = await $fetch('/categories', {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createCategory = async (data: any) => {
const response = await $fetch('/categories', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateCategory = async (id: number, data: any) => {
const response = await $fetch(`/categories/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteCategory = async (id: number) => {
const response = await $fetch(`/categories/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getCategories,
createCategory,
updateCategory,
deleteCategory,
}
const getCategories = (params?: any) => useApiFetch('/categories', { params }).then(parseApiResponse)
const createCategory = (data: any) => useApiFetch('/categories', { method: 'POST', body: data }).then(parseApiResponse)
const updateCategory = (id: number, data: any) => useApiFetch(`/categories/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteCategory = (id: number) => useApiFetch(`/categories/${id}`, { method: 'DELETE' }).then(parseApiResponse)
return { getCategories, createCategory, updateCategory, deleteCategory }
}
// 平台相关API
export const usePanApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getPans = async () => {
const response = await $fetch('/pans', {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getPan = async (id: number) => {
const response = await $fetch(`/pans/${id}`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createPan = async (data: any) => {
const response = await $fetch('/pans', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updatePan = async (id: number, data: any) => {
const response = await $fetch(`/pans/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deletePan = async (id: number) => {
const response = await $fetch(`/pans/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getPans,
getPan,
createPan,
updatePan,
deletePan,
}
const getPans = () => useApiFetch('/pans').then(parseApiResponse)
const getPan = (id: number) => useApiFetch(`/pans/${id}`).then(parseApiResponse)
const createPan = (data: any) => useApiFetch('/pans', { method: 'POST', body: data }).then(parseApiResponse)
const updatePan = (id: number, data: any) => useApiFetch(`/pans/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deletePan = (id: number) => useApiFetch(`/pans/${id}`, { method: 'DELETE' }).then(parseApiResponse)
return { getPans, getPan, createPan, updatePan, deletePan }
}
// Cookie相关API
export const useCksApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getCks = async (params?: any) => {
const response = await $fetch('/cks', {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getCksByID = async (id: number) => {
const response = await $fetch(`/cks/${id}`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createCks = async (data: any) => {
const response = await $fetch('/cks', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateCks = async (id: number, data: any) => {
const response = await $fetch(`/cks/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteCks = async (id: number) => {
const response = await $fetch(`/cks/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const refreshCapacity = async (id: number) => {
const response = await $fetch(`/cks/${id}/refresh-capacity`, {
baseURL: config.public.apiBase,
method: 'POST',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getCks,
getCksByID,
createCks,
updateCks,
deleteCks,
refreshCapacity,
}
const getCks = (params?: any) => useApiFetch('/cks', { params }).then(parseApiResponse)
const getCksByID = (id: number) => useApiFetch(`/cks/${id}`).then(parseApiResponse)
const createCks = (data: any) => useApiFetch('/cks', { method: 'POST', body: data }).then(parseApiResponse)
const updateCks = (id: number, data: any) => useApiFetch(`/cks/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteCks = (id: number) => useApiFetch(`/cks/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const refreshCapacity = (id: number) => useApiFetch(`/cks/${id}/refresh-capacity`, { method: 'POST' }).then(parseApiResponse)
return { getCks, getCksByID, createCks, updateCks, deleteCks, refreshCapacity }
}
// 标签相关API
export const useTagApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getTags = async () => {
const response = await $fetch('/tags', {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getTagsByCategory = async (categoryId: number, params?: any) => {
const response = await $fetch(`/categories/${categoryId}/tags`, {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getTag = async (id: number) => {
const response = await $fetch(`/tags/${id}`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createTag = async (data: any) => {
const response = await $fetch('/tags', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateTag = async (id: number, data: any) => {
const response = await $fetch(`/tags/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteTag = async (id: number) => {
const response = await $fetch(`/tags/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const getResourceTags = async (resourceId: number) => {
const response = await $fetch(`/resources/${resourceId}/tags`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getTags,
getTagsByCategory,
getTag,
createTag,
updateTag,
deleteTag,
getResourceTags,
}
const getTags = (params?: any) => useApiFetch('/tags', { params }).then(parseApiResponse)
const getTagsByCategory = (categoryId: number, params?: any) => useApiFetch(`/categories/${categoryId}/tags`, { params }).then(parseApiResponse)
const getTag = (id: number) => useApiFetch(`/tags/${id}`).then(parseApiResponse)
const createTag = (data: any) => useApiFetch('/tags', { method: 'POST', body: data }).then(parseApiResponse)
const updateTag = (id: number, data: any) => useApiFetch(`/tags/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteTag = (id: number) => useApiFetch(`/tags/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const getResourceTags = (resourceId: number) => useApiFetch(`/resources/${resourceId}/tags`).then(parseApiResponse)
return { getTags, getTagsByCategory, getTag, createTag, updateTag, deleteTag, getResourceTags }
}
// 待处理资源相关API
export const useReadyResourceApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getReadyResources = async (params?: any) => {
const response = await $fetch('/ready-resources', {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createReadyResource = async (data: any) => {
const response = await $fetch('/ready-resources', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const batchCreateReadyResources = async (data: any) => {
const response = await $fetch('/ready-resources/batch', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createReadyResourcesFromText = async (text: string) => {
const getReadyResources = (params?: any) => useApiFetch('/ready-resources', { params }).then(parseApiResponse)
const getFailedResources = (params?: any) => useApiFetch('/ready-resources/errors', { params }).then(parseApiResponse)
const createReadyResource = (data: any) => useApiFetch('/ready-resources', { method: 'POST', body: data }).then(parseApiResponse)
const batchCreateReadyResources = (data: any) => useApiFetch('/ready-resources/batch', { method: 'POST', body: data }).then(parseApiResponse)
const createReadyResourcesFromText = (text: string) => {
const formData = new FormData()
formData.append('text', text)
const response = await $fetch('/ready-resources/text', {
baseURL: config.public.apiBase,
method: 'POST',
body: formData,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
return useApiFetch('/ready-resources/text', { method: 'POST', body: formData }).then(parseApiResponse)
}
const deleteReadyResource = async (id: number) => {
const response = await $fetch(`/ready-resources/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const clearReadyResources = async () => {
const response = await $fetch('/ready-resources', {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getReadyResources,
createReadyResource,
batchCreateReadyResources,
createReadyResourcesFromText,
deleteReadyResource,
const deleteReadyResource = (id: number) => useApiFetch(`/ready-resources/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const clearReadyResources = () => useApiFetch('/ready-resources', { method: 'DELETE' }).then(parseApiResponse)
const clearErrorMsg = (id: number) => useApiFetch(`/ready-resources/${id}/clear-error`, { method: 'POST' }).then(parseApiResponse)
const retryFailedResources = () => useApiFetch('/ready-resources/retry-failed', { method: 'POST' }).then(parseApiResponse)
return {
getReadyResources,
getFailedResources,
createReadyResource,
batchCreateReadyResources,
createReadyResourcesFromText,
deleteReadyResource,
clearReadyResources,
clearErrorMsg,
retryFailedResources
}
}
// 统计相关API
export const useStatsApi = () => {
const config = useRuntimeConfig()
const getStats = async () => {
const response = await $fetch('/stats', {
baseURL: config.public.apiBase,
})
return parseApiResponse(response)
}
return {
getStats,
}
const getStats = () => useApiFetch('/stats').then(parseApiResponse)
return { getStats }
}
// 系统配置相关API
export const useSystemConfigApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getSystemConfig = async () => {
const response = await $fetch('/system/config', {
baseURL: config.public.apiBase,
// GET接口不需要认证头
})
return parseApiResponse(response)
}
const updateSystemConfig = async (data: any) => {
const authHeaders = getAuthHeaders()
console.log('updateSystemConfig - authHeaders:', authHeaders)
console.log('updateSystemConfig - token exists:', !!authHeaders.Authorization)
const response = await $fetch('/system/config', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: authHeaders as Record<string, string>
})
return parseApiResponse(response)
}
return {
getSystemConfig,
updateSystemConfig,
}
const getSystemConfig = () => useApiFetch('/system/config').then(parseApiResponse)
const updateSystemConfig = (data: any) => useApiFetch('/system/config', { method: 'POST', body: data }).then(parseApiResponse)
const toggleAutoProcess = (enabled: boolean) => useApiFetch('/system/config/toggle-auto-process', { method: 'POST', body: { auto_process_ready_resources: enabled } }).then(parseApiResponse)
return { getSystemConfig, updateSystemConfig, toggleAutoProcess }
}
// 热播剧相关API
export const useHotDramaApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getHotDramas = async (params?: any) => {
const response = await $fetch('/hot-dramas', {
baseURL: config.public.apiBase,
params,
})
return parseApiResponse(response)
}
const createHotDrama = async (data: any) => {
const response = await $fetch('/hot-dramas', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateHotDrama = async (id: number, data: any) => {
const response = await $fetch(`/hot-dramas/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteHotDrama = async (id: number) => {
const response = await $fetch(`/hot-dramas/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const fetchHotDramas = async () => {
const response = await $fetch('/hot-dramas/fetch', {
baseURL: config.public.apiBase,
method: 'POST',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getHotDramas,
createHotDrama,
updateHotDrama,
deleteHotDrama,
fetchHotDramas,
}
const getHotDramas = (params?: any) => useApiFetch('/hot-dramas', { params }).then(parseApiResponse)
const createHotDrama = (data: any) => useApiFetch('/hot-dramas', { method: 'POST', body: data }).then(parseApiResponse)
const updateHotDrama = (id: number, data: any) => useApiFetch(`/hot-dramas/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteHotDrama = (id: number) => useApiFetch(`/hot-dramas/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const fetchHotDramas = () => useApiFetch('/hot-dramas/fetch', { method: 'POST' }).then(parseApiResponse)
return { getHotDramas, createHotDrama, updateHotDrama, deleteHotDrama, fetchHotDramas }
}
// 监控相关API
export const useMonitorApi = () => {
const config = useRuntimeConfig()
const getPerformanceStats = async () => {
const response = await $fetch('/performance', {
baseURL: config.public.apiBase,
})
return parseApiResponse(response)
}
const getPerformanceStats = () => useApiFetch('/performance').then(parseApiResponse)
const getSystemInfo = () => useApiFetch('/system/info').then(parseApiResponse)
const getBasicStats = () => useApiFetch('/stats').then(parseApiResponse)
return { getPerformanceStats, getSystemInfo, getBasicStats }
}
const getSystemInfo = async () => {
const response = await $fetch('/system/info', {
baseURL: config.public.apiBase,
})
return parseApiResponse(response)
}
const getBasicStats = async () => {
const response = await $fetch('/stats', {
baseURL: config.public.apiBase,
})
return parseApiResponse(response)
}
return {
getPerformanceStats,
getSystemInfo,
getBasicStats,
}
export const useUserApi = () => {
const getUsers = (params?: any) => useApiFetch('/users', { params }).then(parseApiResponse)
const getUser = (id: number) => useApiFetch(`/users/${id}`).then(parseApiResponse)
const createUser = (data: any) => useApiFetch('/users', { method: 'POST', body: data }).then(parseApiResponse)
const updateUser = (id: number, data: any) => useApiFetch(`/users/${id}`, { method: 'PUT', body: data }).then(parseApiResponse)
const deleteUser = (id: number) => useApiFetch(`/users/${id}`, { method: 'DELETE' }).then(parseApiResponse)
const changePassword = (id: number, newPassword: string) => useApiFetch(`/users/${id}/password`, { method: 'PUT', body: { new_password: newPassword } }).then(parseApiResponse)
return { getUsers, getUser, createUser, updateUser, deleteUser, changePassword }
}
// 用户管理相关API
export const useUserApi = () => {
const config = useRuntimeConfig()
const getAuthHeaders = () => {
const userStore = useUserStore()
return userStore.authHeaders
}
const getUsers = async (params?: any) => {
const response = await $fetch('/users', {
baseURL: config.public.apiBase,
params,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
// 公开获取系统配置API
export const usePublicSystemConfigApi = () => {
const getPublicSystemConfig = () => useApiFetch('/public/system-config').then(res => res)
return { getPublicSystemConfig }
}
const getUser = async (id: number) => {
const response = await $fetch(`/users/${id}`, {
baseURL: config.public.apiBase,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const createUser = async (data: any) => {
const response = await $fetch('/users', {
baseURL: config.public.apiBase,
method: 'POST',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const updateUser = async (id: number, data: any) => {
const response = await $fetch(`/users/${id}`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: data,
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const deleteUser = async (id: number) => {
const response = await $fetch(`/users/${id}`, {
baseURL: config.public.apiBase,
method: 'DELETE',
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
const changePassword = async (id: number, newPassword: string) => {
const response = await $fetch(`/users/${id}/password`, {
baseURL: config.public.apiBase,
method: 'PUT',
body: { new_password: newPassword },
headers: getAuthHeaders() as Record<string, string>
})
return parseApiResponse(response)
}
return {
getUsers,
getUser,
createUser,
updateUser,
deleteUser,
changePassword,
// 日志函数:只在开发环境打印
function log(...args: any[]) {
if (process.env.NODE_ENV !== 'production') {
console.log(...args)
}
}

View File

@@ -0,0 +1,63 @@
import { useRuntimeConfig } from '#app'
import { useUserStore } from '~/stores/user'
export function useApiFetch<T = any>(
url: string,
options: any = {}
): Promise<T> {
const config = useRuntimeConfig()
const userStore = useUserStore()
const baseURL = process.server
? String(config.public.apiServer)
: String(config.public.apiBase)
// 自动带上 token
const headers = {
...(options.headers || {}),
...(userStore.authHeaders || {})
}
return $fetch<T>(url, {
baseURL,
...options,
headers,
onResponse({ response }) {
if (response.status === 401 ||
(response._data && (response._data.code === 401 || response._data.error === '无效的令牌'))
) {
userStore.logout()
if (process.client) {
window.location.href = '/login'
}
// 触发 onResponseError 逻辑
throw Object.assign(new Error('登录已过期,请重新登录'), {
data: response._data,
status: response.status,
})
}
// 统一处理 code/message
if (response._data && response._data.code && response._data.code !== 200) {
throw new Error(response._data.message || '请求失败')
}
},
onResponseError({ error }: { error: any }) {
console.log('error', error)
// 检查是否为"无效的令牌"错误
if (error?.data?.error === '无效的令牌') {
// 清除用户状态
userStore.logout()
// 跳转到登录页面
if (process.client) {
window.location.href = '/login'
}
throw new Error('登录已过期,请重新登录')
}
// 统一错误提示
// 你可以用 naive-ui 的 useMessage() 这里弹窗
// useMessage().error(error.message)
throw error
}
})
}

View File

@@ -40,16 +40,16 @@ export const useSeo = () => {
if (systemConfig.value?.site_title) {
return `${systemConfig.value.site_title} - ${pageTitle}`
}
return `${pageTitle} - 网盘资源数据库`
return `${pageTitle} - 老九网盘资源数据库`
}
// 生成页面元数据
const generateMeta = (customMeta?: Record<string, string>) => {
const defaultMeta = {
description: systemConfig.value?.site_description || '专业的网盘资源数据库',
description: systemConfig.value?.site_description || '专业的老九网盘资源数据库',
keywords: systemConfig.value?.keywords || '网盘,资源管理,文件分享',
author: systemConfig.value?.author || '系统管理员',
copyright: systemConfig.value?.copyright || '© 2024 网盘资源数据库'
copyright: systemConfig.value?.copyright || '© 2024 老九网盘资源数据库'
}
return {

View File

@@ -18,7 +18,7 @@ interface VersionResponse {
export const useVersion = () => {
const versionInfo = ref<VersionInfo>({
version: '1.0.0',
version: '1.0.10',
build_time: '',
git_commit: 'unknown',
git_branch: 'unknown',

View File

@@ -18,12 +18,17 @@
<AdminHeader :title="pageTitle" />
</div>
<!-- 主要内容区域 -->
<div class="p-3 sm:p-5">
<div class="max-w-7xl mx-auto">
<!-- 页面内容插槽 -->
<slot />
<ClientOnly>
<n-notification-provider>
<n-dialog-provider>
<!-- 页面内容插槽 -->
<slot />
</n-dialog-provider>
</n-notification-provider>
</ClientOnly>
</div>
</div>
@@ -33,6 +38,8 @@
</template>
<script setup lang="ts">
import { useSystemConfigStore } from '~/stores/systemConfig'
// 页面加载状态
const pageLoading = ref(false)
@@ -66,8 +73,9 @@ watch(() => route.path, () => {
}, 300)
})
// 页面加载时显示加载状态
const systemConfigStore = useSystemConfigStore()
onMounted(() => {
systemConfigStore.initConfig()
pageLoading.value = true
setTimeout(() => {
pageLoading.value = false

27
web/layouts/single.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<div class="single-layout">
<n-notification-provider>
<slot />
</n-notification-provider>
</div>
</template>
<script setup>
</script>
<style>
body, html {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: #fff;
}
.single-layout {
width: 100%;
height: 100vh;
overflow: hidden;
}
</style>

132
web/middleware/ua.global.ts Normal file
View File

@@ -0,0 +1,132 @@
import { defineNuxtRouteMiddleware, useRequestEvent } from 'nuxt/app'
import { getHeader, getRequestURL, setResponseStatus, setResponseHeader, send } from 'h3'
export default defineNuxtRouteMiddleware((to, from) => {
// 只在服务端执行
if (!process.server) return
const event = useRequestEvent()
if (!event) return
const userAgent = getHeader(event, 'user-agent') || ''
const isForbiddenApp = ['QQ/', 'MicroMessenger', 'WeiBo', 'DingTalk', 'Mail'].some(it => userAgent.includes(it))
if (isForbiddenApp) {
// 获取当前 URL
const currentUrl = getRequestURL(event).href
// 设置响应头
setResponseStatus(event, 200)
setResponseHeader(event, 'Content-Type', 'text/html; charset=utf-8')
// 直接返回 HTML 响应
return send(event, generateForbiddenPage(currentUrl, userAgent))
}
})
// 生成禁止访问页面的函数
function generateForbiddenPage(url: string, userAgent: string) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>请在浏览器中打开</title>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
}
.forbidden-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.top-bar-guidance {
font-size: 15px;
color: #fff;
height: 70%;
line-height: 1.2;
padding-left: 20px;
padding-top: 20px;
background: url('/assets/images/banner.png') center right/cover no-repeat;
}
.top-bar-guidance p {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-bar-guidance .icon-safari {
width: 25px;
height: 25px;
vertical-align: middle;
margin: 0 .2em;
}
.top-bar-guidance-text {
display: flex;
justify-items: center;
word-wrap: nowrap;
}
.top-bar-guidance-text img {
display: inline-block;
width: 25px;
height: 25px;
vertical-align: middle;
margin: 0 .2em;
}
#contens {
font-weight: bold;
color: #2466f4;
text-align: center;
font-size: 20px;
margin-bottom: 125px;
}
.app-download-tip {
margin: 0 auto;
width: 290px;
text-align: center;
font-size: 15px;
color: #2466f4;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAcAQMAAACak0ePAAAABlBMVEUAAAAdYfh+GakkAAAAAXRSTlMAQObYZgAAAA5JREFUCNdjwA8acEkAAAy4AIE4hQq/AAAAAElFTkSuQmCC) left center/auto 15px repeat-x;
}
.app-download-tip .guidance-desc {
background-color: #fff;
padding: 0 5px;
}
</style>
</head>
<body>
<div class="forbidden-page">
<div class="top-bar-guidance">
<p class="top-bar-guidance-text">请按提示在手机 浏览器 打开<img src="/assets/images/3dian.png" class="icon-safari"></p>
<p class="top-bar-guidance-text">苹果设备<img src="/assets/images/iphone.png" class="icon-safari">↗↗↗</p>
<p class="top-bar-guidance-text">安卓设备<img src="/assets/images/android.png" class="icon-safari">↗↗↗</p>
</div>
<div id="contens">
<p><br/><br/></p>
<p>1.本站不支持 微信,QQ等APP 内访问</p>
<p><br/></p>
<p>2.请按提示在手机 浏览器 打开</p>
<p id="device-tip"><br/>3.请在浏览器中打开</p>
</div>
<p><br/><br/></p>
<div class="app-download-tip">
<span class="guidance-desc" id="current-url">${url}</span>
</div>
<p><br/></p>
<div class="app-download-tip">
<span class="guidance-desc">点击右上角···图标 or 复制网址自行打开</span>
</div>
</div>
</body>
</html>`
}

View File

@@ -25,7 +25,7 @@ export default defineNuxtConfig({
})
],
optimizeDeps: {
include: ['naive-ui', 'vueuc', 'date-fns'],
include: ['vueuc', 'date-fns'],
exclude: ["oxc-parser"] // 强制使用 WASM 版本
}
},
@@ -38,11 +38,11 @@ export default defineNuxtConfig({
],
app: {
head: {
title: '开源网盘资源数据库',
title: '老九网盘资源数据库',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: '开源网盘资源数据库 - 一个现代化的资源管理系统' }
{ name: 'description', content: '老九网盘资源管理数据庫,现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘迅雷云盘123云盘115网盘UC网盘' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
@@ -51,7 +51,10 @@ export default defineNuxtConfig({
},
runtimeConfig: {
public: {
apiBase: process.env.API_BASE || 'http://localhost:8080/api'
// 开发环境:直接访问后端,生产环境:通过 Nginx 反代
apiBase: process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:8080/api',
// 服务端:开发环境直接访问,生产环境容器内访问
apiServer: process.env.NODE_ENV === 'production' ? 'http://backend:8080/api' : 'http://localhost:8080/api'
}
},
build: {

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