fix(mindspace): sync nested OA files and count workspace assets
Recursive workspace sync now imports files in oa/private/public subfolders, references workspace paths without duplicating storage, and includes workspace-sourced assets in category itemCount. Refresh counts when leaving a category view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+18
-1
@@ -13,6 +13,7 @@ import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs';
|
|||||||
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
|
import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs';
|
||||||
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
|
import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs';
|
||||||
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs';
|
||||||
|
import { resolveWorkspaceStoragePath, isWorkspaceStorageKey } from './workspace-storage.mjs';
|
||||||
|
|
||||||
const ALLOWED_EXTENSIONS = new Map([
|
const ALLOWED_EXTENSIONS = new Map([
|
||||||
['.txt', 'text/plain'],
|
['.txt', 'text/plain'],
|
||||||
@@ -297,6 +298,17 @@ export function createAssetService(pool, options = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveReadableStoragePath = async (storageKey) => {
|
const resolveReadableStoragePath = async (storageKey) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
for (const candidate of resolveStoragePathCandidates(storageKey)) {
|
for (const candidate of resolveStoragePathCandidates(storageKey)) {
|
||||||
const absolutePath = absoluteStoragePath(candidate);
|
const absolutePath = absoluteStoragePath(candidate);
|
||||||
@@ -661,7 +673,12 @@ export function createAssetService(pool, options = {}) {
|
|||||||
|
|
||||||
const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
|
const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
|
||||||
if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) {
|
if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) {
|
||||||
await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {});
|
await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => {
|
||||||
|
console.warn(
|
||||||
|
`[MindSpace] workspace sync failed (${categoryCode}):`,
|
||||||
|
error?.message ?? error,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
|
const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
|
||||||
const params = [userId];
|
const params = [userId];
|
||||||
|
|||||||
@@ -9,56 +9,99 @@ import {
|
|||||||
} from './user-space.mjs';
|
} from './user-space.mjs';
|
||||||
import { assetInternals } from './mindspace-assets.mjs';
|
import { assetInternals } from './mindspace-assets.mjs';
|
||||||
import { runBasicFileScan } from './mindspace-scan.mjs';
|
import { runBasicFileScan } from './mindspace-scan.mjs';
|
||||||
|
import { buildWorkspaceStorageKey } from './workspace-storage.mjs';
|
||||||
|
|
||||||
const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']);
|
const SKIP_FILENAMES = new Set(['index.html', '.tkmindhints', '.goosehints', '.ls_output']);
|
||||||
|
const SKIP_ZONE_DIR_NAMES = new Set([
|
||||||
|
'node_modules',
|
||||||
|
'.venv',
|
||||||
|
'.venv2',
|
||||||
|
'__pycache__',
|
||||||
|
'.git',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
'.next',
|
||||||
|
'target',
|
||||||
|
'backend',
|
||||||
|
'frontend',
|
||||||
|
]);
|
||||||
|
/** 子目录内常见的项目/脚手架文件,不同步到 OA 资产库 */
|
||||||
|
const NESTED_SKIP_BASENAMES = new Set([
|
||||||
|
'README.md',
|
||||||
|
'requirements.txt',
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
'pnpm-lock.yaml',
|
||||||
|
'yarn.lock',
|
||||||
|
]);
|
||||||
|
|
||||||
function asNumber(value) {
|
function asNumber(value) {
|
||||||
return Number(value ?? 0);
|
return Number(value ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSyncWorkspaceFilename(filename) {
|
export function normalizeWorkspaceRelativePath(relativePath) {
|
||||||
const normalized = String(filename ?? '').trim();
|
const normalized = String(relativePath ?? '')
|
||||||
if (!normalized || normalized.startsWith('.')) return false;
|
.normalize('NFKC')
|
||||||
if (SKIP_FILENAMES.has(normalized)) return false;
|
.trim()
|
||||||
if (normalized.endsWith('.thumbnail.svg')) return false;
|
.replace(/\\/g, '/');
|
||||||
if (normalized.endsWith('.sh')) return false;
|
if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) return null;
|
||||||
return assetInternals.expectedMimeType(normalized) !== null;
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.some((part) => part === '.' || part === '..')) return null;
|
||||||
|
if (parts.some((part) => part.startsWith('.'))) return null;
|
||||||
|
return parts.join('/').slice(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) {
|
export function shouldSyncWorkspaceFilename(filename) {
|
||||||
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return [];
|
const relativePath = normalizeWorkspaceRelativePath(filename);
|
||||||
const zoneDir = resolveZoneDir(workspaceRoot, categoryCode);
|
if (!relativePath) return false;
|
||||||
|
const basename = path.posix.basename(relativePath);
|
||||||
|
if (!basename || basename.startsWith('.')) return false;
|
||||||
|
if (SKIP_FILENAMES.has(basename)) return false;
|
||||||
|
if (basename.endsWith('.thumbnail.svg')) return false;
|
||||||
|
if (basename.endsWith('.sh')) return false;
|
||||||
|
if (relativePath.includes('/') && NESTED_SKIP_BASENAMES.has(basename)) return false;
|
||||||
|
return assetInternals.expectedMimeType(basename) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkWorkspaceZoneDir(zoneDir, relativePrefix, files) {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
entries = await fsPromises.readdir(zoneDir, { withFileTypes: true });
|
entries = await fsPromises.readdir(zoneDir, { withFileTypes: true });
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
const files = [];
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (entry.name.startsWith('.')) continue;
|
||||||
if (!shouldSyncWorkspaceFilename(entry.name)) continue;
|
|
||||||
const absolutePath = path.join(zoneDir, entry.name);
|
const absolutePath = path.join(zoneDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (SKIP_ZONE_DIR_NAMES.has(entry.name)) continue;
|
||||||
|
const nextPrefix = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
||||||
|
await walkWorkspaceZoneDir(absolutePath, nextPrefix, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const filename = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
|
||||||
|
if (!shouldSyncWorkspaceFilename(filename)) continue;
|
||||||
const stat = await fsPromises.stat(absolutePath);
|
const stat = await fsPromises.stat(absolutePath);
|
||||||
files.push({
|
files.push({
|
||||||
filename: entry.name,
|
filename,
|
||||||
absolutePath,
|
absolutePath,
|
||||||
sizeBytes: stat.size,
|
sizeBytes: stat.size,
|
||||||
mtimeMs: stat.mtimeMs,
|
mtimeMs: stat.mtimeMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) {
|
||||||
|
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return [];
|
||||||
|
const zoneDir = resolveZoneDir(workspaceRoot, categoryCode);
|
||||||
|
const files = [];
|
||||||
|
await walkWorkspaceZoneDir(zoneDir, '', files);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) {
|
export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) {
|
||||||
const absoluteStoragePath = (storageKey) => {
|
void storageRoot;
|
||||||
const resolved = path.resolve(storageRoot, storageKey);
|
|
||||||
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
|
|
||||||
throw new Error('存储路径越界');
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadExistingAssets = async (userId, categoryId) => {
|
const loadExistingAssets = async (userId, categoryId) => {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status
|
`SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status
|
||||||
@@ -88,7 +131,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
|
|
||||||
const importWorkspaceFile = async (userId, category, file, buffer) => {
|
const importWorkspaceFile = async (userId, category, file, buffer) => {
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
let finalPath;
|
|
||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
const [spaces] = await conn.query(
|
const [spaces] = await conn.query(
|
||||||
@@ -123,17 +165,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
const checksum = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
const assetId = idFactory();
|
const assetId = idFactory();
|
||||||
const versionId = idFactory();
|
const versionId = idFactory();
|
||||||
const finalStorageKey = path.posix.join(
|
const finalStorageKey = buildWorkspaceStorageKey(
|
||||||
'users',
|
|
||||||
userId,
|
userId,
|
||||||
'assets',
|
category.category_code,
|
||||||
assetId,
|
file.filename,
|
||||||
'versions',
|
|
||||||
versionId,
|
|
||||||
);
|
);
|
||||||
finalPath = absoluteStoragePath(finalStorageKey);
|
|
||||||
await fsPromises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
||||||
await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' });
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const visibility = category.category_code === 'public' ? 'public_candidate' : 'private';
|
const visibility = category.category_code === 'public' ? 'public_candidate' : 'private';
|
||||||
@@ -188,7 +224,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
return { action: 'imported', assetId, filename: file.filename, checksum };
|
return { action: 'imported', assetId, filename: file.filename, checksum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {});
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
@@ -197,7 +232,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
|
|
||||||
const updateWorkspaceFile = async (userId, category, existing, file, buffer) => {
|
const updateWorkspaceFile = async (userId, category, existing, file, buffer) => {
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
let finalPath;
|
|
||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
const [spaces] = await conn.query(
|
const [spaces] = await conn.query(
|
||||||
@@ -238,17 +272,11 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
);
|
);
|
||||||
const versionNo = asNumber(versionRows[0]?.max_version) + 1;
|
const versionNo = asNumber(versionRows[0]?.max_version) + 1;
|
||||||
const versionId = idFactory();
|
const versionId = idFactory();
|
||||||
const finalStorageKey = path.posix.join(
|
const finalStorageKey = buildWorkspaceStorageKey(
|
||||||
'users',
|
|
||||||
userId,
|
userId,
|
||||||
'assets',
|
category.category_code,
|
||||||
existing.id,
|
file.filename,
|
||||||
'versions',
|
|
||||||
versionId,
|
|
||||||
);
|
);
|
||||||
finalPath = absoluteStoragePath(finalStorageKey);
|
|
||||||
await fsPromises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
||||||
await fsPromises.writeFile(finalPath, buffer, { flag: 'wx' });
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await conn.query(
|
await conn.query(
|
||||||
@@ -298,7 +326,6 @@ export function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileByt
|
|||||||
return { action: 'updated', assetId: existing.id, filename: file.filename, checksum };
|
return { action: 'updated', assetId: existing.id, filename: file.filename, checksum };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
if (finalPath) await fsPromises.rm(finalPath, { force: true }).catch(() => {});
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
conn.release();
|
conn.release();
|
||||||
@@ -390,10 +417,20 @@ export function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceB
|
|||||||
|
|
||||||
const watchZoneDir = (dirKey, zoneDir, categoryCode) => {
|
const watchZoneDir = (dirKey, zoneDir, categoryCode) => {
|
||||||
try {
|
try {
|
||||||
fs.watch(zoneDir, (_event, filename) => {
|
fs.watch(
|
||||||
if (!filename || !shouldSyncWorkspaceFilename(filename)) return;
|
zoneDir,
|
||||||
schedule(dirKey, categoryCode);
|
{ recursive: true },
|
||||||
});
|
(_event, filename) => {
|
||||||
|
if (!filename) {
|
||||||
|
schedule(dirKey, categoryCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = String(filename).replace(/\\/g, '/');
|
||||||
|
if (shouldSyncWorkspaceFilename(normalized)) {
|
||||||
|
schedule(dirKey, categoryCode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore unsupported watch targets
|
// ignore unsupported watch targets
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,32 @@ import { resolveUserWorkspaceRoot } from './user-space.mjs';
|
|||||||
|
|
||||||
test('shouldSyncWorkspaceFilename skips helper files', () => {
|
test('shouldSyncWorkspaceFilename skips helper files', () => {
|
||||||
assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true);
|
assert.equal(shouldSyncWorkspaceFilename('端午感怀.docx'), true);
|
||||||
|
assert.equal(shouldSyncWorkspaceFilename('暑假实习报告/暑假生活实习报告.docx'), true);
|
||||||
|
assert.equal(shouldSyncWorkspaceFilename('诗歌散文/夏日的诗篇.md'), true);
|
||||||
|
assert.equal(shouldSyncWorkspaceFilename('sales-manager/README.md'), false);
|
||||||
assert.equal(shouldSyncWorkspaceFilename('index.html'), false);
|
assert.equal(shouldSyncWorkspaceFilename('index.html'), false);
|
||||||
assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false);
|
assert.equal(shouldSyncWorkspaceFilename('note.thumbnail.svg'), false);
|
||||||
|
assert.equal(shouldSyncWorkspaceFilename('../escape.docx'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listWorkspaceZoneFiles returns only supported top-level zone files', async () => {
|
test('listWorkspaceZoneFiles returns supported top-level and nested zone files', async () => {
|
||||||
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-'));
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-'));
|
||||||
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
||||||
await fs.mkdir(path.join(workspace, 'oa'), { recursive: true });
|
await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(workspace, 'oa', '诗歌散文'), { recursive: true });
|
||||||
await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n');
|
await fs.writeFile(path.join(workspace, 'oa', 'report.csv'), 'a,b\n1,2\n');
|
||||||
await fs.writeFile(path.join(workspace, 'oa', 'index.html'), '<html></html>');
|
await fs.writeFile(path.join(workspace, 'oa', 'index.html'), '<html></html>');
|
||||||
await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello');
|
await fs.writeFile(path.join(workspace, 'oa', 'note.txt'), 'hello');
|
||||||
|
await fs.writeFile(path.join(workspace, 'oa', '诗歌散文', '夏日的诗篇.md'), '# 夏日的诗篇\n');
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workspace, 'oa', '暑假实习报告', '暑假生活实习报告.docx'),
|
||||||
|
'fake docx',
|
||||||
|
);
|
||||||
|
|
||||||
const files = await listWorkspaceZoneFiles(workspace, 'oa');
|
const files = await listWorkspaceZoneFiles(workspace, 'oa');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
files.map((item) => item.filename).sort(),
|
files.map((item) => item.filename).sort(),
|
||||||
['note.txt', 'report.csv'],
|
['note.txt', 'report.csv', '暑假实习报告/暑假生活实习报告.docx', '诗歌散文/夏日的诗篇.md'].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,6 +155,116 @@ test('syncUserWorkspace imports new workspace files into asset library', async (
|
|||||||
assert.equal(state.assets.length, 1);
|
assert.equal(state.assets.length, 1);
|
||||||
assert.equal(state.assets[0].original_filename, 'memo.txt');
|
assert.equal(state.assets[0].original_filename, 'memo.txt');
|
||||||
assert.ok(state.versions.length === 1);
|
assert.ok(state.versions.length === 1);
|
||||||
const stored = path.join(storageRoot, state.versions[0].storage_key);
|
assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/oa\/memo\.txt$/);
|
||||||
assert.equal(await fs.readFile(stored, 'utf8'), 'hello workspace\n');
|
assert.equal(await fs.readFile(path.join(workspace, 'oa', 'memo.txt'), 'utf8'), 'hello workspace\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('syncUserWorkspace imports nested workspace files into asset library', async () => {
|
||||||
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-'));
|
||||||
|
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
||||||
|
const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' });
|
||||||
|
await fs.mkdir(path.join(workspace, 'oa', '暑假实习报告'), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(workspace, 'oa', '暑假实习报告', 'report.csv'), 'a,b\n1,2\n');
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
categories: [
|
||||||
|
{ id: 'cat-1', user_id: 'user-1', space_id: 'space-1', category_code: 'oa' },
|
||||||
|
],
|
||||||
|
spaces: [
|
||||||
|
{
|
||||||
|
id: 'space-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
quota_bytes: 5 * 1024 * 1024,
|
||||||
|
used_bytes: 0,
|
||||||
|
reserved_bytes: 0,
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [],
|
||||||
|
versions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) {
|
||||||
|
const category = state.categories.find(
|
||||||
|
(item) => item.user_id === params[0] && item.category_code === params[1],
|
||||||
|
);
|
||||||
|
return [category ? [category] : []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM h5_assets')) {
|
||||||
|
const assets = state.assets.filter((item) => {
|
||||||
|
if (item.user_id !== params[0] || item.category_id !== params[1]) return false;
|
||||||
|
if (sql.includes("status <> 'deleted'") && item.status === 'deleted') return false;
|
||||||
|
if (sql.includes("status = 'deleted'") && item.status !== 'deleted') return false;
|
||||||
|
if (sql.includes("source_type = 'workspace'") && item.source_type !== 'workspace') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return [assets];
|
||||||
|
}
|
||||||
|
return [[]];
|
||||||
|
},
|
||||||
|
async getConnection() {
|
||||||
|
return {
|
||||||
|
async beginTransaction() {},
|
||||||
|
async commit() {},
|
||||||
|
async rollback() {},
|
||||||
|
release() {},
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
||||||
|
const space = state.spaces.find(
|
||||||
|
(item) => item.id === params[0] && item.user_id === params[1],
|
||||||
|
);
|
||||||
|
return [space ? [space] : []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_assets')) {
|
||||||
|
state.assets.push({
|
||||||
|
id: params[0],
|
||||||
|
user_id: params[1],
|
||||||
|
category_id: params[3],
|
||||||
|
original_filename: params[6],
|
||||||
|
checksum: params[10],
|
||||||
|
size_bytes: params[9],
|
||||||
|
current_version_id: params[8],
|
||||||
|
status: params[13],
|
||||||
|
source_type: 'workspace',
|
||||||
|
});
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_asset_versions')) {
|
||||||
|
state.versions.push({
|
||||||
|
id: params[0],
|
||||||
|
asset_id: params[1],
|
||||||
|
storage_key: params[2],
|
||||||
|
scan_status: params[7],
|
||||||
|
});
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (sql.includes('used_bytes = used_bytes +')) {
|
||||||
|
state.spaces[0].used_bytes += params[0];
|
||||||
|
return [[]];
|
||||||
|
}
|
||||||
|
if (sql.includes('COALESCE(MAX(version_no)')) {
|
||||||
|
return [[{ max_version: 0 }]];
|
||||||
|
}
|
||||||
|
return pool.query(sql, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = createWorkspaceAssetSync({
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root,
|
||||||
|
maxFileBytes: 1024 * 1024,
|
||||||
|
idFactory: () => `id-${++nextId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'oa' });
|
||||||
|
assert.equal(result.imported, 1);
|
||||||
|
assert.equal(state.assets[0].original_filename, '暑假实习报告/report.csv');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -179,7 +179,6 @@ export function createMindSpaceService(pool, options = {}) {
|
|||||||
ON a.category_id = c.id
|
ON a.category_id = c.id
|
||||||
AND a.user_id = c.user_id
|
AND a.user_id = c.user_id
|
||||||
AND a.status <> 'deleted'
|
AND a.status <> 'deleted'
|
||||||
AND a.source_type = 'upload'
|
|
||||||
LEFT JOIN h5_page_records p
|
LEFT JOIN h5_page_records p
|
||||||
ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted'
|
ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted'
|
||||||
LEFT JOIN h5_publish_records pr
|
LEFT JOIN h5_publish_records pr
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ test('getSpace scopes space and categories to the authenticated user', async ()
|
|||||||
assert.equal(space.categories[0].code, 'private');
|
assert.equal(space.categories[0].code, 'private');
|
||||||
assert.deepEqual(calls[0].params, ['user-1']);
|
assert.deepEqual(calls[0].params, ['user-1']);
|
||||||
assert.deepEqual(calls[1].params, ['user-1', 'space-1']);
|
assert.deepEqual(calls[1].params, ['user-1', 'space-1']);
|
||||||
|
assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getSpace includes schedule snapshot when schedule service is available', async () => {
|
test('getSpace includes schedule snapshot when schedule service is available', async () => {
|
||||||
|
|||||||
@@ -742,6 +742,7 @@ export function MindSpaceView({
|
|||||||
setPendingDeleteId(null);
|
setPendingDeleteId(null);
|
||||||
setSelectedAssetIds([]);
|
setSelectedAssetIds([]);
|
||||||
setImagePage(0);
|
setImagePage(0);
|
||||||
|
void refreshSpaceQuietly();
|
||||||
routeSync?.pushHome();
|
routeSync?.pushHome();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -106,8 +106,9 @@ ${zoneLines.join('\n')}
|
|||||||
|
|
||||||
## 工作区文件与 OA 界面
|
## 工作区文件与 OA 界面
|
||||||
|
|
||||||
- 写入 \`oa/\`、\`private/\`、\`public/\` 根目录的支持类型文件(docx、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库
|
- 写入 \`oa/\`、\`private/\`、\`public/\` 下任意层级的支持类型文件(docx、md、txt、csv、pdf、图片等)会**自动同步**到 MindSpace 资产库(含子目录,如 \`oa/诗歌散文/夏日的诗篇.md\`)
|
||||||
- 打开对应分区或保存文件后会出现在界面中,可直接预览或下载
|
- 打开对应分区或保存文件后会出现在界面中,可直接预览或下载
|
||||||
|
- 生成 docx/pdf 等 OA 资料时,可直接 \`write_file\` 到 \`oa/<子目录>/<文件名>\`,无需手动复制到根目录
|
||||||
- 用户上传的文件仍以界面入库为准,并镜像到上述分区
|
- 用户上传的文件仍以界面入库为准,并镜像到上述分区
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { resolveUserWorkspaceRoot, resolveZoneFilePath } from './user-space.mjs';
|
||||||
|
|
||||||
|
export const WORKSPACE_STORAGE_PREFIX = 'workspace://';
|
||||||
|
|
||||||
|
export function buildWorkspaceStorageKey(userId, categoryCode, relativeFilename) {
|
||||||
|
const category = String(categoryCode ?? '').trim();
|
||||||
|
const relativePath = String(relativeFilename ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
if (!userId || !category || !relativePath) {
|
||||||
|
throw new Error('invalid workspace storage key parts');
|
||||||
|
}
|
||||||
|
if (relativePath.split('/').some((part) => part === '..' || part === '.')) {
|
||||||
|
throw new Error('invalid workspace relative path');
|
||||||
|
}
|
||||||
|
return `${WORKSPACE_STORAGE_PREFIX}${userId}/${category}/${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceStorageKey(storageKey) {
|
||||||
|
return String(storageKey ?? '').startsWith(WORKSPACE_STORAGE_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceStoragePath(h5Root, storageKey) {
|
||||||
|
if (!h5Root || !isWorkspaceStorageKey(storageKey)) return null;
|
||||||
|
const rest = storageKey.slice(WORKSPACE_STORAGE_PREFIX.length);
|
||||||
|
const slash = rest.indexOf('/');
|
||||||
|
if (slash <= 0) return null;
|
||||||
|
const userId = rest.slice(0, slash);
|
||||||
|
const remainder = rest.slice(slash + 1);
|
||||||
|
const slash2 = remainder.indexOf('/');
|
||||||
|
if (slash2 <= 0) return null;
|
||||||
|
const categoryCode = remainder.slice(0, slash2);
|
||||||
|
const relativeFilename = remainder.slice(slash2 + 1);
|
||||||
|
if (!relativeFilename) return null;
|
||||||
|
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId });
|
||||||
|
const resolved = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, relativeFilename));
|
||||||
|
const zoneRoot = path.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, ''));
|
||||||
|
if (resolved !== zoneRoot && !resolved.startsWith(`${zoneRoot}${path.sep}`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user