611 lines
19 KiB
TypeScript
Executable File
611 lines
19 KiB
TypeScript
Executable File
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||
import { customAlphabet } from 'nanoid-cjs';
|
||
import { db, ObjectType, Prisma, Resource } from '@nice/common';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { ResourceService } from '@server/models/resource/resource.service';
|
||
import * as fs from 'fs'
|
||
import * as path from 'path'
|
||
import dayjs from 'dayjs';
|
||
import utc from 'dayjs/plugin/utc';
|
||
import timezone from 'dayjs/plugin/timezone';
|
||
import { BaseService } from '../base/base.service';
|
||
dayjs.extend(utc);
|
||
dayjs.extend(timezone);
|
||
export interface ShareCode {
|
||
id: string;
|
||
code: string;
|
||
fileId: string;
|
||
createdAt: Date;
|
||
expiresAt: Date;
|
||
isUsed: boolean;
|
||
fileName?: string | null;
|
||
canUseTimes: number | null;
|
||
uploadIp?: string;
|
||
}
|
||
export interface GenerateShareCodeResponse {
|
||
id?: string;
|
||
code: string;
|
||
expiresAt: Date;
|
||
canUseTimes: number;
|
||
fileName?: string;
|
||
resource: {
|
||
id: string;
|
||
type: string;
|
||
url: string;
|
||
meta: ResourceMeta
|
||
}
|
||
uploadIp?: string;
|
||
createdAt?: Date;
|
||
}
|
||
|
||
interface ResourceMeta {
|
||
filename: string;
|
||
filetype: string;
|
||
size: string;
|
||
}
|
||
|
||
const ShareCodeSelect = {
|
||
id: true,
|
||
code: true,
|
||
fileId: true,
|
||
expiresAt: true,
|
||
fileName: true,
|
||
canUseTimes: true,
|
||
resource: {
|
||
select: {
|
||
id: true,
|
||
type: true,
|
||
url: true,
|
||
meta: true,
|
||
}
|
||
},
|
||
uploadIp: true,
|
||
createdAt: true,
|
||
}
|
||
|
||
@Injectable()
|
||
export class ShareCodeService extends BaseService<Prisma.ShareCodeDelegate> {
|
||
private readonly logger = new Logger(ShareCodeService.name);
|
||
// 生成8位分享码,使用易读的字符
|
||
private readonly generateCode = customAlphabet(
|
||
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||
8,
|
||
);
|
||
|
||
constructor(private readonly resourceService: ResourceService) {
|
||
super(db, ObjectType.SHARE_CODE, false);
|
||
}
|
||
// 根据文件ID生成分享码
|
||
async generateShareCodeByFileId(fileId: string, expiresAt: Date, canUseTimes: number, ip: string) {
|
||
try {
|
||
this.logger.log('收到生成分享码请求,fileId:', fileId);
|
||
this.logger.log('客户端IP:', ip);
|
||
const result = await this.generateShareCode(fileId, expiresAt, canUseTimes, undefined, ip);
|
||
this.logger.log('生成分享码结果:', result);
|
||
return result;
|
||
} catch (error) {
|
||
this.logger.error('生成分享码错误:', error);
|
||
return error
|
||
}
|
||
}
|
||
|
||
async generateShareCode(
|
||
fileId: string,
|
||
expiresAt: Date,
|
||
canUseTimes: number,
|
||
fileName?: string,
|
||
uploadIp?: string,
|
||
): Promise<GenerateShareCodeResponse> {
|
||
try {
|
||
// 检查文件是否存在
|
||
const resource = await this.resourceService.findUnique({
|
||
where: { fileId },
|
||
});
|
||
this.logger.log('完整 resource:', resource);
|
||
if (!resource) {
|
||
throw new NotFoundException('文件不存在');
|
||
}
|
||
const { filename, filetype, size } = resource.meta as any as ResourceMeta
|
||
// 生成分享码(修改的逻辑保证分享码的唯一性)
|
||
let code = this.generateCode();
|
||
let existingShareCode;
|
||
do {
|
||
// 查找是否已有相同 shareCode 或者相同 FileID 的分享码记录
|
||
existingShareCode = await super.findFirst({
|
||
where: {
|
||
OR: [
|
||
{ code },
|
||
{ fileId }
|
||
]
|
||
},
|
||
});
|
||
// 如果找到的是已经被删除的码,则可以使用并更新其他信息,否则重新生成
|
||
if(!existingShareCode){
|
||
break
|
||
}
|
||
if (existingShareCode.deleteAt !== null) {
|
||
break
|
||
}
|
||
if (existingShareCode && existingShareCode.code === code) {
|
||
code = this.generateCode();
|
||
}
|
||
} while (existingShareCode && existingShareCode.code === code);
|
||
|
||
if (existingShareCode) {
|
||
// 更新现有记录,但保留原有文件名
|
||
await super.update({
|
||
where: { id: existingShareCode.id },
|
||
data: {
|
||
code,
|
||
expiresAt,
|
||
canUseTimes,
|
||
isUsed: false,
|
||
fileId,
|
||
fileName: filename || "downloaded_file",
|
||
uploadIp,
|
||
createdAt: new Date(),
|
||
deletedAt: null
|
||
},
|
||
});
|
||
} else {
|
||
// 创建新记录
|
||
await super.create({
|
||
data: {
|
||
code,
|
||
fileId,
|
||
expiresAt,
|
||
canUseTimes,
|
||
isUsed: false,
|
||
fileName: filename || "downloaded_file",
|
||
uploadIp,
|
||
},
|
||
});
|
||
}
|
||
this.logger.log(`Generated share code ${code} for file ${fileId} canUseTimes: ${canUseTimes}`);
|
||
return {
|
||
code,
|
||
expiresAt,
|
||
canUseTimes,
|
||
fileName: filename || "downloaded_file",
|
||
resource: {
|
||
id: resource.id,
|
||
type: resource.type,
|
||
url: resource.url,
|
||
meta: {
|
||
filename,
|
||
filetype,
|
||
size,
|
||
}
|
||
},
|
||
uploadIp,
|
||
createdAt: new Date()
|
||
};
|
||
} catch (error) {
|
||
this.logger.error('Failed to generate share code', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async validateAndUseCode(code: string): Promise<ShareCode | null> {
|
||
try {
|
||
this.logger.log(`尝试验证分享码: ${code}`);
|
||
|
||
// 查找有效的分享码
|
||
const shareCode = await super.findFirst({
|
||
where: {
|
||
code,
|
||
isUsed: false,
|
||
expiresAt: { gt: dayjs().tz('Asia/Shanghai').toDate() },
|
||
deletedAt: null
|
||
},
|
||
});
|
||
if (shareCode.canUseTimes <= 0) {
|
||
this.logger.log('分享码已使用次数超过限制');
|
||
return null;
|
||
}
|
||
//更新已使用次数
|
||
await super.update({
|
||
where: { id: shareCode.id },
|
||
data: { canUseTimes: shareCode.canUseTimes - 1 },
|
||
});
|
||
this.logger.log('查询结果:', shareCode);
|
||
|
||
if (!shareCode) {
|
||
this.logger.log('分享码无效或已过期');
|
||
return null;
|
||
}
|
||
// 记录使用日志
|
||
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
|
||
// 返回完整的分享码信息,包括文件名
|
||
return shareCode;
|
||
} catch (error) {
|
||
this.logger.error('Failed to validate share code', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 每天清理过期的分享码
|
||
//@Cron('*/30 * * * * *')
|
||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||
async cleanupExpiredShareCodes() {
|
||
try {
|
||
const shareCodes = await super.findMany({
|
||
where: {
|
||
OR: [
|
||
{ expiresAt: { lt: dayjs().tz('Asia/Shanghai').toDate() } },
|
||
{ isUsed: true },
|
||
{ canUseTimes: { lte: 0 } }
|
||
],
|
||
}
|
||
})
|
||
this.logger.log('需要清理的分享码:', shareCodes);
|
||
//文件资源硬删除
|
||
shareCodes.forEach(code => {
|
||
this.cleanupUploadFolder(code.fileId);
|
||
})
|
||
//数据库资源软删除
|
||
const result = await super.softDeleteByIds(
|
||
[...shareCodes.map(code => code.id)]
|
||
);
|
||
const deleteResource = await this.resourceService.updateMany({
|
||
where: {
|
||
fileId: {
|
||
in: shareCodes.map(code => code.fileId)
|
||
}
|
||
},
|
||
data: {
|
||
deletedAt: new Date()
|
||
}
|
||
})
|
||
this.logger.log(`Cleaned up ${result} ${deleteResource.count} expired share codes`);
|
||
} catch (error) {
|
||
this.logger.error('Failed to cleanup expired share codes', error);
|
||
}
|
||
}
|
||
|
||
async cleanupUploadFolder(file?: string) {
|
||
//const uploadDir = path.join(__dirname, '../../../uploads');
|
||
const uploadDir = path.join('/data/uploads/', file || '');
|
||
this.logger.log('uploadDir:', uploadDir);
|
||
try {
|
||
if (!fs.existsSync(uploadDir)) {
|
||
this.logger.warn(`Upload directory does not exist: ${uploadDir}`);
|
||
return;
|
||
}
|
||
// 递归删除文件夹及其内容
|
||
this.deleteFolderRecursive(uploadDir);
|
||
this.logger.log(`Cleaned up upload folder: ${uploadDir}`);
|
||
} catch (error) {
|
||
this.logger.error('读取上传目录失败:', error);
|
||
return;
|
||
}
|
||
}
|
||
private deleteFolderRecursive(dirPath: string) {
|
||
if (fs.existsSync(dirPath)) {
|
||
fs.readdirSync(dirPath).forEach((file) => {
|
||
const filePath = path.join(dirPath, file);
|
||
if (fs.statSync(filePath).isDirectory()) {
|
||
// 递归删除子目录
|
||
this.deleteFolderRecursive(filePath);
|
||
} else {
|
||
// 删除文件
|
||
fs.unlinkSync(filePath);
|
||
this.logger.log(`Deleted file: ${filePath}`);
|
||
}
|
||
});
|
||
// 删除空文件夹
|
||
fs.rmdirSync(dirPath);
|
||
this.logger.log(`Deleted folder: ${dirPath}`);
|
||
}
|
||
}
|
||
|
||
// 根据分享码获取文件
|
||
async getFileByShareCode(code: string) {
|
||
this.logger.log('收到验证分享码请求,code:', code);
|
||
const shareCode = await this.validateAndUseCode(code);
|
||
this.logger.log('验证分享码结果:', shareCode);
|
||
|
||
if (!shareCode) {
|
||
this.logger.log('分享码无效或已过期');
|
||
return null
|
||
}
|
||
|
||
// 获取文件信息
|
||
const resource = await this.resourceService.findUnique({
|
||
where: { fileId: shareCode.fileId },
|
||
});
|
||
this.logger.log('获取到的资源信息:', resource);
|
||
const { filename, filetype, size } = resource.meta as any as ResourceMeta
|
||
const fileUrl = resource?.url
|
||
if (!resource) {
|
||
throw new NotFoundException('文件不存在');
|
||
}
|
||
|
||
// 直接返回正确的数据结构
|
||
const response = {
|
||
id: shareCode.id,
|
||
code: shareCode.code,
|
||
fileName: filename || 'downloaded_file',
|
||
expiresAt: shareCode.expiresAt,
|
||
canUseTimes: shareCode.canUseTimes - 1,
|
||
resource: {
|
||
id: resource.id,
|
||
type: resource.type,
|
||
url: resource.url,
|
||
meta: {
|
||
filename, filetype, size
|
||
}
|
||
},
|
||
uploadIp: shareCode.uploadIp,
|
||
createdAt: shareCode.createdAt
|
||
};
|
||
|
||
this.logger.log('返回给前端的数据:', response); // 添加日志
|
||
return response;
|
||
}
|
||
|
||
|
||
async getShareCodesWithResources(args: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
where?: Prisma.ShareCodeWhereInput;
|
||
}): Promise<{
|
||
items: Array<ShareCode & { resource?: Resource }>;
|
||
totalPages: number;
|
||
}> {
|
||
try {
|
||
// 使用include直接关联查询Resource
|
||
const { items, totalPages } = await super.findManyWithPagination({
|
||
...args,
|
||
select: ShareCodeSelect
|
||
});
|
||
return {
|
||
items,
|
||
totalPages
|
||
};
|
||
} catch (error) {
|
||
this.logger.error('Failed to get share codes with resources', error);
|
||
throw error;
|
||
}
|
||
}
|
||
async softDeleteShareCodes(ids: string[]): Promise<any> {
|
||
try {
|
||
this.logger.log(`尝试软删除分享码,IDs: ${ids.join(', ')}`);
|
||
const result = await super.softDeleteByIds(ids);
|
||
this.logger.log(`软删除分享码成功,数量: ${result.length}`);
|
||
return result;
|
||
} catch (error) {
|
||
this.logger.error('软删除分享码失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
async updateShareCode(id: string, data: Partial<ShareCode>): Promise<any> {
|
||
try {
|
||
this.logger.log(`尝试更新分享码,ID: ${id},数据:`, data);
|
||
const result = await super.updateById(id, data);
|
||
this.logger.log(`更新分享码成功:`, result);
|
||
return result;
|
||
} catch (error) {
|
||
this.logger.error('更新分享码失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有分享码关联资源的总大小
|
||
* @returns 返回所有资源的总大小和资源数量
|
||
*/
|
||
async getShareCodeResourcesTotalSize(): Promise<{ totalSize: number; resourceCount: number }> {
|
||
try {
|
||
this.logger.log('获取所有分享码关联资源的总大小');
|
||
|
||
// 查询所有有效的分享码及其关联的资源
|
||
const shareCodes = await super.findMany({
|
||
where: {
|
||
deletedAt: null
|
||
},
|
||
select: { ...ShareCodeSelect }
|
||
});
|
||
const {totalSize, resourceCount} = this.calculateTotalSize(shareCodes as any as GenerateShareCodeResponse[]);
|
||
|
||
this.logger.log(`资源总大小: ${totalSize}, 资源数量: ${resourceCount}`);
|
||
return { totalSize, resourceCount };
|
||
} catch (error) {
|
||
this.logger.error('获取分享码资源总大小失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
/**
|
||
* 根据日期范围获取分享码关联资源的大小
|
||
* @param dateType 日期类型: 'today' 或 'yesterday'
|
||
* @returns 返回特定日期范围内的资源总大小和资源数量
|
||
*/
|
||
async getShareCodeResourcesSizeByDateRange(dateType: 'today' | 'yesterday'): Promise<{ totalSize: number; resourceCount: number }> {
|
||
try {
|
||
let startDate: Date;
|
||
let endDate: Date;
|
||
const now = dayjs().tz('Asia/Shanghai');
|
||
|
||
if (dateType === 'today') {
|
||
startDate = now.startOf('day').toDate();
|
||
endDate = now.endOf('day').toDate();
|
||
this.logger.log(`获取今天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`);
|
||
} else {
|
||
startDate = now.subtract(1, 'day').startOf('day').toDate();
|
||
endDate = now.subtract(1, 'day').endOf('day').toDate();
|
||
this.logger.log(`获取昨天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`);
|
||
}
|
||
|
||
// 查询特定日期范围内创建的分享码及其关联的资源
|
||
const shareCodes = await super.findMany({
|
||
where: {
|
||
createdAt: {
|
||
gte: startDate,
|
||
lte: endDate
|
||
},
|
||
deletedAt: null
|
||
},
|
||
select: {
|
||
...ShareCodeSelect
|
||
}
|
||
});
|
||
const {totalSize, resourceCount} = this.calculateTotalSize(shareCodes as any as GenerateShareCodeResponse[]);
|
||
this.logger.log(`${dateType}资源总大小: ${totalSize}, 资源数量: ${resourceCount}`);
|
||
return { totalSize, resourceCount };
|
||
} catch (error) {
|
||
this.logger.error(`获取${dateType === 'today' ? '今天' : '昨天'}的分享码资源大小失败`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
/**
|
||
* 统计不同时间段内独立uploadIp的数量
|
||
* @returns 返回本周和上周的不同uploadIp数量
|
||
*/
|
||
async countDistinctUploadIPs(): Promise<{ thisWeek: number; lastWeek: number; all: number }> {
|
||
try {
|
||
const now = dayjs().tz('Asia/Shanghai');
|
||
|
||
// 本周的开始和结束
|
||
const thisWeekStart = now.startOf('week').toDate();
|
||
const thisWeekEnd = now.endOf('week').toDate();
|
||
|
||
// 上周的开始和结束
|
||
const lastWeekStart = now.subtract(1, 'week').startOf('week').toDate();
|
||
const lastWeekEnd = now.subtract(1, 'week').endOf('week').toDate();
|
||
|
||
this.logger.log(`统计本周IP数量, 日期范围: ${thisWeekStart} 到 ${thisWeekEnd}`);
|
||
this.logger.log(`统计上周IP数量, 日期范围: ${lastWeekStart} 到 ${lastWeekEnd}`);
|
||
// 查询所有不同IP
|
||
const allIPs = await super.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
uploadIp: {
|
||
not: null
|
||
}
|
||
},
|
||
select: {
|
||
uploadIp: true
|
||
},
|
||
distinct: ['uploadIp']
|
||
});
|
||
// 查询本周的不同IP
|
||
const thisWeekIPs = await super.findMany({
|
||
where: {
|
||
createdAt: {
|
||
gte: thisWeekStart,
|
||
lte: thisWeekEnd
|
||
},
|
||
uploadIp: {
|
||
not: null
|
||
},
|
||
deletedAt: null
|
||
},
|
||
select: {
|
||
uploadIp: true
|
||
},
|
||
distinct: ['uploadIp']
|
||
});
|
||
|
||
// 查询上周的不同IP
|
||
const lastWeekIPs = await super.findMany({
|
||
where: {
|
||
createdAt: {
|
||
gte: lastWeekStart,
|
||
lte: lastWeekEnd
|
||
},
|
||
uploadIp: {
|
||
not: null
|
||
},
|
||
deletedAt: null
|
||
},
|
||
select: {
|
||
uploadIp: true
|
||
},
|
||
distinct: ['uploadIp']
|
||
});
|
||
|
||
const thisWeekCount = thisWeekIPs.length;
|
||
const lastWeekCount = lastWeekIPs.length;
|
||
const allCount = allIPs.length;
|
||
|
||
this.logger.log(`本周不同IP数量: ${thisWeekCount}, 上周不同IP数量: ${lastWeekCount}, 所有不同IP数量: ${allCount}`);
|
||
|
||
return { thisWeek: thisWeekCount, lastWeek: lastWeekCount, all: allCount };
|
||
} catch (error) {
|
||
this.logger.error('统计不同uploadIp数量失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取分享码列表,使用ShareCodeSelect并按创建时间倒序排序
|
||
* @param args 查询参数
|
||
* @returns 返回分享码列表
|
||
*/
|
||
async findShareCodes(args?: Omit<Prisma.ShareCodeFindManyArgs, 'select' | 'orderBy'>): Promise<GenerateShareCodeResponse[]> {
|
||
try {
|
||
const result = await super.findMany({
|
||
...args,
|
||
select: ShareCodeSelect,
|
||
});
|
||
this.logger.log(`获取分享码列表成功, 数量: ${result.length}`);
|
||
return result as unknown as GenerateShareCodeResponse[];
|
||
} catch (error) {
|
||
this.logger.error('获取分享码列表失败', error);
|
||
throw error;
|
||
}
|
||
}
|
||
async getAllreadlyDeletedShareCodes(args?: Omit<Prisma.ShareCodeFindManyArgs, 'select' | 'orderBy'>):Promise<{ totalSize: number; resourceCount: number; }> {
|
||
try {
|
||
const result = await super.findMany({
|
||
...args,
|
||
where: {
|
||
deletedAt: {
|
||
not: null
|
||
}
|
||
},
|
||
select: ShareCodeSelect,
|
||
});
|
||
// 计算总大小和资源数量
|
||
const { totalSize, resourceCount } = this.calculateTotalSize(result as unknown as GenerateShareCodeResponse[]);
|
||
this.logger.log(`获取已删除分享码列表成功, 数量: ${resourceCount}, 总大小: ${totalSize}`);
|
||
return {totalSize,resourceCount}
|
||
} catch (err) {
|
||
this.logger.error('获取已删除分享码列表失败', err)
|
||
throw err
|
||
}
|
||
|
||
}
|
||
calculateTotalSize(shareCodes: GenerateShareCodeResponse[]): { totalSize: number; resourceCount: number } {
|
||
let totalSize = 0;
|
||
let resourceCount = 0;
|
||
shareCodes.forEach(shareCode => {
|
||
if ((shareCode as any as GenerateShareCodeResponse).resource && (shareCode as any as GenerateShareCodeResponse).resource.meta) {
|
||
const meta = (shareCode as any as GenerateShareCodeResponse).resource.meta as any;
|
||
if (meta.size) {
|
||
// 如果size是字符串格式(如 "1024"或"1 MB"),需要转换
|
||
let sizeValue: number;
|
||
if (typeof meta.size === 'string') {
|
||
// 尝试直接解析数字
|
||
sizeValue = parseInt(meta.size, 10);
|
||
// 如果解析失败,可能需要更复杂的处理
|
||
if (isNaN(sizeValue)) {
|
||
// 简单处理,实际应用中可能需要更复杂的单位转换
|
||
this.logger.warn(`无法解析资源大小: ${meta.size}`);
|
||
sizeValue = 0;
|
||
}
|
||
} else if (typeof meta.size === 'number') {
|
||
sizeValue = meta.size;
|
||
} else {
|
||
sizeValue = 0;
|
||
}
|
||
|
||
totalSize += sizeValue;
|
||
resourceCount++;
|
||
}
|
||
}
|
||
})
|
||
return { totalSize, resourceCount }
|
||
}
|
||
}
|