Преглед на файлове

refactor: split server.js into route modules and services

server.js (2241 lines) becomes a thin bootstrap; logic moves to server/
- router.js: zero-dependency mini router (:params, 405, fallback)
- middleware.js: unified error boundary (error-code -> status mapping)
- context.js: shared state assembly (config/session/shops/workspace guards/logs)
- routes/: thin HTTP layer per domain (health/session/crawl/settings/
  generate/history/reports/static)
- services/: crawl engine + generation queue, state encapsulated in factories
- packaging scripts now include server/; removed dead code
  (chromaKeyToTransparentPng, handleOpenFolder); API contract unchanged
KenZ1117 преди 2 седмици
родител
ревизия
bb462b107c

+ 19 - 1
README.md

@@ -139,7 +139,25 @@ workspace/
 
 ```text
 .
-├── server.js              # 本地服务、采集调度、生成队列和历史 API
+├── server.js              # 引导入口:装配状态 → 挂载路由 → 监听(含端口自动回收)
+├── server/                # 服务端模块(路由 / 服务 / 中间件分层)
+│   ├── router.js          # 迷你路由(零依赖):路径匹配、:参数、405、fallback
+│   ├── middleware.js      # 标准错误边界:统一异常 → JSON 响应(含错误码映射)
+│   ├── context.js         # 共享状态装配:config / session / 店铺清单 / 路径守卫 / 日志
+│   ├── util.js            # 无状态工具:响应、请求体、文件名、静态资源常量
+│   ├── routes/            # 薄 HTTP 层(按业务域拆分)
+│   │   ├── health.js      #   健康检查 / 默认根目录
+│   │   ├── session.js     #   采集 Cookie 会话
+│   │   ├── crawl.js       #   门店采集:店铺清单、抓取控制、日志
+│   │   ├── settings.js    #   系统设置 + 生图/对话 API 连通性测试
+│   │   ├── generate.js    #   视觉生成、任务中心、画板保存、打开目录
+│   │   ├── history.js     #   门店扫描、历史创作、图片文件服务
+│   │   ├── reports.js     #   数据采集 + 智能问数(同步/查询/导出/周报)
+│   │   ├── static.js      #   静态前端(精确白名单 + /assets/)
+│   │   └── index.js       #   路由注册总入口
+│   └── services/          # 有状态引擎
+│       ├── crawl.js       #   采集引擎:翻页抓取、下载、断点跳过、可取消任务循环
+│       └── generation.js  #   生成引擎:任务队列、供应商 API、轮询、图片合成、任务持久化
 ├── napos-service.js       # 数据采集:取数服务层(同步任务 / 查询聚合 / CSV)
 ├── reports-store.js       # 数据采集:本地数据湖(多主体 / 日期×门店 upsert)
 ├── napos/                 # napos-mcp 取数引擎(CDP 附着商家客户端,零第三方依赖)

Файловите разлики са ограничени, защото са твърде много
+ 18 - 2122
server.js


+ 280 - 0
server/context.js

@@ -0,0 +1,280 @@
+'use strict';
+
+// 共享状态装配:config / session / 手动店铺 / workspace 路径守卫 / 日志。
+// 由 server.js 在启动时调用一次 createContext(),得到的 ctx 贯穿所有服务与路由。
+// 语义与拆分前一致:config 为同一个可变对象;getWorkspaceRoot() 每次重读 config.rootDir。
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT_DIR = path.resolve(__dirname, '..');
+const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
+const CONFIG_PATH = path.join(ROOT_DIR, 'config.json');
+const CONFIG_EXAMPLE_PATH = path.join(ROOT_DIR, 'config.example.json');
+const SESSION_PATH = path.join(ROOT_DIR, 'session.json');
+const MANUAL_SHOPS_PATH = path.join(ROOT_DIR, 'manual-shops.json');
+
+const CONFIG_DEFAULTS = {
+  port: 5177,
+  apiKey: '',
+  apiBase: 'https://api.lk888.ai',
+  model: 'gpt-image-2',
+  maxConcurrentJobs: 4,
+  pageSize: 50,
+  delayMs: 300,
+  requestTimeoutMs: 20000,
+  chatModel: '',
+  chatApiBase: '',
+  chatApiKey: '',
+};
+
+function writeConfigFile(cfg) {
+  const temp = CONFIG_PATH + '.tmp';
+  fs.writeFileSync(temp, JSON.stringify(cfg, null, 2), 'utf8');
+  fs.renameSync(temp, CONFIG_PATH);
+}
+
+// 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
+function loadConfig() {
+  try {
+    return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
+  } catch (e) {
+    if (e.code !== 'ENOENT') {
+      console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
+    }
+    let seeded = {};
+    try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
+    const cfg = { ...CONFIG_DEFAULTS, ...seeded };
+    try {
+      writeConfigFile(cfg);
+      console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
+    } catch { /* 只读目录等场景忽略 */ }
+    return cfg;
+  }
+}
+
+const MODELS = [
+  { id: 'gpt-image-2', label: 'GPT Image 2(默认)' },
+  { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' },
+  { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' },
+  { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' },
+  { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' },
+  { id: 'wan2.6-image', label: '通义万相 wan2.6-image' },
+];
+const MODEL_IDS = new Set(MODELS.map((m) => m.id));
+
+// These seedream variants don't support the '1K' tier the UI's size dropdown offers by
+// default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) -
+// map it up to '2K' rather than sending an invalid enum value.
+const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']);
+
+// ---------- workspace 路径守卫 ----------
+
+function createWorkspaceService(config) {
+  const WORKSPACE_ROOT = path.join(path.dirname(ROOT_DIR), 'workspace');
+
+  function getWorkspaceRoot() {
+    const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
+    return path.resolve(configuredRoot);
+  }
+
+  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;
+  }
+
+  return { WORKSPACE_ROOT, getWorkspaceRoot, isWorkspaceRoot, isWorkspacePath, safeShopDir };
+}
+
+// ---------- 采集 Cookie 会话 ----------
+
+function extractCookieValue(cookie, name) {
+  const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
+  if (!match) return '';
+  try { return decodeURIComponent(match[1]); } catch { return match[1]; }
+}
+
+function createSessionService() {
+  const session = { cookie: '' };
+
+  function loadSession() {
+    try {
+      const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8'));
+      session.cookie = String(saved.cookie || '');
+    } catch { /* no saved session */ }
+  }
+
+  function saveSession() {
+    fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8');
+  }
+
+  function getKsid() { return extractCookieValue(session.cookie, 'ksid'); }
+
+  loadSession();
+  return {
+    get cookie() { return session.cookie; },
+    set cookie(v) { session.cookie = v; },
+    saveSession,
+    getKsid,
+  };
+}
+
+// ---------- 手动店铺清单 ----------
+
+function createShopsService() {
+  let shopList = [];
+
+  function loadManualShops() {
+    try {
+      const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8'));
+      if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效');
+      const shopsById = new Map();
+      for (const shop of data.shops) {
+        if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue;
+        shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' });
+      }
+      shopList = Array.from(shopsById.values());
+    } catch (e) {
+      if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`);
+    }
+  }
+
+  function saveManualShops(shops) {
+    const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`;
+    fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
+    fs.renameSync(temp, MANUAL_SHOPS_PATH);
+  }
+
+  function mergeShops(newOnes) {
+    const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop]));
+    for (const shop of newOnes) shopsById.set(shop.shopId, shop);
+    return Array.from(shopsById.values());
+  }
+
+  loadManualShops();
+  return {
+    getList: () => shopList,
+    setList: (v) => { shopList = v; },
+    mergeShops,
+    saveManualShops,
+  };
+}
+
+// ---------- 日志(系统日志 + 统一序号;采集任务日志由 crawl 服务持有,这里负责合并) ----------
+
+function createLogsService() {
+  const systemLogs = [];
+  let logSeq = 0;
+
+  function detectLogLevel(message) {
+    if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error';
+    if (/停止|跳过|警告|warn/i.test(message)) return 'warn';
+    if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success';
+    return 'info';
+  }
+
+  function createLogEntry(message, source = 'task', level = 'info') {
+    const now = new Date();
+    return {
+      seq: ++logSeq,
+      id: `${source}-${logSeq}`,
+      source,
+      level,
+      time: now.toTimeString().slice(0, 8),
+      timestamp: now.getTime(),
+      message: String(message),
+    };
+  }
+
+  function pushSystemLog(msg, level = 'info') {
+    systemLogs.push(createLogEntry(msg, 'system', level));
+    if (systemLogs.length > 500) systemLogs.shift();
+  }
+
+  function mergeLogs(crawlLog) {
+    return [...crawlLog, ...systemLogs]
+      .sort((a, b) => b.seq - a.seq)
+      .slice(0, 500);
+  }
+
+  function clearAll(crawlLog) {
+    if (crawlLog) crawlLog.length = 0;
+    systemLogs.length = 0;
+  }
+
+  return { detectLogLevel, createLogEntry, pushSystemLog, mergeLogs, clearAll };
+}
+
+function createContext() {
+  const config = loadConfig();
+  const workspace = createWorkspaceService(config);
+  try {
+    fs.mkdirSync(workspace.getWorkspaceRoot(), { recursive: true });
+  } catch (e) {
+    // 历史配置的 rootDir 在本机不可创建(权限/目录被移动)时不要崩,允许进设置页修改
+    console.warn(`输出根目录不可用:${workspace.getWorkspaceRoot()}(${e.code || e.message})。请到「系统设置」修改输出根目录。`);
+  }
+  const session = createSessionService();
+  const shops = createShopsService();
+  const logs = createLogsService();
+
+  function saveConfig() {
+    writeConfigFile(config);
+  }
+
+  return {
+    ROOT_DIR,
+    PUBLIC_DIR,
+    config,
+    saveConfig,
+    models: { MODELS, MODEL_IDS, NO_1K_SUPPORT },
+    workspace,
+    session,
+    shops,
+    logs,
+  };
+}
+
+// 智能问数的对话模型:未在设置中选择时使用的兜底模型,以及「生图 Key 复用」推导。
+// 不放入 CONFIG_DEFAULTS —— 避免落盘到 config.json(保持拆分前的持久化内容)。
+const DEFAULT_CHAT_MODEL = 'deepseek-v4.1-flash';
+
+function makeChatCfg(ctx) {
+  return {
+    chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
+    chatApiKey: ctx.config.chatApiKey || ctx.config.apiKey,
+    chatModel: ctx.config.chatModel || DEFAULT_CHAT_MODEL,
+  };
+}
+
+module.exports = {
+  createContext,
+  CONFIG_DEFAULTS,
+  MODELS,
+  MODEL_IDS,
+  NO_1K_SUPPORT,
+  DEFAULT_CHAT_MODEL,
+  makeChatCfg,
+};

+ 42 - 0
server/middleware.js

@@ -0,0 +1,42 @@
+'use strict';
+
+// 标准中间件:错误边界。
+// 路由处理器只需 throw,由这里统一映射为响应:
+//   · err.status 优先(处理器可对上游错误标注 502 等)
+//   · 智能问数的参数类错误码 → 400
+//   · 其余 → 500
+// 响应体形状与拆分前一致:{ error: message, code?: errCode }
+
+const { sendJson } = require('./util.js');
+
+const ASK_CLIENT_CODES = new Set(['ASK_NO_MODEL', 'ASK_EMPTY', 'ASK_NO_ENTITY']);
+
+function errorStatus(e) {
+  if (e && e.status) return e.status;
+  if (e && ASK_CLIENT_CODES.has(e.code)) return 400;
+  return 500;
+}
+
+function sendError(res, e) {
+  if (res.headersSent) {
+    // 响应已开始(流式文件等)时只能终止连接,与拆分前 writeHead 抛错的表现一致但不崩进程
+    res.destroy();
+    return;
+  }
+  const body = { error: (e && e.message) || String(e) };
+  if (e && e.code !== undefined) body.code = e.code;
+  sendJson(res, errorStatus(e), body);
+}
+
+// 包裹异步处理器:同步/异步异常都进入错误边界
+function errorBoundary(handler) {
+  return async (c) => {
+    try {
+      await handler(c);
+    } catch (e) {
+      sendError(c.res, e);
+    }
+  };
+}
+
+module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary };

+ 75 - 0
server/router.js

@@ -0,0 +1,75 @@
+'use strict';
+
+// 迷你路由(零依赖):
+//   const app = createRouter();
+//   app.get('/api/job/:id', handler);   // :段 提取参数
+//   app.any('/api/reports/status', h);  // 不限方法(保持旧行为)
+//   app.fallback(serveStatic);          // 未命中路由时兜底
+//   http.createServer((req, res) => app.dispatch(req, res));
+// 处理器签名:async ({ req, res, pathname, query, params }),异常由错误边界统一响应。
+
+const { errorBoundary } = require('./middleware.js');
+const { sendJson } = require('./util.js');
+
+function toSegments(pattern) {
+  return pattern.split('/').filter(Boolean)
+    .map((seg) => (seg.startsWith(':') ? { name: seg.slice(1) } : seg));
+}
+
+function createRouter() {
+  const routes = [];
+  let fallbackHandler = null;
+
+  function add(method, pattern, handler) {
+    routes.push({ method, segments: toSegments(pattern), handler: errorBoundary(handler) });
+  }
+
+  return {
+    get: (p, h) => add('GET', p, h),
+    post: (p, h) => add('POST', p, h),
+    del: (p, h) => add('DELETE', p, h),
+    any: (p, h) => add('*', p, h),
+    fallback: (fn) => { fallbackHandler = fn; },
+
+    async dispatch(req, res) {
+      let pathname;
+      let searchParams;
+      try {
+        const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
+        pathname = parsedUrl.pathname;
+        searchParams = parsedUrl.searchParams;
+      } catch {
+        res.writeHead(400);
+        return res.end('bad url');
+      }
+      const query = Object.fromEntries(searchParams);
+      const parts = pathname.split('/').filter(Boolean);
+
+      let pathMatched = false;
+      for (const route of routes) {
+        if (route.segments.length !== parts.length) continue;
+        const params = {};
+        let ok = true;
+        for (let i = 0; i < parts.length; i++) {
+          const seg = route.segments[i];
+          if (typeof seg === 'string') {
+            if (seg !== parts[i]) { ok = false; break; }
+          } else {
+            params[seg.name] = parts[i];
+          }
+        }
+        if (!ok) continue;
+        pathMatched = true;
+        if (route.method !== '*' && route.method !== req.method) continue;
+        return route.handler({ req, res, pathname, query, params });
+      }
+
+      if (pathMatched) return sendJson(res, 405, { error: 'Method Not Allowed' });
+      if (fallbackHandler) return fallbackHandler(pathname, res);
+      res.writeHead(404);
+      res.end('Not found');
+    },
+  };
+}
+
+module.exports = { createRouter };

+ 73 - 0
server/routes/crawl.js

@@ -0,0 +1,73 @@
+'use strict';
+
+// 门店采集路由:店铺清单、抓取任务控制、任务/系统日志
+
+const fs = require('fs');
+const { sendJson, readBody } = require('../util.js');
+
+function handleShopsManual(ctx, body, res) {
+  const lines = String((body && body.text) || '').split(/\r?\n/);
+  const parsed = [];
+  for (const line of lines) {
+    const t = line.trim();
+    if (!t) continue;
+    const parts = t.split(/[\s,,]+/).filter(Boolean);
+    const idPart = parts.find((p) => /^\d+$/.test(p));
+    if (!idPart) continue;
+    const shopId = Number(idPart);
+    const shopName = parts.filter((p) => p !== idPart).join(' ') || String(shopId);
+    parsed.push({ shopId, shopName, source: 'manual' });
+  }
+  const previousShops = ctx.shops.getList();
+  const mergedShops = ctx.shops.mergeShops(parsed);
+  try { ctx.shops.saveManualShops(mergedShops); ctx.shops.setList(mergedShops); }
+  catch (e) { ctx.shops.setList(previousShops); throw e; }
+  sendJson(res, 200, { shops: ctx.shops.getList(), addedCount: parsed.length });
+}
+
+function handleShopsList(ctx, root, res) {
+  if (root && !ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+  const completed = root && fs.existsSync(root) ? ctx.crawl.readCompletionState(root) : {};
+  sendJson(res, 200, {
+    shops: ctx.shops.getList().map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
+  });
+}
+
+function register(app, ctx) {
+  app.get('/api/shops', async ({ res, query }) => {
+    // 前端把 root 做 base64url 编码传入,避免中文路径在 URL 里出问题
+    const encodedRoot = query.root;
+    const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
+    return handleShopsList(ctx, root, res);
+  });
+
+  app.post('/api/shops/manual', async ({ req, res }) => {
+    const body = await readBody(req);
+    return handleShopsManual(ctx, body, res);
+  });
+
+  app.post('/api/crawl/start', async ({ req, res }) => {
+    const body = await readBody(req);
+    return ctx.crawl.startCrawl(body, res);
+  });
+
+  app.post('/api/crawl/stop', async ({ res }) => {
+    return ctx.crawl.stopCrawl(res);
+  });
+
+  app.get('/api/crawl/status', async ({ res }) => {
+    sendJson(res, 200, ctx.crawl.getState());
+  });
+
+  app.get('/api/logs', async ({ res }) => {
+    sendJson(res, 200, { logs: ctx.logs.mergeLogs(ctx.crawl.getLog()) });
+  });
+
+  app.post('/api/logs/clear', async ({ res }) => {
+    ctx.logs.clearAll(ctx.crawl.getLog());
+    ctx.logs.pushSystemLog('日志已清空', 'warn');
+    return sendJson(res, 200, { ok: true });
+  });
+}
+
+module.exports = { register };

+ 259 - 0
server/routes/generate.js

@@ -0,0 +1,259 @@
+'use strict';
+
+// 视觉生成路由:品牌资产/菜品图生成提交、任务查询、任务中心、提示词配置、画板保存、打开目录
+
+const fs = require('fs');
+const path = require('path');
+const { execFile } = require('child_process');
+const { sendJson, readBody, sanitizeFileName } = require('../util.js');
+const { getImageSize } = require('../../imageMeta.js');
+
+const POSTER_CANVAS_SPECS = {
+  banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 },
+  signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 },
+};
+
+async function handleGenerateBrand(ctx, body, res) {
+  const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body;
+  const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' };
+  if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
+    return sendJson(res, 400, { error: '参数不完整' });
+  }
+  const shopDir = ctx.workspace.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
+  const shouldAddLogo = !!addLogo && kind !== 'logo';
+  const { images, prompt: finalPrompt, warning, compositeOverlays } = ctx.generation.buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
+    addLogo: shouldAddLogo,
+    logoPosition,
+    logoMode,
+  });
+  const job = ctx.generation.createJob({
+    kind,
+    root,
+    folderName,
+    shopName,
+    model,
+    prompt: finalPrompt,
+    size: size || '1K',
+    aspectRatio: aspectRatio || undefined,
+    images,
+    outputDir: path.join(shopDir, '_生成图片'),
+    outputBaseName: outNameMap[kind],
+    warning,
+    compositeOverlays,
+  });
+  sendJson(res, 200, { jobId: job.id });
+}
+
+async function handleGenerateDish(ctx, body, res) {
+  const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body;
+  if (!root || !folderName || !dishFile || !prompt) {
+    return sendJson(res, 400, { error: '参数不完整' });
+  }
+  const shopDir = ctx.workspace.safeShopDir(root, folderName);
+  if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
+  const dishPath = path.resolve(shopDir, dishFile);
+  if (!ctx.workspace.isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
+    return sendJson(res, 403, { error: '菜品文件无效' });
+  }
+  let dishImage;
+  try {
+    dishImage = ctx.generation.readAsDataUri(dishPath);
+  } catch (e) {
+    return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
+  }
+  const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = ctx.generation.buildImagesAndPrompt(shopDir, prompt, [], {
+    addLogo: !!addLogo,
+    logoPosition,
+    logoMode,
+    addSticker: !!addSticker,
+    stickerPosition,
+    stickerMode,
+    sourceImageFirst: true,
+  });
+  const job = ctx.generation.createJob({
+    kind: 'dish',
+    root,
+    folderName,
+    shopName,
+    dishName,
+    model,
+    prompt: finalPrompt,
+    size: size || '1K',
+    aspectRatio: '1:1',
+    images: [dishImage, ...overlayImages],
+    outputDir: path.join(shopDir, '_生成图片', '菜品图'),
+    outputBaseName: sanitizeFileName(dishName),
+    warning,
+    compositeOverlays,
+  });
+  sendJson(res, 200, { jobId: job.id });
+}
+
+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 register(app, ctx) {
+  app.post('/api/generate/brand', async ({ req, res }) => {
+    const body = await readBody(req);
+    return handleGenerateBrand(ctx, body, res);
+  });
+
+  app.post('/api/generate/dish', async ({ req, res }) => {
+    const body = await readBody(req);
+    return handleGenerateDish(ctx, body, res);
+  });
+
+  app.get('/api/job/:id', async ({ res, params }) => {
+    const job = ctx.generation.getJob(params.id);
+    if (!job) return sendJson(res, 404, { error: 'job not found' });
+    sendJson(res, 200, {
+      id: job.id,
+      kind: job.kind,
+      dishName: job.dishName,
+      state: job.state,
+      progress: job.progress,
+      cost: job.cost,
+      resultUrl: job.resultUrl,
+      width: job.width,
+      height: job.height,
+      fileSizeBytes: job.fileSizeBytes,
+      error: job.error,
+      warning: job.warning,
+    });
+  });
+
+  app.get('/api/tasks', async ({ res, query }) => {
+    if (query.root && !ctx.workspace.isWorkspaceRoot(query.root)) {
+      return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+    }
+    const root = ctx.workspace.getWorkspaceRoot();
+    const tasks = ctx.generation.listJobs()
+      .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 });
+  });
+
+  app.post('/api/save-config', async ({ req, res }) => {
+    const body = await readBody(req);
+    const { root, folderName, shopName, prompts } = body;
+    if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
+    const shopDir = ctx.workspace.safeShopDir(root, folderName);
+    if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
+    const dir = path.join(shopDir, '_生成图片');
+    fs.mkdirSync(dir, { recursive: true });
+    const cfgPath = path.join(dir, 'prompt-config.json');
+    let existing = {};
+    if (fs.existsSync(cfgPath)) {
+      try {
+        existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+      } catch {
+        // start fresh if corrupt
+      }
+    }
+    const merged = {
+      shopName: shopName !== undefined ? shopName : existing.shopName,
+      prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
+    };
+    fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
+    sendJson(res, 200, { ok: true });
+  });
+
+  app.post('/api/save-poster-canvas', async ({ req, res }) => {
+    const body = await readBody(req);
+    const { root, folderName, kind, imageDataUrl } = body;
+    const spec = POSTER_CANVAS_SPECS[kind];
+    if (!root || !folderName || !spec || !imageDataUrl) {
+      return sendJson(res, 400, { error: '参数不完整' });
+    }
+    if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
+      return sendJson(res, 400, { error: '店铺目录名无效' });
+    }
+    const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
+    if (!dataUrlMatch) {
+      return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
+    }
+
+    const base64 = dataUrlMatch[1];
+    if (!base64 || base64.length > 10 * 1024 * 1024) {
+      return sendJson(res, 400, { error: '画板图数据无效或过大' });
+    }
+
+    let imageBuffer;
+    try {
+      imageBuffer = Buffer.from(base64, 'base64');
+    } catch {
+      return sendJson(res, 400, { error: '画板图数据无法读取' });
+    }
+    const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+    if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
+      return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
+    }
+    const dimensions = getImageSize(imageBuffer);
+    if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
+      return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
+    }
+
+    const resolvedRoot = path.resolve(root);
+    if (!ctx.workspace.isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+    const shopDir = path.resolve(resolvedRoot, folderName);
+    if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
+      return sendJson(res, 400, { error: '店铺目录无效' });
+    }
+    const dir = path.join(shopDir, '_生成图片');
+    const savePath = path.join(dir, spec.fileName);
+    try {
+      fs.mkdirSync(dir, { recursive: true });
+      fs.writeFileSync(savePath, imageBuffer);
+    } catch (e) {
+      return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
+    }
+    sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
+  });
+
+  app.post('/api/open-folder', async ({ req, res }) => {
+    const body = await readBody(req);
+    if (body.root && !ctx.workspace.isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+    const dir = body.dir || path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '', '_生成图片');
+    const fallbackDir = body.dir ? null : path.join(body.root || ctx.workspace.getWorkspaceRoot(), body.folderName || '');
+    const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
+    if (!targetDir || !ctx.workspace.isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
+    const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
+    execFile(command, [targetDir], () => {});
+    sendJson(res, 200, { ok: true, opened: targetDir });
+  });
+}
+
+module.exports = { register };

+ 26 - 0
server/routes/health.js

@@ -0,0 +1,26 @@
+'use strict';
+
+// 健康检查与默认根目录
+
+const fs = require('fs');
+const { sendJson } = require('../util.js');
+
+function register(app, ctx) {
+  app.get('/api/default-root', async ({ res }) => {
+    sendJson(res, 200, { root: ctx.workspace.getWorkspaceRoot() });
+  });
+
+  app.get('/healthz', async ({ res }) => {
+    sendJson(res, 200, { ok: true, workspace: ctx.workspace.getWorkspaceRoot() });
+  });
+
+  app.get('/readyz', async ({ res }) => {
+    sendJson(res, 200, {
+      ok: !!ctx.config.apiKey,
+      workspaceReady: fs.existsSync(ctx.workspace.getWorkspaceRoot()),
+      crawlReady: !!ctx.session.getKsid(),
+    });
+  });
+}
+
+module.exports = { register };

+ 286 - 0
server/routes/history.js

@@ -0,0 +1,286 @@
+'use strict';
+
+// 历史创作路由:门店/生成物扫描、创作历史图片墙、workspace 图片文件服务
+
+const fs = require('fs');
+const path = require('path');
+const { sendJson, IMAGE_EXT, MIME_BY_EXT, SKIP_DIR_NAMES } = require('../util.js');
+const { getImageSize } = require('../../imageMeta.js');
+
+// ---------- scan helpers ----------
+
+function fileInfo(p) {
+  const buf = fs.readFileSync(p);
+  const dims = getImageSize(buf) || {};
+  return {
+    url: '/files?path=' + encodeURIComponent(p),
+    width: dims.width || null,
+    height: dims.height || null,
+    fileSizeBytes: buf.length,
+  };
+}
+
+function scanGenerated(shopDir) {
+  const dir = path.join(shopDir, '_生成图片');
+  const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} };
+  if (!fs.existsSync(dir)) return result;
+  for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) {
+    for (const ext of IMAGE_EXT) {
+      const p = path.join(dir, base + ext);
+      if (fs.existsSync(p)) {
+        try {
+          result[kind] = fileInfo(p);
+        } catch {
+          // ignore unreadable file
+        }
+        break;
+      }
+    }
+  }
+  const dishDir = path.join(dir, '菜品图');
+  if (fs.existsSync(dishDir)) {
+    for (const f of fs.readdirSync(dishDir)) {
+      const ext = path.extname(f).toLowerCase();
+      if (!IMAGE_EXT.has(ext)) continue;
+      const name = f.slice(0, -ext.length);
+      try {
+        result.dishes[name] = fileInfo(path.join(dishDir, f));
+      } catch {
+        // ignore unreadable file
+      }
+    }
+  }
+  return result;
+}
+
+function handleScan(ctx, query, res) {
+  const root = query.root;
+  if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
+  if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
+    return sendJson(res, 400, { error: '目录不存在: ' + root });
+  }
+  let selectedFolders = null;
+  if (query.folders) {
+    try {
+      selectedFolders = JSON.parse(query.folders);
+    } catch {
+      return sendJson(res, 400, { error: '指定店铺参数无效' });
+    }
+    if (!Array.isArray(selectedFolders) || !selectedFolders.length ||
+        selectedFolders.some((name) => typeof name !== 'string' || !name ||
+          name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) {
+      return sendJson(res, 400, { error: '指定店铺参数无效' });
+    }
+    selectedFolders = [...new Set(selectedFolders)];
+  }
+  let entries;
+  try {
+    entries = fs.readdirSync(root, { withFileTypes: true });
+  } catch (e) {
+    return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
+  }
+  const shops = [];
+  for (const ent of entries) {
+    if (!ent.isDirectory()) continue;
+    if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue;
+    if (selectedFolders && !selectedFolders.includes(ent.name)) continue;
+    const folderName = ent.name;
+    const shopDir = path.join(root, folderName);
+    let files;
+    try {
+      files = fs.readdirSync(shopDir, { withFileTypes: true });
+    } catch {
+      continue;
+    }
+    const dishes = [];
+    for (const f of files) {
+      if (!f.isFile()) continue;
+      const ext = path.extname(f.name).toLowerCase();
+      if (!IMAGE_EXT.has(ext)) continue;
+      dishes.push({
+        file: f.name,
+        name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''),
+        url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)),
+      });
+    }
+    if (!dishes.length) continue;
+    dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
+
+    let overrides = {};
+    const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json');
+    if (fs.existsSync(cfgPath)) {
+      try {
+        overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+      } catch {
+        // ignore malformed override file
+      }
+    }
+    const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, '');
+    shops.push({
+      folderName,
+      shopName: overrides.shopName || parsedName,
+      dishes,
+      promptOverrides: overrides.prompts || {},
+      generated: scanGenerated(shopDir),
+    });
+  }
+  shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh'));
+  sendJson(res, 200, { root, shops });
+}
+
+// ---------- 历史创作 ----------
+
+const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']);
+
+function getCreationKind(directoryName, fileName) {
+  if (directoryName === '菜品图') return 'dish';
+  if (/^logo\./i.test(fileName)) return 'logo';
+  if (/^(banner|店内海报)/i.test(fileName)) return 'banner';
+  if (/^招牌头图/i.test(fileName)) return 'signage';
+  if (/^贴纸/i.test(fileName)) return 'sticker';
+  return 'other';
+}
+
+function handleCreations(ctx, query, res) {
+  const root = query.root;
+  if (!ctx.workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
+  const resolvedRoot = path.resolve(String(root));
+  if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
+    return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
+  }
+  const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600);
+  const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000;
+  const items = [];
+  let entries;
+  try {
+    entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
+  } catch (e) {
+    return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
+  }
+  for (const entry of entries) {
+    if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue;
+    const shopDir = path.join(resolvedRoot, entry.name);
+    const outputDir = path.join(shopDir, '_生成图片');
+    if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue;
+    let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, '');
+    const cfgPath = path.join(outputDir, 'prompt-config.json');
+    if (fs.existsSync(cfgPath)) {
+      try {
+        const overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+        shopName = overrides.shopName || shopName;
+      } catch {}
+    }
+    const nestedHistoryRecords = ctx.generation.loadCreationHistory(path.join(outputDir, '菜品图'))
+      .map((record) => ({
+        ...record,
+        relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')),
+      }));
+    const historyRecords = [...ctx.generation.loadCreationHistory(outputDir), ...nestedHistoryRecords];
+    const seenHistoryIds = new Set();
+    const historyPaths = new Set();
+    for (const record of historyRecords) {
+      if (record.id && seenHistoryIds.has(record.id)) continue;
+      if (record.id) seenHistoryIds.add(record.id);
+      if (!record.remoteUrl || !record.fileName) continue;
+      const generatedAt = record.generatedAt || record.modifiedAt;
+      const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0;
+      if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue;
+      const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName);
+      if (!kind || kind === 'other') continue;
+      const relativePath = path.normalize(record.relativePath || record.fileName);
+      historyPaths.add(relativePath);
+      items.push({
+        folderName: entry.name,
+        shopName: record.shopName || shopName,
+        kind,
+        name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)),
+        fileName: record.fileName,
+        url: record.remoteUrl,
+        remoteUrl: record.remoteUrl,
+        localPath: path.join(outputDir, relativePath),
+        source: 'remote',
+        model: record.model,
+        cost: record.cost,
+        modifiedAt: generatedAt,
+        modifiedTimeMs: generatedTimeMs,
+        width: record.width,
+        height: record.height,
+        fileSizeBytes: record.fileSizeBytes,
+      });
+    }
+    for (const relativeDir of ['.', '菜品图']) {
+      const currentDir = path.join(outputDir, relativeDir);
+      if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue;
+      let files;
+      try {
+        files = fs.readdirSync(currentDir, { withFileTypes: true });
+      } catch {
+        continue;
+      }
+      for (const file of files) {
+        if (!file.isFile()) continue;
+        const ext = path.extname(file.name).toLowerCase();
+        if (!IMAGE_EXT.has(ext)) continue;
+        const kind = getCreationKind(relativeDir, file.name);
+        if (kind === 'other') continue;
+        const fullPath = path.join(currentDir, file.name);
+        const relativePath = path.relative(outputDir, fullPath);
+        if (historyPaths.has(path.normalize(relativePath))) continue;
+        let stat;
+        try {
+          stat = fs.statSync(fullPath);
+        } catch {
+          continue;
+        }
+        if (stat.mtimeMs < retentionCutoff) continue;
+        items.push({
+          folderName: entry.name,
+          shopName,
+          kind,
+          name: path.basename(file.name, ext),
+          fileName: file.name,
+          url: '/files?path=' + encodeURIComponent(fullPath),
+          modifiedAt: stat.mtime.toISOString(),
+          modifiedTimeMs: stat.mtimeMs,
+          fileSizeBytes: stat.size,
+        });
+      }
+    }
+  }
+  items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs);
+  sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) });
+}
+
+// ---------- workspace 图片文件服务 ----------
+
+function handleFiles(ctx, query, res) {
+  const p = query.path;
+  if (!p) {
+    res.writeHead(400);
+    return res.end('missing path');
+  }
+  const resolved = path.resolve(p);
+  if (!ctx.workspace.isWorkspacePath(resolved)) {
+    res.writeHead(403);
+    return res.end('forbidden');
+  }
+  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
+    res.writeHead(404);
+    return res.end('not found');
+  }
+  const ext = path.extname(resolved).toLowerCase();
+  if (!IMAGE_EXT.has(ext)) {
+    res.writeHead(403);
+    return res.end('forbidden');
+  }
+  res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' });
+  fs.createReadStream(resolved).pipe(res);
+}
+
+function register(app, ctx) {
+  app.get('/api/scan', async ({ res, query }) => handleScan(ctx, query, res));
+  app.get('/api/creations', async ({ res, query }) => handleCreations(ctx, query, res));
+  app.get('/files', async ({ res, query }) => handleFiles(ctx, query, res));
+}
+
+module.exports = { register };

+ 16 - 0
server/routes/index.js

@@ -0,0 +1,16 @@
+'use strict';
+
+// 路由注册总入口:按域挂载;static 必须最后注册(作为 fallback)
+
+function registerRoutes(app, ctx) {
+  require('./health.js').register(app, ctx);
+  require('./session.js').register(app, ctx);
+  require('./crawl.js').register(app, ctx);
+  require('./settings.js').register(app, ctx);
+  require('./generate.js').register(app, ctx);
+  require('./history.js').register(app, ctx);
+  require('./reports.js').register(app, ctx);
+  require('./static.js').register(app, ctx);
+}
+
+module.exports = { registerRoutes };

+ 145 - 0
server/routes/reports.js

@@ -0,0 +1,145 @@
+'use strict';
+
+// 数据采集与智能分析路由:同步任务、数据湖查询/聚合/导出、经营周报 HTML、智能问数
+
+const { sendJson, readBody } = require('../util.js');
+const { ASK_CLIENT_CODES } = require('../middleware.js');
+const { makeChatCfg } = require('../context.js');
+const naposService = require('../../napos-service.js');
+const nlQuery = require('../../nl-query.js');
+const reportHtml = require('../../report-html.js');
+
+function register(app, ctx) {
+  // 不限方法(保持拆分前行为:该路由未做方法检查)
+  app.any('/api/reports/status', async ({ res }) => {
+    const napos = await naposService.naposStatus();
+    sendJson(res, 200, { napos, entities: naposService.store.listEntities() });
+  });
+
+  // ---------- 智能问数(NL2Query,对话模型驱动) ----------
+
+  app.post('/api/reports/ask', async ({ req, res }) => {
+    const body = await readBody(req);
+    const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
+    if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
+    try {
+      const result = await nlQuery.ask({ q: body.q, entity, cfg: makeChatCfg(ctx), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
+      sendJson(res, 200, result);
+    } catch (e) {
+      // 参数类错误码 → 400(错误边界统一处理),其余(模型/网络失败)保持 502
+      if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502;
+      throw e;
+    }
+  });
+
+  app.get('/api/reports/report-html', async ({ res, query }) => {
+    const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
+    if (!entity) {
+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+      return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
+    }
+    const range = nlQuery.lakeDateRange(entity, naposService);
+    const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
+    const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
+    const html = reportHtml.buildReportHtml({ entity, from, to });
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+    return res.end(html);
+  });
+
+  app.get('/api/reports/entities', async ({ res }) => {
+    sendJson(res, 200, { entities: naposService.store.listEntities() });
+  });
+
+  app.get('/api/reports/tree', async ({ res, query }) => {
+    try {
+      const tree = await naposService.fetchTree({ refresh: query.refresh === '1' });
+      sendJson(res, 200, {
+        merchant: tree.merchant, chains: tree.chains,
+        shops: tree.shops.map((s) => ({ shopId: String(s.shopId), shopName: s.shopName, chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '', busyLevel: s.busyLevel || null })),
+        entity: tree.entity,
+      });
+    } catch (e) {
+      // 取数通道不可达属于上游失败,保持 502
+      e.status = 502;
+      throw e;
+    }
+  });
+
+  app.get('/api/reports/columns', async ({ res }) => {
+    sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
+  });
+
+  app.post('/api/reports/sync', async ({ req, res }) => {
+    const body = await readBody(req);
+    naposService.runSync({
+      from: body.from, to: body.to,
+      scope: body.scope || 'all',
+      chainId: body.chainId || '',
+      shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
+    }).catch(() => {});
+    return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
+  });
+
+  app.get('/api/reports/sync/status', async ({ res }) => {
+    sendJson(res, 200, naposService.syncSnapshot());
+  });
+
+  app.post('/api/reports/sync/cancel', async ({ res }) => {
+    sendJson(res, 200, naposService.requestSyncCancel());
+  });
+
+  app.get('/api/reports/daily', async ({ res, query }) => {
+    sendJson(res, 200, naposService.queryReport({
+      entity: query.entity, from: query.from, to: query.to,
+      shopId: query.shopId,
+      shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+      scope: query.scope || 'shop', q: query.q,
+    }));
+  });
+
+  app.get('/api/reports/aggregate', async ({ res, query }) => {
+    sendJson(res, 200, naposService.aggregateReport({
+      entity: query.entity, from: query.from, to: query.to,
+      group: query.group || 'shop',
+      shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+    }));
+  });
+
+  app.get('/api/reports/export', async ({ res, query }) => {
+    const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
+    let rows;
+    if (group) {
+      const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
+      rows = agg.rows;
+    } else {
+      const daily = naposService.queryReport({
+        entity: query.entity, from: query.from, to: query.to,
+        shopId: query.shopId,
+        shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
+        scope: query.scope || 'shop', q: query.q,
+      });
+      rows = daily.rows;
+    }
+    const columns = naposService.REPORT_COLUMNS;
+    const csv = naposService.rowsToCsv(columns, rows);
+    res.writeHead(200, {
+      'Content-Type': 'text/csv; charset=utf-8',
+      'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
+    });
+    return res.end(csv);
+  });
+
+  app.get('/api/reports/syncs', async ({ res, query }) => {
+    const key = naposService.store.sanitizeKey(query.entity);
+    sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
+  });
+
+  app.del('/api/reports/data', async ({ res, query }) => {
+    if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
+    naposService.store.deleteEntityData(query.entity);
+    ctx.logs.pushSystemLog(`数据报表:已清除主体 ${query.entity} 的本地数据`, 'warn');
+    sendJson(res, 200, { ok: true });
+  });
+}
+
+module.exports = { register };

+ 24 - 0
server/routes/session.js

@@ -0,0 +1,24 @@
+'use strict';
+
+// 采集 Cookie 会话(淘宝闪购后台)
+
+const { sendJson, readBody } = require('../util.js');
+
+function register(app, ctx) {
+  app.get('/api/session', async ({ res }) => {
+    sendJson(res, 200, {
+      cookieConfigured: !!ctx.session.cookie,
+      ksidConfigured: !!ctx.session.getKsid(),
+    });
+  });
+
+  app.post('/api/session', async ({ req, res }) => {
+    const body = await readBody(req);
+    ctx.session.cookie = String((body && body.cookie) || '');
+    ctx.session.saveSession();
+    ctx.logs.pushSystemLog('采集 Cookie 已更新', 'success');
+    sendJson(res, 200, { ok: true });
+  });
+}
+
+module.exports = { register };

+ 121 - 0
server/routes/settings.js

@@ -0,0 +1,121 @@
+'use strict';
+
+// 系统设置:模型列表、配置读写(脱敏)、生图/对话 API 连通性测试
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const { URL } = require('url');
+const { sendJson, readBody } = require('../util.js');
+const { DEFAULT_CHAT_MODEL } = require('../context.js');
+
+function register(app, ctx) {
+  app.get('/api/models', async ({ res }) => {
+    sendJson(res, 200, { models: ctx.models.MODELS, default: ctx.config.model });
+  });
+
+  app.get('/api/settings', async ({ res }) => {
+    sendJson(res, 200, {
+      apiKey: ctx.config.apiKey ? ctx.config.apiKey.slice(0, 8) + '••••••••' : '',
+      apiBase: ctx.config.apiBase,
+      model: ctx.config.model,
+      rootDir: ctx.workspace.getWorkspaceRoot(),
+      pageSize: ctx.config.pageSize,
+      delayMs: ctx.config.delayMs,
+      cookieConfigured: !!ctx.session.cookie && !!ctx.session.getKsid(),
+      port: ctx.config.port,
+      chatModel: ctx.config.chatModel || '',
+      chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
+      chatApiKey: ctx.config.chatApiKey ? ctx.config.chatApiKey.slice(0, 8) + '••••••••' : '',
+      chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
+    });
+  });
+
+  app.post('/api/settings', async ({ req, res }) => {
+    const body = await readBody(req);
+    const config = ctx.config;
+    const previousApiKey = config.apiKey;
+    const previousCookie = ctx.session.cookie;
+    if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey;
+    if (body.model && ctx.models.MODEL_IDS.has(body.model)) config.model = body.model;
+    if (body.rootDir) {
+      const rootDir = path.resolve(String(body.rootDir).trim());
+      if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
+        return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir });
+      }
+      config.rootDir = rootDir;
+    }
+    if (body.pageSize !== undefined) {
+      const pageSize = Number(body.pageSize);
+      if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
+        return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' });
+      }
+      config.pageSize = pageSize;
+    }
+    if (body.delayMs !== undefined) {
+      const delayMs = Number(body.delayMs);
+      if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) {
+        return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' });
+      }
+      config.delayMs = delayMs;
+    }
+    if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
+    if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
+    if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
+    ctx.saveConfig();
+    if (body.cookie !== undefined) {
+      ctx.session.cookie = body.cookie;
+      ctx.session.saveSession();
+    }
+    ctx.logs.pushSystemLog(
+      `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` +
+      (previousApiKey !== config.apiKey ? ',API Key 已更新' : '') +
+      (body.cookie !== undefined && previousCookie !== ctx.session.cookie ? ',Cookie 已更新' : ''),
+      'success'
+    );
+    sendJson(res, 200, { ok: true });
+  });
+
+  // 生图 API 连通性测试:GET /v1/models(轻量、不产生生图费用)
+  app.post('/api/image/test', async ({ res }) => {
+    return new Promise((resolve) => {
+      let base;
+      try { base = new URL(ctx.config.apiBase); } catch { return sendJson(res, 400, { error: 'API 地址不合法: ' + ctx.config.apiBase }); }
+      const options = {
+        hostname: base.hostname, port: base.port || 443, path: '/v1/models', method: 'GET',
+        headers: { Authorization: `Bearer ${ctx.config.apiKey}` }, timeout: 15000,
+      };
+      const up = https.request(options, (upRes) => {
+        const chunks = [];
+        upRes.on('data', (c) => chunks.push(c));
+        upRes.on('end', () => {
+          let json = null;
+          try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* 保留空 */ }
+          const models = json && Array.isArray(json.data) ? json.data.map((m) => (m && m.id) || '').filter(Boolean) : null;
+          sendJson(res, 200, { statusCode: upRes.statusCode, models, modelFound: !!(models && models.includes(ctx.config.model)), model: ctx.config.model });
+        });
+      });
+      up.on('error', (e) => sendJson(res, 200, { statusCode: 0, error: e.message || String(e) }));
+      up.on('timeout', () => up.destroy(new Error('请求超时')));
+      up.end();
+    });
+  });
+
+  app.post('/api/chat/test', async ({ req, res }) => {
+    const body = await readBody(req);
+    const nlQuery = require('../../nl-query.js');
+    const cfg = {
+      chatApiBase: body.chatApiBase || ctx.config.chatApiBase || ctx.config.apiBase,
+      chatApiKey: (body.chatApiKey && !String(body.chatApiKey).includes('••••')) ? body.chatApiKey : (ctx.config.chatApiKey || ctx.config.apiKey),
+      chatModel: body.chatModel || ctx.config.chatModel || DEFAULT_CHAT_MODEL,
+    };
+    try {
+      const reply = await nlQuery.ping(cfg);
+      sendJson(res, 200, { ok: true, reply });
+    } catch (e) {
+      sendJson(res, 502, { error: e.message });
+    }
+  });
+}
+
+module.exports = { register };

+ 55 - 0
server/routes/static.js

@@ -0,0 +1,55 @@
+'use strict';
+
+// 静态前端:精确路径白名单(新增 public 文件需在 STATIC_MAP 登记)+ /assets/ 单段文件
+
+const fs = require('fs');
+const path = require('path');
+
+const STATIC_MAP = {
+  '/': ['index.html', 'text/html; charset=utf-8'],
+  '/index.html': ['index.html', 'text/html; charset=utf-8'],
+  '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
+  '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
+  '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
+  '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
+  '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
+  '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
+  '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
+  '/style.css': ['style.css', 'text/css; charset=utf-8'],
+};
+
+const STATIC_TYPES = {
+  '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
+  '.ico': 'image/x-icon', '.css': 'text/css', '.js': 'application/javascript',
+};
+
+function serveStatic(publicDir, pathname, res) {
+  const entry = STATIC_MAP[pathname];
+  if (entry) {
+    const full = path.join(publicDir, entry[0]);
+    fs.readFile(full, (err, data) => {
+      if (err) { res.writeHead(404); return res.end('Not found'); }
+      res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' });
+      res.end(data);
+    });
+    return;
+  }
+  if (pathname.startsWith('/assets/')) {
+    const rel = pathname.replace('/assets/', '');
+    if (rel.includes('..') || rel.includes('/')) { res.writeHead(403); return res.end('Forbidden'); }
+    const full = path.join(publicDir, 'assets', rel);
+    if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end('Not found'); }
+    const ext = path.extname(full).toLowerCase();
+    res.writeHead(200, { 'Content-Type': STATIC_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
+    fs.createReadStream(full).pipe(res);
+    return;
+  }
+  res.writeHead(404);
+  res.end('Not found');
+}
+
+function register(app, ctx) {
+  app.fallback((pathname, res) => serveStatic(ctx.PUBLIC_DIR, pathname, res));
+}
+
+module.exports = { register };

+ 339 - 0
server/services/crawl.js

@@ -0,0 +1,339 @@
+'use strict';
+
+// 采集引擎:按店铺批量抓取菜品原图。
+// 从 server.js 原样搬入:会话式 invokeApi、翻页、下载、断点跳过、店铺完成状态、可取消的任务循环。
+// 状态(crawlState/cancelFlag)收敛在本工厂内,通过返回的接口访问。
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+const { sendJson, sleep, sanitizeFileName, guessExtFromUrl, SKIP_DIR_NAMES } = require('../util.js');
+
+const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+
+function createCrawlService(ctx) {
+  const { config, session, shops, workspace, logs } = ctx;
+
+  let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
+  let cancelFlag = false;
+
+  function pushLog(msg) {
+    const entry = logs.createLogEntry(msg, 'task', logs.detectLogLevel(msg));
+    crawlState.log.push(entry);
+    if (crawlState.log.length > 500) crawlState.log.shift();
+  }
+
+  function invokeApi(service, method, requestParams, extraMetas) {
+    return new Promise((resolve, reject) => {
+      const body = {
+        id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
+        method, service,
+        params: { request: requestParams || {} },
+        metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: session.getKsid() }, extraMetas || {}),
+        ncp: '2.0.0', adapter: 'ncp',
+        extendConfig: { docMode: 'master,,' },
+      };
+      const data = JSON.stringify(body);
+      const options = {
+        hostname: 'app-api.shop.ele.me', port: 443,
+        path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`,
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
+          Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
+          Cookie: session.cookie || '', 'User-Agent': UA,
+          'Content-Length': Buffer.byteLength(data),
+        },
+        timeout: config.requestTimeoutMs,
+      };
+      const req = https.request(options, (res) => {
+        const chunks = [];
+        res.on('data', (c) => chunks.push(c));
+        res.on('end', () => {
+          const raw = Buffer.concat(chunks).toString('utf8');
+          let json = null;
+          try { json = JSON.parse(raw); } catch { /* keep raw */ }
+          resolve({ statusCode: res.statusCode, json, raw });
+        });
+      });
+      req.on('error', reject);
+      req.on('timeout', () => req.destroy(new Error('请求超时')));
+      req.write(data);
+      req.end();
+    });
+  }
+
+  function isAuthError(resp) {
+    if (resp.statusCode === 401 || resp.statusCode === 403) return true;
+    const j = resp.json;
+    if (!j) return false;
+    if (j.result !== undefined) return false;
+    return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
+  }
+
+  function downloadImage(urlStr, redirectsLeft) {
+    if (redirectsLeft === undefined) redirectsLeft = 5;
+    return new Promise((resolve, reject) => {
+      let u;
+      try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
+      const mod = u.protocol === 'http:' ? http : https;
+      mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
+          res.resume();
+          return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
+        }
+        if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
+        const chunks = [];
+        res.on('data', (c) => chunks.push(c));
+        res.on('end', () => resolve(Buffer.concat(chunks)));
+      }).on('error', reject);
+    });
+  }
+
+  // ---------- 店铺完成状态(root 下 _膳绘_店铺状态.json) ----------
+
+  function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
+
+  function readCompletionState(root) {
+    try {
+      const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
+      return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
+    } catch { return {}; }
+  }
+
+  function writeCompletionState(root, shopsState) {
+    const target = completionStatePath(root);
+    const temp = `${target}.${process.pid}.tmp`;
+    fs.writeFileSync(temp, JSON.stringify({ version: 1, shops: shopsState }, null, 2), 'utf8');
+    fs.renameSync(temp, target);
+  }
+
+  function markShopCompleted(root, shopEntry) {
+    const shopsState = readCompletionState(root);
+    shopsState[String(shopEntry.shopId)] = {
+      shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
+      completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
+      skipped: shopEntry.skipped, total: shopEntry.total,
+    };
+    writeCompletionState(root, shopsState);
+  }
+
+  // ---------- 门店目录分配 ----------
+
+  function listRootDirs(root) {
+    return fs.readdirSync(root, { withFileTypes: true })
+      .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
+      .map((e) => e.name);
+  }
+
+  function resolveShopDir(root, shopId, shopName) {
+    const dirs = listRootDirs(root);
+    const suffix = '_' + String(shopId);
+    const match = dirs.find((d) => d.endsWith(suffix));
+    if (match) return { dir: path.join(root, match), folderName: match, reused: true };
+    let maxSeq = 0;
+    for (const d of dirs) {
+      const mm = /^(\d+)_/.exec(d);
+      if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
+    }
+    const seq = String(maxSeq + 1).padStart(3, '0');
+    const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
+    const dir = path.join(root, folderName);
+    fs.mkdirSync(dir, { recursive: true });
+    return { dir, folderName, reused: false };
+  }
+
+  // ---------- 任务循环 ----------
+
+  async function startCrawl(body, res) {
+    if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
+    if (!session.cookie || !session.getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
+    const root = String((body && body.root) || workspace.getWorkspaceRoot()).trim();
+    if (!workspace.isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
+    if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
+      return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
+    }
+    const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
+    const selectedShops = shopIds.map((id) => shops.getList().find((s) => s.shopId === id)).filter(Boolean);
+    if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
+    const forceFullCrawl = Boolean(body && body.forceFullCrawl);
+    const completed = readCompletionState(root);
+    const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
+    const pendingShops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
+    const pageSize = Number(body && body.pageSize) || config.pageSize;
+    const delayMs = Number(body && body.delayMs) || config.delayMs;
+
+    cancelFlag = false;
+    crawlState = {
+      status: pendingShops.length ? 'running' : 'done', startedAt: Date.now(),
+      finishedAt: pendingShops.length ? null : Date.now(), forceFullCrawl,
+      shops: [
+        ...skippedCompleted.map((s) => ({
+          shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
+          state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
+          downloaded: 0, skipped: 0, failed: 0, error: null,
+        })),
+        ...pendingShops.map((s) => ({
+          shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
+          total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
+        })),
+      ],
+      log: [],
+    };
+    pushLog(`开始抓取,实际抓取 ${pendingShops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`);
+    sendJson(res, 200, { ok: true, crawlCount: pendingShops.length, skippedCompletedCount: skippedCompleted.length });
+    if (!pendingShops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
+
+    runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
+      crawlState.status = 'error';
+      crawlState.finishedAt = Date.now();
+      pushLog('抓取流程异常终止: ' + (e.message || e));
+    });
+  }
+
+  async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
+    for (const shopEntry of crawlState.shops) {
+      if (shopEntry.state === 'skipped_completed') continue;
+      if (cancelFlag) {
+        crawlState.status = 'stopped';
+        crawlState.finishedAt = Date.now();
+        pushLog('已停止');
+        return;
+      }
+      shopEntry.state = 'running';
+      pushLog(`[${shopEntry.shopName}] 开始`);
+
+      let dirInfo;
+      try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
+      catch (e) {
+        shopEntry.state = 'failed';
+        shopEntry.error = '创建目录失败: ' + (e.message || e);
+        pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
+        continue;
+      }
+      shopEntry.folderName = dirInfo.folderName;
+      pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
+
+      const allItems = [];
+      const seen = new Set();
+      let nextId;
+      let page = 0;
+      const MAX_PAGES = 200;
+      let authFailed = false;
+
+      while (page < MAX_PAGES) {
+        if (cancelFlag) break;
+        page++;
+        const requestParams = {
+          shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
+          searchKey: '', searchType: ['ITEM_NAME'],
+        };
+        if (nextId) requestParams.nextId = nextId;
+
+        let resp;
+        try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); }
+        catch (e) {
+          shopEntry.error = '请求失败: ' + (e.message || e);
+          pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`);
+          break;
+        }
+        if (isAuthError(resp)) {
+          authFailed = true;
+          shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie';
+          pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`);
+          break;
+        }
+        const items = (resp.json && resp.json.result && resp.json.result.result) || [];
+        nextId = resp.json && resp.json.result && resp.json.result.nextId;
+        if (!items.length) {
+          pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
+          break;
+        }
+        let newCount = 0;
+        for (const it of items) {
+          const gid = it.itemGlobalId;
+          if (gid !== undefined && gid !== null && seen.has(gid)) continue;
+          if (gid !== undefined && gid !== null) seen.add(gid);
+          allItems.push(it);
+          newCount++;
+        }
+        pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
+        if (!nextId) break;
+        await sleep(200);
+      }
+
+      shopEntry.total = allItems.length;
+      if (authFailed && !allItems.length) {
+        shopEntry.state = 'failed';
+        continue;
+      }
+
+      pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
+      const manifest = [];
+      const usedNames = new Set();
+      for (const it of allItems) {
+        if (cancelFlag) break;
+        const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
+        const photoUrl = it.photoUrl || it.thumbnailUrl;
+        if (!photoUrl) {
+          shopEntry.skipped++;
+          manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
+          continue;
+        }
+        let finalBase = sanitizeFileName(itemName);
+        let n = 1;
+        while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
+        usedNames.add(finalBase);
+        try {
+          const buf = await downloadImage(photoUrl);
+          const ext = guessExtFromUrl(photoUrl);
+          const fileName = `${finalBase}.${ext}`;
+          fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
+          shopEntry.downloaded++;
+          manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
+        } catch (e) {
+          shopEntry.failed++;
+          manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
+          pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
+        }
+        await sleep(delayMs);
+      }
+
+      try {
+        fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
+        if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
+      } catch (e) {
+        shopEntry.failed++;
+        shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
+        pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
+      }
+      shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
+      pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
+    }
+
+    crawlState.status = cancelFlag ? 'stopped' : 'done';
+    crawlState.finishedAt = Date.now();
+    pushLog('抓取任务结束');
+  }
+
+  function stopCrawl(res) {
+    if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
+    cancelFlag = true;
+    pushLog('收到停止请求,将在当前步骤完成后停止');
+    sendJson(res, 200, { ok: true });
+  }
+
+  return {
+    startCrawl,
+    stopCrawl,
+    getState: () => crawlState,
+    getLog: () => crawlState.log,
+    clearLog: () => { crawlState.log = []; },
+    readCompletionState,
+  };
+}
+
+module.exports = { createCrawlService };

+ 611 - 0
server/services/generation.js

@@ -0,0 +1,611 @@
+'use strict';
+
+// 生成引擎:生图任务队列、供应商 API 客户端、轮询、图片合成、创作历史与任务持久化。
+// 从 server.js 原样搬入;状态(jobs/pendingQueue/activeCount/persistedTasks)收敛在本工厂内。
+// 死代码 chromaKeyToTransparentPng / STICKER_* 未随迁(拆分前已无任何调用)。
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const https = require('https');
+const http = require('http');
+const { URL } = require('url');
+const jpeg = require('jpeg-js');
+const { PNG } = require('pngjs');
+const { getImageSize } = require('../../imageMeta.js');
+const { sleep, guessExtFromUrl, IMAGE_EXT } = require('../util.js');
+
+function createGenerationService(ctx) {
+  const { config, workspace, logs, models } = ctx;
+
+  // ---------- api.lk888.ai client ----------
+  // Base host is fixed to config.apiBase on purpose:
+  // several responses from this API contain "tips"/error text suggesting other
+  // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
+  // external source and is never used to pick a request target.
+
+  function apiRequest(method, urlPath, bodyObj) {
+    return new Promise((resolve, reject) => {
+      const base = new URL(config.apiBase);
+      const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
+      const options = {
+        hostname: base.hostname,
+        port: base.port || 443,
+        path: urlPath,
+        method,
+        headers: {
+          Authorization: `Bearer ${config.apiKey}`,
+          'Content-Type': 'application/json; charset=utf-8',
+        },
+        timeout: 300000,
+      };
+      if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
+      const req = https.request(options, (res) => {
+        const chunks = [];
+        res.on('data', (c) => chunks.push(c));
+        res.on('end', () => {
+          const raw = Buffer.concat(chunks).toString('utf8');
+          let json = null;
+          try {
+            json = JSON.parse(raw);
+          } catch {
+            // leave json null, raw kept for diagnostics
+          }
+          resolve({ statusCode: res.statusCode, json, raw });
+        });
+      });
+      req.on('error', reject);
+      req.on('timeout', () => req.destroy(new Error('请求超时')));
+      if (data) req.write(data);
+      req.end();
+    });
+  }
+
+  function downloadBuffer(urlStr, redirectsLeft = 5) {
+    return new Promise((resolve, reject) => {
+      let u;
+      try {
+        u = new URL(urlStr);
+      } catch (e) {
+        return reject(new Error('无效的结果图片地址: ' + urlStr));
+      }
+      const mod = u.protocol === 'http:' ? http : https;
+      mod
+        .get(u, (res) => {
+          if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
+            res.resume();
+            return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
+          }
+          if (res.statusCode !== 200) {
+            res.resume();
+            return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
+          }
+          const chunks = [];
+          res.on('data', (c) => chunks.push(c));
+          res.on('end', () => resolve(Buffer.concat(chunks)));
+        })
+        .on('error', reject);
+    });
+  }
+
+  // ---------- 参考图与提示词组装 ----------
+
+  const OVERLAY_POSITION_LABELS = {
+    'top-left': '左上角',
+    'top-right': '右上角',
+    'bottom-left': '左下角',
+    'bottom-right': '右下角',
+  };
+
+  function findExistingAsset(shopDir, baseName) {
+    const dir = path.join(shopDir, '_生成图片');
+    for (const ext of IMAGE_EXT) {
+      const p = path.join(dir, baseName + ext);
+      if (fs.existsSync(p)) return p;
+    }
+    return null;
+  }
+
+  const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
+  const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
+
+  function readAsDataUri(filePath) {
+    const buf = fs.readFileSync(filePath);
+    let ext = path.extname(filePath).toLowerCase().replace('.', '');
+    if (ext === 'jpg') ext = 'jpeg';
+    return `data:image/${ext};base64,${buf.toString('base64')}`;
+  }
+
+  // Builds the reference-image list and final prompt for a generation request, optionally
+  // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
+  // can use either of two modes:
+  //  - 'ai' (default): the asset is passed as an extra reference image and the model is asked
+  //    to paint it into the frame itself.
+  //  - 'composite': the model is only asked to leave the target corner visually clean; the
+  //    actual pixel fusion is done afterwards by applyCompositeOverlays() with no LLM call.
+  function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
+    const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
+    const images = [];
+    for (const f of (refDishFiles || []).slice(0, 3)) {
+      try {
+        images.push(readAsDataUri(path.join(shopDir, f)));
+      } catch {
+        // skip unreadable reference image
+      }
+    }
+    const warnings = [];
+    const overlayClauses = [];
+    const compositeOverlays = [];
+
+    if (addLogo) {
+      const logoPath = findExistingLogo(shopDir);
+      if (logoPath) {
+        const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
+        if (logoMode === 'composite') {
+          compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
+          overlayClauses.push(
+            opts.sourceImageFirst
+              ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
+              : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
+          );
+        } else {
+          try {
+            images.push(readAsDataUri(logoPath));
+            overlayClauses.push(
+              opts.sourceImageFirst
+                ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
+                : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
+            );
+          } catch {
+            warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
+          }
+        }
+      } else {
+        warnings.push('未找到可用LOGO,请先生成');
+      }
+    }
+    if (addSticker) {
+      const stickerPath = findExistingSticker(shopDir);
+      if (stickerPath) {
+        const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
+        if (stickerMode === 'composite') {
+          compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
+          overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
+        } else {
+          try {
+            images.push(readAsDataUri(stickerPath));
+            overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
+          } catch {
+            warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
+          }
+        }
+      } else {
+        warnings.push('未找到可用贴纸,请先生成');
+      }
+    }
+
+    let prompt = basePrompt;
+    if (overlayClauses.length) {
+      prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
+    }
+    return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
+  }
+
+  // ---------- pure image-processing overlay compositing (no LLM call) ----------
+  // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
+  // image directly onto the generated result via alpha blending, entirely in-process.
+
+  function decodeToRGBA(buf) {
+    if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
+      const png = PNG.sync.read(buf);
+      return { width: png.width, height: png.height, data: png.data };
+    }
+    const img = jpeg.decode(buf, { useTArray: true });
+    return { width: img.width, height: img.height, data: img.data };
+  }
+
+  function resizeRGBA(src, srcW, srcH, dstW, dstH) {
+    if (srcW === dstW && srcH === dstH) return src;
+    const dst = Buffer.alloc(dstW * dstH * 4);
+    for (let y = 0; y < dstH; y++) {
+      const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
+      const y0 = Math.floor(srcYf);
+      const y1 = Math.min(srcH - 1, y0 + 1);
+      const wy = srcYf - y0;
+      for (let x = 0; x < dstW; x++) {
+        const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
+        const x0 = Math.floor(srcXf);
+        const x1 = Math.min(srcW - 1, x0 + 1);
+        const wx = srcXf - x0;
+        const o = (y * dstW + x) * 4;
+        for (let c = 0; c < 4; c++) {
+          const p00 = src[(y0 * srcW + x0) * 4 + c];
+          const p10 = src[(y0 * srcW + x1) * 4 + c];
+          const p01 = src[(y1 * srcW + x0) * 4 + c];
+          const p11 = src[(y1 * srcW + x1) * 4 + c];
+          const top = p00 + (p10 - p00) * wx;
+          const bottom = p01 + (p11 - p01) * wx;
+          dst[o + c] = Math.round(top + (bottom - top) * wy);
+        }
+      }
+    }
+    return dst;
+  }
+
+  const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
+
+  // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
+  // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
+  // output are the same {width, height, data} shape produced by decodeToRGBA().
+  function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
+    const targetW = Math.max(1, Math.round(base.width * widthRatio));
+    const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
+    const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
+    const margin = Math.round(base.width * 0.04);
+    let x, y;
+    if (position === 'top-left') { x = margin; y = margin; }
+    else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
+    else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
+    else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
+    x = Math.max(0, Math.min(x, base.width - targetW));
+    y = Math.max(0, Math.min(y, base.height - targetH));
+
+    const out = Buffer.from(base.data);
+    for (let j = 0; j < targetH; j++) {
+      const by = y + j;
+      if (by < 0 || by >= base.height) continue;
+      for (let i = 0; i < targetW; i++) {
+        const bx = x + i;
+        if (bx < 0 || bx >= base.width) continue;
+        const so = (j * targetW + i) * 4;
+        const alpha = resized[so + 3] / 255;
+        if (alpha <= 0) continue;
+        const bo = (by * base.width + bx) * 4;
+        out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
+        out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
+        out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
+      }
+    }
+    return { width: base.width, height: base.height, data: out };
+  }
+
+  function encodeRGBAToPng(decoded) {
+    const png = new PNG({ width: decoded.width, height: decoded.height });
+    Buffer.from(decoded.data).copy(png.data);
+    return PNG.sync.write(png);
+  }
+
+  // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
+  // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
+  // original buffer/ext (with a warning appended to job.warning) if the base image can't be
+  // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
+  function applyCompositeOverlays(job, buf, ext) {
+    if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
+    let base;
+    try {
+      base = decodeToRGBA(buf);
+    } catch (e) {
+      job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
+      return { buf, ext };
+    }
+    for (const ov of job.compositeOverlays) {
+      try {
+        const overlay = decodeToRGBA(fs.readFileSync(ov.path));
+        base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
+      } catch (e) {
+        const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
+        job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
+      }
+    }
+    return { buf: encodeRGBAToPng(base), ext: 'png' };
+  }
+
+  // ---------- job queue ----------
+
+  const jobs = new Map();
+  const pendingQueue = [];
+  let activeCount = 0;
+  const persistedTasks = new Map();
+  let taskStoreLoaded = false;
+
+  function getTaskStoreFile() {
+    return path.join(workspace.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 (!models.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) {
+    const id = crypto.randomUUID();
+    const job = {
+      id,
+      root: opts.root,
+      folderName: opts.folderName,
+      kind: opts.kind,
+      shopName: opts.shopName,
+      dishName: opts.dishName || null,
+      model: models.MODEL_IDS.has(opts.model) ? opts.model : config.model,
+      prompt: opts.prompt,
+      size: opts.size,
+      aspectRatio: opts.aspectRatio,
+      images: opts.images || [],
+      outputDir: opts.outputDir,
+      outputBaseName: opts.outputBaseName,
+      compositeOverlays: opts.compositeOverlays || [],
+      state: 'queued',
+      progress: '0%',
+      taskId: null,
+      cost: null,
+      resultUrl: null,
+      remoteUrl: null,
+      width: null,
+      height: null,
+      fileSizeBytes: null,
+      error: null,
+      warning: opts.warning || null,
+      createdAt: Date.now(),
+      startedAt: null,
+      finishedAt: null,
+    };
+    jobs.set(id, job);
+    persistJob(job);
+    const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
+    logs.pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
+    if (activeCount < config.maxConcurrentJobs) {
+      startJob(job);
+    } else {
+      pendingQueue.push(id);
+    }
+    return job;
+  }
+
+  function startJob(job) {
+    runJob(job);
+  }
+
+  function runJob(job) {
+    activeCount++;
+    job.state = 'running';
+    job.startedAt = job.startedAt || Date.now();
+    persistJob(job);
+    processJob(job)
+      .catch((e) => {
+        job.state = 'failed';
+        job.error = job.error || e.message || String(e);
+      })
+      .finally(() => {
+        const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
+        if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
+        else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
+        job.finishedAt = Date.now();
+        persistJob(job);
+        activeCount--;
+        const nextId = pendingQueue.shift();
+        if (nextId) {
+          const nextJob = jobs.get(nextId);
+          if (nextJob) startJob(nextJob);
+        }
+      });
+  }
+
+  // Builds the model-specific `params` object for the /v1/media/generate request. Different
+  // image models on this API expose different (and sometimes confusingly named) parameters -
+  // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
+  // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
+  function buildParamsForModel(modelId, job) {
+    const params = {};
+    if (modelId === 'gpt-image-2') {
+      params.size = 'auto';
+      params.quality = 'auto';
+      params.resolution = job.size || '1K';
+      params.n = 1;
+      params.response_format = 'url';
+      if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
+    } else if (modelId === 'gpt-image-2-guan') {
+      params.size = 'auto';
+      params.quality = 'auto';
+    } else if (modelId === 'wan2.6-image') {
+      params.size = job.aspectRatio || job.size || 'auto';
+      params.prompt_extend = true;
+    } else {
+      if (job.size) params.size = models.NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
+      if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
+    }
+    let images = job.images;
+    if (modelId === 'wan2.6-image' && images.length > 4) {
+      job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
+      images = images.slice(0, 4);
+    }
+    if (images.length === 1) params.images = images[0];
+    else if (images.length > 1) params.images = images;
+    return params;
+  }
+
+  async function processJob(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)));
+      }
+      job.taskId = taskId;
+      persistJob(job);
+    }
+
+    await sleep(resuming ? 1000 : 7000);
+    const start = job.startedAt || Date.now();
+    const TIMEOUT_MS = 10 * 60 * 1000;
+    for (;;) {
+      if (Date.now() - start > TIMEOUT_MS) {
+        job.state = 'failed';
+        job.error = '轮询超时(10分钟未完成)';
+        return;
+      }
+      const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
+        ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
+        : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
+      const statusResp = await apiRequest('GET', statusPath);
+      const status = statusResp.json;
+      if (!status) {
+        await sleep(5000);
+        continue;
+      }
+      job.progress = String(status.progress ?? job.progress);
+      if (status.cost !== undefined) job.cost = status.cost;
+      persistJob(job);
+
+      if (status.is_final) {
+        if (status.state === 'success' && status.result_url) {
+          job.remoteUrl = status.result_url;
+          const buf = await downloadBuffer(status.result_url);
+          let finalBuf, ext;
+          finalBuf = buf;
+          ext = guessExtFromUrl(status.result_url);
+          ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
+          fs.mkdirSync(job.outputDir, { recursive: true });
+          const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
+          fs.writeFileSync(savePath, finalBuf);
+          const dims = getImageSize(finalBuf) || {};
+          job.width = dims.width || null;
+          job.height = dims.height || null;
+          job.fileSizeBytes = finalBuf.length;
+          job.resultPath = savePath;
+          job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
+          const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
+            ? path.dirname(job.outputDir)
+            : job.outputDir;
+          appendCreationHistory(creationHistoryDir, {
+            id: job.id,
+            root: job.root,
+            folderName: job.folderName,
+            shopName: job.shopName,
+            kind: job.kind,
+            name: job.dishName || job.kind,
+            dishName: job.dishName,
+            fileName: path.basename(savePath),
+            relativePath: path.relative(creationHistoryDir, savePath),
+            remoteUrl: job.remoteUrl,
+            model: job.model,
+            cost: job.cost,
+            width: job.width,
+            height: job.height,
+            fileSizeBytes: job.fileSizeBytes,
+            generatedAt: new Date().toISOString(),
+          });
+          job.state = 'success';
+        } else {
+          job.state = 'failed';
+          job.error = status.error || status.status || '生成失败';
+          job.cost = status.cost ?? job.cost;
+        }
+        return;
+      }
+      await sleep(5000);
+    }
+  }
+
+  // ---------- 创作历史(generation-history.json) ----------
+
+  function loadCreationHistory(outputDir) {
+    const historyPath = path.join(outputDir, 'generation-history.json');
+    if (!fs.existsSync(historyPath)) return [];
+    try {
+      const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
+      return Array.isArray(records) ? records : [];
+    } catch {
+      return [];
+    }
+  }
+
+  function appendCreationHistory(outputDir, record) {
+    fs.mkdirSync(outputDir, { recursive: true });
+    const historyPath = path.join(outputDir, 'generation-history.json');
+    const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
+    records.push(record);
+    const tempPath = historyPath + '.tmp';
+    fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
+    fs.renameSync(tempPath, historyPath);
+  }
+
+  return {
+    createJob,
+    getJob: (id) => jobs.get(id),
+    listJobs: () => Array.from(jobs.values()),
+    loadPersistedJobs,
+    buildImagesAndPrompt,
+    readAsDataUri,
+    loadCreationHistory,
+    appendCreationHistory,
+  };
+}
+
+module.exports = { createGenerationService };

+ 81 - 0
server/util.js

@@ -0,0 +1,81 @@
+'use strict';
+
+// 通用小工具:响应、请求体、文件名/扩展名处理(无状态,可被任意层引用)
+
+const path = require('path');
+
+const MAX_BODY_BYTES = 32 * 1024 * 1024;
+
+const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
+const MIME_BY_EXT = {
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.png': 'image/png',
+  '.webp': 'image/webp',
+  '.gif': 'image/gif',
+};
+
+// 采集输出根目录下永远跳过的目录名
+const SKIP_DIR_NAMES = new Set(['膳绘']);
+
+function sendJson(res, statusCode, obj) {
+  const data = JSON.stringify(obj);
+  res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
+  res.end(data);
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function sanitizeFileName(name) {
+  return String(name).replace(/[\\/:*?"<>|]/g, '_').trim() || 'file';
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    const chunks = [];
+    let size = 0;
+    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', () => {
+      if (!chunks.length) return resolve({});
+      try {
+        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
+      } catch (e) {
+        reject(e);
+      }
+    });
+    req.on('error', reject);
+  });
+}
+
+function guessExtFromUrl(urlStr) {
+  try {
+    const u = new URL(urlStr);
+    let ext = path.extname(u.pathname).replace('.', '').toLowerCase();
+    if (ext === 'jpeg') ext = 'jpg';
+    if (['jpg', 'png', 'gif', 'webp'].includes(ext)) return ext;
+  } catch {
+    // fall through
+  }
+  return 'jpg';
+}
+
+module.exports = {
+  MAX_BODY_BYTES,
+  IMAGE_EXT,
+  MIME_BY_EXT,
+  SKIP_DIR_NAMES,
+  sendJson,
+  sleep,
+  sanitizeFileName,
+  readBody,
+  guessExtFromUrl,
+};

+ 1 - 0
打包分发.bat

@@ -14,6 +14,7 @@ for %%f in (server.js napos-service.js reports-store.js reports-mcp.js nl-query.
 )
 robocopy public "%OUT%\public" /E /NFL /NDL /NJH /NJS >nul
 robocopy napos "%OUT%\napos" /E /NFL /NDL /NJH /NJS >nul
+robocopy server "%OUT%\server" /E /NFL /NDL /NJH /NJS >nul
 robocopy node_modules "%OUT%\node_modules" /E /NFL /NDL /NJH /NJS >nul
 
 powershell -NoProfile -Command "Compress-Archive -LiteralPath '%OUT%' -DestinationPath '%ZIP%' -Force"

+ 1 - 1
打包分发.command

@@ -18,7 +18,7 @@ for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js
 done
 
 # 目录(前端 / 取数引擎 / 依赖)
-for d in public napos node_modules; do
+for d in public server napos node_modules; do
   [ -d "$d" ] && cp -R "$d" "$OUT/"
 done
 

+ 1 - 1
打包分发.sh

@@ -18,7 +18,7 @@ for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js
 done
 
 # 目录(前端 / 取数引擎 / 依赖)
-for d in public napos node_modules; do
+for d in public server napos node_modules; do
   [ -d "$d" ] && cp -R "$d" "$OUT/"
 done
 

Някои файлове не бяха показани, защото твърде много файлове са промени