book_manage/apps/server/src/models/share-code/share-code.service.ts

611 lines
19 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}
}