This commit is contained in:
Rao 2025-04-02 21:59:19 +08:00
parent 0008f405fe
commit 6985ee863f
14 changed files with 397 additions and 231 deletions

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { ResourceModule } from '../resource/resource.module';
import { ShareCodeService } from './share-code.service';
import { ShareCodeRouter } from './share-code.router';
@Module({
imports: [ResourceModule],
providers: [TrpcService, ShareCodeService, ShareCodeRouter],
exports: [ShareCodeService, ShareCodeRouter],
controllers: [],
})
export class ShareCodeModule { }

View File

@ -0,0 +1,49 @@
import { z } from "zod";
import { ShareCodeService } from "./share-code.service";
import { TrpcService } from "@server/trpc/trpc.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ShareCodeRouter {
constructor(
private readonly shareCodeService: ShareCodeService,
private readonly trpc: TrpcService
) {}
router = this.trpc.router({
generateShareCode: this.trpc.procedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ input }) => {
return this.shareCodeService.generateShareCode(input.fileId);
}),
validateAndUseCode: this.trpc.procedure
.input(z.object({ code: z.string() }))
.mutation(async ({ input }) => {
return this.shareCodeService.validateAndUseCode(input.code);
}),
getShareCodeInfo: this.trpc.procedure
.input(z.object({ code: z.string() }))
.query(async ({ input }) => {
return this.shareCodeService.getShareCodeInfo(input.code);
}),
hasActiveShareCode: this.trpc.procedure
.input(z.object({ fileId: z.string() }))
.query(async ({ input }) => {
return this.shareCodeService.hasActiveShareCode(input.fileId);
}),
getFileShareHistory: this.trpc.procedure
.input(z.object({ fileId: z.string() }))
.query(async ({ input }) => {
return this.shareCodeService.getFileShareHistory(input.fileId);
}),
getFileByShareCode: this.trpc.procedure
.input(z.object({ code: z.string() }))
.query(async ({ input }) => {
return this.shareCodeService.getFileByShareCode(input.code);
}),
generateShareCodeByFileId: this.trpc.procedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ input }) => {
return this.shareCodeService.generateShareCodeByFileId(input.fileId);
}),
});
}

View File

@ -0,0 +1,239 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { customAlphabet } from 'nanoid-cjs';
import { db } from '@nice/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ResourceService } from '@server/models/resource/resource.service';
export interface ShareCode {
id: string;
code: string;
fileId: string;
createdAt: Date;
expiresAt: Date;
isUsed: boolean;
fileName?: string | null;
}
export interface GenerateShareCodeResponse {
code: string;
expiresAt: Date;
}
interface ResourceMeta {
filename: string;
filetype: string;
filesize: string;
}
@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,
fileName?: 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小时后过期
// 查找是否已有分享码记录
const existingShareCode = await db.shareCode.findUnique({
where: { fileId },
});
if (existingShareCode) {
// 更新现有记录,但保留原有文件名
await db.shareCode.update({
where: { fileId },
data: {
code,
expiresAt,
isUsed: false,
// 只在没有现有文件名且提供了新文件名时才更新文件名
...(fileName && !existingShareCode.fileName ? { fileName } : {}),
},
});
} else {
// 创建新记录
await db.shareCode.create({
data: {
code,
fileId,
expiresAt,
isUsed: false,
fileName: fileName || null,
},
});
}
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<ShareCode | null> {
try {
console.log(`尝试验证分享码: ${code}`);
// 查找有效的分享码
const shareCode = await db.shareCode.findFirst({
where: {
code,
isUsed: false,
expiresAt: { gt: new Date() },
},
});
console.log('查询结果:', shareCode);
if (!shareCode) {
console.log('分享码无效或已过期');
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;
} 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 [];
}
}
// 根据分享码获取文件
async getFileByShareCode(code: string) {
console.log('收到验证分享码请求code:', code);
const shareCode = await this.validateAndUseCode(code);
console.log('验证分享码结果:', shareCode);
if (!shareCode) {
throw new NotFoundException('分享码无效或已过期');
}
// 获取文件信息
const resource = await this.resourceService.findUnique({
where: { fileId: shareCode.fileId },
});
console.log('获取到的资源信息:', resource);
const { filename } = resource.meta as any as ResourceMeta
if (!resource) {
throw new NotFoundException('文件不存在');
}
// 直接返回正确的数据结构
const response = {
fileId: shareCode.fileId,
fileName: filename || 'downloaded_file',
code: shareCode.code,
expiresAt: shareCode.expiresAt
};
console.log('返回给前端的数据:', response); // 添加日志
return response;
}
// 根据文件ID生成分享码
async generateShareCodeByFileId(fileId: string) {
try {
console.log('收到生成分享码请求fileId:', fileId);
const result = await this.generateShareCode(fileId);
console.log('生成分享码结果:', result);
return result;
} catch (error) {
console.error('生成分享码错误:', error);
return error
}
}
}

View File

@ -14,7 +14,7 @@ import { VisitModule } from '@server/models/visit/visit.module';
import { WebSocketModule } from '@server/socket/websocket.module';
import { RoleMapModule } from '@server/models/rbac/rbac.module';
import { TransformModule } from '@server/models/transform/transform.module';
import { ShareCodeModule } from '@server/models/share-code/share-code.module';
import { ResourceModule } from '@server/models/resource/resource.module';
@Module({
@ -33,6 +33,7 @@ import { ResourceModule } from '@server/models/resource/resource.module';
VisitModule,
WebSocketModule,
ResourceModule,
ShareCodeModule,
],
controllers: [],
providers: [TrpcService, TrpcRouter, Logger],

View File

@ -14,7 +14,7 @@ import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
import { TransformRouter } from '@server/models/transform/transform.router';
import { RoleRouter } from '@server/models/rbac/role.router';
import { ResourceRouter } from '../models/resource/resource.router';
import { ShareCodeRouter } from '@server/models/share-code/share-code.router';
@Injectable()
export class TrpcRouter {
logger = new Logger(TrpcRouter.name);
@ -32,6 +32,7 @@ export class TrpcRouter {
private readonly message: MessageRouter,
private readonly visitor: VisitRouter,
private readonly resource: ResourceRouter,
private readonly shareCode: ShareCodeRouter,
) {}
getRouter() {
return;
@ -49,6 +50,7 @@ export class TrpcRouter {
app_config: this.app_config.router,
visitor: this.visitor.router,
resource: this.resource.router,
shareCode: this.shareCode.router,
});
wss: WebSocketServer = undefined;

View File

@ -19,6 +19,13 @@ import { Request, Response } from 'express';
import { TusService } from './tus.service';
import { ShareCodeService } from './share-code.service';
import { ResourceService } from '@server/models/resource/resource.service';
interface ResourceMeta {
filename: string;
filetype: string;
filesize: string;
}
@Controller('upload')
export class UploadController {
constructor(
@ -61,7 +68,7 @@ export class UploadController {
where: { fileId: shareCode.fileId },
});
console.log('获取到的资源信息:', resource);
const {filename} = resource.meta as any as ResourceMeta
if (!resource) {
throw new NotFoundException('文件不存在');
}
@ -69,7 +76,7 @@ export class UploadController {
// 直接返回正确的数据结构
const response = {
fileId: shareCode.fileId,
fileName:shareCode.fileName || 'downloaded_file',
fileName:filename || 'downloaded_file',
code: shareCode.code,
expiresAt: shareCode.expiresAt
};

2
apps/web/public/logo.svg Executable file → Normal file

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 828 B

2
apps/web/public/vite.svg Executable file → Normal file

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 828 B

View File

@ -1,10 +1,11 @@
import { useTusUpload } from "@web/src/hooks/useTusUpload";
import { ShareCodeGenerator } from "../sharecode/sharecodegenerator";
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
import { useState, useRef, useCallback } from "react";
import { message, Progress, Button, Tabs, DatePicker } from "antd";
import { useState, useRef, useCallback, useEffect } from "react";
import { message, Progress, Button, Tabs, DatePicker, Form } from "antd";
import { UploadOutlined, DeleteOutlined, InboxOutlined } from "@ant-design/icons";
import { env } from '../../../env'
import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
const { TabPane } = Tabs;
export default function DeptSettingPage() {
@ -15,18 +16,11 @@ export default function DeptSettingPage() {
const [isDragging, setIsDragging] = useState(false);
const [expireTime, setExpireTime] = useState<Date | null>(null);
const dropRef = useRef<HTMLDivElement>(null);
const [form] = Form.useForm();
const [currentFile, setCurrentFile] = useState<string[]>([])
const uploadFileId = Form.useWatch(["file"], form)?.[0]
// 使用您的 useTusUpload hook
const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload({
onSuccess: (result) => {
setUploadedFileId(result.fileId);
setUploadedFileName(result.fileName);
message.success('文件上传成功');
},
onError: (error: Error) => {
message.error('上传失败:' + error.message);
}
});
const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload();
// 清除已上传文件
const handleClearFile = () => {
@ -202,150 +196,32 @@ export default function DeptSettingPage() {
<TabPane tab="上传分享" key="upload">
{/* 文件上传区域 */}
<div style={{ marginBottom: '40px' }}>
<h3></h3>
<span className="text-lg block text-zinc-700 py-2"></span>
{/* 如果没有已上传文件,显示上传区域 */}
{uploadedFiles.length === 0 ? (
<div
ref={dropRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
padding: '20px',
border: `2px dashed ${isDragging ? '#1890ff' : '#d9d9d9'}`,
borderRadius: '8px',
textAlign: 'center',
backgroundColor: isDragging ? 'rgba(24, 144, 255, 0.05)' : 'transparent',
transition: 'all 0.3s',
marginBottom: '20px'
}}
>
<InboxOutlined style={{ fontSize: '48px', color: isDragging ? '#1890ff' : '#d9d9d9' }} />
<p></p>
<p style={{ fontSize: '12px', color: '#888' }}></p>
<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: '8px 16px',
backgroundColor: '#1890ff',
color: 'white',
borderRadius: '4px',
cursor: 'pointer',
marginTop: '10px'
}}
>
<UploadOutlined />
</label>
</div>
) : (
<div style={{ marginBottom: '20px' }}>
<div style={{
padding: '10px',
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '4px',
marginBottom: '10px'
}}>
<p style={{ color: '#52c41a', margin: 0 }}>
</p>
</div>
</div>
)}
{/* 已上传文件列表 */}
{uploadedFiles.length > 0 && (
<div style={{
border: '1px solid #f0f0f0',
borderRadius: '4px',
overflow: 'hidden'
}}>
{uploadedFiles.map((file) => (
<div key={file.id} style={{
display: 'flex',
alignItems: 'center',
padding: '10px 15px',
backgroundColor: '#fafafa'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
flex: 1
}}>
<div style={{
width: '20px',
height: '20px',
borderRadius: '50%',
backgroundColor: '#52c41a',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginRight: '10px'
}}>
<span style={{ color: 'white', fontSize: '12px' }}></span>
</div>
<span>{file.name}</span>
</div>
<Button
type="text"
icon={<DeleteOutlined style={{ color: '#ff4d4f' }} />}
onClick={() => handleDeleteFile(file.id)}
title="删除此文件"
/>
</div>
))}
</div>
)}
{isUploading && (
<div style={{ marginTop: '20px' }}>
<Progress
percent={Math.round(Object.values(uploadProgress)[0] || 0)}
status="active"
/>
</div>
)}
{uploadError && (
<div style={{ color: '#ff4d4f', marginTop: '10px' }}>
{uploadError}
</div>
)}
<Form form={form}>
<Form.Item name="file">
<TusUploader
multiple={false}
style={"w-full py-4"}
></TusUploader>
</Form.Item>
</Form>
</div>
{/* 生成分享码区域 */}
{uploadedFileId && (
<div style={{ marginBottom: '40px' }}>
<h3></h3>
<ShareCodeGenerator
fileId={uploadedFileId}
onSuccess={handleShareSuccess}
/>
</div>
)}
<div style={{ marginBottom: '40px' }}>
<span className="text-lg block text-zinc-700 py-4"></span>
<ShareCodeGenerator
fileId={uploadFileId}
onSuccess={handleShareSuccess}
/>
</div>
</TabPane>
{/* 使用分享码区域 */}
<TabPane tab="下载文件" key="download">
<div>
<h3>使</h3>
<span className="text-lg block text-zinc-700 py-4">使</span>
<ShareCodeValidator
onValidSuccess={handleValidSuccess}
/>

View File

@ -1,14 +1,20 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Button, message } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import {env} from '../../../env'
import { env } from '../../../env'
import { useQueryClient } from '@tanstack/react-query';
import { getQueryKey } from '@trpc/react-query';
import { api } from '@nice/client';
interface ShareCodeGeneratorProps {
fileId: string;
onSuccess?: (code: string) => void;
fileName?: string;
}
interface ShareCodeResponse {
code?: string;
expiresAt?: Date;
}
export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
fileId,
onSuccess,
@ -17,53 +23,29 @@ export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
const [loading, setLoading] = useState(false);
const [shareCode, setShareCode] = useState<string>('');
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const queryClient = useQueryClient();
const queryKey = getQueryKey(api.term);
const [isGenerate, setIsGenerate] = useState(false);
const [currentFileId, setCurrentFileId] = useState<string>('');
const generateShareCode = api.shareCode.generateShareCodeByFileId.useMutation({
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey });
},
});
useEffect(() => {
if (fileId !== currentFileId || !fileId) {
setIsGenerate(false);
}
setCurrentFileId(fileId);
}, [fileId])
const generateCode = async () => {
setLoading(true);
console.log('开始生成分享码fileId:', fileId, 'fileName:', fileName);
try {
const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/share/${fileId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
fileId
})
});
console.log('Current fileId:', fileId); // 确保 fileId 有效
console.log('请求URL:', `http://${env.SERVER_IP}:${env.SERVER_PORT}/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('响应中没有分享码');
}
const data: ShareCodeResponse = await generateShareCode.mutateAsync({ fileId });
console.log('生成分享码结果:', data);
setShareCode(data.code);
setIsGenerate(true);
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
onSuccess?.(data.code);
message.success('分享码生成成功');
@ -79,10 +61,10 @@ export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
<div style={{ marginBottom: '10px' }}>
{/* 添加调试信息 */}
<small style={{ color: '#666' }}>ID: {fileId}</small>
<small style={{ color: '#666' }}>ID: {fileId ? fileId : '未选择文件'}</small>
</div>
{!shareCode ? (
{!isGenerate ? (
<Button
type="primary"
onClick={generateCode}

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Input, Button, message } from 'antd';
import styles from './ShareCodeValidator.module.css';
import {env} from '../../../env'
import { api } from '@nice/client';
interface ShareCodeValidatorProps {
onValidSuccess: (fileId: string, fileName: string) => void;
}
@ -11,8 +12,16 @@ export const ShareCodeValidator: React.FC<ShareCodeValidatorProps> = ({
}) => {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const { data: result, isLoading } = api.shareCode.getFileByShareCode.useQuery(
{ code: code.trim() },
{
enabled: !!code.trim()
}
)
const validateCode = async () => {
const validateCode = useCallback(() => {
if (!code.trim()) {
message.warning('请输入分享码');
return;
@ -20,31 +29,16 @@ export const ShareCodeValidator: React.FC<ShareCodeValidatorProps> = ({
setLoading(true);
try {
const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/share/${code.trim()}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || '分享码无效或已过期');
}
const data = await response.json();
console.log('验证分享码返回数据:', data);
if (!data.fileId) {
throw new Error('未找到文件ID');
}
const fileName = data.fileName || 'downloaded_file';
onValidSuccess(data.fileId, fileName);
message.success(`验证成功,文件名:${fileName}`);
console.log('验证分享码返回数据:', result);
onValidSuccess(result.fileId, result.fileName);
message.success(`验证成功,文件名:${result.fileName}`);
} catch (error) {
console.error('验证分享码失败:', error);
message.error('分享码无效或已过期');
} finally {
setLoading(false);
}
};
},[result])
return (
<div className={styles.container}>

View File

@ -86,6 +86,7 @@ export const TusUploader = ({
handleFileUpload(
file,
(result) => {
console.log(result)
setCompletedFiles((prev) => [
...prev,
{

View File

@ -10,7 +10,7 @@ interface UploadResult {
fileName: string;
}
export function useTusUpload(p0?: { onSuccess: (result: UploadResult) => void; onError: (error: Error) => void; }) {
export function useTusUpload() {
const [uploadProgress, setUploadProgress] = useState<
Record<string, number>
>({});
@ -77,6 +77,7 @@ export function useTusUpload(p0?: { onSuccess: (result: UploadResult) => void; o
onSuccess: async (payload) => {
if (upload.url) {
const fileId = getFileId(upload.url);
//console.log(fileId)
const url = getResourceUrl(upload.url);
setIsUploading(false);
setUploadProgress((prev) => ({

View File

@ -29,10 +29,10 @@ export type CustomRouteObject =
| CustomNonIndexRouteObject;
export const routes: CustomRouteObject[] = [
{
path:'/',
element:<DeptSettingPage></DeptSettingPage>,
path: "/",
element: <DeptSettingPage></DeptSettingPage>,
errorElement: <ErrorPage />,
}
},
];
export const router = createBrowserRouter(routes);