Files
memind/mindspace-assets.mjs
T
john 70492d9eba Add attachment text extraction, auto web news skill, and chat/voice UI updates.
Simplify asset upload temp paths, refresh deploy docs for Aliyun DNS topology, and ship MindSpace content-scan and auth improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 15:08:10 +08:00

954 lines
34 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs';
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
import {
assetThumbnailKey,
ensureHtmlThumbnail,
scheduleHtmlThumbnail,
} from './mindspace-thumbnails.mjs';
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
const ALLOWED_EXTENSIONS = new Map([
['.txt', 'text/plain'],
['.md', 'text/markdown'],
['.csv', 'text/csv'],
['.pdf', 'application/pdf'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.doc', 'application/msword'],
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['.xls', 'application/vnd.ms-excel'],
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['.ppt', 'application/vnd.ms-powerpoint'],
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
['.html', 'text/html'],
['.htm', 'text/html'],
]);
const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024;
function asNumber(value) {
return Number(value ?? 0);
}
function normalizeFilename(filename) {
const normalized = String(filename ?? '').normalize('NFKC').trim();
if (
!normalized ||
normalized === '.' ||
normalized === '..' ||
normalized.includes('/') ||
normalized.includes('\\') ||
normalized.includes('\0') ||
/[\u0000-\u001f\u007f]/.test(normalized)
) {
throw Object.assign(new Error('文件名无效'), { code: 'invalid_filename' });
}
return normalized.slice(0, 255);
}
function expectedMimeType(filename) {
return ALLOWED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? null;
}
function detectMimeType(buffer, filename) {
const head = buffer.subarray(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<!doctype html') || head.startsWith('<html')) return 'text/html';
if (buffer.subarray(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return 'image/png';
}
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
if (
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'image/webp';
}
const extensionMime = expectedMimeType(filename);
if (buffer.subarray(0, 2).toString('ascii') === 'PK') return extensionMime;
if (extensionMime?.startsWith('text/')) return extensionMime;
if (['.doc', '.xls', '.ppt'].includes(path.extname(filename).toLowerCase())) {
return extensionMime;
}
return null;
}
function assetTypeForMime(mimeType) {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'text/html') return 'html';
if (mimeType === 'application/pdf') return 'pdf';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'excel';
if (mimeType.includes('word')) return 'word';
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return 'ppt';
if (mimeType === 'text/markdown') return 'markdown';
return 'file';
}
function visibilityForCategory(categoryCode) {
return categoryCode === 'public' ? 'public_candidate' : 'private';
}
function assetResponse(row) {
return {
id: row.id,
categoryId: row.category_id,
categoryCode: row.category_code,
assetType: row.asset_type,
mimeType: row.mime_type,
filename: row.original_filename,
displayName: row.display_name,
sizeBytes: asNumber(row.size_bytes),
checksum: row.checksum,
riskLevel: row.risk_level,
visibility: row.visibility,
status: row.status,
scanStatus: row.scan_status ?? 'passed',
sourceType: row.source_type,
hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'),
sourcePageId: row.source_page_id ?? null,
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
}
function assertAssetDownloadable(asset) {
if (asset.status === 'quarantined') {
throw Object.assign(new Error('文件尚未通过安全检查'), { code: 'security_scan_required' });
}
if (asset.scan_status === 'blocked') {
throw Object.assign(new Error('文件存在安全风险,暂不可下载'), { code: 'security_risk_blocked' });
}
if (asset.scan_status === 'pending') {
throw Object.assign(new Error('文件尚未完成安全检查'), { code: 'security_scan_required' });
}
}
export function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
const normalizedFilename = normalizeFilename(filename);
const normalizedSize = Number(sizeBytes);
if (!Number.isSafeInteger(normalizedSize) || normalizedSize <= 0) {
throw Object.assign(new Error('文件不能为空'), { code: 'invalid_file_size' });
}
if (normalizedSize > maxFileBytes) {
throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
}
const mimeType = expectedMimeType(normalizedFilename);
if (!mimeType) {
throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' });
}
if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType };
}
export function createAssetService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const h5Root = options.h5Root ? path.resolve(options.h5Root) : null;
const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000);
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const workspaceSync = createWorkspaceAssetSync({
pool,
storageRoot,
h5Root,
maxFileBytes,
idFactory,
});
const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
if (!h5Root) return null;
return mirrorAssetToZone({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode,
filename,
sourcePath,
});
};
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
throw new Error('存储路径越界');
}
return resolved;
};
const resolveStoragePathCandidates = (storageKey) => {
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
if (!match) return [normalized];
const [, userId, scope, entityId, versionTag] = match;
return [
normalized,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
].filter((candidate, index, list) => list.indexOf(candidate) === index);
};
const resolveReadableStoragePath = async (storageKey) => {
let lastError = null;
for (const candidate of resolveStoragePathCandidates(storageKey)) {
const absolutePath = absoluteStoragePath(candidate);
try {
await fs.stat(absolutePath);
return absolutePath;
} catch (error) {
if (error?.code === 'ENOENT') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
};
const createUpload = async (userId, input) => {
const validated = validateUploadRequest({ ...input, maxFileBytes });
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [categories] = await conn.query(
`SELECT c.id, c.space_id, c.category_code
FROM h5_space_categories c
WHERE c.id = ? AND c.user_id = ?
LIMIT 1
FOR UPDATE`,
[input.categoryId, userId],
);
const category = categories[0];
if (!category) {
throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
}
if (!['oa', 'private', 'public'].includes(category.category_code)) {
throw Object.assign(new Error('该分类不允许直接上传'), {
code: 'category_not_uploadable',
});
}
const [spaces] = await conn.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[category.space_id, userId],
);
const space = spaces[0];
if (!space || space.status !== 'active') {
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
}
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (available < validated.sizeBytes) {
throw Object.assign(new Error('剩余空间不足'), {
code: 'quota_exceeded',
details: { requiredBytes: validated.sizeBytes, availableBytes: Math.max(0, available) },
});
}
const uploadId = idFactory();
const now = Date.now();
const temporaryStorageKey = path.posix.join('users', userId, 'temp', `${uploadId}.upload`);
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = reserved_bytes + ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[validated.sizeBytes, now, space.id, userId],
);
await conn.query(
`INSERT INTO h5_upload_sessions
(id, user_id, space_id, category_id, filename, expected_size, declared_mime_type,
reserved_bytes, temporary_storage_key, status, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`,
[
uploadId,
userId,
space.id,
category.id,
validated.filename,
validated.sizeBytes,
input.declaredMimeType || null,
validated.sizeBytes,
temporaryStorageKey,
now + uploadTtlMs,
now,
],
);
await conn.commit();
return {
id: uploadId,
filename: validated.filename,
expectedSize: validated.sizeBytes,
uploadUrl: `/api/mindspace/v1/uploads/${uploadId}/content`,
expiresAt: now + uploadTtlMs,
reservedBytes: validated.sizeBytes,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const writeUploadContent = async (userId, uploadId, buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw Object.assign(new Error('上传内容为空'), { code: 'invalid_file_size' });
}
const [rows] = await pool.query(
`SELECT id, filename, expected_size, temporary_storage_key, status, expires_at
FROM h5_upload_sessions
WHERE id = ? AND user_id = ?
LIMIT 1`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (upload.status !== 'reserved') {
throw Object.assign(new Error('上传会话状态无效'), { code: 'invalid_upload_state' });
}
if (asNumber(upload.expires_at) <= Date.now()) {
throw Object.assign(new Error('上传会话已过期'), { code: 'upload_expired' });
}
if (buffer.length !== asNumber(upload.expected_size) || buffer.length > maxFileBytes) {
throw Object.assign(new Error('上传内容大小与预期不一致'), {
code: 'file_size_mismatch',
});
}
const detectedMimeType = detectMimeType(buffer, upload.filename);
if (!detectedMimeType) {
throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
}
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
const target = absoluteStoragePath(upload.temporary_storage_key);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, buffer, { flag: 'wx' }).catch(async (error) => {
if (error?.code !== 'EEXIST') throw error;
await fs.writeFile(target, buffer);
});
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
await pool.query(
`UPDATE h5_upload_sessions
SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded'
WHERE id = ? AND user_id = ? AND status = 'reserved'`,
[buffer.length, detectedMimeType, checksum, uploadId, userId],
);
return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum };
};
const completeUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let temporaryPath;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT u.*, c.category_code
FROM h5_upload_sessions u
JOIN h5_space_categories c ON c.id = u.category_id AND c.user_id = u.user_id
WHERE u.id = ? AND u.user_id = ?
LIMIT 1
FOR UPDATE`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (upload.status === 'completed' && upload.completed_asset_id) {
const [existing] = await conn.query(
`SELECT a.*, c.category_code
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id
WHERE a.id = ? AND a.user_id = ?`,
[upload.completed_asset_id, userId],
);
await conn.commit();
return existing[0] ? assetResponse(existing[0]) : null;
}
if (upload.status !== 'uploaded') {
throw Object.assign(new Error('文件内容尚未上传'), { code: 'invalid_upload_state' });
}
if (
asNumber(upload.actual_size) !== asNumber(upload.expected_size) ||
!upload.detected_mime_type ||
!upload.checksum
) {
throw Object.assign(new Error('上传内容不完整'), { code: 'file_size_mismatch' });
}
const assetId = idFactory();
const versionId = idFactory();
const finalStorageKey = upload.temporary_storage_key;
temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
const fileBuffer = await fs.readFile(temporaryPath);
const scan = runBasicFileScan(fileBuffer, {
filename: upload.filename,
mimeType: upload.detected_mime_type,
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const now = Date.now();
const visibility = visibilityForCategory(upload.category_code);
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
[
assetId,
userId,
upload.space_id,
upload.category_id,
assetTypeForMime(upload.detected_mime_type),
upload.detected_mime_type,
upload.filename,
upload.filename,
versionId,
upload.actual_size,
upload.checksum,
scan.riskLevel,
visibility,
assetStatus,
now,
now,
],
);
await conn.query(
`INSERT INTO h5_asset_versions
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
created_by, change_note, scan_status, created_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, '初始上传', ?, ?)`,
[
versionId,
assetId,
finalStorageKey,
upload.actual_size,
upload.checksum,
upload.detected_mime_type,
userId,
versionScanStatus,
now,
],
);
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?),
used_bytes = used_bytes + ?,
updated_at = ?
WHERE id = ? AND user_id = ?`,
[upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId],
);
await conn.query(
`UPDATE h5_upload_sessions
SET status = 'completed', completed_at = ?, completed_asset_id = ?
WHERE id = ? AND user_id = ?`,
[now, assetId, uploadId, userId],
);
await conn.commit();
return {
id: assetId,
categoryId: upload.category_id,
categoryCode: upload.category_code,
assetType: assetTypeForMime(upload.detected_mime_type),
mimeType: upload.detected_mime_type,
filename: upload.filename,
displayName: upload.filename,
sizeBytes: asNumber(upload.actual_size),
checksum: upload.checksum,
riskLevel: scan.riskLevel,
visibility,
status: assetStatus,
scanStatus: versionScanStatus,
sourceType: 'upload',
createdAt: now,
updatedAt: now,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const cancelUpload = async (userId, uploadId) => {
const conn = await pool.getConnection();
let storageKey;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT id, space_id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[uploadId, userId],
);
const upload = rows[0];
if (!upload) throw Object.assign(new Error('上传会话不存在'), { code: 'upload_not_found' });
if (['completed', 'cancelled', 'expired'].includes(upload.status)) {
await conn.commit();
return { cancelled: upload.status !== 'completed' };
}
storageKey = upload.temporary_storage_key;
const now = Date.now();
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[upload.reserved_bytes, now, upload.space_id, userId],
);
await conn.query(
`UPDATE h5_upload_sessions SET status = 'cancelled' WHERE id = ? AND user_id = ?`,
[uploadId, userId],
);
await conn.commit();
if (storageKey) await fs.rm(absoluteStoragePath(storageKey), { force: true });
return { cancelled: true };
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) {
await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {});
}
const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
const params = [userId];
if (categoryId) {
filters.push(`a.category_id = ?`);
params.push(categoryId);
}
if (categoryCode) {
filters.push(`c.category_code = ?`);
params.push(categoryCode);
}
const [rows] = await pool.query(
`SELECT a.*, c.category_code,
ANY_VALUE(p.id) AS source_page_id
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id
LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id
WHERE ${filters.join(' AND ')}
GROUP BY a.id
ORDER BY a.updated_at DESC
LIMIT 100`,
params,
);
return rows.map(assetResponse);
};
const deleteAsset = async (userId, assetId) => {
const conn = await pool.getConnection();
let mirrorCleanup = null;
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, c.category_code
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
WHERE a.id = ? AND a.user_id = ?
LIMIT 1
FOR UPDATE`,
[assetId, userId],
);
const asset = rows[0];
if (!asset || asset.status === 'deleted') {
throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
}
const [pageReferences] = await conn.query(
`SELECT p.id, p.title, p.status,
EXISTS(
SELECT 1 FROM h5_publish_records pr
WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
) AS published_online
FROM h5_page_versions pv
JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted'
WHERE pv.content_asset_id = ? OR pv.bundle_asset_id = ?
GROUP BY p.id, p.title, p.status
LIMIT 10`,
[assetId, assetId],
);
if (pageReferences.length > 0) {
throw Object.assign(new Error('资产正在被页面使用,不能删除'), {
code: 'asset_in_use',
details: {
hint: '请打开关联页面并删除;删除页面会自动下线公开链接并删除页面内容资产,之后即可删除原始资料。',
references: pageReferences.map((page) => ({
type: 'page',
id: page.id,
title: page.title,
status: page.status,
publishedOnline: Boolean(page.published_online),
})),
},
});
}
const now = Date.now();
await conn.query(
`UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[now, now, assetId, userId],
);
await conn.query(
`UPDATE h5_user_spaces
SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[asset.size_bytes, now, asset.space_id, userId],
);
await conn.commit();
if (h5Root) {
mirrorCleanup = {
categoryCode: asset.category_code,
filename: asset.original_filename,
};
}
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
if (mirrorCleanup) {
try {
removeZoneMirror({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode: mirrorCleanup.categoryCode,
filename: mirrorCleanup.filename,
});
if (/\.html$/i.test(mirrorCleanup.filename)) {
const thumbName = mirrorCleanup.filename.replace(/\.html$/i, '.thumbnail.svg');
removeZoneMirror({
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
categoryCode: mirrorCleanup.categoryCode,
filename: thumbName,
});
}
} catch {
// Best-effort workspace mirror cleanup; DB delete already committed.
}
}
return { deleted: true };
};
const readAsset = async (userId, assetId) => {
const [rows] = await pool.query(
`SELECT a.*, c.category_code, v.storage_key, v.scan_status
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.id = ? AND a.user_id = ? AND a.status <> 'deleted'
LIMIT 1`,
[assetId, userId],
);
const asset = rows[0];
if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' });
assertAssetDownloadable(asset);
return {
asset: assetResponse(asset),
path: await resolveReadableStoragePath(asset.storage_key),
};
};
const createChatAsset = async (
userId,
{ categoryCode, buffer, filename, displayName, sourceType = 'chat' },
) => {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw Object.assign(new Error('资产内容为空'), { code: 'invalid_file_size' });
}
if (buffer.length > maxFileBytes) {
throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' });
}
const normalizedFilename = normalizeFilename(filename);
const detectedMimeType = detectMimeType(buffer, normalizedFilename);
if (!detectedMimeType) {
throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
}
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
const conn = await pool.getConnection();
let finalPath;
try {
await conn.beginTransaction();
const [categories] = await conn.query(
`SELECT c.id, c.space_id, c.category_code
FROM h5_space_categories c
WHERE c.user_id = ? AND c.category_code = ?
LIMIT 1
FOR UPDATE`,
[userId, categoryCode],
);
const category = categories[0];
if (!category) {
throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
}
if (!['oa', 'private', 'public'].includes(category.category_code)) {
throw Object.assign(new Error('该分类不允许保存聊天资产'), {
code: 'category_not_uploadable',
});
}
const [spaces] = await conn.query(
`SELECT id, quota_bytes, used_bytes, reserved_bytes, status
FROM h5_user_spaces
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[category.space_id, userId],
);
const space = spaces[0];
if (!space || space.status !== 'active') {
throw Object.assign(new Error('用户空间不可用'), { code: 'space_unavailable' });
}
const available =
asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes);
if (available < buffer.length) {
throw Object.assign(new Error('剩余空间不足'), {
code: 'quota_exceeded',
details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) },
});
}
const assetId = idFactory();
const versionId = idFactory();
const finalStorageKey = path.posix.join(
'users',
userId,
'assets',
assetId,
'versions',
versionId,
);
finalPath = absoluteStoragePath(finalStorageKey);
await fs.mkdir(path.dirname(finalPath), { recursive: true });
await fs.writeFile(finalPath, buffer, { flag: 'wx' });
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
const scan = runBasicFileScan(buffer, {
filename: normalizedFilename,
mimeType: detectedMimeType,
});
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
const now = Date.now();
const visibility = visibilityForCategory(category.category_code);
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
userId,
category.space_id,
category.id,
assetTypeForMime(detectedMimeType),
detectedMimeType,
normalizedFilename,
displayName || normalizedFilename,
versionId,
buffer.length,
checksum,
scan.riskLevel,
visibility,
assetStatus,
sourceType,
now,
now,
],
);
await conn.query(
`INSERT INTO h5_asset_versions
(id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
created_by, change_note, scan_status, created_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, '从聊天保存', ?, ?)`,
[
versionId,
assetId,
finalStorageKey,
buffer.length,
checksum,
detectedMimeType,
userId,
versionScanStatus,
now,
],
);
await conn.query(
`UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[buffer.length, now, category.space_id, userId],
);
await conn.commit();
if (detectedMimeType === 'text/html') {
scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), {
title: displayName || normalizedFilename,
subtitle: category.category_code.toUpperCase(),
contentStorageKey: finalStorageKey,
});
}
return assetResponse({
id: assetId,
category_id: category.id,
category_code: category.category_code,
asset_type: assetTypeForMime(detectedMimeType),
mime_type: detectedMimeType,
original_filename: normalizedFilename,
display_name: displayName || normalizedFilename,
size_bytes: buffer.length,
checksum,
risk_level: scan.riskLevel,
visibility,
status: assetStatus,
scan_status: versionScanStatus,
source_type: sourceType,
created_at: now,
updated_at: now,
has_thumbnail: detectedMimeType === 'text/html',
});
} catch (error) {
await conn.rollback();
if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {});
throw error;
} finally {
conn.release();
}
};
const renderAssetThumbnail = async (userId, assetId) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
if (asset.mimeType !== 'text/html') {
throw Object.assign(new Error('该资产不支持缩略图'), { code: 'thumbnail_not_supported' });
}
const [versions] = await pool.query(
`SELECT storage_key FROM h5_asset_versions
WHERE asset_id = ? AND version_no = 1
LIMIT 1`,
[assetId],
);
const html = await fs.readFile(assetPath, 'utf8');
return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, {
title: asset.displayName,
subtitle: asset.categoryCode?.toUpperCase?.() ?? 'HTML',
contentStorageKey: versions[0]?.storage_key,
});
};
const renderAssetPreview = async (userId, assetId, { downloadPath = null } = {}) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
if (!canPreviewAsset(asset.mimeType)) {
throw Object.assign(new Error('该资产不支持预览'), { code: 'preview_not_supported' });
}
const buffer = await fs.readFile(assetPath);
const downloadUrl =
downloadPath ??
`/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`;
return renderAssetPreviewHtml({ asset, buffer, downloadUrl });
};
const extractAssetText = async (userId, assetId) => {
const { asset, path: assetPath } = await readAsset(userId, assetId);
const buffer = await fs.readFile(assetPath);
const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename);
return {
asset: assetResponse(asset),
...extracted,
charCount: extracted.text.length,
};
};
const expireStaleUploads = async (now = Date.now()) => {
const [rows] = await pool.query(
`SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE status IN ('reserved', 'uploaded') AND expires_at <= ?
LIMIT 200`,
[now],
);
let expired = 0;
for (const upload of rows) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [locked] = await conn.query(
`SELECT id, reserved_bytes, temporary_storage_key, status
FROM h5_upload_sessions
WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded')
LIMIT 1
FOR UPDATE`,
[upload.id, upload.user_id],
);
const row = locked[0];
if (!row) {
await conn.commit();
continue;
}
await conn.query(
`UPDATE h5_user_spaces
SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
WHERE id = ? AND user_id = ?`,
[row.reserved_bytes, now, upload.space_id, upload.user_id],
);
await conn.query(
`UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
[upload.id, upload.user_id],
);
await conn.commit();
if (row.temporary_storage_key) {
await fs.rm(absoluteStoragePath(row.temporary_storage_key), { force: true });
}
expired += 1;
} catch {
await conn.rollback();
} finally {
conn.release();
}
}
return expired;
};
return {
storageRoot,
createUpload,
writeUploadContent,
completeUpload,
cancelUpload,
createChatAsset,
listAssets,
deleteAsset,
readAsset,
renderAssetPreview,
extractAssetText,
renderAssetThumbnail,
syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
expireStaleUploads,
};
}
export const assetInternals = {
detectMimeType,
normalizeFilename,
expectedMimeType,
assetTypeForMime,
};