feat: 后端搜索接口开发

This commit is contained in:
2025-05-11 09:41:48 +08:00
parent 7350f43a72
commit 9b2584d32c
20 changed files with 12571 additions and 6790 deletions
+132
View File
@@ -0,0 +1,132 @@
# 百盘搜 (BPS)
一个聚合网盘资源搜索的 Web 应用程序。
## 功能特点
- 支持多个网盘源(百度网盘、夸克网盘、UC网盘、迅雷网盘)
- 实时搜索和结果过滤
- 一键获取临时分享链接
- 自动清理过期资源
## 技术栈
- 前端:Nuxt.js (Vue 3) + TypeScript + Tailwind CSS
- 后端:Express.js + TypeScript
- 数据库:MySQL 8.0 + Prisma ORM
- 缓存:Redis
- 部署:Docker + Docker Compose
## 开发环境设置
### 前置要求
- Node.js 20+
- Docker 和 Docker Compose
- MySQL 8.0
- Redis 7.0
### 安装步骤
1. 克隆仓库:
```bash
git clone https://github.com/yourusername/bps.git
cd bps
```
2. 安装依赖:
```bash
# 安装前端依赖
cd frontend
npm install
# 安装后端依赖
cd ../backend
npm install
```
3. 配置环境变量:
```bash
# 后端
cp backend/.env.example backend/.env
# 编辑 .env 文件,填入必要的配置信息
```
4. 初始化数据库:
```bash
cd backend
npx prisma migrate dev
```
5. 启动开发服务器:
```bash
# 启动前端(在 frontend 目录下)
npm run dev
# 启动后端(在 backend 目录下)
npm run dev
```
## 使用 Docker 部署
1. 构建和启动所有服务:
```bash
docker-compose up -d
```
2. 初始化数据库(首次部署时):
```bash
docker-compose exec backend npx prisma migrate deploy
```
3. 查看日志:
```bash
docker-compose logs -f
```
## 项目结构
```
bps/
├── frontend/ # Nuxt.js 前端应用
├── backend/ # Express.js 后端服务
│ ├── src/
│ │ ├── adapters/ # 网盘适配器
│ │ ├── api/ # API 路由
│ │ ├── services/ # 业务逻辑
│ │ └── utils/ # 工具函数
│ └── prisma/ # 数据库模型和迁移
├── doc/ # 项目文档
└── docker-compose.yml # Docker 配置
```
## API 文档
### 搜索接口
```
GET /api/search?keyword={keyword}
```
### 获取链接接口
```
POST /api/generate-link
Content-Type: application/json
{
"itemId": "string"
}
```
## 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
MIT License - 详见 [LICENSE](LICENSE) 文件
+28
View File
@@ -0,0 +1,28 @@
# Build stage
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/prisma ./prisma
RUN npm install --production
ENV NODE_ENV=production
ENV PORT=4000
EXPOSE 4000
CMD ["node", "dist/index.js"]
+3781 -283
View File
File diff suppressed because it is too large Load Diff
+32 -21
View File
@@ -1,34 +1,45 @@
{
"name": "search-service-backend",
"name": "bps-backend",
"version": "1.0.0",
"description": "Backend service for search functionality",
"description": "Backend for BPS (Baidu Pan Search)",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "ts-node-dev src/index.ts",
"build": "tsc",
"test": "jest"
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn src/index.ts",
"test": "jest",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"axios": "^1.9.0",
"dotenv": "^16.5.0",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.9.1",
"redis": "^4.7.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-rate-limit": "^6.0.2",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.17.32",
"@prisma/client": "^5.10.0",
"@types/axios": "^0.14.4",
"@types/redis": "^4.0.11",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"axios": "^1.9.0",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.18.0",
"express-rate-limit": "^7.1.0",
"ioredis": "^5.3.0",
"node-cron": "^3.0.0",
"redis": "^5.0.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/cors": "^2.8.0",
"@types/express": "^4.17.0",
"@types/jest": "^29.0.0",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.0",
"jest": "^29.0.0",
"prisma": "^5.10.0",
"ts-jest": "^29.0.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
"typescript": "^5.0.0"
}
}
+3054 -1237
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Account {
id Int @id @default(autoincrement())
driveType String @unique
accountIdentifier String
credentials String @db.Text
status String @default("active")
lastChecked DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transcodeRecords TranscodeRecord[]
activityLogs ActivityLog[]
}
model TranscodeRecord {
id Int @id @default(autoincrement())
itemId String @index
sourceLink String @db.Text
driveType String
managedAccountId Int
status String @default("pending")
tempLink String? @db.Text
transferredFileId String?
transferStartTime DateTime @default(now())
transferEndTime DateTime?
errorCode String?
errorMessage String? @db.Text
cleanupStatus String @default("pending") @index
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
account Account @relation(fields: [managedAccountId], references: [id])
}
model ActivityLog {
id BigInt @id @default(autoincrement())
timestamp DateTime @default(now())
operationType String
clientIp String?
keyword String?
itemId String? @index
driveType String?
managedAccountId Int?
generatedLink String? @db.Text
errorCode String?
errorMessage String? @db.Text
durationMs Int?
details String? @db.Text
account Account? @relation(fields: [managedAccountId], references: [id])
}
+56
View File
@@ -0,0 +1,56 @@
import { Account } from '@prisma/client';
import { DriveAdapter, TransferResult } from './DriveAdapter';
import { logger } from '../utils/logger';
export class BaiduDriveAdapter implements DriveAdapter {
async transferAndShare(
sourceLink: string,
sourceLinkType: string,
account: Account
): Promise<TransferResult> {
try {
// TODO: 实现百度网盘的具体转存和分享逻辑
// 1. 解析账号凭证
const credentials = JSON.parse(account.credentials);
// 2. 转存文件
// const transferredFileId = await this.transferFile(sourceLink, credentials);
// 3. 生成分享链接
// const tempLink = await this.generateShareLink(transferredFileId, credentials);
// 临时返回模拟数据
return {
tempLink: 'https://pan.baidu.com/s/example',
transferredFileId: 'example_file_id'
};
} catch (error) {
logger.error('Baidu drive transfer failed', { error, accountId: account.id });
throw new Error('Failed to transfer and share file');
}
}
async delete(fileId: string, account: Account): Promise<void> {
try {
// TODO: 实现百度网盘的文件删除逻辑
const credentials = JSON.parse(account.credentials);
// await this.deleteFile(fileId, credentials);
} catch (error) {
logger.error('Baidu drive delete failed', { error, accountId: account.id });
throw new Error('Failed to delete file');
}
}
async checkAccountStatus(account: Account): Promise<boolean> {
try {
// TODO: 实现百度网盘的账号状态检查逻辑
const credentials = JSON.parse(account.credentials);
// const isValid = await this.checkAccount(credentials);
// return isValid;
return true; // 临时返回
} catch (error) {
logger.error('Baidu drive account check failed', { error, accountId: account.id });
return false;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Account } from '@prisma/client';
export interface TransferResult {
tempLink: string;
transferredFileId: string;
}
export interface DriveAdapter {
/**
* 转存文件并生成临时分享链接
* @param sourceLink 源文件链接
* @param sourceLinkType 源链接类型
* @param account 网盘账号信息
* @returns 包含临时链接和转存文件ID的结果
*/
transferAndShare(
sourceLink: string,
sourceLinkType: string,
account: Account
): Promise<TransferResult>;
/**
* 删除网盘中的文件
* @param fileId 文件ID
* @param account 网盘账号信息
*/
delete(fileId: string, account: Account): Promise<void>;
/**
* 检查账号状态
* @param account 网盘账号信息
* @returns 账号是否有效
*/
checkAccountStatus(account: Account): Promise<boolean>;
}
+7 -3
View File
@@ -4,9 +4,13 @@ dotenv.config();
// Add debug logging
console.log('Environment variables loaded:', {
REDIS_HOST: process.env.REDIS_HOST,
REDIS_PORT: process.env.REDIS_PORT,
REDIS_PASSWORD: process.env.REDIS_PASSWORD
PORT: process.env.PORT,
SEARCH_SERVICE_URL: process.env.SEARCH_SERVICE_URL,
JWT_SECRET: process.env.JWT_SECRET,
MYSQL_HOST: process.env.MYSQL_HOST,
MYSQL_PORT: process.env.MYSQL_PORT,
MYSQL_USER: process.env.MYSQL_USER,
MYSQL_PASSWORD: process.env.MYSQL_PASSWORD,
});
export const config = {
+4 -3
View File
@@ -1,4 +1,5 @@
import swaggerJsdoc from 'swagger-jsdoc';
import { config } from './config';
const options: swaggerJsdoc.Options = {
definition: {
@@ -14,7 +15,7 @@ const options: swaggerJsdoc.Options = {
},
servers: [
{
url: 'http://localhost:3005',
url: `http://localhost:${config.server.port}`,
description: '开发服务器'
}
],
@@ -31,7 +32,7 @@ const options: swaggerJsdoc.Options = {
bearerAuth: []
}]
},
apis: ['./src/routes/*.ts', './src/controllers/*.ts'] // 指定API注释文件的位置
apis: ['./src/routes/*.ts', './src/controllers/*.ts']
};
export const swaggerSpec = swaggerJsdoc(options);
export const swaggerSpec = swaggerJsdoc(options);
+18 -3
View File
@@ -1,12 +1,25 @@
import express from 'express';
import { config } from './config/config';
import searchRoutes from './routes/searchRoutes';
import swaggerUi from 'swagger-ui-express';
import { swaggerSpec } from './config/swagger';
import cors from 'cors';
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// Swagger UI
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// API Documentation in JSON format
app.get('/api-docs.json', (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
// Routes
app.use('/api', searchRoutes);
@@ -20,7 +33,9 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
});
});
const PORT = config.server.port;
// Start server
app.listen(config.server.port, () => {
console.log(`Server is running on port ${config.server.port}`);
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Swagger documentation available at http://localhost:${PORT}/api-docs`);
});
+26
View File
@@ -0,0 +1,26 @@
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
new winston.transports.File({
filename: 'error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'combined.log'
})
]
});
export { logger };
+179
View File
@@ -0,0 +1,179 @@
# ---
**百盘搜 \- V1.0 需求文档**
**最后更新时间:** 2025年5月6日
## **1\. 项目概述**
### **1.1. 目标**
构建一个Web应用程序,主要目标是聚合来自特定外部源的网盘资源搜索结果,提供统一的搜索界面。用户可以根据网盘类型筛选结果,并能为选定的资源触发一个“获取链接”操作,该操作通过后端管理的网盘账号进行文件转存,并生成一个有时效性的临时分享链接供用户使用。
### **1.2. 目标用户**
面向普通互联网用户,无需注册或登录即可使用核心搜索和获取链接功能。
### **1.3. 初始范围**
* **支持网盘:** 百度网盘 (baidu), 夸克网盘 (quark), UC网盘 (uc), 迅雷网盘 (xunlei)。搜索结果需能按这些类型筛选。
* **核心功能:**
* 关键词搜索。
* 按网盘类型筛选。
* 获取临时分享链接(通过后端转存)。
* **后台:** 实现必要的API、缓存、数据库记录和日志。
## **2\. 技术栈**
* **前端:** Nuxt.js (使用TypeScript)
* **后端:** Express.js (使用TypeScript)
* **数据库:** MySQL 8.0 (ORM: Prisma)
* **缓存:** Redis
## **3\. 架构概述**
* **前端:** 负责用户界面展示、接收用户输入、向后端请求数据、存储从后端获取的完整搜索结果列表、在本地实现所有筛选和分页逻辑。
* **后端:**
* 提供RESTful API供前端调用。
* 与唯一的外部搜索API (http://192.168.1.99:8008/api/search) 交互。
* 处理和缓存搜索结果。
* 管理系统自有的网盘账号(每种类型一个)。
* 实现核心的“转存与分享”逻辑(DriveAdapter模式)。
* 执行资源清理的后台任务。
* 记录详细的操作日志到数据库。
* **Drive Adapters:** 后端采用适配器模式,为每种支持的网盘(Baidu, Quark, UC, Xunlei)实现统一接口,封装其特定的API调用逻辑(如转存、生成分享链接、删除文件)。
## **4\. 功能需求 \- 前端**
### **4.1. 搜索页面 (/)**
* **界面元素:**
* 顶部包含一个关键词输入框和搜索按钮。
* 提供按**网盘类型**(百度、夸克、UC、迅雷)筛选的控件(如按钮组或复选框)。
* 搜索结果列表区域。
* 分页控件。
* **结果展示:**
* 搜索结果以列表形式展示。
* 每项结果至少清晰显示:
* **标题/文件名 (displayTitle)**
* **来源网盘 (driveType)**: 可以用文字或小图标表示。
* **发布日期 (publishDate)**
* 每项结果旁边提供一个 **“获取链接”** 按钮。
* **交互逻辑:**
* 用户输入关键词并点击搜索,前端调用后端 GET /api/search 接口。
* 前端接收并**存储全部**搜索结果数据。
* 用户点击网盘类型筛选控件,前端**在本地**对已存储的数据进行过滤显示。
* 用户使用分页控件,前端**在本地**计算并显示对应页码的数据。
* 用户点击某项结果的“获取链接”按钮,前端调用后端 POST /api/generate-link 接口,并传递该项对应的 itemId。前端需要处理等待状态,并在获取到链接或错误码后给用户反馈。
## **5\. 功能需求 \- 后端 API**
### **5.1. GET /api/search**
* **输入:** keyword (Query Parameter, String, Required)
* **外部API认证配置:**
* 后端应用需要安全地配置用于登录外部搜索接口 (http://192.168.1.99:8008/api/user/login) 的 username 和 password。这些凭证**绝不能**硬编码,应通过环境变量或加密配置文件提供。
* 后端需要一个机制来存储和管理从外部登录接口获取的当前有效的 token (例如,存储在内存变量中,并考虑持久化到Redis以支持多实例部署和重启后恢复,或者每次应用启动时重新登录获取)。
* **处理流程:**
1. **确保外部API Token有效:** a. 检查当前是否已持有有效的 token。可以通过检查 token 的过期时间(如果JWT token中包含exp声明)或通过一个简单的状态标记来判断。 b. **如果 token 不存在、无效或即将过期:** i. 调用外部登录接口 POST http://192.168.1.99:8008/api/user/login,请求体包含配置好的 username 和 password。 ii. 如果登录成功,获取返回的新 token,并更新后端持有的当前 token 及其过期信息。记录登录成功日志。 iii.如果登录失败,记录严重错误日志(例如,无法连接外部认证服务,凭证错误),并向上游(即我们的前端)返回一个特定的错误码,表明搜索服务暂时不可用。
2. **执行搜索(结合缓存逻辑):** a. 构建Redis缓存Key (例如: search:${keyword})。尝试从Redis获取缓存。若命中,直接返回缓存数据(包含itemId的列表)。 b. 若未命中缓存: i. **调用外部搜索API (携带Token):** GET http://192.168.1.99:8008/api/search?keyword={keyword} 在请求头中添加 Authorization: Bearer \<当前有效的token\>。 ii. **处理Token失效(重试机制):** 如果调用外部搜索API返回401或403错误(表示Token无效或过期): 1\. 立即执行上述**步骤 1.b (重新登录获取新Token)**。 2\. 如果成功获取新Token,则**重试一次**步骤 2.b.i (调用外部搜索API)。 3\. 如果重试仍然失败,或重新登录失败,则记录严重错误日志,并向上游返回错误。 iii.处理外部API的响应数据。对于返回的每个资源项: 1\. 生成一个全局唯一的 itemId (例如: UUID)。 2\. **数据提取与格式化:** 确保包含 driveType (统一格式), publishDate (ISO 8601), displayTitle, 以及后续“获取链接”所需的原始信息(如 cloudLinks, 原始 messageId)。 3\. 将该项的**完整处理后信息**存入RedisKey为 itemId**TTL设置为 1-2 天**。 iv. 记录本次搜索操作(成功或因外部API问题失败)到 ActivityLog 表(包含关键词、来源IP等)。 v. 如果外部API调用成功,将包含所有处理后结果项(至少含 itemId, driveType, publishDate, displayTitle)的**完整列表**存入RedisKey为 search:${keyword}**TTL建议设置为 5 分钟**。 vi. 将此完整列表返回给前端(或在发生不可恢复错误时返回错误响应)。
* **输出:** \[{ itemId, displayTitle, driveType, publishDate, ... }, ...\] 或错误响应。
* **并发管理 (建议):** 当多个并发请求发现token失效时,应有机制避免同时多次调用外部登录接口(例如,使用一个简单的锁或请求队列来管理token的更新过程)。
### **5.2. POST /api/generate-link**
* **输入:** JSON Body { "itemId": "..." } (String, Required)
* **处理流程 (同步执行):**
1. 记录操作开始 (GET\_LINK\_START) 到 ActivityLog(包含 itemId, IP等)。
2. 使用 itemId 从Redis获取资源的完整信息。若获取失败(Key不存在或过期),返回错误码。
3. 从资源信息中确定所需的 driveType。
4. 从数据库 Account 表中查找该 driveType 对应的**唯一**管理账号。若找不到或账号状态无效,记录错误并返回错误码。
5. 根据 driveType 获取相应的 DriveAdapter 实例。
6. 调用 adapter.transferAndShare(sourceLink, sourceLinkType, managedAccount) 方法执行核心逻辑。
7. **若成功:** a. transferAndShare 方法应返回生成的 tempLink 和用于后续删除的 transferredFileId。 b. 在 TranscodeRecord 表中记录或更新本次操作的状态为 'success',并保存 tempLink, transferredFileId, 完成时间等信息。 c. 记录操作成功 (GET\_LINK\_SUCCESS) 到 ActivityLog。 d. 向前端返回 { "success": true, "tempLink": "..." }。
8. **若失败 (在任何步骤):** a. 在 TranscodeRecord 表中记录或更新本次操作的状态为 'failed',并保存错误信息。 b. 记录操作失败 (GET\_LINK\_FAILURE) 到 ActivityLog,包含返回给前端的错误码和详细的内部错误信息。 c. 向前端返回 { "success": false, "errorCode": "SOME\_ERROR\_CODE" } (错误码应预定义,不暴露内部细节)。
* **输出:** 成功时返回包含临时链接的JSON,失败时返回包含错误码的JSON。
## **6\. 功能需求 \- 后台任务**
### **6.1. 资源清理任务**
* **触发:** 定时执行(例如: 使用 node-cron 或类似库,每5分钟执行一次)。
* **逻辑:**
1. 查询 TranscodeRecord 表,筛选条件为:status \== 'success', cleanupStatus \== 'pending', 且 transferEndTime 早于当前时间减去约10分钟。
2. 对每个符合条件的记录: a. 获取 transferredFileId, driveType, managedAccountId。 b. 获取对应的 Account 信息和 DriveAdapter 实例。 c. 调用 adapter.delete(transferredFileId, managedAccount) 方法删除网盘中的文件。 d. **若删除成功:** 更新 TranscodeRecord 的 cleanupStatus 为 'done'。记录清理成功 (CLEANUP\_SUCCESS) 到 ActivityLog。 e. **若删除失败:** 更新 TranscodeRecord 的 cleanupStatus 为 'failed'。记录清理失败 (CLEANUP\_FAILURE) 到 ActivityLog,包含错误信息。
## **7\. 数据模型 (Database \- MySQL/Prisma)**
### **7.1. Account 表 (管理系统自有的网盘账号)**
* id: Int @id @default(autoincrement())
* driveType: String @unique (例如: 'baidu', 'quark', 'uc', 'xunlei')
* accountIdentifier: String (用于标识账号, 如用户名)
* credentials: String @db.Text (存储加密后的JSON字符串,包含API Key/Secret, Cookie等)
* status: String @default("active") (例如: 'active', 'inactive', 'error')
* lastChecked: DateTime?
* createdAt: DateTime @default(now())
* updatedAt: DateTime @updatedAt
### **7.2. TranscodeRecord 表 (跟踪转存和清理状态)**
* id: Int @id @default(autoincrement())
* itemId: String @index (关联的资源项ID)
* sourceLink: String @db.Text
* driveType: String
* managedAccountId: Int (外键关联 Account.id)
* status: String @default("pending") ('pending', 'success', 'failed')
* tempLink: String? @db.Text
* transferredFileId: String? (由Adapter返回,用于删除)
* transferStartTime: DateTime @default(now())
* transferEndTime: DateTime?
* errorCode: String?
* errorMessage: String? @db.Text
* cleanupStatus: String @default("pending") ('pending', 'done', 'failed') @index
* createdAt: DateTime @default(now())
* updatedAt: DateTime @updatedAt
* account: Account @relation(fields: \[managedAccountId\], references: \[id\])
### **7.3. ActivityLog 表 (操作审计日志)**
* id: BigInt @id @default(autoincrement())
* timestamp: DateTime @default(now())
* operationType: String ('SEARCH', 'GET\_LINK\_START', 'GET\_LINK\_SUCCESS', 'GET\_LINK\_FAILURE', 'CLEANUP\_SUCCESS', 'CLEANUP\_FAILURE')
* clientIp: String?
* keyword: String?
* itemId: String? @index
* driveType: String?
* managedAccountId: Int? (关联 Account.id)
* generatedLink: String? @db.Text
* errorCode: String?
* errorMessage: String? @db.Text
* durationMs: Int?
* details: String? @db.Text (存储额外JSON格式的上下文信息)
## **8\. 非功能性需求**
* **缓存:**
* 搜索结果列表缓存 (search:{keyword} \-\> Item List): Redis, TTL \~5分钟。
* 单个项目详细信息缓存 (itemId \-\> Full Item Data): Redis, TTL 1-2天。
* **安全:**
* Account 表中的 credentials 字段必须加密存储 (例如: 使用Node.js的 crypto 模块进行 AES 加密,密钥需妥善管理)。
* 后端API需实施速率限制 (例如: 使用 express-rate-limit 中间件,限制 IP 频率,特别是 POST /api/generate-link)。
* 记录请求来源IP地址到 ActivityLog。
* **日志:**
* 后端应使用成熟的日志库 (如 Winston, pino) 进行结构化日志记录。
* 关键操作、错误均需记录到 ActivityLog 数据库表。
* **错误处理:**
* 后端API在发生内部错误时,应返回预定义的、不暴露细节的错误码给前端。
* 详细的错误信息和堆栈应记录在后端日志和 ActivityLog 的 errorMessage 字段中。
* 前端根据错误码显示统一的、用户友好的错误提示。
## **9\. V1.0 版本范围之外**
* 管理后台界面及相关API (/api/stats, /api/accounts)。
* 用户注册、登录系统。
* 除“网盘类型”外的其他高级搜索筛选(文件类型、大小、时间等)。
* 搜索结果中的文件列表预览功能。
* 异步处理“获取链接”请求。
* 支持每种网盘类型配置多个管理账号及选择策略。
* 与日志聚合系统(如Loki)的集成。
+50
View File
@@ -0,0 +1,50 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
depends_on:
- backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "4000:4000"
environment:
- NODE_ENV=production
- DATABASE_URL=mysql://bps:bps_password@db:3306/bps
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
db:
image: mysql:8.0
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=bps
- MYSQL_USER=bps
- MYSQL_PASSWORD=bps_password
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7.0
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
+26
View File
@@ -0,0 +1,26 @@
# Build stage
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/package*.json ./
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
+1 -1
View File
@@ -11,6 +11,6 @@ export default defineNuxtConfig({
port: 3000
},
app: {
baseURL: '/absproxy/3000'
// baseURL: '/absproxy/3000'
}
})
+11 -5
View File
@@ -1,7 +1,7 @@
{
"name": "nuxt-app",
"name": "bps-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@@ -10,8 +10,14 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"nuxt": "^3.17.1",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
"@nuxtjs/tailwindcss": "^6.11.0",
"nuxt": "^3.10.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}
+4710 -5232
View File
File diff suppressed because it is too large Load Diff
+356 -1
View File
@@ -5,7 +5,138 @@
"packages": {
"": {
"dependencies": {
"dotenv": "^16.5.0"
"@types/axios": "^0.14.4",
"@types/redis": "^4.0.11",
"axios": "^1.9.0",
"dotenv": "^16.5.0",
"redis": "^5.0.1"
}
},
"node_modules/@redis/bloom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.0.1.tgz",
"integrity": "sha512-F7L+rnuJvq/upKaVoEgsf8VT7g5pLQYWRqSUOV3uO4vpVtARzSKJ7CLyJjVsQS+wZVCGxsLMh8DwAIDcny1B+g==",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.0.1"
}
},
"node_modules/@redis/client": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.0.1.tgz",
"integrity": "sha512-k0EJvlMGEyBqUD3orKe0UMZ66fPtfwqPIr+ZSd853sXj2EyhNtPXSx+J6sENXJNgAlEBhvD+57Dwt0qTisKB0A==",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@redis/json": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.0.1.tgz",
"integrity": "sha512-t94HOTk5myfhvaHZzlUzk2hoUvH2jsjftcnMgJWuHL/pzjAJQoZDCUJzjkoXIUjWXuyJixTguaaDyOZWwqH2Kg==",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.0.1"
}
},
"node_modules/@redis/search": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.0.1.tgz",
"integrity": "sha512-wipK6ZptY7K68B7YLVhP5I/wYCDUU+mDJMyJiUcQLuOs7/eKOBc8lTXKUSssor8QnzZSPy4A5ulcC5PZY22Zgw==",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.0.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.0.1.tgz",
"integrity": "sha512-k6PgbrakhnohsEWEAdQZYt3e5vSKoIzpKvgQt8//lnWLrTZx+c3ed2sj0+pKIF4FvnSeuXLo4bBWcH0Z7Urg1A==",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.0.1"
}
},
"node_modules/@types/axios": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz",
"integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==",
"deprecated": "This is a stub types definition. axios provides its own type definitions, so you do not need this installed.",
"dependencies": {
"axios": "*"
}
},
"node_modules/@types/redis": {
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.11.tgz",
"integrity": "sha512-bI+gth8La8Wg/QCR1+V1fhrL9+LZUSWfcqpOj2Kc80ZQ4ffbdL173vQd5wovmoV9i071FU9oP2g6etLuEwb6Rg==",
"deprecated": "This is a stub types definition. redis provides its own type definitions, so you do not need this installed.",
"dependencies": {
"redis": "*"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dotenv": {
@@ -18,6 +149,230 @@
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/redis": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.0.1.tgz",
"integrity": "sha512-J8nqUjrfSq0E8NQkcHDZ4HdEQk5RMYjP3jZq02PE+ERiRxolbDNxPaTT4xh6tdrme+lJ86Goje9yMt9uzh23hQ==",
"dependencies": {
"@redis/bloom": "5.0.1",
"@redis/client": "5.0.1",
"@redis/json": "5.0.1",
"@redis/search": "5.0.1",
"@redis/time-series": "5.0.1"
},
"engines": {
"node": ">= 18"
}
}
}
}
+5 -1
View File
@@ -1,5 +1,9 @@
{
"dependencies": {
"dotenv": "^16.5.0"
"@types/axios": "^0.14.4",
"@types/redis": "^4.0.11",
"axios": "^1.9.0",
"dotenv": "^16.5.0",
"redis": "^5.0.1"
}
}