diff --git a/.gitignore b/.gitignore index 54fd942..50c6dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ output/ # Prisma backend/prisma/dev.db backend/prisma/dev.db-journal +backend/prisma/generated/ # 其他 frontend/.output/ diff --git a/backend/bin/linux/BaiduPCS-Go b/backend/bin/linux/BaiduPCS-Go new file mode 100644 index 0000000..5c3f131 Binary files /dev/null and b/backend/bin/linux/BaiduPCS-Go differ diff --git a/backend/bin/windows/BaiduPCS-Go.exe b/backend/bin/windows/BaiduPCS-Go.exe new file mode 100644 index 0000000..25d48c9 Binary files /dev/null and b/backend/bin/windows/BaiduPCS-Go.exe differ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 18b25de..9efdefa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + output = "./generated/client" } datasource db { @@ -10,55 +11,107 @@ datasource db { url = env("DATABASE_URL") } +/// 网盘类型枚举 +enum DriveType { + /// 百度网盘 + BAIDU + /// 阿里云盘 + ALIYUN + /// 其他网盘类型可以在这里添加 +} + +/// 网盘账号信息表 model Account { + /// 账号ID id Int @id @default(autoincrement()) - driveType String @unique + /// 网盘类型 + driveType DriveType + /// 账号标识符(如:用户名、邮箱等) accountIdentifier String + /// 账号凭证(密码) credentials String @db.Text + /// 账号状态(active: 活跃, inactive: 未激活, blocked: 被封禁) status String @default("active") + /// 最后检查时间 lastChecked DateTime? + /// 创建时间 createdAt DateTime @default(now()) + /// 更新时间 updatedAt DateTime @updatedAt + /// 转码记录关联 transcodeRecords TranscodeRecord[] + /// 活动日志关联 activityLogs ActivityLog[] } +/// 转码记录表 model TranscodeRecord { + /// 记录ID id Int @id @default(autoincrement()) + /// 项目ID itemId String + /// 源文件链接 sourceLink String @db.Text - driveType String + /// 网盘类型 + driveType DriveType + /// 管理的账号ID managedAccountId Int + /// 转码状态(pending: 等待中, processing: 处理中, completed: 完成, failed: 失败) status String @default("pending") + /// 临时分享链接 tempLink String? @db.Text + /// 转存后的文件ID transferredFileId String? + /// 转存开始时间 transferStartTime DateTime @default(now()) + /// 转存结束时间 transferEndTime DateTime? + /// 错误代码 errorCode String? + /// 错误信息 errorMessage String? @db.Text + /// 清理状态(pending: 待清理, completed: 已清理, failed: 清理失败) cleanupStatus String @default("pending") + /// 创建时间 createdAt DateTime @default(now()) + /// 更新时间 updatedAt DateTime @updatedAt + /// 关联的账号 account Account @relation(fields: [managedAccountId], references: [id]) @@index([itemId]) @@index([cleanupStatus]) } +/// 活动日志表 model ActivityLog { + /// 日志ID id BigInt @id @default(autoincrement()) + /// 时间戳 timestamp DateTime @default(now()) + /// 操作类型 operationType String + /// 客户端IP clientIp String? + /// 关键词 keyword String? + /// 项目ID itemId String? - driveType String? + /// 网盘类型 + driveType DriveType? + /// 管理的账号ID 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]) @@index([itemId]) diff --git a/backend/src/adapters/BaiduDriveAdapter.ts b/backend/src/adapters/BaiduDriveAdapter.ts index 9402231..1478171 100644 --- a/backend/src/adapters/BaiduDriveAdapter.ts +++ b/backend/src/adapters/BaiduDriveAdapter.ts @@ -1,8 +1,45 @@ -import { Account } from '@prisma/client'; -import { DriveAdapter, TransferResult } from './DriveAdapter'; +import { Account, PrismaClient } from '@prisma/client'; +import { DriveAdapter } from './DriveAdapter'; import { logger } from '../utils/logger'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; +import * as os from 'os'; +import { DRIVE_TYPE, DriveType, TransferResult } from '../entity/drive.types'; + +const execFileAsync = promisify(execFile); +const prisma = new PrismaClient(); export class BaiduDriveAdapter implements DriveAdapter { + private getBaiduPCSExecutable(): string { + const isWindows = os.platform() === 'win32'; + const binDir = path.join(__dirname, '../../bin'); + const platformDir = isWindows ? 'windows' : 'linux'; + const executable = isWindows ? 'BaiduPCS-Go.exe' : 'BaiduPCS-Go'; + return path.join(binDir, platformDir, executable); + } + + private async executeCommand(args: string[]): Promise { + try { + const { stdout } = await execFileAsync(this.getBaiduPCSExecutable(), args); + return stdout.trim(); + } catch (error) { + logger.error('BaiduPCS command execution failed', { error, args }); + throw error; + } + } + + private async updateAccountStatus(account: Account, status: string, error?: string): Promise { + await prisma.account.update({ + where: { id: account.id }, + data: { + status, + lastChecked: new Date(), + ...(error && { credentials: error }) + } + }); + } + async transferAndShare( sourceLink: string, sourceLinkType: string, @@ -10,22 +47,42 @@ export class BaiduDriveAdapter implements DriveAdapter { ): Promise { try { logger.info('Baidu drive transferAndShare', { sourceLink, sourceLinkType, accountId: account.id }); - // TODO: 实现百度网盘的具体转存和分享逻辑 - // 1. 解析账号凭证 - const credentials = JSON.parse(account.credentials); - // 2. 转存文件 - // const transferredFileId = await this.transferFile(sourceLink, credentials); + // 1. 确保账号状态正常 + if (account.status !== 'active') { + throw new Error(`Account is not active, current status: ${account.status}`); + } + + // 2. 确保已登录 + await this.ensureLoggedIn(account); - // 3. 生成分享链接 - // const tempLink = await this.generateShareLink(transferredFileId, credentials); + // 3. 转存文件 + const [link, pwd] = sourceLink.split('?pwd='); + const transferArgs = ['transfer', link]; + if (pwd) { + transferArgs.push(pwd); + } + + const transferResult = await this.executeCommand(transferArgs); + logger.info('File transfer completed', { transferResult }); + + // 4. 获取转存后的文件ID + const transferredFileId = this.parseFileIdFromTransferResult(transferResult); + + // 5. 生成分享链接 + const shareResult = await this.executeCommand(['share', transferredFileId]); + const tempLink = this.parseShareLinkFromShareResult(shareResult); + + // 6. 更新账号状态 + await this.updateAccountStatus(account, 'active'); - // 临时返回模拟数据 return { - tempLink: 'https://pan.baidu.com/s/example', - transferredFileId: 'example_file_id' + tempLink, + transferredFileId }; - } catch (error) { + } catch (error: any) { + // 更新账号状态为错误 + await this.updateAccountStatus(account, 'error', error?.message || 'Unknown error'); logger.error('Baidu drive transfer failed', { error, accountId: account.id }); throw new Error('Failed to transfer and share file'); } @@ -33,10 +90,23 @@ export class BaiduDriveAdapter implements DriveAdapter { async delete(fileId: string, account: Account): Promise { try { - // TODO: 实现百度网盘的文件删除逻辑 - const credentials = JSON.parse(account.credentials); - // await this.deleteFile(fileId, credentials); - } catch (error) { + // 1. 确保账号状态正常 + if (account.status !== 'active') { + throw new Error(`Account is not active, current status: ${account.status}`); + } + + // 2. 确保已登录 + await this.ensureLoggedIn(account); + + // 3. 删除文件 + await this.executeCommand(['rm', fileId]); + logger.info('File deleted successfully', { fileId, accountId: account.id }); + + // 4. 更新账号状态 + await this.updateAccountStatus(account, 'active'); + } catch (error: any) { + // 更新账号状态为错误 + await this.updateAccountStatus(account, 'error', error?.message || 'Unknown error'); logger.error('Baidu drive delete failed', { error, accountId: account.id }); throw new Error('Failed to delete file'); } @@ -44,14 +114,46 @@ export class BaiduDriveAdapter implements DriveAdapter { async checkAccountStatus(account: Account): Promise { try { - // TODO: 实现百度网盘的账号状态检查逻辑 - const credentials = JSON.parse(account.credentials); - // const isValid = await this.checkAccount(credentials); - // return isValid; - return true; // 临时返回 - } catch (error) { + // 1. 尝试登录 + await this.ensureLoggedIn(account); + + // 2. 验证登录状态 + await this.executeCommand(['pwd']); + + // 3. 更新账号状态为活跃 + await this.updateAccountStatus(account, 'active'); + return true; + } catch (error: any) { + // 更新账号状态为错误 + await this.updateAccountStatus(account, 'error', error?.message || 'Unknown error'); logger.error('Baidu drive account check failed', { error, accountId: account.id }); return false; } } + + private async ensureLoggedIn(account: Account): Promise { + try { + // 先尝试执行一个命令来检查是否已登录 + await this.executeCommand(['pwd']); + } catch (error) { + // 如果未登录,则执行登录 + await this.executeCommand([ + 'login', + account.accountIdentifier, + account.credentials + ]); + } + } + + private parseFileIdFromTransferResult(result: string): string { + // 从转存结果中解析文件ID + const match = result.match(/文件ID: (\d+)/); + return match ? match[1] : ''; + } + + private parseShareLinkFromShareResult(result: string): string { + // 从分享结果中解析分享链接 + const match = result.match(/分享链接: (https:\/\/pan\.baidu\.com\/s\/\w+)/); + return match ? match[1] : ''; + } } \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index e0d6103..5334945 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,9 +4,17 @@ import rateLimit from 'express-rate-limit' import swaggerUi from 'swagger-ui-express' import { swaggerSpec } from './config/swagger' import { getSearchResults, getLinkForResource } from './controllers/searchController' +import { initRedis } from './lib/redis' +import logger from './lib/logger' const app = express() +// 初始化 Redis 连接 +initRedis().catch(err => { + logger.error('Redis 初始化失败,应用启动终止', { error: err }) + process.exit(1) +}) + app.use(cors()) app.use(express.json()) app.use(rateLimit({ windowMs: 1000, max: 5 })) diff --git a/backend/src/constants/redis.keys.ts b/backend/src/constants/redis.keys.ts new file mode 100644 index 0000000..45153e7 --- /dev/null +++ b/backend/src/constants/redis.keys.ts @@ -0,0 +1,29 @@ +/** + * Redis key 常量定义 + * 命名规范: + * 1. 使用大写字母和下划线 + * 2. 按功能模块分组 + * 3. 添加注释说明用途 + */ + +// 搜索服务相关 +export const SEARCH_SERVICE = { + /** 搜索服务认证 token */ + TOKEN: 'search_service_token', +} as const; + +// 用户相关 +export const USER = { + /** 用户会话信息 */ + SESSION: 'user:session:', + /** 用户令牌 */ + TOKEN: 'user:token:', +} as const; + +// 系统相关 +export const SYSTEM = { + /** 系统配置缓存 */ + CONFIG: 'system:config', + /** 系统状态 */ + STATUS: 'system:status', +} as const; \ No newline at end of file diff --git a/backend/src/entity/drive.types.ts b/backend/src/entity/drive.types.ts new file mode 100644 index 0000000..47f1309 --- /dev/null +++ b/backend/src/entity/drive.types.ts @@ -0,0 +1,11 @@ +export const DRIVE_TYPE = { + BAIDU: 'BAIDU', + ALIYUN: 'ALIYUN' +} as const; + +export type DriveType = typeof DRIVE_TYPE[keyof typeof DRIVE_TYPE]; + +export interface TransferResult { + tempLink: string; + transferredFileId: string; +} \ No newline at end of file diff --git a/backend/src/entity/search.types.ts b/backend/src/entity/search.types.ts new file mode 100644 index 0000000..e1ae65b --- /dev/null +++ b/backend/src/entity/search.types.ts @@ -0,0 +1,63 @@ +export interface LoginResponse { + success: boolean; + code: number; + data: { + token: string; + }; + message: string; +} + +export interface SearchResponse { + success: boolean; + code: number; + data: Array<{ + list: Array<{ + messageId: string; + title: string; + pubDate: string; + content: string; + image?: string; + cloudLinks?: Array<{ + link: string; + cloudType: string; + }>; + tags?: string[]; + channel: string; + channelId: string; + }>; + channelInfo: { + id: string; + name: string; + index: number; + channelLogo?: string; + }; + }>; + message: string; +} + +export interface SearchParams { + keyword: string; + types: string[]; + page: number; + fileType?: string; + fileSize?: string; + fileTime?: string; + mode?: 'standard' | 'fuzzy'; +} + +export interface SearchResult { + success: boolean; + code: number; + data: any[]; + message: string; +} + +export interface LinkResult { + success: boolean; + code: number; + data: { + url: string; + expireTime: string; + }; + message: string; +} \ No newline at end of file diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 0000000..302b8c1 --- /dev/null +++ b/backend/src/lib/redis.ts @@ -0,0 +1,29 @@ +import { createClient } from 'redis'; +import { config } from '../config/config'; +import logger from './logger'; + +// 创建 Redis 客户端实例 +const redis = createClient({ + url: `redis://${config.redis.host}:${config.redis.port}`, + password: config.redis.password +}); + +// Redis 错误处理和连接监控 +redis.on('error', err => logger.error('Redis 客户端错误:', { error: err })); +redis.on('connect', () => logger.info('Redis 客户端已连接')); +redis.on('ready', () => logger.info('Redis 客户端就绪')); +redis.on('reconnecting', () => logger.warn('Redis 客户端正在重连')); + +// 初始化 Redis 连接 +export async function initRedis(): Promise { + try { + await redis.connect(); + logger.info('Redis 连接初始化成功'); + } catch (err) { + logger.error('Redis 连接初始化失败:', { error: err }); + throw err; + } +} + +// 导出 Redis 客户端实例 +export { redis }; \ No newline at end of file diff --git a/backend/src/services/activityLogService.ts b/backend/src/services/activityLogService.ts index 4e49b2a..836e6ec 100644 --- a/backend/src/services/activityLogService.ts +++ b/backend/src/services/activityLogService.ts @@ -1,5 +1,6 @@ import { prisma } from '../lib/prisma'; import { Prisma } from '@prisma/client'; +import { DriveType } from '../entity/drive.types'; // 监听 SQL 查询 (prisma as any).$on('query', (e: Prisma.QueryEvent) => { @@ -35,7 +36,7 @@ export class ActivityLogService { clientIp?: string; keyword?: string; itemId?: string; - driveType?: string; + driveType?: DriveType; managedAccountId?: number; generatedLink?: string; errorCode?: string; diff --git a/backend/src/services/searchService.ts b/backend/src/services/searchService.ts index 1ddae5d..a8b7fe7 100644 --- a/backend/src/services/searchService.ts +++ b/backend/src/services/searchService.ts @@ -1,91 +1,11 @@ import axios from 'axios'; import { config } from '../config/config'; -import { createClient } from 'redis'; import logger from '../lib/logger'; import { handleError } from '../decorators/errorHandler'; import { AppError } from '../utils/AppError'; - -const redis = createClient({ - url: `redis://${config.redis.host}:${config.redis.port}`, - password: config.redis.password -}); - -// Redis 错误处理和连接监控 -redis.on('error', err => logger.error('Redis 客户端错误:', { error: err })); -redis.on('connect', () => logger.info('Redis 客户端已连接')); -redis.on('ready', () => logger.info('Redis 客户端就绪')); -redis.on('reconnecting', () => logger.warn('Redis 客户端正在重连')); - -// 初始化 Redis 连接 -redis.connect().catch(err => { - logger.error('Redis 连接错误:', { error: err }); -}); - -const TOKEN_KEY = 'search_service_token'; - -interface LoginResponse { - success: boolean; - code: number; - data: { - token: string; - }; - message: string; -} - -interface SearchResponse { - success: boolean; - code: number; - data: Array<{ - list: Array<{ - messageId: string; - title: string; - pubDate: string; - content: string; - image?: string; - cloudLinks?: Array<{ - link: string; - cloudType: string; - }>; - tags?: string[]; - channel: string; - channelId: string; - }>; - channelInfo: { - id: string; - name: string; - index: number; - channelLogo?: string; - }; - }>; - message: string; -} - -interface SearchParams { - keyword: string; - types: string[]; - page: number; - fileType?: string; - fileSize?: string; - fileTime?: string; - mode?: 'standard' | 'fuzzy'; -} - -interface SearchResult { - success: boolean; - code: number; - data: any[]; - message: string; -} - -interface LinkResult { - success: boolean; - code: number; - data: { - url: string; - expireTime: string; - }; - message: string; -} +import { LoginResponse, SearchResponse, SearchParams, SearchResult, LinkResult } from '../entity/search.types'; +import { SEARCH_SERVICE } from '../constants/redis.keys'; +import { redis } from '../lib/redis'; export class SearchService { private baseUrl: string; @@ -98,7 +18,7 @@ export class SearchService { @handleError('获取认证 token 失败') private async getToken(): Promise { // 尝试从 Redis 获取 token - const cachedToken = await redis.get(TOKEN_KEY); + const cachedToken = await redis.get(SEARCH_SERVICE.TOKEN); if (cachedToken) { logger.debug('从缓存中获取到 token'); return cachedToken; @@ -120,7 +40,7 @@ export class SearchService { logger.info('成功获取新 token'); // 将 token 缓存到 Redis - await redis.set(TOKEN_KEY, response.data.data.token, { + await redis.set(SEARCH_SERVICE.TOKEN, response.data.data.token, { EX: 21600, // 6 小时 }); return response.data.data.token;