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 { 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 { 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 { 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; 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 { 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): Promise { 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): Promise { 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):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 } } }