feat: 百度网盘相关功能开发&代码整理
This commit is contained in:
@@ -21,6 +21,7 @@ output/
|
||||
# Prisma
|
||||
backend/prisma/dev.db
|
||||
backend/prisma/dev.db-journal
|
||||
backend/prisma/generated/
|
||||
|
||||
# 其他
|
||||
frontend/.output/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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])
|
||||
|
||||
@@ -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<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(
|
||||
sourceLink: string,
|
||||
sourceLinkType: string,
|
||||
@@ -10,22 +47,42 @@ export class BaiduDriveAdapter implements DriveAdapter {
|
||||
): Promise<TransferResult> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 3. 生成分享链接
|
||||
// const tempLink = await this.generateShareLink(transferredFileId, credentials);
|
||||
// 2. 确保已登录
|
||||
await this.ensureLoggedIn(account);
|
||||
|
||||
// 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<void> {
|
||||
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<boolean> {
|
||||
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<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] : '';
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> {
|
||||
// 尝试从 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;
|
||||
|
||||
Reference in New Issue
Block a user