This commit is contained in:
linfeng 2025-03-21 10:55:44 +08:00
parent 305ecf78ed
commit 48c22c9348
14 changed files with 666 additions and 10 deletions

View File

@ -36,7 +36,7 @@ import { DailyTrainModule } from '@server/models/daily-train/dailyTrain.module';
ResourceModule, ResourceModule,
TrainContentModule, TrainContentModule,
TrainSituationModule, TrainSituationModule,
DailyTrainModule DailyTrainModule,
], ],
controllers: [], controllers: [],
providers: [TrpcService, TrpcRouter, Logger], providers: [TrpcService, TrpcRouter, Logger],

View File

@ -0,0 +1,149 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { customAlphabet } from 'nanoid-cjs';
import { db } from '@nice/common';
import { ShareCode, GenerateShareCodeResponse } from './types';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ResourceService } from '@server/models/resource/resource.service';
@Injectable()
export class ShareCodeService {
private readonly logger = new Logger(ShareCodeService.name);
// 生成8位分享码使用易读的字符
private readonly generateCode = customAlphabet(
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
8
);
constructor(private readonly resourceService: ResourceService) {}
async generateShareCode(fileId: string): Promise<GenerateShareCodeResponse> {
try {
// 检查文件是否存在
const resource = await this.resourceService.findUnique({
where: { fileId },
});
console.log('完整 fileId:', fileId); // 确保与前端一致
if (!resource) {
throw new NotFoundException('文件不存在');
}
// 生成分享码
const code = this.generateCode();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24小时后过期
// 创建分享码记录
await db.shareCode.create({
data: {
code,
fileId,
expiresAt,
isUsed: false,
},
});
this.logger.log(`Generated share code ${code} for file ${fileId}`);
return {
code,
expiresAt,
};
} catch (error) {
this.logger.error('Failed to generate share code', error);
throw error;
}
}
async validateAndUseCode(code: string): Promise<string | null> {
try {
// 查找有效的分享码
const shareCode = await db.shareCode.findFirst({
where: {
code,
isUsed: false,
expiresAt: { gt: new Date() },
},
});
if (!shareCode) {
return null;
}
// 标记分享码为已使用
await db.shareCode.update({
where: { id: shareCode.id },
data: { isUsed: true },
});
// 记录使用日志
this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`);
return shareCode.fileId;
} catch (error) {
this.logger.error('Failed to validate share code', error);
return null;
}
}
// 每天清理过期的分享码
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupExpiredShareCodes() {
try {
const result = await db.shareCode.deleteMany({
where: {
OR: [
{ expiresAt: { lt: new Date() } },
{ isUsed: true },
],
},
});
this.logger.log(`Cleaned up ${result.count} expired share codes`);
} catch (error) {
this.logger.error('Failed to cleanup expired share codes', error);
}
}
// 获取分享码信息
async getShareCodeInfo(code: string): Promise<ShareCode | null> {
try {
return await db.shareCode.findFirst({
where: { code },
});
} catch (error) {
this.logger.error('Failed to get share code info', error);
return null;
}
}
// 检查文件是否已经生成过分享码
async hasActiveShareCode(fileId: string): Promise<boolean> {
try {
const activeCode = await db.shareCode.findFirst({
where: {
fileId,
isUsed: false,
expiresAt: { gt: new Date() },
},
});
return !!activeCode;
} catch (error) {
this.logger.error('Failed to check active share code', error);
return false;
}
}
// 获取文件的所有分享记录
async getFileShareHistory(fileId: string) {
try {
return await db.shareCode.findMany({
where: { fileId },
orderBy: { createdAt: 'desc' },
});
} catch (error) {
this.logger.error('Failed to get file share history', error);
return [];
}
}
}

View File

@ -131,4 +131,5 @@ export class TusService implements OnModuleInit {
// console.log(req) // console.log(req)
return this.tusServer.handle(req, res); return this.tusServer.handle(req, res);
} }
} }

View File

@ -20,6 +20,20 @@ export interface UploadLock {
clientId: string; clientId: string;
timestamp: number; timestamp: number;
} }
export interface ShareCode {
code: string;
fileId: string;
createdAt: Date;
expiresAt: Date;
isUsed: boolean;
}
export interface GenerateShareCodeResponse {
code: string;
expiresAt: Date;
}
// 添加重试机制,处理临时网络问题 // 添加重试机制,处理临时网络问题
// 实现定期清理过期的临时文件 // 实现定期清理过期的临时文件
// 添加文件完整性校验 // 添加文件完整性校验
@ -27,3 +41,10 @@ export interface UploadLock {
// 添加并发限制,防止系统资源耗尽 // 添加并发限制,防止系统资源耗尽
// 实现文件去重功能,避免重复上传 // 实现文件去重功能,避免重复上传
// 添加日志记录和监控机制 // 添加日志记录和监控机制
// 添加文件类型限制
// 添加文件大小限制
// 添加文件上传时间限制
// 添加文件上传速度限制
// 添加文件上传队列管理
// 添加文件上传断点续传
// 添加文件上传进度条

View File

@ -10,13 +10,19 @@ import {
Delete, Delete,
Head, Head,
Options, Options,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { TusService } from './tus.service'; import { TusService } from './tus.service';
import { ShareCodeService } from './share-code.service';
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
constructor(private readonly tusService: TusService) {} constructor(
private readonly tusService: TusService,
private readonly shareCodeService: ShareCodeService,
) {}
// @Post() // @Post()
// async handlePost(@Req() req: Request, @Res() res: Response) { // async handlePost(@Req() req: Request, @Res() res: Response) {
// return this.tusService.handleTus(req, res); // return this.tusService.handleTus(req, res);
@ -51,4 +57,45 @@ export class UploadController {
async handleUpload(@Req() req: Request, @Res() res: Response) { async handleUpload(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Post('share/:fileId(*)')
async generateShareCode(@Param('fileId') fileId: string) {
try {
console.log('收到生成分享码请求fileId:', fileId);
const result = await this.shareCodeService.generateShareCode(fileId);
console.log('生成分享码结果:', result);
return result;
} catch (error) {
console.error('生成分享码错误:', error);
throw new HttpException(
{
message: (error as Error).message || '生成分享码失败',
error: 'SHARE_CODE_GENERATION_FAILED'
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Get('share/:code')
async validateShareCode(@Param('code') code: string) {
const fileId = await this.shareCodeService.validateAndUseCode(code);
if (!fileId) {
throw new NotFoundException('分享码无效或已过期');
}
return { fileId };
}
@Get('share/info/:code')
async getShareCodeInfo(@Param('code') code: string) {
const info = await this.shareCodeService.getShareCodeInfo(code);
if (!info) {
throw new NotFoundException('分享码不存在');
}
return info;
}
@Get('share/history/:fileId')
async getFileShareHistory(@Param('fileId') fileId: string) {
return this.shareCodeService.getFileShareHistory(fileId);
}
} }

View File

@ -3,7 +3,7 @@ import { UploadController } from './upload.controller';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { TusService } from './tus.service'; import { TusService } from './tus.service';
import { ResourceModule } from '@server/models/resource/resource.module'; import { ResourceModule } from '@server/models/resource/resource.module';
import { ShareCodeService } from './share-code.service';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({ BullModule.registerQueue({
@ -12,6 +12,6 @@ import { ResourceModule } from '@server/models/resource/resource.module';
ResourceModule, ResourceModule,
], ],
controllers: [UploadController], controllers: [UploadController],
providers: [TusService], providers: [TusService, ShareCodeService],
}) })
export class UploadModule {} export class UploadModule {}

View File

@ -1,7 +1,165 @@
import { useTusUpload } from "@web/src/hooks/useTusUpload";
import { ShareCodeGenerator } from "../sharecode/sharecodegenerator";
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
import { useState } from "react";
import { message, Progress, Button, Tabs } from "antd";
import { UploadOutlined } from "@ant-design/icons";
const { TabPane } = Tabs;
export default function DeptSettingPage() { export default function DeptSettingPage() {
const [uploadedFileId, setUploadedFileId] = useState<string>('');
// 使用您的 useTusUpload hook
const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload({
onSuccess: (fileId: string) => {
setUploadedFileId(fileId);
message.success('文件上传成功');
},
onError: (error: Error) => {
message.error('上传失败:' + error.message);
}
});
// 处理文件上传
const handleFileSelect = async (file: File) => {
const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识
handleFileUpload(
file,
(result) => {
setUploadedFileId(result.fileId);
message.success('文件上传成功');
},
(error) => {
message.error('上传失败:' + error.message);
},
fileKey
);
};
// 处理分享码生成成功
const handleShareSuccess = (code: string) => {
message.success('分享码生成成功:' + code);
// 可以在这里添加其他逻辑,比如保存到历史记录
};
// 处理分享码验证成功
const handleValidSuccess = async (fileId: string) => {
try {
// 构建下载URL
const response = await fetch(`/api/upload/download/${fileId}`);
if (!response.ok) {
throw new Error('文件下载失败');
}
// 获取文件名
const contentDisposition = response.headers.get('content-disposition');
let filename = 'downloaded-file';
if (contentDisposition) {
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}
}
// 创建下载链接
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('文件下载开始');
} catch (error) {
console.error('下载失败:', error);
message.error('文件下载失败');
}
};
return ( return (
<div> <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1></h1> <h2></h2>
<Tabs defaultActiveKey="upload">
<TabPane tab="上传分享" key="upload">
{/* 文件上传区域 */}
<div style={{ marginBottom: '40px' }}>
<h3></h3>
<div style={{
padding: '20px',
border: '2px dashed #d9d9d9',
borderRadius: '8px',
textAlign: 'center'
}}>
<input
type="file"
id="file-input"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
}}
disabled={isUploading}
/>
<label
htmlFor="file-input"
style={{
display: 'inline-block',
padding: '12px 24px',
backgroundColor: '#1890ff',
color: 'white',
borderRadius: '6px',
cursor: 'pointer',
}}
>
<UploadOutlined />
</label>
{isUploading && (
<div style={{ marginTop: '20px' }}>
<Progress
percent={Object.values(uploadProgress)[0] || 0}
status="active"
/>
</div>
)}
{uploadError && (
<div style={{ color: '#ff4d4f', marginTop: '10px' }}>
{uploadError}
</div>
)}
</div>
</div>
{/* 生成分享码区域 */}
{uploadedFileId && (
<div style={{ marginBottom: '40px' }}>
<h3></h3>
<ShareCodeGenerator
fileId={uploadedFileId}
onSuccess={handleShareSuccess}
/>
</div>
)}
</TabPane>
{/* 使用分享码区域 */}
<TabPane tab="下载文件" key="download">
<div>
<h3>使</h3>
<ShareCodeValidator
onValidSuccess={handleValidSuccess}
/>
</div>
</TabPane>
</Tabs>
</div> </div>
); );
} }

View File

@ -0,0 +1,68 @@
.container {
padding: 20px;
border-radius: 8px;
background-color: #f8f9fa;
}
.generateButton {
width: 100%;
padding: 12px;
border: none;
border-radius: 6px;
background-color: #1890ff;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
.generateButton:hover {
background-color: #40a9ff;
}
.generateButton:disabled {
background-color: #d9d9d9;
cursor: not-allowed;
}
.codeDisplay {
text-align: center;
}
.codeWrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin: 16px 0;
}
.code {
font-size: 24px;
font-weight: bold;
letter-spacing: 2px;
color: #1890ff;
padding: 8px 16px;
background-color: #e6f7ff;
border-radius: 4px;
}
.copyButton {
border: none;
background: none;
cursor: pointer;
color: #1890ff;
font-size: 18px;
padding: 4px;
}
.expireInfo {
color: #666;
margin: 8px 0;
}
.hint {
color: #ff4d4f;
margin: 8px 0;
font-size: 14px;
}

View File

@ -0,0 +1,12 @@
.container {
display: flex;
gap: 12px;
padding: 20px;
border-radius: 8px;
background-color: #f8f9fa;
}
.input {
font-size: 16px;
text-transform: uppercase;
}

View File

@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Button, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
interface ShareCodeGeneratorProps {
fileId: string;
onSuccess?: (code: string) => void;
}
export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
fileId,
onSuccess,
}) => {
const [loading, setLoading] = useState(false);
const [shareCode, setShareCode] = useState<string>('');
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const generateCode = async () => {
setLoading(true);
console.log('开始生成分享码fileId:', fileId);
try {
const response = await fetch(`/upload/share/${fileId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
})
});
console.log('Current fileId:', fileId); // 确保 fileId 有效
console.log('请求URL:', `/upload/share/${fileId}`);
console.log('API响应状态:', response.status);
const responseText = await response.text();
console.log('API原始响应:', responseText);
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${responseText || '无错误信息'}`);
}
// 确保响应不为空
if (!responseText) {
throw new Error('服务器返回空响应');
}
// 尝试解析JSON
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
console.error('解析响应JSON失败:', e);
throw new Error('服务器响应格式错误');
}
console.log('解析后的响应数据:', data); // 调试日志
if (!data.code) {
throw new Error('响应中没有分享码');
}
setShareCode(data.code);
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
onSuccess?.(data.code);
message.success('分享码生成成功');
} catch (error) {
console.error('生成分享码错误:', error);
message.error('生成分享码失败: ' + (error instanceof Error ? error.message : '未知错误'));
} finally {
setLoading(false);
}
};
return (
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<div style={{ marginBottom: '10px' }}>
{/* 添加调试信息 */}
<small style={{ color: '#666' }}>ID: {fileId}</small>
</div>
{!shareCode ? (
<Button
type="primary"
onClick={generateCode}
loading={loading}
block
>
</Button>
) : (
<div style={{ textAlign: 'center' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
margin: '16px 0'
}}>
<span style={{
fontSize: '24px',
fontWeight: 'bold',
letterSpacing: '2px',
color: '#1890ff',
padding: '8px 16px',
backgroundColor: '#e6f7ff',
borderRadius: '4px'
}}>
{shareCode}
</span>
<Button
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(shareCode);
message.success('分享码已复制');
}}
/>
</div>
{expiresAt && (
<div style={{ color: '#666' }}>
: {expiresAt.toLocaleString()}
</div>
)}
</div>
)}
</div>
);
};

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { Input, Button, message } from 'antd';
import styles from './ShareCodeValidator.module.css';
interface ShareCodeValidatorProps {
onValidSuccess: (fileId: string) => void;
}
export const ShareCodeValidator: React.FC<ShareCodeValidatorProps> = ({
onValidSuccess,
}) => {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const validateCode = async () => {
if (!code.trim()) {
message.warning('请输入分享码');
return;
}
setLoading(true);
try {
const response = await fetch(`/api/upload/share/${code.trim()}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || '分享码无效或已过期');
}
const data = await response.json();
onValidSuccess(data.fileId);
message.success('验证成功');
} catch (error) {
message.error('分享码无效或已过期');
} finally {
setLoading(false);
}
};
return (
<div className={styles.container}>
<Input
className={styles.input}
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="请输入分享码"
maxLength={8}
onPressEnter={validateCode}
/>
<Button
type="primary"
onClick={validateCode}
loading={loading}
disabled={!code.trim()}
>
</Button>
</div>
);
};

View File

@ -9,7 +9,7 @@ interface UploadResult {
fileId: string; fileId: string;
} }
export function useTusUpload() { export function useTusUpload(p0: { onSuccess: (fileId: string) => void; onError: (error: Error) => void; }) {
const [uploadProgress, setUploadProgress] = useState< const [uploadProgress, setUploadProgress] = useState<
Record<string, number> Record<string, number>
>({}); >({});

View File

@ -14,8 +14,7 @@ import DailyPage from "../app/main/daily/page";
import Dashboard from "../app/main/home/page"; import Dashboard from "../app/main/home/page";
import WeekPlanPage from "../app/main/plan/weekplan/page"; import WeekPlanPage from "../app/main/plan/weekplan/page";
import StaffInformation from "../app/main/staffinformation/page"; import StaffInformation from "../app/main/staffinformation/page";
import SettingPage from "../app/main/admin/deptsettingpage/settingpage"; import DeptSettingPage from "../app/main/admin/deptsettingpage/page";
import DeptSettingPage from "../app/main/admin/deptsettingpage/settingpage";
interface CustomIndexRouteObject extends IndexRouteObject { interface CustomIndexRouteObject extends IndexRouteObject {
name?: string; name?: string;
breadcrumb?: string; breadcrumb?: string;

View File

@ -536,3 +536,16 @@ model TrainPlan {
@@map("train_plan") @@map("train_plan")
} }
model ShareCode {
id String @id @default(cuid())
code String @unique
fileId String
createdAt DateTime @default(now())
expiresAt DateTime
isUsed Boolean @default(false)
@@index([code])
@@index([fileId])
@@index([expiresAt])
}