feat: 百度网盘相关功能开发&代码整理

This commit is contained in:
mazxd
2025-05-14 21:46:38 +08:00
parent 0894d9ccdf
commit 3ae21ac4c2
12 changed files with 329 additions and 112 deletions
+1
View File
@@ -21,6 +21,7 @@ output/
# Prisma # Prisma
backend/prisma/dev.db backend/prisma/dev.db
backend/prisma/dev.db-journal backend/prisma/dev.db-journal
backend/prisma/generated/
# 其他 # 其他
frontend/.output/ frontend/.output/
Binary file not shown.
Binary file not shown.
+56 -3
View File
@@ -3,6 +3,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "./generated/client"
} }
datasource db { datasource db {
@@ -10,55 +11,107 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
/// 网盘类型枚举
enum DriveType {
/// 百度网盘
BAIDU
/// 阿里云盘
ALIYUN
/// 其他网盘类型可以在这里添加
}
/// 网盘账号信息表
model Account { model Account {
/// 账号ID
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
driveType String @unique /// 网盘类型
driveType DriveType
/// 账号标识符(如:用户名、邮箱等)
accountIdentifier String accountIdentifier String
/// 账号凭证(密码)
credentials String @db.Text credentials String @db.Text
/// 账号状态(active: 活跃, inactive: 未激活, blocked: 被封禁)
status String @default("active") status String @default("active")
/// 最后检查时间
lastChecked DateTime? lastChecked DateTime?
/// 创建时间
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// 更新时间
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// 转码记录关联
transcodeRecords TranscodeRecord[] transcodeRecords TranscodeRecord[]
/// 活动日志关联
activityLogs ActivityLog[] activityLogs ActivityLog[]
} }
/// 转码记录表
model TranscodeRecord { model TranscodeRecord {
/// 记录ID
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
/// 项目ID
itemId String itemId String
/// 源文件链接
sourceLink String @db.Text sourceLink String @db.Text
driveType String /// 网盘类型
driveType DriveType
/// 管理的账号ID
managedAccountId Int managedAccountId Int
/// 转码状态(pending: 等待中, processing: 处理中, completed: 完成, failed: 失败)
status String @default("pending") status String @default("pending")
/// 临时分享链接
tempLink String? @db.Text tempLink String? @db.Text
/// 转存后的文件ID
transferredFileId String? transferredFileId String?
/// 转存开始时间
transferStartTime DateTime @default(now()) transferStartTime DateTime @default(now())
/// 转存结束时间
transferEndTime DateTime? transferEndTime DateTime?
/// 错误代码
errorCode String? errorCode String?
/// 错误信息
errorMessage String? @db.Text errorMessage String? @db.Text
/// 清理状态(pending: 待清理, completed: 已清理, failed: 清理失败)
cleanupStatus String @default("pending") cleanupStatus String @default("pending")
/// 创建时间
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// 更新时间
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// 关联的账号
account Account @relation(fields: [managedAccountId], references: [id]) account Account @relation(fields: [managedAccountId], references: [id])
@@index([itemId]) @@index([itemId])
@@index([cleanupStatus]) @@index([cleanupStatus])
} }
/// 活动日志表
model ActivityLog { model ActivityLog {
/// 日志ID
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
/// 时间戳
timestamp DateTime @default(now()) timestamp DateTime @default(now())
/// 操作类型
operationType String operationType String
/// 客户端IP
clientIp String? clientIp String?
/// 关键词
keyword String? keyword String?
/// 项目ID
itemId String? itemId String?
driveType String? /// 网盘类型
driveType DriveType?
/// 管理的账号ID
managedAccountId Int? managedAccountId Int?
/// 生成的链接
generatedLink String? @db.Text generatedLink String? @db.Text
/// 错误代码
errorCode String? errorCode String?
/// 错误信息
errorMessage String? @db.Text errorMessage String? @db.Text
/// 操作持续时间(毫秒)
durationMs Int? durationMs Int?
/// 详细信息
details String? @db.Text details String? @db.Text
/// 关联的账号
account Account? @relation(fields: [managedAccountId], references: [id]) account Account? @relation(fields: [managedAccountId], references: [id])
@@index([itemId]) @@index([itemId])
+125 -23
View File
@@ -1,8 +1,45 @@
import { Account } from '@prisma/client'; import { Account, PrismaClient } from '@prisma/client';
import { DriveAdapter, TransferResult } from './DriveAdapter'; import { DriveAdapter } from './DriveAdapter';
import { logger } from '../utils/logger'; 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 { 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<string> {
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<void> {
await prisma.account.update({
where: { id: account.id },
data: {
status,
lastChecked: new Date(),
...(error && { credentials: error })
}
});
}
async transferAndShare( async transferAndShare(
sourceLink: string, sourceLink: string,
sourceLinkType: string, sourceLinkType: string,
@@ -10,22 +47,42 @@ export class BaiduDriveAdapter implements DriveAdapter {
): Promise<TransferResult> { ): Promise<TransferResult> {
try { try {
logger.info('Baidu drive transferAndShare', { sourceLink, sourceLinkType, accountId: account.id }); logger.info('Baidu drive transferAndShare', { sourceLink, sourceLinkType, accountId: account.id });
// TODO: 实现百度网盘的具体转存和分享逻辑
// 1. 解析账号凭证
const credentials = JSON.parse(account.credentials);
// 2. 转存文件 // 1. 确保账号状态正常
// const transferredFileId = await this.transferFile(sourceLink, credentials); if (account.status !== 'active') {
throw new Error(`Account is not active, current status: ${account.status}`);
}
// 2. 确保已登录
await this.ensureLoggedIn(account);
// 3. 生成分享链接 // 3. 转存文件
// const tempLink = await this.generateShareLink(transferredFileId, credentials); 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 { return {
tempLink: 'https://pan.baidu.com/s/example', tempLink,
transferredFileId: 'example_file_id' 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 }); logger.error('Baidu drive transfer failed', { error, accountId: account.id });
throw new Error('Failed to transfer and share file'); 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<void> { async delete(fileId: string, account: Account): Promise<void> {
try { try {
// TODO: 实现百度网盘的文件删除逻辑 // 1. 确保账号状态正常
const credentials = JSON.parse(account.credentials); if (account.status !== 'active') {
// await this.deleteFile(fileId, credentials); throw new Error(`Account is not active, current status: ${account.status}`);
} catch (error) { }
// 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 }); logger.error('Baidu drive delete failed', { error, accountId: account.id });
throw new Error('Failed to delete file'); throw new Error('Failed to delete file');
} }
@@ -44,14 +114,46 @@ export class BaiduDriveAdapter implements DriveAdapter {
async checkAccountStatus(account: Account): Promise<boolean> { async checkAccountStatus(account: Account): Promise<boolean> {
try { try {
// TODO: 实现百度网盘的账号状态检查逻辑 // 1. 尝试登录
const credentials = JSON.parse(account.credentials); await this.ensureLoggedIn(account);
// const isValid = await this.checkAccount(credentials);
// return isValid; // 2. 验证登录状态
return true; // 临时返回 await this.executeCommand(['pwd']);
} catch (error) {
// 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 }); logger.error('Baidu drive account check failed', { error, accountId: account.id });
return false; return false;
} }
} }
private async ensureLoggedIn(account: Account): Promise<void> {
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] : '';
}
} }
+8
View File
@@ -4,9 +4,17 @@ import rateLimit from 'express-rate-limit'
import swaggerUi from 'swagger-ui-express' import swaggerUi from 'swagger-ui-express'
import { swaggerSpec } from './config/swagger' import { swaggerSpec } from './config/swagger'
import { getSearchResults, getLinkForResource } from './controllers/searchController' import { getSearchResults, getLinkForResource } from './controllers/searchController'
import { initRedis } from './lib/redis'
import logger from './lib/logger'
const app = express() const app = express()
// 初始化 Redis 连接
initRedis().catch(err => {
logger.error('Redis 初始化失败,应用启动终止', { error: err })
process.exit(1)
})
app.use(cors()) app.use(cors())
app.use(express.json()) app.use(express.json())
app.use(rateLimit({ windowMs: 1000, max: 5 })) app.use(rateLimit({ windowMs: 1000, max: 5 }))
+29
View File
@@ -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;
+11
View File
@@ -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;
}
+63
View File
@@ -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;
}
+29
View File
@@ -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<void> {
try {
await redis.connect();
logger.info('Redis 连接初始化成功');
} catch (err) {
logger.error('Redis 连接初始化失败:', { error: err });
throw err;
}
}
// 导出 Redis 客户端实例
export { redis };
+2 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { DriveType } from '../entity/drive.types';
// 监听 SQL 查询 // 监听 SQL 查询
(prisma as any).$on('query', (e: Prisma.QueryEvent) => { (prisma as any).$on('query', (e: Prisma.QueryEvent) => {
@@ -35,7 +36,7 @@ export class ActivityLogService {
clientIp?: string; clientIp?: string;
keyword?: string; keyword?: string;
itemId?: string; itemId?: string;
driveType?: string; driveType?: DriveType;
managedAccountId?: number; managedAccountId?: number;
generatedLink?: string; generatedLink?: string;
errorCode?: string; errorCode?: string;
+5 -85
View File
@@ -1,91 +1,11 @@
import axios from 'axios'; import axios from 'axios';
import { config } from '../config/config'; import { config } from '../config/config';
import { createClient } from 'redis';
import logger from '../lib/logger'; import logger from '../lib/logger';
import { handleError } from '../decorators/errorHandler'; import { handleError } from '../decorators/errorHandler';
import { AppError } from '../utils/AppError'; import { AppError } from '../utils/AppError';
import { LoginResponse, SearchResponse, SearchParams, SearchResult, LinkResult } from '../entity/search.types';
const redis = createClient({ import { SEARCH_SERVICE } from '../constants/redis.keys';
url: `redis://${config.redis.host}:${config.redis.port}`, import { redis } from '../lib/redis';
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;
}
export class SearchService { export class SearchService {
private baseUrl: string; private baseUrl: string;
@@ -98,7 +18,7 @@ export class SearchService {
@handleError('获取认证 token 失败') @handleError('获取认证 token 失败')
private async getToken(): Promise<string> { private async getToken(): Promise<string> {
// 尝试从 Redis 获取 token // 尝试从 Redis 获取 token
const cachedToken = await redis.get(TOKEN_KEY); const cachedToken = await redis.get(SEARCH_SERVICE.TOKEN);
if (cachedToken) { if (cachedToken) {
logger.debug('从缓存中获取到 token'); logger.debug('从缓存中获取到 token');
return cachedToken; return cachedToken;
@@ -120,7 +40,7 @@ export class SearchService {
logger.info('成功获取新 token'); logger.info('成功获取新 token');
// 将 token 缓存到 Redis // 将 token 缓存到 Redis
await redis.set(TOKEN_KEY, response.data.data.token, { await redis.set(SEARCH_SERVICE.TOKEN, response.data.data.token, {
EX: 21600, // 6 小时 EX: 21600, // 6 小时
}); });
return response.data.data.token; return response.data.data.token;