2026-06-15 15:04:43 -07:00
|
|
|
|
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';
|
2026-06-20 15:08:10 +08:00
|
|
|
|
import { extractAttachmentText } from './mindspace-attachment-text.mjs';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
import {
|
|
|
|
|
|
assetThumbnailKey,
|
|
|
|
|
|
ensureHtmlThumbnail,
|
|
|
|
|
|
scheduleHtmlThumbnail,
|
|
|
|
|
|
} from './mindspace-thumbnails.mjs';
|
2026-06-20 15:08:10 +08:00
|
|
|
|
import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
|
2026-07-02 21:08:41 +08:00
|
|
|
|
import { PUBLIC_ZONE_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
|
|
|
|
|
|
import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildPublicImagePaths,
|
|
|
|
|
|
buildUserImagePublicUrl,
|
|
|
|
|
|
isPublicImageStorageKey,
|
|
|
|
|
|
} from './user-image-url.mjs';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
|
|
|
|
|
|
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
2026-06-29 09:45:52 +08:00
|
|
|
|
import { resolveWorkspaceStoragePath, isWorkspaceStorageKey } from './workspace-storage.mjs';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DEFAULT_IMAGE_INPUT_MAX_PIXELS,
|
|
|
|
|
|
DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
|
|
|
|
|
|
normalizeImageForStorage,
|
|
|
|
|
|
} from './user-image-normalize.mjs';
|
2026-07-05 23:14:41 +08:00
|
|
|
|
import {
|
|
|
|
|
|
normalizeWorkspaceRelativePath,
|
|
|
|
|
|
resolveAssetWorkspaceRelativePath,
|
|
|
|
|
|
} from './mindspace-workspace-path.mjs';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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'],
|
|
|
|
|
|
]);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const MAX_IMAGE_UPLOAD_BYTES = DEFAULT_IMAGE_UPLOAD_MAX_BYTES;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';
|
|
|
|
|
|
|
|
|
|
|
|
const PUBLIC_IMAGE_EXTENSIONS = new Map([
|
|
|
|
|
|
['image/png', '.png'],
|
|
|
|
|
|
['image/jpeg', '.jpg'],
|
|
|
|
|
|
['image/webp', '.webp'],
|
|
|
|
|
|
]);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 08:25:02 +08:00
|
|
|
|
function extractImageDimensions(buffer, mimeType) {
|
|
|
|
|
|
if (mimeType === 'image/png' && buffer.length >= 24) {
|
|
|
|
|
|
const width = buffer.readUInt32BE(16);
|
|
|
|
|
|
const height = buffer.readUInt32BE(20);
|
|
|
|
|
|
return { width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mimeType === 'image/jpeg' && buffer.length >= 2) {
|
|
|
|
|
|
let offset = 2;
|
|
|
|
|
|
while (offset < buffer.length - 9) {
|
|
|
|
|
|
if (buffer[offset] !== 0xff) break;
|
|
|
|
|
|
const marker = buffer[offset + 1];
|
|
|
|
|
|
if (marker === 0xd9) break;
|
|
|
|
|
|
if (marker >= 0xd0 && marker <= 0xd8) {
|
|
|
|
|
|
offset += 2;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const segmentLength = buffer.readUInt16BE(offset + 2);
|
|
|
|
|
|
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
|
|
|
|
|
|
const height = buffer.readUInt16BE(offset + 5);
|
|
|
|
|
|
const width = buffer.readUInt16BE(offset + 7);
|
|
|
|
|
|
return { width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
offset += segmentLength + 2;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mimeType === 'image/webp' && buffer.length >= 30) {
|
|
|
|
|
|
const width = buffer.readUInt32LE(24) + 1;
|
|
|
|
|
|
const height = buffer.readUInt32LE(28) + 1;
|
|
|
|
|
|
return { width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function validateImagePixels(buffer, mimeType) {
|
|
|
|
|
|
if (!mimeType?.startsWith('image/')) return null;
|
|
|
|
|
|
const dims = extractImageDimensions(buffer, mimeType);
|
|
|
|
|
|
if (!dims) return { ok: true };
|
|
|
|
|
|
const { width, height } = dims;
|
|
|
|
|
|
const megapixels = (width * height) / 1_000_000;
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const maxMegapixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS / 1_000_000;
|
|
|
|
|
|
if (megapixels > maxMegapixels) {
|
2026-06-27 08:25:02 +08:00
|
|
|
|
throw Object.assign(
|
2026-06-29 22:20:04 +08:00
|
|
|
|
new Error(`图片像素超过限制(${megapixels.toFixed(1)}MP > ${maxMegapixels.toFixed(0)}MP)`),
|
2026-06-27 08:25:02 +08:00
|
|
|
|
{ code: 'image_pixel_exceeded' }
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return { ok: true, width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const UPLOADABLE_CATEGORY_CODES = ['oa', 'public'];
|
|
|
|
|
|
|
|
|
|
|
|
function resolvePublicImagePaths(userId, assetId, mimeType, filename, uniqueSuffix = null) {
|
|
|
|
|
|
const suffix = uniqueSuffix ?? String(assetId ?? '').replace(/-/g, '').slice(0, 12);
|
|
|
|
|
|
return buildPublicImagePaths({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
mimeType,
|
|
|
|
|
|
originalFilename: filename,
|
|
|
|
|
|
uniqueSuffix: suffix,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 15:19:03 +08:00
|
|
|
|
function isPublicImageAsset(row) {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const mime = String(row?.mime_type ?? '');
|
|
|
|
|
|
if (!mime.startsWith('image/')) return false;
|
|
|
|
|
|
if (isPublicImageStorageKey(row?.storage_key)) return true;
|
|
|
|
|
|
return row?.category_code === 'public';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 23:14:41 +08:00
|
|
|
|
function isCanonicalPublicImageStorageKey(storageKey) {
|
|
|
|
|
|
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
|
|
|
|
|
|
if (isWorkspaceStorageKey(normalized)) return false;
|
|
|
|
|
|
return isPublicImageStorageKey(normalized);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
function publicUrlForAsset(row) {
|
|
|
|
|
|
if (!isPublicImageAsset(row)) return null;
|
2026-07-05 23:14:41 +08:00
|
|
|
|
if (row?.storage_key && isCanonicalPublicImageStorageKey(row.storage_key)) {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
return buildUserImagePublicUrl({
|
|
|
|
|
|
userId: row.user_id,
|
|
|
|
|
|
storageKey: row.storage_key,
|
|
|
|
|
|
mimeType: row.mime_type,
|
|
|
|
|
|
originalFilename: row.original_filename,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-05 23:14:41 +08:00
|
|
|
|
const workspacePath = normalizeWorkspaceRelativePath(row?.workspace_relative_path);
|
|
|
|
|
|
if (workspacePath && isWorkspaceStorageKey(row?.storage_key)) {
|
|
|
|
|
|
return createMindSpacePublicUrl({
|
|
|
|
|
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
|
|
|
|
ownerKey: row.user_id,
|
|
|
|
|
|
relativePath: workspacePath,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (row?.category_code === 'public' && row?.id) {
|
2026-07-05 23:14:41 +08:00
|
|
|
|
return publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
return null;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') {
|
|
|
|
|
|
const fallbackExtension = path.extname(String(fallbackFilename)).toLowerCase();
|
|
|
|
|
|
const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || '.img';
|
|
|
|
|
|
return `${assetId}${extension}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = '') {
|
|
|
|
|
|
return path.posix.join(
|
|
|
|
|
|
'users',
|
|
|
|
|
|
userId,
|
|
|
|
|
|
PUBLIC_ZONE_DIR,
|
|
|
|
|
|
PUBLIC_TEMP_IMAGE_DIR,
|
|
|
|
|
|
publicTempImageFilename(assetId, mimeType, fallbackFilename),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') {
|
|
|
|
|
|
const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename);
|
2026-07-02 21:08:41 +08:00
|
|
|
|
return createMindSpacePublicUrl({
|
|
|
|
|
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
|
|
|
|
ownerKey: userId,
|
|
|
|
|
|
relativePath: `${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${filename}`,
|
|
|
|
|
|
});
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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'),
|
2026-06-29 22:20:04 +08:00
|
|
|
|
publicUrl: publicUrlForAsset(row),
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const mimeType = expectedMimeType(normalizedFilename);
|
|
|
|
|
|
if (!mimeType) {
|
|
|
|
|
|
throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const effectiveMaxBytes = mimeType.startsWith('image/')
|
|
|
|
|
|
? MAX_IMAGE_UPLOAD_BYTES
|
|
|
|
|
|
: maxFileBytes;
|
|
|
|
|
|
if (normalizedSize > effectiveMaxBytes) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(mimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'),
|
|
|
|
|
|
{ code: 'file_too_large' },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) {
|
|
|
|
|
|
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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());
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage;
|
2026-07-02 21:28:51 +08:00
|
|
|
|
const conversationPackageRegistry = options.conversationPackageRegistry ?? null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const workspaceSync = createWorkspaceAssetSync({
|
|
|
|
|
|
pool,
|
|
|
|
|
|
storageRoot,
|
|
|
|
|
|
h5Root,
|
|
|
|
|
|
maxFileBytes,
|
|
|
|
|
|
idFactory,
|
2026-07-02 21:55:07 +08:00
|
|
|
|
conversationPackageRegistry,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
|
|
|
|
|
|
if (!h5Root) return null;
|
|
|
|
|
|
return mirrorAssetToZone({
|
|
|
|
|
|
workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
|
|
|
|
|
|
categoryCode,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
sourcePath,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const writePublicImageMirror = async (userId, workspaceRelativePath, sourcePath) => {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (!h5Root) return null;
|
|
|
|
|
|
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const target = path.join(workspaceRoot, workspaceRelativePath);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
|
|
|
|
await fs.copyFile(sourcePath, target);
|
|
|
|
|
|
return target;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const removePublicImageMirror = async (userId, workspaceRelativePath) => {
|
|
|
|
|
|
if (!h5Root || !workspaceRelativePath) return;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const target = path.join(workspaceRoot, workspaceRelativePath);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
await fs.rm(target, { force: true });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const resolvePublicCategory = async (conn, userId) => {
|
|
|
|
|
|
const [rows] = await conn.query(
|
|
|
|
|
|
`SELECT id, space_id, category_code
|
|
|
|
|
|
FROM h5_space_categories
|
|
|
|
|
|
WHERE user_id = ? AND category_code = 'public'
|
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
|
[userId],
|
|
|
|
|
|
);
|
|
|
|
|
|
return rows[0] ?? null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const absoluteStoragePath = (storageKey) => {
|
|
|
|
|
|
const resolved = path.resolve(storageRoot, storageKey);
|
|
|
|
|
|
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
|
|
|
|
|
|
throw new Error('存储路径越界');
|
|
|
|
|
|
}
|
|
|
|
|
|
return resolved;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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) => {
|
2026-06-29 09:45:52 +08:00
|
|
|
|
if (h5Root && isWorkspaceStorageKey(storageKey)) {
|
|
|
|
|
|
const workspacePath = resolveWorkspaceStoragePath(h5Root, storageKey);
|
|
|
|
|
|
if (workspacePath) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fs.stat(workspacePath);
|
|
|
|
|
|
return workspacePath;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
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' });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const normalizeStoredAssetImage = async ({ buffer, mimeType, filename }) => {
|
|
|
|
|
|
if (!mimeType?.startsWith('image/')) {
|
|
|
|
|
|
return { buffer, mimeType, filename };
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalizeStoredImage({
|
|
|
|
|
|
buffer,
|
|
|
|
|
|
mimeType,
|
|
|
|
|
|
filename,
|
|
|
|
|
|
maxUploadBytes: MAX_IMAGE_UPLOAD_BYTES,
|
|
|
|
|
|
maxPixels: DEFAULT_IMAGE_INPUT_MAX_PIXELS,
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code)) {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
throw Object.assign(new Error('该分类不允许直接上传'), {
|
|
|
|
|
|
code: 'category_not_uploadable',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 22:20:04 +08:00
|
|
|
|
let targetCategory = category;
|
|
|
|
|
|
if (validated.expectedMimeType.startsWith('image/')) {
|
|
|
|
|
|
const publicCategory = await resolvePublicCategory(conn, userId);
|
|
|
|
|
|
if (publicCategory) targetCategory = publicCategory;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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();
|
2026-06-20 15:08:10 +08:00
|
|
|
|
const temporaryStorageKey = path.posix.join('users', userId, 'temp', `${uploadId}.upload`);
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-07-02 21:28:51 +08:00
|
|
|
|
reserved_bytes, temporary_storage_key, status, expires_at, created_at,
|
|
|
|
|
|
source_session_id, source_message_id)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?, ?, ?)`,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
[
|
|
|
|
|
|
uploadId,
|
|
|
|
|
|
userId,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
targetCategory.space_id,
|
|
|
|
|
|
targetCategory.id,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
validated.filename,
|
|
|
|
|
|
validated.sizeBytes,
|
|
|
|
|
|
input.declaredMimeType || null,
|
|
|
|
|
|
validated.sizeBytes,
|
|
|
|
|
|
temporaryStorageKey,
|
|
|
|
|
|
now + uploadTtlMs,
|
|
|
|
|
|
now,
|
2026-07-02 21:28:51 +08:00
|
|
|
|
input.sourceSessionId ?? input.source_session_id ?? null,
|
|
|
|
|
|
input.sourceMessageId ?? input.source_message_id ?? null,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
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' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (buffer.length !== asNumber(upload.expected_size)) {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const effectiveMaxBytes = detectedMimeType.startsWith('image/')
|
|
|
|
|
|
? MAX_IMAGE_UPLOAD_BYTES
|
|
|
|
|
|
: maxFileBytes;
|
|
|
|
|
|
if (buffer.length > effectiveMaxBytes) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(detectedMimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'),
|
|
|
|
|
|
{ code: 'file_too_large' },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
|
|
|
|
|
|
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
|
|
|
|
|
|
}
|
2026-06-27 08:25:02 +08:00
|
|
|
|
if (detectedMimeType.startsWith('image/')) {
|
|
|
|
|
|
validateImagePixels(buffer, detectedMimeType);
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-06-20 15:08:10 +08:00
|
|
|
|
const conn = await pool.getConnection();
|
|
|
|
|
|
let temporaryPath;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
let finalPath;
|
|
|
|
|
|
let publicMirror = null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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();
|
|
|
|
|
|
temporaryPath = absoluteStoragePath(upload.temporary_storage_key);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const sourceBuffer = await fs.readFile(temporaryPath);
|
|
|
|
|
|
const normalizedImage = await normalizeStoredAssetImage({
|
|
|
|
|
|
buffer: sourceBuffer,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
mimeType: upload.detected_mime_type,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
filename: upload.filename,
|
|
|
|
|
|
});
|
|
|
|
|
|
const storedBuffer = normalizedImage.buffer;
|
|
|
|
|
|
const storedMimeType = normalizedImage.mimeType;
|
|
|
|
|
|
const storedUploadFilename = normalizedImage.filename;
|
|
|
|
|
|
const storedSizeBytes = storedBuffer.length;
|
|
|
|
|
|
const storedChecksum = crypto.createHash('sha256').update(storedBuffer).digest('hex');
|
|
|
|
|
|
const scan = runBasicFileScan(storedBuffer, {
|
|
|
|
|
|
filename: storedUploadFilename,
|
|
|
|
|
|
mimeType: storedMimeType,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
});
|
|
|
|
|
|
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
|
|
|
|
|
|
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const isImage = storedMimeType.startsWith('image/');
|
|
|
|
|
|
const shouldPublishImage = isImage && scan.scanStatus === 'passed';
|
|
|
|
|
|
let finalStorageKey = upload.temporary_storage_key;
|
|
|
|
|
|
let workspaceRelativePath = null;
|
|
|
|
|
|
let storedFilename = storedUploadFilename;
|
|
|
|
|
|
if (shouldPublishImage) {
|
|
|
|
|
|
const imagePaths = resolvePublicImagePaths(
|
|
|
|
|
|
userId,
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
storedMimeType,
|
|
|
|
|
|
storedUploadFilename,
|
|
|
|
|
|
);
|
|
|
|
|
|
finalStorageKey = imagePaths.storageKey;
|
|
|
|
|
|
workspaceRelativePath = imagePaths.workspaceRelativePath;
|
|
|
|
|
|
storedFilename = imagePaths.workspaceRelativePath;
|
|
|
|
|
|
}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
finalPath = absoluteStoragePath(finalStorageKey);
|
|
|
|
|
|
if (finalStorageKey !== upload.temporary_storage_key) {
|
|
|
|
|
|
await fs.mkdir(path.dirname(finalPath), { recursive: true });
|
2026-06-29 22:20:04 +08:00
|
|
|
|
await fs.writeFile(finalPath, storedBuffer);
|
|
|
|
|
|
} else if (isImage) {
|
|
|
|
|
|
await fs.writeFile(finalPath, storedBuffer);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (shouldPublishImage && workspaceRelativePath) {
|
|
|
|
|
|
publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(upload.category_code);
|
2026-07-05 23:14:41 +08:00
|
|
|
|
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
|
|
|
|
|
|
categoryCode: upload.category_code,
|
|
|
|
|
|
originalFilename: storedFilename,
|
|
|
|
|
|
explicitPath: workspaceRelativePath,
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
await conn.query(
|
|
|
|
|
|
`INSERT INTO h5_assets
|
|
|
|
|
|
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
status, source_type, created_at, updated_at)
|
2026-07-05 23:14:41 +08:00
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
[
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
upload.space_id,
|
|
|
|
|
|
upload.category_id,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
assetTypeForMime(storedMimeType),
|
|
|
|
|
|
storedMimeType,
|
|
|
|
|
|
storedFilename,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
upload.filename,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
indexedWorkspacePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
versionId,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
storedSizeBytes,
|
|
|
|
|
|
storedChecksum,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
storedSizeBytes,
|
|
|
|
|
|
storedChecksum,
|
|
|
|
|
|
storedMimeType,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 = ?`,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
[upload.reserved_bytes, storedSizeBytes, now, upload.space_id, userId],
|
2026-06-15 15:04:43 -07:00
|
|
|
|
);
|
|
|
|
|
|
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();
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const result = {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
id: assetId,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
user_id: userId,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
categoryId: upload.category_id,
|
|
|
|
|
|
categoryCode: upload.category_code,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
assetType: assetTypeForMime(storedMimeType),
|
|
|
|
|
|
mimeType: storedMimeType,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
filename: upload.filename,
|
|
|
|
|
|
displayName: upload.filename,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
sizeBytes: storedSizeBytes,
|
|
|
|
|
|
checksum: storedChecksum,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
riskLevel: scan.riskLevel,
|
|
|
|
|
|
visibility,
|
|
|
|
|
|
status: assetStatus,
|
|
|
|
|
|
scanStatus: versionScanStatus,
|
|
|
|
|
|
sourceType: 'upload',
|
|
|
|
|
|
createdAt: now,
|
|
|
|
|
|
updatedAt: now,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
publicUrl: shouldPublishImage
|
|
|
|
|
|
? buildUserImagePublicUrl({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
storageKey: finalStorageKey,
|
|
|
|
|
|
mimeType: storedMimeType,
|
|
|
|
|
|
originalFilename: storedUploadFilename,
|
|
|
|
|
|
})
|
2026-06-26 15:19:03 +08:00
|
|
|
|
: null,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (finalStorageKey !== upload.temporary_storage_key) {
|
|
|
|
|
|
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
|
|
|
|
|
}
|
2026-07-02 21:28:51 +08:00
|
|
|
|
await registerUploadArtifactForConversation({
|
|
|
|
|
|
registry: conversationPackageRegistry,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
upload,
|
|
|
|
|
|
asset: result,
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
storageKey: finalStorageKey,
|
|
|
|
|
|
canonicalUrl: result.publicUrl,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
2026-06-29 22:20:04 +08:00
|
|
|
|
return result;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await conn.rollback();
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {});
|
|
|
|
|
|
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
throw error;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
conn.release();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-02 23:46:05 +08:00
|
|
|
|
const claimUploadArtifactsForConversation = async (userId, { sessionId, messageId } = {}) => {
|
|
|
|
|
|
const normalizedSessionId = String(sessionId ?? '').trim();
|
|
|
|
|
|
const normalizedMessageId = String(messageId ?? '').trim();
|
|
|
|
|
|
if (!normalizedSessionId || !normalizedMessageId) return { claimedCount: 0 };
|
|
|
|
|
|
|
|
|
|
|
|
const conn = await pool.getConnection();
|
|
|
|
|
|
try {
|
|
|
|
|
|
await conn.beginTransaction();
|
|
|
|
|
|
const [rows] = await conn.query(
|
|
|
|
|
|
`SELECT
|
|
|
|
|
|
u.*,
|
|
|
|
|
|
a.id AS asset_id,
|
|
|
|
|
|
a.mime_type AS asset_mime_type,
|
|
|
|
|
|
a.original_filename AS asset_original_filename,
|
|
|
|
|
|
a.display_name AS asset_display_name,
|
|
|
|
|
|
a.size_bytes AS asset_size_bytes,
|
|
|
|
|
|
a.updated_at AS asset_updated_at,
|
|
|
|
|
|
v.storage_key AS asset_storage_key,
|
|
|
|
|
|
c.category_code
|
|
|
|
|
|
FROM h5_upload_sessions u
|
|
|
|
|
|
JOIN h5_assets a
|
|
|
|
|
|
ON a.id = u.completed_asset_id AND a.user_id = u.user_id
|
|
|
|
|
|
LEFT JOIN h5_asset_versions v
|
|
|
|
|
|
ON v.id = a.current_version_id AND v.asset_id = a.id
|
|
|
|
|
|
LEFT JOIN h5_space_categories c
|
|
|
|
|
|
ON c.id = a.category_id
|
|
|
|
|
|
WHERE u.user_id = ?
|
|
|
|
|
|
AND u.source_message_id = ?
|
|
|
|
|
|
AND u.status = 'completed'
|
|
|
|
|
|
AND (u.source_session_id IS NULL OR u.source_session_id = '')
|
|
|
|
|
|
LIMIT 20
|
|
|
|
|
|
FOR UPDATE`,
|
|
|
|
|
|
[userId, normalizedMessageId],
|
|
|
|
|
|
);
|
|
|
|
|
|
if (rows.length === 0) {
|
|
|
|
|
|
await conn.commit();
|
|
|
|
|
|
return { claimedCount: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
await conn.query(
|
|
|
|
|
|
`UPDATE h5_upload_sessions
|
|
|
|
|
|
SET source_session_id = ?
|
|
|
|
|
|
WHERE user_id = ?
|
|
|
|
|
|
AND source_message_id = ?
|
|
|
|
|
|
AND status = 'completed'
|
|
|
|
|
|
AND (source_session_id IS NULL OR source_session_id = '')`,
|
|
|
|
|
|
[normalizedSessionId, userId, normalizedMessageId],
|
|
|
|
|
|
);
|
|
|
|
|
|
await conn.commit();
|
|
|
|
|
|
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
let claimedCount = 0;
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const upload = {
|
|
|
|
|
|
...row,
|
|
|
|
|
|
source_session_id: normalizedSessionId,
|
|
|
|
|
|
source_message_id: normalizedMessageId,
|
|
|
|
|
|
detected_mime_type: row.asset_mime_type,
|
|
|
|
|
|
actual_size: row.asset_size_bytes,
|
|
|
|
|
|
};
|
|
|
|
|
|
const asset = {
|
|
|
|
|
|
id: row.asset_id,
|
|
|
|
|
|
user_id: userId,
|
|
|
|
|
|
categoryCode: row.category_code,
|
|
|
|
|
|
mimeType: row.asset_mime_type,
|
|
|
|
|
|
filename: row.asset_original_filename,
|
|
|
|
|
|
displayName: row.asset_display_name,
|
|
|
|
|
|
sizeBytes: asNumber(row.asset_size_bytes),
|
|
|
|
|
|
updatedAt: asNumber(row.asset_updated_at),
|
|
|
|
|
|
};
|
|
|
|
|
|
const artifact = await registerUploadArtifactForConversation({
|
|
|
|
|
|
registry: conversationPackageRegistry,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
upload,
|
|
|
|
|
|
asset,
|
|
|
|
|
|
assetId: row.asset_id,
|
|
|
|
|
|
storageKey: row.asset_storage_key,
|
|
|
|
|
|
canonicalUrl: publicUrlForAsset({
|
|
|
|
|
|
id: row.asset_id,
|
|
|
|
|
|
user_id: userId,
|
|
|
|
|
|
category_code: row.category_code,
|
|
|
|
|
|
mime_type: row.asset_mime_type,
|
|
|
|
|
|
storage_key: row.asset_storage_key,
|
|
|
|
|
|
original_filename: row.asset_original_filename,
|
|
|
|
|
|
}),
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (artifact) claimedCount += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return { claimedCount };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await conn.rollback();
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
conn.release();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 } = {}) => {
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (syncWorkspace && categoryCode && UPLOADABLE_CATEGORY_CODES.includes(categoryCode)) {
|
2026-06-29 09:45:52 +08:00
|
|
|
|
await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
`[MindSpace] workspace sync failed (${categoryCode}):`,
|
|
|
|
|
|
error?.message ?? error,
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}
|
|
|
|
|
|
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(
|
2026-06-29 22:20:04 +08:00
|
|
|
|
`SELECT a.*, c.category_code, v.storage_key,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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
|
2026-06-29 22:20:04 +08:00
|
|
|
|
JOIN h5_asset_versions v ON v.id = a.current_version_id
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 ')}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
GROUP BY a.id, v.storage_key
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
let storageCleanup = null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await conn.beginTransaction();
|
|
|
|
|
|
const [rows] = await conn.query(
|
2026-06-26 15:19:03 +08:00
|
|
|
|
`SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type,
|
|
|
|
|
|
c.category_code, v.storage_key
|
2026-06-15 15:04:43 -07:00
|
|
|
|
FROM h5_assets a
|
|
|
|
|
|
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
|
2026-06-26 15:19:03 +08:00
|
|
|
|
LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 = {
|
2026-06-26 15:19:03 +08:00
|
|
|
|
id: asset.id,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
categoryCode: asset.category_code,
|
|
|
|
|
|
filename: asset.original_filename,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
mimeType: asset.mime_type,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (asset.storage_key) storageCleanup = asset.storage_key;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (mirrorCleanup.mimeType?.startsWith?.('image/')) {
|
|
|
|
|
|
await removePublicImageMirror(userId, mirrorCleanup.filename);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} catch {
|
|
|
|
|
|
// Best-effort workspace mirror cleanup; DB delete already committed.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (storageCleanup) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fs.rm(absoluteStoragePath(storageCleanup), { force: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Best-effort physical storage cleanup; DB delete already committed.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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);
|
2026-06-19 23:06:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
asset: assetResponse(asset),
|
|
|
|
|
|
path: await resolveReadableStoragePath(asset.storage_key),
|
|
|
|
|
|
};
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-28 12:18:26 +08:00
|
|
|
|
const readPublicAsset = async (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.status <> 'deleted'
|
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
|
[assetId],
|
|
|
|
|
|
);
|
|
|
|
|
|
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),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalizedFilename = normalizeFilename(filename);
|
|
|
|
|
|
const detectedMimeType = detectMimeType(buffer, normalizedFilename);
|
|
|
|
|
|
if (!detectedMimeType) {
|
|
|
|
|
|
throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const effectiveMaxBytes = detectedMimeType.startsWith('image/')
|
|
|
|
|
|
? MAX_IMAGE_UPLOAD_BYTES
|
|
|
|
|
|
: maxFileBytes;
|
|
|
|
|
|
if (buffer.length > effectiveMaxBytes) {
|
|
|
|
|
|
throw Object.assign(
|
|
|
|
|
|
new Error(detectedMimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'),
|
|
|
|
|
|
{ code: 'file_too_large' },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
|
|
|
|
|
|
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const normalizedImage = await normalizeStoredAssetImage({
|
|
|
|
|
|
buffer,
|
|
|
|
|
|
mimeType: detectedMimeType,
|
|
|
|
|
|
filename: normalizedFilename,
|
|
|
|
|
|
});
|
|
|
|
|
|
const storedBuffer = normalizedImage.buffer;
|
|
|
|
|
|
const storedMimeType = normalizedImage.mimeType;
|
|
|
|
|
|
const storedFilenameInput = normalizedImage.filename;
|
|
|
|
|
|
const storedSizeBytes = storedBuffer.length;
|
|
|
|
|
|
const storedChecksum = crypto.createHash('sha256').update(storedBuffer).digest('hex');
|
|
|
|
|
|
const isImage = storedMimeType.startsWith('image/');
|
|
|
|
|
|
const effectiveCategoryCode = isImage ? 'public' : categoryCode;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const conn = await pool.getConnection();
|
|
|
|
|
|
let finalPath;
|
2026-06-26 15:19:03 +08:00
|
|
|
|
let publicMirror = null;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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`,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
[userId, effectiveCategoryCode],
|
2026-06-15 15:04:43 -07:00
|
|
|
|
);
|
|
|
|
|
|
const category = categories[0];
|
|
|
|
|
|
if (!category) {
|
|
|
|
|
|
throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' });
|
|
|
|
|
|
}
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code) && category.category_code !== 'draft') {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (available < storedSizeBytes) {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
throw Object.assign(new Error('剩余空间不足'), {
|
|
|
|
|
|
code: 'quota_exceeded',
|
2026-06-29 22:20:04 +08:00
|
|
|
|
details: { requiredBytes: storedSizeBytes, availableBytes: Math.max(0, available) },
|
2026-06-15 15:04:43 -07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const assetId = idFactory();
|
|
|
|
|
|
const versionId = idFactory();
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const scan = runBasicFileScan(storedBuffer, {
|
|
|
|
|
|
filename: storedFilenameInput,
|
|
|
|
|
|
mimeType: storedMimeType,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
});
|
|
|
|
|
|
const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined';
|
|
|
|
|
|
const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked';
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const shouldPublishImage = isImage && scan.scanStatus === 'passed';
|
|
|
|
|
|
let finalStorageKey = path.posix.join('users', userId, 'assets', assetId, 'versions', versionId);
|
|
|
|
|
|
let workspaceRelativePath = null;
|
|
|
|
|
|
let storedFilename = storedFilenameInput;
|
|
|
|
|
|
if (shouldPublishImage) {
|
|
|
|
|
|
const imagePaths = resolvePublicImagePaths(
|
2026-06-26 15:19:03 +08:00
|
|
|
|
userId,
|
|
|
|
|
|
assetId,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
storedMimeType,
|
|
|
|
|
|
storedFilenameInput,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
);
|
2026-06-29 22:20:04 +08:00
|
|
|
|
finalStorageKey = imagePaths.storageKey;
|
|
|
|
|
|
workspaceRelativePath = imagePaths.workspaceRelativePath;
|
|
|
|
|
|
storedFilename = imagePaths.workspaceRelativePath;
|
|
|
|
|
|
}
|
|
|
|
|
|
finalPath = absoluteStoragePath(finalStorageKey);
|
|
|
|
|
|
await fs.mkdir(path.dirname(finalPath), { recursive: true });
|
|
|
|
|
|
await fs.writeFile(finalPath, storedBuffer, { flag: 'wx' });
|
|
|
|
|
|
if (shouldPublishImage && workspaceRelativePath) {
|
|
|
|
|
|
publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath);
|
2026-06-26 15:19:03 +08:00
|
|
|
|
}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
const now = Date.now();
|
2026-06-29 22:20:04 +08:00
|
|
|
|
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(category.category_code);
|
2026-07-05 23:14:41 +08:00
|
|
|
|
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
|
|
|
|
|
|
categoryCode: category.category_code,
|
|
|
|
|
|
originalFilename: storedFilename,
|
|
|
|
|
|
explicitPath: workspaceRelativePath,
|
|
|
|
|
|
});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
await conn.query(
|
|
|
|
|
|
`INSERT INTO h5_assets
|
|
|
|
|
|
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
status, source_type, created_at, updated_at)
|
2026-07-05 23:14:41 +08:00
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
[
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
category.space_id,
|
|
|
|
|
|
category.id,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
assetTypeForMime(storedMimeType),
|
|
|
|
|
|
storedMimeType,
|
|
|
|
|
|
storedFilename,
|
|
|
|
|
|
displayName || path.basename(storedFilename),
|
2026-07-05 23:14:41 +08:00
|
|
|
|
indexedWorkspacePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
versionId,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
storedSizeBytes,
|
|
|
|
|
|
storedChecksum,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
storedSizeBytes,
|
|
|
|
|
|
storedChecksum,
|
|
|
|
|
|
storedMimeType,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
userId,
|
|
|
|
|
|
versionScanStatus,
|
|
|
|
|
|
now,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
await conn.query(
|
|
|
|
|
|
`UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ?
|
|
|
|
|
|
WHERE id = ? AND user_id = ?`,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
[storedSizeBytes, now, category.space_id, userId],
|
2026-06-15 15:04:43 -07:00
|
|
|
|
);
|
|
|
|
|
|
await conn.commit();
|
2026-06-29 22:20:04 +08:00
|
|
|
|
if (storedMimeType === 'text/html') {
|
|
|
|
|
|
scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), storedBuffer.toString('utf8'), {
|
2026-06-15 15:04:43 -07:00
|
|
|
|
title: displayName || normalizedFilename,
|
|
|
|
|
|
subtitle: category.category_code.toUpperCase(),
|
|
|
|
|
|
contentStorageKey: finalStorageKey,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return assetResponse({
|
|
|
|
|
|
id: assetId,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
user_id: userId,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
category_id: category.id,
|
|
|
|
|
|
category_code: category.category_code,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
asset_type: assetTypeForMime(storedMimeType),
|
|
|
|
|
|
mime_type: storedMimeType,
|
|
|
|
|
|
original_filename: storedFilename,
|
|
|
|
|
|
display_name: displayName || path.basename(storedFilename),
|
|
|
|
|
|
storage_key: finalStorageKey,
|
|
|
|
|
|
size_bytes: storedSizeBytes,
|
|
|
|
|
|
checksum: storedChecksum,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
risk_level: scan.riskLevel,
|
|
|
|
|
|
visibility,
|
|
|
|
|
|
status: assetStatus,
|
|
|
|
|
|
scan_status: versionScanStatus,
|
|
|
|
|
|
source_type: sourceType,
|
|
|
|
|
|
created_at: now,
|
|
|
|
|
|
updated_at: now,
|
2026-06-29 22:20:04 +08:00
|
|
|
|
has_thumbnail: storedMimeType === 'text/html',
|
2026-06-15 15:04:43 -07:00
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
await conn.rollback();
|
|
|
|
|
|
if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {});
|
2026-06-26 15:19:03 +08:00
|
|
|
|
if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {});
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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 });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-20 15:08:10 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-05 23:14:41 +08:00
|
|
|
|
const findAssetByWorkspaceRelativePath = async (userId, relativePath) => {
|
|
|
|
|
|
const normalized = normalizeWorkspaceRelativePath(relativePath);
|
|
|
|
|
|
if (!normalized) return null;
|
|
|
|
|
|
const [rows] = await pool.query(
|
|
|
|
|
|
`SELECT a.*, c.category_code, av.storage_key
|
|
|
|
|
|
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 av ON av.id = a.current_version_id
|
|
|
|
|
|
WHERE a.user_id = ?
|
|
|
|
|
|
AND a.status <> 'deleted'
|
|
|
|
|
|
AND (
|
|
|
|
|
|
a.workspace_relative_path = ?
|
|
|
|
|
|
OR (
|
|
|
|
|
|
(a.workspace_relative_path IS NULL OR a.workspace_relative_path = '')
|
|
|
|
|
|
AND (
|
|
|
|
|
|
a.original_filename = ?
|
|
|
|
|
|
OR CONCAT(c.category_code, '/', a.original_filename) = ?
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
ORDER BY a.updated_at DESC, a.id DESC
|
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
|
[userId, normalized, normalized, normalized],
|
|
|
|
|
|
);
|
|
|
|
|
|
return rows[0] ? assetResponse(rows[0]) : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
return {
|
|
|
|
|
|
storageRoot,
|
|
|
|
|
|
createUpload,
|
|
|
|
|
|
writeUploadContent,
|
|
|
|
|
|
completeUpload,
|
2026-07-02 23:46:05 +08:00
|
|
|
|
claimUploadArtifactsForConversation,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
cancelUpload,
|
|
|
|
|
|
createChatAsset,
|
|
|
|
|
|
listAssets,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
findAssetByWorkspaceRelativePath,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
deleteAsset,
|
|
|
|
|
|
readAsset,
|
2026-06-28 12:18:26 +08:00
|
|
|
|
readPublicAsset,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
renderAssetPreview,
|
2026-06-20 15:08:10 +08:00
|
|
|
|
extractAssetText,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
renderAssetThumbnail,
|
|
|
|
|
|
syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
|
|
|
|
|
|
expireStaleUploads,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 21:28:51 +08:00
|
|
|
|
async function registerUploadArtifactForConversation({
|
|
|
|
|
|
registry,
|
|
|
|
|
|
userId,
|
|
|
|
|
|
upload,
|
|
|
|
|
|
asset,
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
storageKey,
|
|
|
|
|
|
canonicalUrl,
|
|
|
|
|
|
now,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const sessionId = String(upload?.source_session_id ?? '').trim();
|
|
|
|
|
|
if (!registry || !sessionId || !assetId) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const packageRecord = await registry.ensurePackage({
|
|
|
|
|
|
userId,
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
const isImage = String(asset?.mimeType ?? '').startsWith('image/');
|
|
|
|
|
|
const artifact = await registry.recordArtifact({
|
|
|
|
|
|
id: `ca_${assetId}`,
|
|
|
|
|
|
packageId: packageRecord.id,
|
|
|
|
|
|
artifactKind: isImage ? 'input_image' : 'input_file',
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
assetId,
|
|
|
|
|
|
messageId: upload.source_message_id ?? null,
|
|
|
|
|
|
displayName: asset?.displayName ?? asset?.filename ?? upload.filename,
|
|
|
|
|
|
mimeType: asset?.mimeType ?? upload.detected_mime_type ?? null,
|
|
|
|
|
|
sizeBytes: asset?.sizeBytes ?? upload.actual_size ?? null,
|
|
|
|
|
|
storageKey,
|
|
|
|
|
|
canonicalUrl,
|
|
|
|
|
|
sortOrder: Number.isFinite(Number(now)) ? Number(now) : 0,
|
|
|
|
|
|
now,
|
|
|
|
|
|
});
|
|
|
|
|
|
await registry.writeManifestForSession({ userId, sessionId });
|
|
|
|
|
|
return artifact;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
'[MindSpace] conversation package upload artifact registration failed:',
|
|
|
|
|
|
error instanceof Error ? error.message : error,
|
|
|
|
|
|
);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
export const assetInternals = {
|
|
|
|
|
|
detectMimeType,
|
|
|
|
|
|
normalizeFilename,
|
|
|
|
|
|
expectedMimeType,
|
|
|
|
|
|
assetTypeForMime,
|
2026-07-05 23:14:41 +08:00
|
|
|
|
publicUrlForAsset,
|
2026-07-02 21:28:51 +08:00
|
|
|
|
registerUploadArtifactForConversation,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
};
|