|
@@ -23,6 +23,35 @@ function getWorkspaceRoot() {
|
|
|
const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
|
|
const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
|
|
|
return path.resolve(configuredRoot);
|
|
return path.resolve(configuredRoot);
|
|
|
}
|
|
}
|
|
|
|
|
+const HOST = process.env.HOST || '127.0.0.1';
|
|
|
|
|
+const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
|
|
|
+
|
|
|
|
|
+function isWorkspaceRoot(candidate) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function isWorkspacePath(candidate) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const resolved = path.resolve(String(candidate));
|
|
|
|
|
+ const root = getWorkspaceRoot();
|
|
|
|
|
+ return resolved === root || resolved.startsWith(root + path.sep);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function safeShopDir(root, folderName) {
|
|
|
|
|
+ if (!isWorkspaceRoot(root) || typeof folderName !== 'string' ||
|
|
|
|
|
+ !folderName || folderName === '.' || folderName === '..' ||
|
|
|
|
|
+ folderName.includes('/') || folderName.includes('\\')) return null;
|
|
|
|
|
+ const rootDir = path.resolve(root);
|
|
|
|
|
+ const shopDir = path.resolve(rootDir, folderName);
|
|
|
|
|
+ return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
|
|
|
|
|
+}
|
|
|
fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
|
|
fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
|
|
|
const SESSION_PATH = path.join(__dirname, 'session.json');
|
|
const SESSION_PATH = path.join(__dirname, 'session.json');
|
|
|
const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
|
|
const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
|
|
@@ -77,7 +106,15 @@ function sanitizeFileName(name) {
|
|
|
function readBody(req) {
|
|
function readBody(req) {
|
|
|
return new Promise((resolve, reject) => {
|
|
return new Promise((resolve, reject) => {
|
|
|
const chunks = [];
|
|
const chunks = [];
|
|
|
|
|
+ let size = 0;
|
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('data', (c) => chunks.push(c));
|
|
|
|
|
+ req.on('data', (c) => {
|
|
|
|
|
+ size += c.length;
|
|
|
|
|
+ if (size > MAX_BODY_BYTES) {
|
|
|
|
|
+ req.destroy(new Error('请求体过大'));
|
|
|
|
|
+ reject(new Error('请求体过大'));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
req.on('end', () => {
|
|
req.on('end', () => {
|
|
|
if (!chunks.length) return resolve({});
|
|
if (!chunks.length) return resolve({});
|
|
|
try {
|
|
try {
|
|
@@ -262,6 +299,7 @@ function handleShopsManual(body, res) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function handleShopsList(root, res) {
|
|
function handleShopsList(root, res) {
|
|
|
|
|
+ if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
|
|
const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
|
|
|
sendJson(res, 200, {
|
|
sendJson(res, 200, {
|
|
|
shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
|
|
shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
|
|
@@ -338,6 +376,7 @@ async function startCrawl(body, res) {
|
|
|
if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
|
|
if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
|
|
|
if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
|
|
if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
|
|
|
const root = String((body && body.root) || getWorkspaceRoot()).trim();
|
|
const root = String((body && body.root) || getWorkspaceRoot()).trim();
|
|
|
|
|
+ if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
|
|
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
|
return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
|
|
return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
|
|
|
}
|
|
}
|
|
@@ -843,6 +882,77 @@ function downloadBuffer(urlStr, redirectsLeft = 5) {
|
|
|
const jobs = new Map();
|
|
const jobs = new Map();
|
|
|
const pendingQueue = [];
|
|
const pendingQueue = [];
|
|
|
let activeCount = 0;
|
|
let activeCount = 0;
|
|
|
|
|
+const persistedTasks = new Map();
|
|
|
|
|
+let taskStoreLoaded = false;
|
|
|
|
|
+
|
|
|
|
|
+function getTaskStoreFile() {
|
|
|
|
|
+ return path.join(getWorkspaceRoot(), '.genpic', 'tasks.json');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function taskSnapshot(job) {
|
|
|
|
|
+ const { images, ...safeJob } = job;
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...safeJob,
|
|
|
|
|
+ imageCount: Array.isArray(images) ? images.length : 0,
|
|
|
|
|
+ updatedAt: Date.now(),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function persistJob(job) {
|
|
|
|
|
+ if (!job || !job.id) return;
|
|
|
|
|
+ persistedTasks.set(job.id, taskSnapshot(job));
|
|
|
|
|
+ const storeFile = getTaskStoreFile();
|
|
|
|
|
+ fs.mkdirSync(path.dirname(storeFile), { recursive: true });
|
|
|
|
|
+ const tempPath = `${storeFile}.${process.pid}.tmp`;
|
|
|
|
|
+ fs.writeFileSync(tempPath, JSON.stringify({
|
|
|
|
|
+ version: 1,
|
|
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
|
|
+ tasks: Array.from(persistedTasks.values()),
|
|
|
|
|
+ }, null, 2));
|
|
|
|
|
+ fs.renameSync(tempPath, storeFile);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function loadPersistedJobs() {
|
|
|
|
|
+ if (taskStoreLoaded) return;
|
|
|
|
|
+ taskStoreLoaded = true;
|
|
|
|
|
+ const storeFile = getTaskStoreFile();
|
|
|
|
|
+ let parsed;
|
|
|
|
|
+ try {
|
|
|
|
|
+ parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
|
|
|
|
|
+ for (const raw of parsed.tasks) {
|
|
|
|
|
+ if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
|
|
|
|
|
+ const job = {
|
|
|
|
|
+ ...raw,
|
|
|
|
|
+ images: [],
|
|
|
|
|
+ compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
|
|
|
|
|
+ };
|
|
|
|
|
+ if (!MODEL_IDS.has(job.model)) job.model = config.model;
|
|
|
|
|
+ jobs.set(job.id, job);
|
|
|
|
|
+ persistedTasks.set(job.id, taskSnapshot(job));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const job of jobs.values()) {
|
|
|
|
|
+ if (job.state === 'queued') {
|
|
|
|
|
+ job.state = 'failed';
|
|
|
|
|
+ job.finishedAt = Date.now();
|
|
|
|
|
+ job.error = '服务重启前任务仍在排队,请重新提交';
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
+ } else if (job.state === 'running' || job.state === 'submitted') {
|
|
|
|
|
+ if (!job.taskId) {
|
|
|
|
|
+ job.state = 'failed';
|
|
|
|
|
+ job.finishedAt = Date.now();
|
|
|
|
|
+ job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ runJob(job);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
function createJob(opts) {
|
|
function createJob(opts) {
|
|
|
const id = crypto.randomUUID();
|
|
const id = crypto.randomUUID();
|
|
@@ -873,8 +983,11 @@ function createJob(opts) {
|
|
|
error: null,
|
|
error: null,
|
|
|
warning: opts.warning || null,
|
|
warning: opts.warning || null,
|
|
|
createdAt: Date.now(),
|
|
createdAt: Date.now(),
|
|
|
|
|
+ startedAt: null,
|
|
|
|
|
+ finishedAt: null,
|
|
|
};
|
|
};
|
|
|
jobs.set(id, job);
|
|
jobs.set(id, job);
|
|
|
|
|
+ persistJob(job);
|
|
|
const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
|
|
const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
|
|
|
pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
|
|
pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
|
|
|
if (activeCount < config.maxConcurrentJobs) {
|
|
if (activeCount < config.maxConcurrentJobs) {
|
|
@@ -886,8 +999,14 @@ function createJob(opts) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function startJob(job) {
|
|
function startJob(job) {
|
|
|
|
|
+ runJob(job);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function runJob(job) {
|
|
|
activeCount++;
|
|
activeCount++;
|
|
|
job.state = 'running';
|
|
job.state = 'running';
|
|
|
|
|
+ job.startedAt = job.startedAt || Date.now();
|
|
|
|
|
+ persistJob(job);
|
|
|
processJob(job)
|
|
processJob(job)
|
|
|
.catch((e) => {
|
|
.catch((e) => {
|
|
|
job.state = 'failed';
|
|
job.state = 'failed';
|
|
@@ -897,6 +1016,8 @@ function startJob(job) {
|
|
|
const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
|
|
const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
|
|
|
if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
|
|
if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
|
|
|
else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
|
|
else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
|
|
|
|
|
+ job.finishedAt = Date.now();
|
|
|
|
|
+ persistJob(job);
|
|
|
activeCount--;
|
|
activeCount--;
|
|
|
const nextId = pendingQueue.shift();
|
|
const nextId = pendingQueue.shift();
|
|
|
if (nextId) {
|
|
if (nextId) {
|
|
@@ -940,21 +1061,25 @@ function buildParamsForModel(modelId, job) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function processJob(job) {
|
|
async function processJob(job) {
|
|
|
- const body = {
|
|
|
|
|
- model: job.model,
|
|
|
|
|
- prompt: job.prompt,
|
|
|
|
|
- params: buildParamsForModel(job.model, job),
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const resuming = !!job.taskId;
|
|
|
|
|
+ if (!resuming) {
|
|
|
|
|
+ const body = {
|
|
|
|
|
+ model: job.model,
|
|
|
|
|
+ prompt: job.prompt,
|
|
|
|
|
+ params: buildParamsForModel(job.model, job),
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
- const submit = await apiRequest('POST', '/v1/media/generate', body);
|
|
|
|
|
- const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
|
|
|
|
|
- if (submit.statusCode !== 200 || !submit.json || !taskId) {
|
|
|
|
|
- throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
|
|
|
|
|
|
|
+ const submit = await apiRequest('POST', '/v1/media/generate', body);
|
|
|
|
|
+ const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
|
|
|
|
|
+ if (submit.statusCode !== 200 || !submit.json || !taskId) {
|
|
|
|
|
+ throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
|
|
|
|
|
+ }
|
|
|
|
|
+ job.taskId = taskId;
|
|
|
|
|
+ persistJob(job);
|
|
|
}
|
|
}
|
|
|
- job.taskId = taskId;
|
|
|
|
|
|
|
|
|
|
- await sleep(7000);
|
|
|
|
|
- const start = Date.now();
|
|
|
|
|
|
|
+ await sleep(resuming ? 1000 : 7000);
|
|
|
|
|
+ const start = job.startedAt || Date.now();
|
|
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
for (;;) {
|
|
for (;;) {
|
|
|
if (Date.now() - start > TIMEOUT_MS) {
|
|
if (Date.now() - start > TIMEOUT_MS) {
|
|
@@ -973,6 +1098,7 @@ async function processJob(job) {
|
|
|
}
|
|
}
|
|
|
job.progress = String(status.progress ?? job.progress);
|
|
job.progress = String(status.progress ?? job.progress);
|
|
|
if (status.cost !== undefined) job.cost = status.cost;
|
|
if (status.cost !== undefined) job.cost = status.cost;
|
|
|
|
|
+ persistJob(job);
|
|
|
|
|
|
|
|
if (status.is_final) {
|
|
if (status.is_final) {
|
|
|
if (status.state === 'success' && status.result_url) {
|
|
if (status.state === 'success' && status.result_url) {
|
|
@@ -1093,6 +1219,7 @@ function appendCreationHistory(outputDir, record) {
|
|
|
|
|
|
|
|
function handleScan(query, res) {
|
|
function handleScan(query, res) {
|
|
|
const root = query.root;
|
|
const root = query.root;
|
|
|
|
|
+ if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
|
|
|
if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
|
return sendJson(res, 400, { error: '目录不存在: ' + root });
|
|
return sendJson(res, 400, { error: '目录不存在: ' + root });
|
|
|
}
|
|
}
|
|
@@ -1178,7 +1305,7 @@ function getCreationKind(directoryName, fileName) {
|
|
|
|
|
|
|
|
function handleCreations(query, res) {
|
|
function handleCreations(query, res) {
|
|
|
const root = query.root;
|
|
const root = query.root;
|
|
|
- if (!root) return sendJson(res, 400, { error: '参数不完整' });
|
|
|
|
|
|
|
+ if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
const resolvedRoot = path.resolve(String(root));
|
|
const resolvedRoot = path.resolve(String(root));
|
|
|
if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
|
|
if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
|
|
|
return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
|
|
return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
|
|
@@ -1293,6 +1420,10 @@ function handleFiles(query, res) {
|
|
|
return res.end('missing path');
|
|
return res.end('missing path');
|
|
|
}
|
|
}
|
|
|
const resolved = path.resolve(p);
|
|
const resolved = path.resolve(p);
|
|
|
|
|
+ if (!isWorkspacePath(resolved)) {
|
|
|
|
|
+ res.writeHead(403);
|
|
|
|
|
+ return res.end('forbidden');
|
|
|
|
|
+ }
|
|
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
|
res.writeHead(404);
|
|
res.writeHead(404);
|
|
|
return res.end('not found');
|
|
return res.end('not found');
|
|
@@ -1312,7 +1443,8 @@ async function handleGenerateBrand(body, res) {
|
|
|
if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
|
|
if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
|
|
|
return sendJson(res, 400, { error: '参数不完整' });
|
|
return sendJson(res, 400, { error: '参数不完整' });
|
|
|
}
|
|
}
|
|
|
- const shopDir = path.join(root, folderName);
|
|
|
|
|
|
|
+ const shopDir = safeShopDir(root, folderName);
|
|
|
|
|
+ if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
|
|
|
// logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
|
|
// logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
|
|
|
const shouldAddLogo = !!addLogo && kind !== 'logo';
|
|
const shouldAddLogo = !!addLogo && kind !== 'logo';
|
|
|
const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
|
|
const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
|
|
@@ -1343,10 +1475,15 @@ async function handleGenerateDish(body, res) {
|
|
|
if (!root || !folderName || !dishFile || !prompt) {
|
|
if (!root || !folderName || !dishFile || !prompt) {
|
|
|
return sendJson(res, 400, { error: '参数不完整' });
|
|
return sendJson(res, 400, { error: '参数不完整' });
|
|
|
}
|
|
}
|
|
|
- const shopDir = path.join(root, folderName);
|
|
|
|
|
|
|
+ const shopDir = safeShopDir(root, folderName);
|
|
|
|
|
+ if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
|
|
|
|
|
+ const dishPath = path.resolve(shopDir, dishFile);
|
|
|
|
|
+ if (!isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
|
|
|
|
|
+ return sendJson(res, 403, { error: '菜品文件无效' });
|
|
|
|
|
+ }
|
|
|
let dishImage;
|
|
let dishImage;
|
|
|
try {
|
|
try {
|
|
|
- dishImage = readAsDataUri(path.join(shopDir, dishFile));
|
|
|
|
|
|
|
+ dishImage = readAsDataUri(dishPath);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
|
|
return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
|
|
|
}
|
|
}
|
|
@@ -1398,10 +1535,57 @@ function handleJob(pathname, res) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function publicTask(job) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: job.id,
|
|
|
|
|
+ type: 'generation',
|
|
|
|
|
+ status: job.state,
|
|
|
|
|
+ root: job.root,
|
|
|
|
|
+ folderName: job.folderName,
|
|
|
|
|
+ shopName: job.shopName,
|
|
|
|
|
+ dishName: job.dishName,
|
|
|
|
|
+ name: job.dishName || job.kind,
|
|
|
|
|
+ kind: job.kind,
|
|
|
|
|
+ model: job.model,
|
|
|
|
|
+ providerTaskId: job.taskId,
|
|
|
|
|
+ progress: job.progress,
|
|
|
|
|
+ cost: job.cost,
|
|
|
|
|
+ resultUrl: job.resultUrl,
|
|
|
|
|
+ remoteUrl: job.remoteUrl,
|
|
|
|
|
+ width: job.width,
|
|
|
|
|
+ height: job.height,
|
|
|
|
|
+ fileSizeBytes: job.fileSizeBytes,
|
|
|
|
|
+ error: job.error,
|
|
|
|
|
+ warning: job.warning,
|
|
|
|
|
+ createdAt: job.createdAt,
|
|
|
|
|
+ startedAt: job.startedAt,
|
|
|
|
|
+ finishedAt: job.finishedAt,
|
|
|
|
|
+ updatedAt: job.updatedAt || job.createdAt,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleTasks(query, res) {
|
|
|
|
|
+ if (query.root && !isWorkspaceRoot(query.root)) {
|
|
|
|
|
+ return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
|
|
+ }
|
|
|
|
|
+ const root = getWorkspaceRoot();
|
|
|
|
|
+ const tasks = Array.from(jobs.values())
|
|
|
|
|
+ .filter((job) => path.resolve(job.root) === root)
|
|
|
|
|
+ .sort((a, b) => b.createdAt - a.createdAt)
|
|
|
|
|
+ .map(publicTask);
|
|
|
|
|
+ const summary = tasks.reduce((acc, task) => {
|
|
|
|
|
+ acc[task.status] = (acc[task.status] || 0) + 1;
|
|
|
|
|
+ return acc;
|
|
|
|
|
+ }, { all: tasks.length });
|
|
|
|
|
+ sendJson(res, 200, { root, summary, tasks });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
async function handleSaveConfig(body, res) {
|
|
async function handleSaveConfig(body, res) {
|
|
|
const { root, folderName, shopName, prompts } = body;
|
|
const { root, folderName, shopName, prompts } = body;
|
|
|
if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
|
|
if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
|
|
|
- const dir = path.join(root, folderName, '_生成图片');
|
|
|
|
|
|
|
+ const shopDir = safeShopDir(root, folderName);
|
|
|
|
|
+ if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
|
|
|
|
|
+ const dir = path.join(shopDir, '_生成图片');
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
const cfgPath = path.join(dir, 'prompt-config.json');
|
|
const cfgPath = path.join(dir, 'prompt-config.json');
|
|
|
let existing = {};
|
|
let existing = {};
|
|
@@ -1455,6 +1639,7 @@ async function handleSavePosterCanvas(body, res) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const resolvedRoot = path.resolve(root);
|
|
const resolvedRoot = path.resolve(root);
|
|
|
|
|
+ if (!isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
const shopDir = path.resolve(resolvedRoot, folderName);
|
|
const shopDir = path.resolve(resolvedRoot, folderName);
|
|
|
if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
|
|
if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
|
|
|
return sendJson(res, 400, { error: '店铺目录无效' });
|
|
return sendJson(res, 400, { error: '店铺目录无效' });
|
|
@@ -1494,6 +1679,7 @@ const STATIC_MAP = {
|
|
|
'/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
|
|
'/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
|
|
|
'/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
|
|
'/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
|
|
|
'/history.js': ['history.js', 'application/javascript; charset=utf-8'],
|
|
'/history.js': ['history.js', 'application/javascript; charset=utf-8'],
|
|
|
|
|
+ '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
|
|
|
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -1545,8 +1731,28 @@ const server = http.createServer((req, res) => {
|
|
|
if (req.method === 'GET' && pathname === '/api/default-root') {
|
|
if (req.method === 'GET' && pathname === '/api/default-root') {
|
|
|
return sendJson(res, 200, { root: getWorkspaceRoot() });
|
|
return sendJson(res, 200, { root: getWorkspaceRoot() });
|
|
|
}
|
|
}
|
|
|
|
|
+ if (req.method === 'GET' && pathname === '/healthz') {
|
|
|
|
|
+ return sendJson(res, 200, { ok: true, workspace: getWorkspaceRoot() });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (req.method === 'GET' && pathname === '/readyz') {
|
|
|
|
|
+ return sendJson(res, 200, {
|
|
|
|
|
+ ok: !!config.apiKey,
|
|
|
|
|
+ workspaceReady: fs.existsSync(getWorkspaceRoot()),
|
|
|
|
|
+ crawlReady: !!getKsid(),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (req.method === 'GET' && pathname === '/api/tasks') {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return handleTasks(query, res);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return respond500(e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
if (req.method === 'GET' && pathname === '/api/session') {
|
|
if (req.method === 'GET' && pathname === '/api/session') {
|
|
|
- return sendJson(res, 200, session);
|
|
|
|
|
|
|
+ return sendJson(res, 200, {
|
|
|
|
|
+ cookieConfigured: !!session.cookie,
|
|
|
|
|
+ ksidConfigured: !!getKsid(),
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
if (req.method === 'POST' && pathname === '/api/session') {
|
|
if (req.method === 'POST' && pathname === '/api/session') {
|
|
|
return readBody(req)
|
|
return readBody(req)
|
|
@@ -1561,6 +1767,7 @@ const server = http.createServer((req, res) => {
|
|
|
if (req.method === 'GET' && pathname === '/api/shops') {
|
|
if (req.method === 'GET' && pathname === '/api/shops') {
|
|
|
const encodedRoot = parsedUrl.searchParams.get('root');
|
|
const encodedRoot = parsedUrl.searchParams.get('root');
|
|
|
const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
|
|
const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
|
|
|
|
|
+ if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
return handleShopsList(root, res);
|
|
return handleShopsList(root, res);
|
|
|
}
|
|
}
|
|
|
if (req.method === 'POST' && pathname === '/api/shops/manual') {
|
|
if (req.method === 'POST' && pathname === '/api/shops/manual') {
|
|
@@ -1595,7 +1802,7 @@ const server = http.createServer((req, res) => {
|
|
|
rootDir: getWorkspaceRoot(),
|
|
rootDir: getWorkspaceRoot(),
|
|
|
pageSize: config.pageSize,
|
|
pageSize: config.pageSize,
|
|
|
delayMs: config.delayMs,
|
|
delayMs: config.delayMs,
|
|
|
- cookie: session.cookie,
|
|
|
|
|
|
|
+ cookieConfigured: !!session.cookie && !!getKsid(),
|
|
|
port: config.port,
|
|
port: config.port,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
@@ -1682,10 +1889,11 @@ const server = http.createServer((req, res) => {
|
|
|
}
|
|
}
|
|
|
if (req.method === 'POST' && pathname === '/api/open-folder') {
|
|
if (req.method === 'POST' && pathname === '/api/open-folder') {
|
|
|
return readBody(req).then((body) => {
|
|
return readBody(req).then((body) => {
|
|
|
|
|
+ if (body.root && !isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
|
|
|
const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
|
|
const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
|
|
|
const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
|
|
const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
|
|
|
const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
|
|
const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
|
|
|
- if (!targetDir) return sendJson(res, 400, { error: '目录不存在: ' + dir });
|
|
|
|
|
|
|
+ if (!targetDir || !isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
|
|
|
const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
|
execFile(command, [targetDir], () => {});
|
|
execFile(command, [targetDir], () => {});
|
|
|
sendJson(res, 200, { ok: true, opened: targetDir });
|
|
sendJson(res, 200, { ok: true, opened: targetDir });
|
|
@@ -1695,8 +1903,10 @@ const server = http.createServer((req, res) => {
|
|
|
return serveStatic(pathname, res);
|
|
return serveStatic(pathname, res);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-server.listen(config.port, () => {
|
|
|
|
|
- const url = `http://localhost:${config.port}`;
|
|
|
|
|
|
|
+loadPersistedJobs();
|
|
|
|
|
+
|
|
|
|
|
+server.listen(config.port, HOST, () => {
|
|
|
|
|
+ const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${config.port}`;
|
|
|
pushSystemLog(`服务已启动:${url}`, 'success');
|
|
pushSystemLog(`服务已启动:${url}`, 'success');
|
|
|
console.log(`门店装修工具已启动: ${url}`);
|
|
console.log(`门店装修工具已启动: ${url}`);
|
|
|
// "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
|
|
// "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
|