feat(room): 增加加入令牌及分块传输支持

- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌
- 新增房间加入令牌验证接口,加入时需提供房间号和令牌
- 前端HomeView新增加入令牌输入框及验证逻辑
- Clipboard工具增加写入API支持及复制按钮
- FileDropZone支持选择文件夹批量上传
- FileMessage和ImageMessage新增分片进度提示及失败重试功能
- API层新增分块上传及断点续传实现,支持大文件分片上传
- 文件上传存储时计算文件sha256,响应中返回该值
- 下载接口支持断点续传,优化大文件下载体验
- README新增加入令牌安全说明及压力测试使用示例
- 资源清理与配置优化,添加磁盘使用水位阈值控制
This commit is contained in:
2026-03-06 03:15:18 +08:00
parent ed07dfaa2e
commit 1c99136944
23 changed files with 1448 additions and 124 deletions

View File

@@ -14,9 +14,11 @@
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
<RoomPanel
:room-code="roomCode"
:join-token="joinToken"
:user-list="wsStore.userList"
:connection-status="wsStore.status"
:my-user-id="wsStore.myUserId"
:transfer-stats="transferStats"
@leave="handleLeave"
@clear-history="handleClearHistory"
@export-json="handleExportJson"
@@ -52,7 +54,9 @@
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
@download="handleImageDownload(msg)"
@retry="handleRetryUpload(msg)"
/>
<!-- 普通文件消息 -->
<FileMessage
@@ -63,7 +67,9 @@
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
@download="handleFileDownload(msg)"
@retry="handleRetryUpload(msg)"
/>
</template>
</div>
@@ -86,7 +92,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseButton from '@/components/ui/BaseButton.vue';
import RoomPanel from '@/components/RoomPanel.vue';
@@ -99,6 +105,7 @@ import { getFileDownloadUrl, downloadWithProgress } from '@/api/room';
import { useWsStore } from '@/stores/wsStore';
import type { RoomMessagePayload } from '@/types/room';
import { isImageMimeType } from '@/types/room';
import { sha256HexOfBlob } from '@/utils/hash';
// 未设置 VITE_API_BASE 时使用同源:开发时走 Vite 代理Docker/生产时与页面同 host:port避免硬编码 localhost 导致部署后连不上
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
@@ -106,12 +113,41 @@ const API_BASE = import.meta.env.VITE_API_BASE ?? '';
const route = useRoute();
const router = useRouter();
const roomCode = route.params.roomCode as string;
const joinToken = computed(() => {
const token = route.query.token;
return typeof token === 'string' ? token : '';
});
const messageListRef = ref<HTMLElement | null>(null);
const wsStore = useWsStore();
const downloadToast = ref('');
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
const transferStats = computed(() => {
let sendingCount = 0;
let receivingCount = 0;
for (const msg of wsStore.roomMessages) {
if (msg.type !== 'FILE' && msg.type !== 'IMAGE') continue;
const fileId = msg.fileId ?? '';
if (!fileId) continue;
const progress = wsStore.getFileProgress(fileId);
if (progress < 0 || progress >= 100) continue;
if (msg.senderId === wsStore.myUserId) {
sendingCount += 1;
} else if (wsStore.isDownloading(fileId)) {
receivingCount += 1;
}
}
return {
messageCount: wsStore.roomMessages.length,
sendingCount,
receivingCount,
queueCount: wsStore.uploadQueueSize,
resumedCount: wsStore.uploadResumeCount,
retryCount: wsStore.uploadRetryCount,
};
});
function showDownloadToast(message: string) {
downloadToast.value = message;
if (downloadToastTimer) clearTimeout(downloadToastTimer);
@@ -130,8 +166,12 @@ watch(
() => wsStore.status,
(status) => {
if (status === 'connected' && roomCode) {
if (!joinToken.value) {
showDownloadToast('缺少加入令牌,请返回首页重新加入房间');
return;
}
// 使用 store 中已生成的随机中国人名
wsStore.joinRoom({ roomCode, nickname: wsStore.myNickname });
wsStore.joinRoom({ roomCode, joinToken: joinToken.value, nickname: wsStore.myNickname });
}
},
{ immediate: true },
@@ -160,6 +200,9 @@ function resolveSenderName(senderId?: string): string {
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
const fileId = msg.fileId ?? '';
if (msg.senderId === wsStore.myUserId && wsStore.isFailedUpload(fileId)) {
return 'error';
}
const progress = wsStore.getFileProgress(fileId);
const isMe = msg.senderId === wsStore.myUserId;
// 发送方099 显示「发送中」
@@ -169,6 +212,18 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done
return 'done';
}
async function handleRetryUpload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
if (!fileId) {
showDownloadToast('重试失败:文件标识缺失');
return;
}
const result = await wsStore.retryFailedUpload(fileId);
if (!result.ok && result.error) {
showDownloadToast(result.error);
}
}
/**
* 判断消息是否为图片类型
* - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP与文件一致
@@ -203,6 +258,25 @@ function getImageSrc(msg: RoomMessagePayload): string | undefined {
return undefined;
}
async function verifyDownloadedBlob(
msg: RoomMessagePayload,
blob: Blob,
): Promise<boolean> {
const expected = (msg.sha256 ?? '').trim().toLowerCase();
if (!expected) return true;
const actual = await sha256HexOfBlob(blob);
if (!actual) {
showDownloadToast('当前环境不支持 SHA-256 校验,已继续下载');
return true;
}
if (actual !== expected) {
showDownloadToast('文件完整性校验失败,请重试或让发送方重传');
return false;
}
return true;
}
async function handleFileSelected(files: File[]) {
for (const file of files) {
const result = await wsStore.sendFile(roomCode, file);
@@ -226,6 +300,8 @@ async function handleImageDownload(msg: RoomMessagePayload) {
const blob = await downloadWithProgress(url, (percent) => {
wsStore.setFileProgress(fileId, percent);
});
const verified = await verifyDownloadedBlob(msg, blob);
if (!verified) return;
wsStore.setFileProgress(fileId, 100);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -281,6 +357,8 @@ async function handleFileDownload(msg: RoomMessagePayload) {
const blob = await downloadWithProgress(url, (percent) => {
wsStore.setFileProgress(fileId, percent);
});
const verified = await verifyDownloadedBlob(msg, blob);
if (!verified) return;
wsStore.setFileProgress(fileId, 100);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');