server.js 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241
  1. 'use strict';
  2. const http = require('http');
  3. const https = require('https');
  4. const fs = require('fs');
  5. const path = require('path');
  6. const crypto = require('crypto');
  7. const { execFile } = require('child_process');
  8. const { URL } = require('url');
  9. const jpeg = require('jpeg-js');
  10. const { PNG } = require('pngjs');
  11. const { getImageSize } = require('./imageMeta.js');
  12. const naposService = require('./napos-service.js');
  13. const nlQuery = require('./nl-query.js');
  14. const reportHtml = require('./report-html.js');
  15. const CONFIG_PATH = path.join(__dirname, 'config.json');
  16. const CONFIG_EXAMPLE_PATH = path.join(__dirname, 'config.example.json');
  17. const CONFIG_DEFAULTS = {
  18. port: 5177,
  19. apiKey: '',
  20. apiBase: 'https://api.lk888.ai',
  21. model: 'gpt-image-2',
  22. maxConcurrentJobs: 4,
  23. pageSize: 50,
  24. delayMs: 300,
  25. requestTimeoutMs: 20000,
  26. chatModel: '',
  27. chatApiBase: '',
  28. chatApiKey: '',
  29. };
  30. function writeConfigFile(cfg) {
  31. const temp = CONFIG_PATH + '.tmp';
  32. fs.writeFileSync(temp, JSON.stringify(cfg, null, 2), 'utf8');
  33. fs.renameSync(temp, CONFIG_PATH);
  34. }
  35. // 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
  36. function loadConfig() {
  37. try {
  38. return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
  39. } catch (e) {
  40. if (e.code !== 'ENOENT') {
  41. console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
  42. }
  43. let seeded = {};
  44. try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
  45. const cfg = { ...CONFIG_DEFAULTS, ...seeded };
  46. try {
  47. writeConfigFile(cfg);
  48. console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
  49. } catch { /* 只读目录等场景忽略 */ }
  50. return cfg;
  51. }
  52. }
  53. let config = loadConfig();
  54. function saveConfig() {
  55. writeConfigFile(config);
  56. }
  57. const WORKSPACE_ROOT = path.join(path.dirname(__dirname), 'workspace');
  58. function getWorkspaceRoot() {
  59. const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
  60. return path.resolve(configuredRoot);
  61. }
  62. const HOST = process.env.HOST || '127.0.0.1';
  63. const MAX_BODY_BYTES = 32 * 1024 * 1024;
  64. function isWorkspaceRoot(candidate) {
  65. try {
  66. return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot();
  67. } catch {
  68. return false;
  69. }
  70. }
  71. function isWorkspacePath(candidate) {
  72. try {
  73. const resolved = path.resolve(String(candidate));
  74. const root = getWorkspaceRoot();
  75. return resolved === root || resolved.startsWith(root + path.sep);
  76. } catch {
  77. return false;
  78. }
  79. }
  80. function safeShopDir(root, folderName) {
  81. if (!isWorkspaceRoot(root) || typeof folderName !== 'string' ||
  82. !folderName || folderName === '.' || folderName === '..' ||
  83. folderName.includes('/') || folderName.includes('\\')) return null;
  84. const rootDir = path.resolve(root);
  85. const shopDir = path.resolve(rootDir, folderName);
  86. return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
  87. }
  88. try {
  89. fs.mkdirSync(getWorkspaceRoot(), { recursive: true });
  90. } catch (e) {
  91. // 历史配置的 rootDir 在本机不可创建(权限/目录被移动)时不要崩,允许进设置页修改
  92. console.warn(`输出根目录不可用:${getWorkspaceRoot()}(${e.code || e.message})。请到「系统设置」修改输出根目录。`);
  93. }
  94. const SESSION_PATH = path.join(__dirname, 'session.json');
  95. const MANUAL_SHOPS_PATH = path.join(__dirname, 'manual-shops.json');
  96. const MODELS = [
  97. { id: 'gpt-image-2', label: 'GPT Image 2(默认)' },
  98. { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' },
  99. { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' },
  100. { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' },
  101. { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' },
  102. { id: 'wan2.6-image', label: '通义万相 wan2.6-image' },
  103. ];
  104. const MODEL_IDS = new Set(MODELS.map((m) => m.id));
  105. // These seedream variants don't support the '1K' tier the UI's size dropdown offers by
  106. // default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) -
  107. // map it up to '2K' rather than sending an invalid enum value.
  108. const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']);
  109. const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
  110. const MIME_BY_EXT = {
  111. '.jpg': 'image/jpeg',
  112. '.jpeg': 'image/jpeg',
  113. '.png': 'image/png',
  114. '.webp': 'image/webp',
  115. '.gif': 'image/gif',
  116. };
  117. const POSTER_CANVAS_SPECS = {
  118. banner: { fileName: '店内海报_1138x292.png', width: 1138, height: 292 },
  119. signage: { fileName: '招牌头图_750x288.png', width: 750, height: 288 },
  120. };
  121. 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';
  122. const SKIP_DIR_NAMES = new Set(['膳绘']);
  123. // ---------- small utils ----------
  124. function sendJson(res, statusCode, obj) {
  125. const data = JSON.stringify(obj);
  126. res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
  127. res.end(data);
  128. }
  129. function sleep(ms) {
  130. return new Promise((resolve) => setTimeout(resolve, ms));
  131. }
  132. function sanitizeFileName(name) {
  133. return String(name).replace(/[\\/:*?"<>|]/g, '_').trim() || 'file';
  134. }
  135. function readBody(req) {
  136. return new Promise((resolve, reject) => {
  137. const chunks = [];
  138. let size = 0;
  139. req.on('data', (c) => chunks.push(c));
  140. req.on('data', (c) => {
  141. size += c.length;
  142. if (size > MAX_BODY_BYTES) {
  143. req.destroy(new Error('请求体过大'));
  144. reject(new Error('请求体过大'));
  145. }
  146. });
  147. req.on('end', () => {
  148. if (!chunks.length) return resolve({});
  149. try {
  150. resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
  151. } catch (e) {
  152. reject(e);
  153. }
  154. });
  155. req.on('error', reject);
  156. });
  157. }
  158. // ============================================================
  159. // CRAWLER MODULE — 闪购菜品图爬取
  160. // ============================================================
  161. let session = { cookie: '' };
  162. function extractCookieValue(cookie, name) {
  163. const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
  164. if (!match) return '';
  165. try { return decodeURIComponent(match[1]); } catch { return match[1]; }
  166. }
  167. function getKsid() { return extractCookieValue(session.cookie, 'ksid'); }
  168. function loadSession() {
  169. try {
  170. const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8'));
  171. session.cookie = String(saved.cookie || '');
  172. } catch { /* no saved session */ }
  173. }
  174. function saveSession() {
  175. fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8');
  176. }
  177. loadSession();
  178. function invokeApi(service, method, requestParams, extraMetas) {
  179. return new Promise((resolve, reject) => {
  180. const body = {
  181. id: crypto.randomBytes(16).toString('hex').toUpperCase() + '|' + Date.now(),
  182. method, service,
  183. params: { request: requestParams || {} },
  184. metas: Object.assign({ appName: 'melody', appVersion: '4.4.0', ksid: getKsid() }, extraMetas || {}),
  185. ncp: '2.0.0', adapter: 'ncp',
  186. extendConfig: { docMode: 'master,,' },
  187. };
  188. const data = JSON.stringify(body);
  189. const options = {
  190. hostname: 'app-api.shop.ele.me', port: 443,
  191. path: `/nevermore.goods/invoke?method=${encodeURIComponent(service + '.' + method)}`,
  192. method: 'POST',
  193. headers: {
  194. 'Content-Type': 'application/json; charset=UTF-8', Accept: 'application/json',
  195. Origin: 'https://napos-goods-pc.faas.ele.me', Referer: 'https://napos-goods-pc.faas.ele.me/',
  196. Cookie: session.cookie || '', 'User-Agent': UA,
  197. 'Content-Length': Buffer.byteLength(data),
  198. },
  199. timeout: config.requestTimeoutMs,
  200. };
  201. const req = https.request(options, (res) => {
  202. const chunks = [];
  203. res.on('data', (c) => chunks.push(c));
  204. res.on('end', () => {
  205. const raw = Buffer.concat(chunks).toString('utf8');
  206. let json = null;
  207. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  208. resolve({ statusCode: res.statusCode, json, raw });
  209. });
  210. });
  211. req.on('error', reject);
  212. req.on('timeout', () => req.destroy(new Error('请求超时')));
  213. req.write(data);
  214. req.end();
  215. });
  216. }
  217. function isAuthError(resp) {
  218. if (resp.statusCode === 401 || resp.statusCode === 403) return true;
  219. const j = resp.json;
  220. if (!j) return false;
  221. if (j.result !== undefined) return false;
  222. return /未登录|请登录|login|unauthorized|token.*expired|session.*expired/i.test(JSON.stringify(j));
  223. }
  224. function downloadImage(urlStr, redirectsLeft) {
  225. if (redirectsLeft === undefined) redirectsLeft = 5;
  226. return new Promise((resolve, reject) => {
  227. let u;
  228. try { u = new URL(urlStr); } catch (e) { return reject(new Error('无效的图片地址: ' + urlStr)); }
  229. const mod = u.protocol === 'http:' ? http : https;
  230. mod.get(u, { headers: { Referer: 'https://melody.shop.ele.me/', 'User-Agent': UA } }, (res) => {
  231. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  232. res.resume();
  233. return resolve(downloadImage(res.headers.location, redirectsLeft - 1));
  234. }
  235. if (res.statusCode !== 200) { res.resume(); return reject(new Error('下载图片失败 HTTP ' + res.statusCode)); }
  236. const chunks = [];
  237. res.on('data', (c) => chunks.push(c));
  238. res.on('end', () => resolve(Buffer.concat(chunks)));
  239. }).on('error', reject);
  240. });
  241. }
  242. let shopList = [];
  243. function loadManualShops() {
  244. try {
  245. const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8'));
  246. if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效');
  247. const shopsById = new Map();
  248. for (const shop of data.shops) {
  249. if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue;
  250. shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' });
  251. }
  252. shopList = Array.from(shopsById.values());
  253. } catch (e) {
  254. if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`);
  255. }
  256. }
  257. function saveManualShops(shops) {
  258. const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`;
  259. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
  260. fs.renameSync(temp, MANUAL_SHOPS_PATH);
  261. }
  262. function mergeShops(newOnes) {
  263. const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop]));
  264. for (const shop of newOnes) shopsById.set(shop.shopId, shop);
  265. return Array.from(shopsById.values());
  266. }
  267. loadManualShops();
  268. function completionStatePath(root) { return path.join(root, '_膳绘_店铺状态.json'); }
  269. function readCompletionState(root) {
  270. try {
  271. const data = JSON.parse(fs.readFileSync(completionStatePath(root), 'utf8'));
  272. return data.version === 1 && data.shops && typeof data.shops === 'object' ? data.shops : {};
  273. } catch { return {}; }
  274. }
  275. function writeCompletionState(root, shops) {
  276. const target = completionStatePath(root);
  277. const temp = `${target}.${process.pid}.tmp`;
  278. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
  279. fs.renameSync(temp, target);
  280. }
  281. function markShopCompleted(root, shopEntry) {
  282. const shops = readCompletionState(root);
  283. shops[String(shopEntry.shopId)] = {
  284. shopId: shopEntry.shopId, shopName: shopEntry.shopName, folderName: shopEntry.folderName,
  285. completedAt: new Date().toISOString(), downloaded: shopEntry.downloaded,
  286. skipped: shopEntry.skipped, total: shopEntry.total,
  287. };
  288. writeCompletionState(root, shops);
  289. }
  290. function handleShopsManual(body, res) {
  291. const lines = String((body && body.text) || '').split(/\r?\n/);
  292. const parsed = [];
  293. for (const line of lines) {
  294. const t = line.trim();
  295. if (!t) continue;
  296. const parts = t.split(/[\s,,]+/).filter(Boolean);
  297. const idPart = parts.find((p) => /^\d+$/.test(p));
  298. if (!idPart) continue;
  299. const shopId = Number(idPart);
  300. const shopName = parts.filter((p) => p !== idPart).join(' ') || String(shopId);
  301. parsed.push({ shopId, shopName, source: 'manual' });
  302. }
  303. const previousShops = shopList;
  304. const mergedShops = mergeShops(parsed);
  305. try { saveManualShops(mergedShops); shopList = mergedShops; }
  306. catch (e) { shopList = previousShops; throw e; }
  307. sendJson(res, 200, { shops: shopList, addedCount: parsed.length });
  308. }
  309. function handleShopsList(root, res) {
  310. if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  311. const completed = root && fs.existsSync(root) ? readCompletionState(root) : {};
  312. sendJson(res, 200, {
  313. shops: shopList.map((shop) => ({ ...shop, completed: completed[String(shop.shopId)] || null })),
  314. });
  315. }
  316. function listRootDirs(root) {
  317. return fs.readdirSync(root, { withFileTypes: true })
  318. .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIR_NAMES.has(e.name))
  319. .map((e) => e.name);
  320. }
  321. function resolveShopDir(root, shopId, shopName) {
  322. const dirs = listRootDirs(root);
  323. const suffix = '_' + String(shopId);
  324. const match = dirs.find((d) => d.endsWith(suffix));
  325. if (match) return { dir: path.join(root, match), folderName: match, reused: true };
  326. let maxSeq = 0;
  327. for (const d of dirs) {
  328. const mm = /^(\d+)_/.exec(d);
  329. if (mm) maxSeq = Math.max(maxSeq, parseInt(mm[1], 10));
  330. }
  331. const seq = String(maxSeq + 1).padStart(3, '0');
  332. const folderName = `${seq}_${sanitizeFileName(shopName)}_${shopId}`;
  333. const dir = path.join(root, folderName);
  334. fs.mkdirSync(dir, { recursive: true });
  335. return { dir, folderName, reused: false };
  336. }
  337. let crawlState = { status: 'idle', startedAt: null, finishedAt: null, shops: [], log: [] };
  338. let cancelFlag = false;
  339. const systemLogs = [];
  340. let logSeq = 0;
  341. function detectLogLevel(message) {
  342. if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error';
  343. if (/停止|跳过|警告|warn/i.test(message)) return 'warn';
  344. if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success';
  345. return 'info';
  346. }
  347. function createLogEntry(message, source = 'task', level = 'info') {
  348. const now = new Date();
  349. return {
  350. seq: ++logSeq,
  351. id: `${source}-${logSeq}`,
  352. source,
  353. level,
  354. time: now.toTimeString().slice(0, 8),
  355. timestamp: now.getTime(),
  356. message: String(message),
  357. };
  358. }
  359. function pushLog(msg) {
  360. const entry = createLogEntry(msg, 'task', detectLogLevel(msg));
  361. crawlState.log.push(entry);
  362. if (crawlState.log.length > 500) crawlState.log.shift();
  363. }
  364. function pushSystemLog(msg, level = 'info') {
  365. systemLogs.push(createLogEntry(msg, 'system', level));
  366. if (systemLogs.length > 500) systemLogs.shift();
  367. }
  368. function getMergedLogs() {
  369. return [...crawlState.log, ...systemLogs]
  370. .sort((a, b) => b.seq - a.seq)
  371. .slice(0, 500);
  372. }
  373. async function startCrawl(body, res) {
  374. if (crawlState.status === 'running') return sendJson(res, 409, { error: '已有抓取任务在进行中' });
  375. if (!session.cookie || !getKsid()) return sendJson(res, 400, { error: '请先填写包含 ksid 的 Cookie' });
  376. const root = String((body && body.root) || getWorkspaceRoot()).trim();
  377. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许采集当前输出根目录' });
  378. if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  379. return sendJson(res, 400, { error: '输出根目录不存在: ' + root });
  380. }
  381. const shopIds = Array.isArray(body && body.shopIds) ? body.shopIds.map(Number) : [];
  382. const selectedShops = shopIds.map((id) => shopList.find((s) => s.shopId === id)).filter(Boolean);
  383. if (!selectedShops.length) return sendJson(res, 400, { error: '没有可抓取的店铺,请先填写手动店铺清单并勾选' });
  384. const forceFullCrawl = Boolean(body && body.forceFullCrawl);
  385. const completed = readCompletionState(root);
  386. const skippedCompleted = forceFullCrawl ? [] : selectedShops.filter((s) => completed[String(s.shopId)]);
  387. const shops = forceFullCrawl ? selectedShops : selectedShops.filter((s) => !completed[String(s.shopId)]);
  388. const pageSize = Number(body && body.pageSize) || config.pageSize;
  389. const delayMs = Number(body && body.delayMs) || config.delayMs;
  390. cancelFlag = false;
  391. crawlState = {
  392. status: shops.length ? 'running' : 'done', startedAt: Date.now(),
  393. finishedAt: shops.length ? null : Date.now(), forceFullCrawl,
  394. shops: [
  395. ...skippedCompleted.map((s) => ({
  396. shopId: s.shopId, shopName: s.shopName, folderName: completed[String(s.shopId)].folderName || null,
  397. state: 'skipped_completed', total: completed[String(s.shopId)].total || 0,
  398. downloaded: 0, skipped: 0, failed: 0, error: null,
  399. })),
  400. ...shops.map((s) => ({
  401. shopId: s.shopId, shopName: s.shopName, folderName: null, state: 'pending',
  402. total: 0, downloaded: 0, skipped: 0, failed: 0, error: null,
  403. })),
  404. ],
  405. log: [],
  406. };
  407. pushLog(`开始抓取,实际抓取 ${shops.length} 家${skippedCompleted.length ? `,历史完成跳过 ${skippedCompleted.length} 家` : ''},root=${root}`);
  408. sendJson(res, 200, { ok: true, crawlCount: shops.length, skippedCompletedCount: skippedCompleted.length });
  409. if (!shops.length) return pushLog('所选店铺均已完成;勾选"强制全量抓取"可重新下载');
  410. runCrawlLoop(root, pageSize, delayMs, forceFullCrawl).catch((e) => {
  411. crawlState.status = 'error';
  412. crawlState.finishedAt = Date.now();
  413. pushLog('抓取流程异常终止: ' + (e.message || e));
  414. });
  415. }
  416. async function runCrawlLoop(root, pageSize, delayMs, forceFullCrawl) {
  417. for (const shopEntry of crawlState.shops) {
  418. if (shopEntry.state === 'skipped_completed') continue;
  419. if (cancelFlag) {
  420. crawlState.status = 'stopped';
  421. crawlState.finishedAt = Date.now();
  422. pushLog('已停止');
  423. return;
  424. }
  425. shopEntry.state = 'running';
  426. pushLog(`[${shopEntry.shopName}] 开始`);
  427. let dirInfo;
  428. try { dirInfo = resolveShopDir(root, shopEntry.shopId, shopEntry.shopName); }
  429. catch (e) {
  430. shopEntry.state = 'failed';
  431. shopEntry.error = '创建目录失败: ' + (e.message || e);
  432. pushLog(`[${shopEntry.shopName}] 创建目录失败: ${e.message || e}`);
  433. continue;
  434. }
  435. shopEntry.folderName = dirInfo.folderName;
  436. pushLog(`[${shopEntry.shopName}] ${dirInfo.reused ? '复用已有目录' : '新建目录'} ${dirInfo.folderName}`);
  437. const allItems = [];
  438. const seen = new Set();
  439. let nextId;
  440. let page = 0;
  441. const MAX_PAGES = 200;
  442. let authFailed = false;
  443. while (page < MAX_PAGES) {
  444. if (cancelFlag) break;
  445. page++;
  446. const requestParams = {
  447. shopId: shopEntry.shopId, pageSize, queryType: 'SHOP',
  448. searchKey: '', searchType: ['ITEM_NAME'],
  449. };
  450. if (nextId) requestParams.nextId = nextId;
  451. let resp;
  452. try { resp = await invokeApi('ItemPhotoProcessService', 'getItemPhotoGallery', requestParams, { shopId: shopEntry.shopId }); }
  453. catch (e) {
  454. shopEntry.error = '请求失败: ' + (e.message || e);
  455. pushLog(`[${shopEntry.shopName}] 请求失败: ${e.message || e}`);
  456. break;
  457. }
  458. if (isAuthError(resp)) {
  459. authFailed = true;
  460. shopEntry.error = '会话可能已过期,请重新登录后更新 Cookie';
  461. pushLog(`[${shopEntry.shopName}] 鉴权失败(ksid/Cookie 可能已过期)`);
  462. break;
  463. }
  464. const items = (resp.json && resp.json.result && resp.json.result.result) || [];
  465. nextId = resp.json && resp.json.result && resp.json.result.nextId;
  466. if (!items.length) {
  467. pushLog(`[${shopEntry.shopName}] 第${page}页无数据,结束翻页`);
  468. break;
  469. }
  470. let newCount = 0;
  471. for (const it of items) {
  472. const gid = it.itemGlobalId;
  473. if (gid !== undefined && gid !== null && seen.has(gid)) continue;
  474. if (gid !== undefined && gid !== null) seen.add(gid);
  475. allItems.push(it);
  476. newCount++;
  477. }
  478. pushLog(`[${shopEntry.shopName}] 第${page}页 新增${newCount}条`);
  479. if (!nextId) break;
  480. await sleep(200);
  481. }
  482. shopEntry.total = allItems.length;
  483. if (authFailed && !allItems.length) {
  484. shopEntry.state = 'failed';
  485. continue;
  486. }
  487. pushLog(`[${shopEntry.shopName}] 共${allItems.length}个菜品,开始下载`);
  488. const manifest = [];
  489. const usedNames = new Set();
  490. for (const it of allItems) {
  491. if (cancelFlag) break;
  492. const itemName = String(it.itemName || it.itemGlobalId || 'unnamed').trim();
  493. const photoUrl = it.photoUrl || it.thumbnailUrl;
  494. if (!photoUrl) {
  495. shopEntry.skipped++;
  496. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl: null, savedFile: null, status: 'no_photo' });
  497. continue;
  498. }
  499. let finalBase = sanitizeFileName(itemName);
  500. let n = 1;
  501. while (usedNames.has(finalBase)) { finalBase = `${sanitizeFileName(itemName)}_${n++}`; }
  502. usedNames.add(finalBase);
  503. try {
  504. const buf = await downloadImage(photoUrl);
  505. const ext = guessExtFromUrl(photoUrl);
  506. const fileName = `${finalBase}.${ext}`;
  507. fs.writeFileSync(path.join(dirInfo.dir, fileName), buf);
  508. shopEntry.downloaded++;
  509. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: fileName, status: 'downloaded' });
  510. } catch (e) {
  511. shopEntry.failed++;
  512. manifest.push({ itemName, itemGlobalId: it.itemGlobalId, photoUrl, savedFile: null, status: 'download_failed', error: e.message || String(e) });
  513. pushLog(`[${shopEntry.shopName}] 下载失败 ${itemName}: ${e.message || e}`);
  514. }
  515. await sleep(delayMs);
  516. }
  517. try {
  518. fs.writeFileSync(path.join(dirInfo.dir, '_菜品图爬取记录.json'), JSON.stringify(manifest, null, 2), 'utf8');
  519. if (!shopEntry.failed && !cancelFlag) markShopCompleted(root, shopEntry);
  520. } catch (e) {
  521. shopEntry.failed++;
  522. shopEntry.error = `保存抓取状态失败: ${e.message || e}`;
  523. pushLog(`[${shopEntry.shopName}] 保存抓取状态失败: ${e.message || e}`);
  524. }
  525. shopEntry.state = shopEntry.failed ? 'failed' : cancelFlag ? 'stopped' : 'done';
  526. pushLog(`[${shopEntry.shopName}] 完成: 成功${shopEntry.downloaded} 跳过${shopEntry.skipped} 失败${shopEntry.failed}`);
  527. }
  528. crawlState.status = cancelFlag ? 'stopped' : 'done';
  529. crawlState.finishedAt = Date.now();
  530. pushLog('抓取任务结束');
  531. }
  532. function stopCrawl(res) {
  533. if (crawlState.status !== 'running') return sendJson(res, 200, { ok: true, note: '当前没有正在进行的任务' });
  534. cancelFlag = true;
  535. pushLog('收到停止请求,将在当前步骤完成后停止');
  536. sendJson(res, 200, { ok: true });
  537. }
  538. // ============================================================
  539. // GENERATOR MODULE — 店铺视觉图生成
  540. // ============================================================
  541. const OVERLAY_POSITION_LABELS = {
  542. 'top-left': '左上角',
  543. 'top-right': '右上角',
  544. 'bottom-left': '左下角',
  545. 'bottom-right': '右下角',
  546. };
  547. function findExistingAsset(shopDir, baseName) {
  548. const dir = path.join(shopDir, '_生成图片');
  549. for (const ext of IMAGE_EXT) {
  550. const p = path.join(dir, baseName + ext);
  551. if (fs.existsSync(p)) return p;
  552. }
  553. return null;
  554. }
  555. const findExistingLogo = (shopDir) => findExistingAsset(shopDir, 'logo');
  556. const findExistingSticker = (shopDir) => findExistingAsset(shopDir, '贴纸');
  557. function readAsDataUri(filePath) {
  558. const buf = fs.readFileSync(filePath);
  559. let ext = path.extname(filePath).toLowerCase().replace('.', '');
  560. if (ext === 'jpg') ext = 'jpeg';
  561. return `data:image/${ext};base64,${buf.toString('base64')}`;
  562. }
  563. // Builds the reference-image list and final prompt for a generation request, optionally
  564. // fusing the shop's already-generated LOGO and/or 贴纸(sticker) into the frame. Each overlay
  565. // can use either of two modes:
  566. // - 'ai' (default): the asset is passed as an extra reference image and the model is asked
  567. // to paint it into the frame itself.
  568. // - 'composite': the model is only asked to leave the target corner visually clean; the
  569. // actual pixel fusion is done afterwards by compositeOverlaysOntoImage() with no LLM call.
  570. function buildImagesAndPrompt(shopDir, basePrompt, refDishFiles, opts) {
  571. const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
  572. const images = [];
  573. for (const f of (refDishFiles || []).slice(0, 3)) {
  574. try {
  575. images.push(readAsDataUri(path.join(shopDir, f)));
  576. } catch {
  577. // skip unreadable reference image
  578. }
  579. }
  580. const warnings = [];
  581. const overlayClauses = [];
  582. const compositeOverlays = [];
  583. if (addLogo) {
  584. const logoPath = findExistingLogo(shopDir);
  585. if (logoPath) {
  586. const posLabel = OVERLAY_POSITION_LABELS[logoPosition] || OVERLAY_POSITION_LABELS['bottom-right'];
  587. if (logoMode === 'composite') {
  588. compositeOverlays.push({ type: 'logo', path: logoPath, position: logoPosition || 'bottom-right' });
  589. overlayClauses.push(
  590. opts.sourceImageFirst
  591. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景;不要模仿或保留这些旧标识。画面${posLabel}保持干净,留出用于程序叠加店铺指定LOGO的区域。`
  592. : `画面的${posLabel}请保持背景简洁、不绘制任何LOGO或品牌图案(该区域将在生成后由程序直接叠加店铺LOGO图片)`
  593. );
  594. } else {
  595. try {
  596. images.push(readAsDataUri(logoPath));
  597. overlayClauses.push(
  598. opts.sourceImageFirst
  599. ? `第一张图是菜品原图,第二张图是店铺指定LOGO。店铺指定LOGO是唯一权威品牌标识;必须彻底移除菜品原图中已有的LOGO、水印、店铺名、徽章和旧品牌标识,并自然修复该区域背景。只在画面${posLabel}使用店铺指定LOGO,严格保持其图形、文字、配色和比例完整清晰,不要复制、模仿或保留原图旧标识。`
  600. : `参考图中的店铺LOGO图案(简洁品牌标识)请原样添加到画面的${posLabel}`
  601. );
  602. } catch {
  603. warnings.push('读取已生成的LOGO文件失败,本次生成未附加LOGO');
  604. }
  605. }
  606. } else {
  607. warnings.push('未找到可用LOGO,请先生成');
  608. }
  609. }
  610. if (addSticker) {
  611. const stickerPath = findExistingSticker(shopDir);
  612. if (stickerPath) {
  613. const posLabel = OVERLAY_POSITION_LABELS[stickerPosition] || OVERLAY_POSITION_LABELS['bottom-left'];
  614. if (stickerMode === 'composite') {
  615. compositeOverlays.push({ type: 'sticker', path: stickerPath, position: stickerPosition || 'bottom-left' });
  616. overlayClauses.push(`画面的${posLabel}请保持背景简洁、不绘制任何贴纸/徽章图案(该区域将在生成后由程序直接叠加店铺贴纸图片)`);
  617. } else {
  618. try {
  619. images.push(readAsDataUri(stickerPath));
  620. overlayClauses.push(`参考图中的店铺贴纸插画(徽章/贴纸风格图案)请原样叠加到画面的${posLabel}`);
  621. } catch {
  622. warnings.push('读取已生成的贴纸文件失败,本次生成未附加贴纸');
  623. }
  624. }
  625. } else {
  626. warnings.push('未找到可用贴纸,请先生成');
  627. }
  628. }
  629. let prompt = basePrompt;
  630. if (overlayClauses.length) {
  631. prompt += `\n\n${overlayClauses.join(';')},保持图案本身完整、清晰、不变形、不被裁切,不要遮挡画面主体;其余参考图仅作为风格/内容参考,不必出现在画面里。`;
  632. }
  633. return { images, prompt, warning: warnings.length ? warnings.join(';') : null, compositeOverlays };
  634. }
  635. // ---------- sticker: chroma-key background removal (pure-JS, no image editor needed) ----------
  636. // The generation model has no true alpha-channel/transparent-background output, so for the
  637. // "贴纸" (sticker) asset we ask it to render on a flat key-color background and cut that
  638. // color out ourselves, producing a real transparent PNG.
  639. const STICKER_KEY_COLOR = [255, 0, 255]; // pure magenta, rarely present in food photography
  640. const STICKER_THRESHOLD = 60;
  641. const STICKER_FEATHER = 40;
  642. function decodeToRGBA(buf) {
  643. if (buf.length > 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
  644. const png = PNG.sync.read(buf);
  645. return { width: png.width, height: png.height, data: png.data };
  646. }
  647. const img = jpeg.decode(buf, { useTArray: true });
  648. return { width: img.width, height: img.height, data: img.data };
  649. }
  650. function chromaKeyToTransparentPng(decoded, keyColor, threshold, feather) {
  651. const { width, height, data } = decoded;
  652. const png = new PNG({ width, height });
  653. const [kr, kg, kb] = keyColor;
  654. for (let i = 0; i < width * height; i++) {
  655. const o = i * 4;
  656. const r = data[o];
  657. const g = data[o + 1];
  658. const b = data[o + 2];
  659. const dist = Math.sqrt((r - kr) ** 2 + (g - kg) ** 2 + (b - kb) ** 2);
  660. let alpha;
  661. if (dist <= threshold) alpha = 0;
  662. else if (dist >= threshold + feather) alpha = 255;
  663. else alpha = Math.round(((dist - threshold) / feather) * 255);
  664. png.data[o] = r;
  665. png.data[o + 1] = g;
  666. png.data[o + 2] = b;
  667. png.data[o + 3] = alpha;
  668. }
  669. return PNG.sync.write(png);
  670. }
  671. // ---------- pure image-processing overlay compositing (no LLM call) ----------
  672. // Alternative to the AI-fusion overlay path above: pastes an already-generated logo/sticker
  673. // image directly onto the generated result via alpha blending, entirely in-process.
  674. function resizeRGBA(src, srcW, srcH, dstW, dstH) {
  675. if (srcW === dstW && srcH === dstH) return src;
  676. const dst = Buffer.alloc(dstW * dstH * 4);
  677. for (let y = 0; y < dstH; y++) {
  678. const srcYf = Math.min(srcH - 1, Math.max(0, ((y + 0.5) * srcH) / dstH - 0.5));
  679. const y0 = Math.floor(srcYf);
  680. const y1 = Math.min(srcH - 1, y0 + 1);
  681. const wy = srcYf - y0;
  682. for (let x = 0; x < dstW; x++) {
  683. const srcXf = Math.min(srcW - 1, Math.max(0, ((x + 0.5) * srcW) / dstW - 0.5));
  684. const x0 = Math.floor(srcXf);
  685. const x1 = Math.min(srcW - 1, x0 + 1);
  686. const wx = srcXf - x0;
  687. const o = (y * dstW + x) * 4;
  688. for (let c = 0; c < 4; c++) {
  689. const p00 = src[(y0 * srcW + x0) * 4 + c];
  690. const p10 = src[(y0 * srcW + x1) * 4 + c];
  691. const p01 = src[(y1 * srcW + x0) * 4 + c];
  692. const p11 = src[(y1 * srcW + x1) * 4 + c];
  693. const top = p00 + (p10 - p00) * wx;
  694. const bottom = p01 + (p11 - p01) * wx;
  695. dst[o + c] = Math.round(top + (bottom - top) * wy);
  696. }
  697. }
  698. }
  699. return dst;
  700. }
  701. const OVERLAY_WIDTH_RATIO = { logo: 0.16, sticker: 0.22 };
  702. // Alpha-blends `overlay` (resized to a fraction of the base width, keeping its aspect ratio)
  703. // onto `base` at the given corner and returns a new decoded-RGBA object. Both inputs and the
  704. // output are the same {width, height, data} shape produced by decodeToRGBA().
  705. function compositeOverlayOntoBase(base, overlay, position, widthRatio) {
  706. const targetW = Math.max(1, Math.round(base.width * widthRatio));
  707. const targetH = Math.max(1, Math.round((overlay.height / overlay.width) * targetW));
  708. const resized = resizeRGBA(overlay.data, overlay.width, overlay.height, targetW, targetH);
  709. const margin = Math.round(base.width * 0.04);
  710. let x, y;
  711. if (position === 'top-left') { x = margin; y = margin; }
  712. else if (position === 'top-right') { x = base.width - margin - targetW; y = margin; }
  713. else if (position === 'bottom-left') { x = margin; y = base.height - margin - targetH; }
  714. else { x = base.width - margin - targetW; y = base.height - margin - targetH; } // bottom-right (default)
  715. x = Math.max(0, Math.min(x, base.width - targetW));
  716. y = Math.max(0, Math.min(y, base.height - targetH));
  717. const out = Buffer.from(base.data);
  718. for (let j = 0; j < targetH; j++) {
  719. const by = y + j;
  720. if (by < 0 || by >= base.height) continue;
  721. for (let i = 0; i < targetW; i++) {
  722. const bx = x + i;
  723. if (bx < 0 || bx >= base.width) continue;
  724. const so = (j * targetW + i) * 4;
  725. const alpha = resized[so + 3] / 255;
  726. if (alpha <= 0) continue;
  727. const bo = (by * base.width + bx) * 4;
  728. out[bo] = Math.round(resized[so] * alpha + out[bo] * (1 - alpha));
  729. out[bo + 1] = Math.round(resized[so + 1] * alpha + out[bo + 1] * (1 - alpha));
  730. out[bo + 2] = Math.round(resized[so + 2] * alpha + out[bo + 2] * (1 - alpha));
  731. }
  732. }
  733. return { width: base.width, height: base.height, data: out };
  734. }
  735. function encodeRGBAToPng(decoded) {
  736. const png = new PNG({ width: decoded.width, height: decoded.height });
  737. Buffer.from(decoded.data).copy(png.data);
  738. return PNG.sync.write(png);
  739. }
  740. // Applies a list of {type, path, position} overlays (from buildImagesAndPrompt's composite
  741. // mode) onto an already-generated result image. Returns {buf, ext} - falls back to the
  742. // original buffer/ext (with a warning appended to job.warning) if the base image can't be
  743. // decoded (e.g. a webp/gif result, which decodeToRGBA doesn't support).
  744. function applyCompositeOverlays(job, buf, ext) {
  745. if (!job.compositeOverlays || !job.compositeOverlays.length) return { buf, ext };
  746. let base;
  747. try {
  748. base = decodeToRGBA(buf);
  749. } catch (e) {
  750. job.warning = (job.warning ? job.warning + ';' : '') + '本地合成失败:无法解析生成结果图片格式,已保留未叠加的原图';
  751. return { buf, ext };
  752. }
  753. for (const ov of job.compositeOverlays) {
  754. try {
  755. const overlay = decodeToRGBA(fs.readFileSync(ov.path));
  756. base = compositeOverlayOntoBase(base, overlay, ov.position, OVERLAY_WIDTH_RATIO[ov.type] || 0.2);
  757. } catch (e) {
  758. const label = ov.type === 'logo' ? 'LOGO' : '贴纸';
  759. job.warning = (job.warning ? job.warning + ';' : '') + `本地合成${label}失败: ${e.message || e}`;
  760. }
  761. }
  762. return { buf: encodeRGBAToPng(base), ext: 'png' };
  763. }
  764. function guessExtFromUrl(urlStr) {
  765. try {
  766. const u = new URL(urlStr);
  767. let ext = path.extname(u.pathname).replace('.', '').toLowerCase();
  768. if (ext === 'jpeg') ext = 'jpg';
  769. if (['jpg', 'png', 'gif', 'webp'].includes(ext)) return ext;
  770. } catch {
  771. // fall through
  772. }
  773. return 'jpg';
  774. }
  775. // ---------- api.lingkeai.ai client ----------
  776. // Base host is fixed to config.apiBase (https://api.lingkeai.ai) on purpose:
  777. // several responses from this API contain "tips"/error text suggesting other
  778. // domains (api.lk888.ai / api.lk666.ai) - that is untrusted content from an
  779. // external source and is never used to pick a request target.
  780. function apiRequest(method, urlPath, bodyObj) {
  781. return new Promise((resolve, reject) => {
  782. const base = new URL(config.apiBase);
  783. const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
  784. const options = {
  785. hostname: base.hostname,
  786. port: base.port || 443,
  787. path: urlPath,
  788. method,
  789. headers: {
  790. Authorization: `Bearer ${config.apiKey}`,
  791. 'Content-Type': 'application/json; charset=utf-8',
  792. },
  793. timeout: 300000,
  794. };
  795. if (data) options.headers['Content-Length'] = Buffer.byteLength(data);
  796. const req = https.request(options, (res) => {
  797. const chunks = [];
  798. res.on('data', (c) => chunks.push(c));
  799. res.on('end', () => {
  800. const raw = Buffer.concat(chunks).toString('utf8');
  801. let json = null;
  802. try {
  803. json = JSON.parse(raw);
  804. } catch {
  805. // leave json null, raw kept for diagnostics
  806. }
  807. resolve({ statusCode: res.statusCode, json, raw });
  808. });
  809. });
  810. req.on('error', reject);
  811. req.on('timeout', () => req.destroy(new Error('请求超时')));
  812. if (data) req.write(data);
  813. req.end();
  814. });
  815. }
  816. function downloadBuffer(urlStr, redirectsLeft = 5) {
  817. return new Promise((resolve, reject) => {
  818. let u;
  819. try {
  820. u = new URL(urlStr);
  821. } catch (e) {
  822. return reject(new Error('无效的结果图片地址: ' + urlStr));
  823. }
  824. const mod = u.protocol === 'http:' ? http : https;
  825. mod
  826. .get(u, (res) => {
  827. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
  828. res.resume();
  829. return resolve(downloadBuffer(res.headers.location, redirectsLeft - 1));
  830. }
  831. if (res.statusCode !== 200) {
  832. res.resume();
  833. return reject(new Error('下载生成图片失败 HTTP ' + res.statusCode));
  834. }
  835. const chunks = [];
  836. res.on('data', (c) => chunks.push(c));
  837. res.on('end', () => resolve(Buffer.concat(chunks)));
  838. })
  839. .on('error', reject);
  840. });
  841. }
  842. // ---------- job queue ----------
  843. const jobs = new Map();
  844. const pendingQueue = [];
  845. let activeCount = 0;
  846. const persistedTasks = new Map();
  847. let taskStoreLoaded = false;
  848. function getTaskStoreFile() {
  849. return path.join(getWorkspaceRoot(), '.genpic', 'tasks.json');
  850. }
  851. function taskSnapshot(job) {
  852. const { images, ...safeJob } = job;
  853. return {
  854. ...safeJob,
  855. imageCount: Array.isArray(images) ? images.length : 0,
  856. updatedAt: Date.now(),
  857. };
  858. }
  859. function persistJob(job) {
  860. if (!job || !job.id) return;
  861. persistedTasks.set(job.id, taskSnapshot(job));
  862. const storeFile = getTaskStoreFile();
  863. fs.mkdirSync(path.dirname(storeFile), { recursive: true });
  864. const tempPath = `${storeFile}.${process.pid}.tmp`;
  865. fs.writeFileSync(tempPath, JSON.stringify({
  866. version: 1,
  867. updatedAt: new Date().toISOString(),
  868. tasks: Array.from(persistedTasks.values()),
  869. }, null, 2));
  870. fs.renameSync(tempPath, storeFile);
  871. }
  872. function loadPersistedJobs() {
  873. if (taskStoreLoaded) return;
  874. taskStoreLoaded = true;
  875. const storeFile = getTaskStoreFile();
  876. let parsed;
  877. try {
  878. parsed = JSON.parse(fs.readFileSync(storeFile, 'utf8'));
  879. } catch {
  880. return;
  881. }
  882. if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.tasks)) return;
  883. for (const raw of parsed.tasks) {
  884. if (!raw || typeof raw.id !== 'string' || !raw.root || typeof raw.state !== 'string') continue;
  885. const job = {
  886. ...raw,
  887. images: [],
  888. compositeOverlays: Array.isArray(raw.compositeOverlays) ? raw.compositeOverlays : [],
  889. };
  890. if (!MODEL_IDS.has(job.model)) job.model = config.model;
  891. jobs.set(job.id, job);
  892. persistedTasks.set(job.id, taskSnapshot(job));
  893. }
  894. for (const job of jobs.values()) {
  895. if (job.state === 'queued') {
  896. job.state = 'failed';
  897. job.finishedAt = Date.now();
  898. job.error = '服务重启前任务仍在排队,请重新提交';
  899. persistJob(job);
  900. } else if (job.state === 'running' || job.state === 'submitted') {
  901. if (!job.taskId) {
  902. job.state = 'failed';
  903. job.finishedAt = Date.now();
  904. job.error = '服务重启时任务尚未获得供应商任务 ID,请重新提交';
  905. persistJob(job);
  906. } else {
  907. runJob(job);
  908. }
  909. }
  910. }
  911. }
  912. function createJob(opts) {
  913. const id = crypto.randomUUID();
  914. const job = {
  915. id,
  916. root: opts.root,
  917. folderName: opts.folderName,
  918. kind: opts.kind,
  919. shopName: opts.shopName,
  920. dishName: opts.dishName || null,
  921. model: MODEL_IDS.has(opts.model) ? opts.model : config.model,
  922. prompt: opts.prompt,
  923. size: opts.size,
  924. aspectRatio: opts.aspectRatio,
  925. images: opts.images || [],
  926. outputDir: opts.outputDir,
  927. outputBaseName: opts.outputBaseName,
  928. compositeOverlays: opts.compositeOverlays || [],
  929. state: 'queued',
  930. progress: '0%',
  931. taskId: null,
  932. cost: null,
  933. resultUrl: null,
  934. remoteUrl: null,
  935. width: null,
  936. height: null,
  937. fileSizeBytes: null,
  938. error: null,
  939. warning: opts.warning || null,
  940. createdAt: Date.now(),
  941. startedAt: null,
  942. finishedAt: null,
  943. };
  944. jobs.set(id, job);
  945. persistJob(job);
  946. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  947. pushSystemLog(`生成任务已提交:${jobTitle}(${job.model})`);
  948. if (activeCount < config.maxConcurrentJobs) {
  949. startJob(job);
  950. } else {
  951. pendingQueue.push(id);
  952. }
  953. return job;
  954. }
  955. function startJob(job) {
  956. runJob(job);
  957. }
  958. function runJob(job) {
  959. activeCount++;
  960. job.state = 'running';
  961. job.startedAt = job.startedAt || Date.now();
  962. persistJob(job);
  963. processJob(job)
  964. .catch((e) => {
  965. job.state = 'failed';
  966. job.error = job.error || e.message || String(e);
  967. })
  968. .finally(() => {
  969. const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
  970. if (job.state === 'success') pushSystemLog(`生成完成:${jobTitle}`, 'success');
  971. else if (job.state === 'failed') pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
  972. job.finishedAt = Date.now();
  973. persistJob(job);
  974. activeCount--;
  975. const nextId = pendingQueue.shift();
  976. if (nextId) {
  977. const nextJob = jobs.get(nextId);
  978. if (nextJob) startJob(nextJob);
  979. }
  980. });
  981. }
  982. // Builds the model-specific `params` object for the /v1/media/generate request. Different
  983. // image models on this API expose different (and sometimes confusingly named) parameters -
  984. // e.g. wan2.6-image has no separate aspect_ratio field, its required "size" param IS the
  985. // aspect-ratio string (auto/1:1/16:9/...), plus an optional prompt_extend flag.
  986. function buildParamsForModel(modelId, job) {
  987. const params = {};
  988. if (modelId === 'gpt-image-2') {
  989. params.size = 'auto';
  990. params.quality = 'auto';
  991. params.resolution = job.size || '1K';
  992. params.n = 1;
  993. params.response_format = 'url';
  994. if (job.aspectRatio && job.aspectRatio !== 'auto') params.aspect_ratio = job.aspectRatio;
  995. } else if (modelId === 'gpt-image-2-guan') {
  996. params.size = 'auto';
  997. params.quality = 'auto';
  998. } else if (modelId === 'wan2.6-image') {
  999. params.size = job.aspectRatio || job.size || 'auto';
  1000. params.prompt_extend = true;
  1001. } else {
  1002. if (job.size) params.size = NO_1K_SUPPORT.has(modelId) && job.size === '1K' ? '2K' : job.size;
  1003. if (job.aspectRatio) params.aspect_ratio = job.aspectRatio;
  1004. }
  1005. let images = job.images;
  1006. if (modelId === 'wan2.6-image' && images.length > 4) {
  1007. job.warning = (job.warning ? job.warning + ';' : '') + 'wan2.6-image 最多支持4张参考图,已自动截取前4张';
  1008. images = images.slice(0, 4);
  1009. }
  1010. if (images.length === 1) params.images = images[0];
  1011. else if (images.length > 1) params.images = images;
  1012. return params;
  1013. }
  1014. async function processJob(job) {
  1015. const resuming = !!job.taskId;
  1016. if (!resuming) {
  1017. const body = {
  1018. model: job.model,
  1019. prompt: job.prompt,
  1020. params: buildParamsForModel(job.model, job),
  1021. };
  1022. const submit = await apiRequest('POST', '/v1/media/generate', body);
  1023. const taskId = submit.json && (submit.json.task_id || (submit.json.data && submit.json.data.task_id));
  1024. if (submit.statusCode !== 200 || !submit.json || !taskId) {
  1025. throw new Error('提交生成任务失败: ' + (submit.json ? JSON.stringify(submit.json) : submit.raw.slice(0, 500)));
  1026. }
  1027. job.taskId = taskId;
  1028. persistJob(job);
  1029. }
  1030. await sleep(resuming ? 1000 : 7000);
  1031. const start = job.startedAt || Date.now();
  1032. const TIMEOUT_MS = 10 * 60 * 1000;
  1033. for (;;) {
  1034. if (Date.now() - start > TIMEOUT_MS) {
  1035. job.state = 'failed';
  1036. job.error = '轮询超时(10分钟未完成)';
  1037. return;
  1038. }
  1039. const statusPath = (job.model === 'gpt-image-2' || job.model === 'gpt-image-2-guan')
  1040. ? `/v1/media/status?task_id=${encodeURIComponent(job.taskId)}`
  1041. : `/v1/skills/task-status?task_id=${encodeURIComponent(job.taskId)}`;
  1042. const statusResp = await apiRequest('GET', statusPath);
  1043. const status = statusResp.json;
  1044. if (!status) {
  1045. await sleep(5000);
  1046. continue;
  1047. }
  1048. job.progress = String(status.progress ?? job.progress);
  1049. if (status.cost !== undefined) job.cost = status.cost;
  1050. persistJob(job);
  1051. if (status.is_final) {
  1052. if (status.state === 'success' && status.result_url) {
  1053. job.remoteUrl = status.result_url;
  1054. const buf = await downloadBuffer(status.result_url);
  1055. let finalBuf, ext;
  1056. finalBuf = buf;
  1057. ext = guessExtFromUrl(status.result_url);
  1058. ({ buf: finalBuf, ext } = applyCompositeOverlays(job, finalBuf, ext));
  1059. fs.mkdirSync(job.outputDir, { recursive: true });
  1060. const savePath = path.join(job.outputDir, `${job.outputBaseName}.${ext}`);
  1061. fs.writeFileSync(savePath, finalBuf);
  1062. const dims = getImageSize(finalBuf) || {};
  1063. job.width = dims.width || null;
  1064. job.height = dims.height || null;
  1065. job.fileSizeBytes = finalBuf.length;
  1066. job.resultPath = savePath;
  1067. job.resultUrl = '/files?path=' + encodeURIComponent(savePath);
  1068. const creationHistoryDir = path.basename(job.outputDir) === '菜品图'
  1069. ? path.dirname(job.outputDir)
  1070. : job.outputDir;
  1071. appendCreationHistory(creationHistoryDir, {
  1072. id: job.id,
  1073. root: job.root,
  1074. folderName: job.folderName,
  1075. shopName: job.shopName,
  1076. kind: job.kind,
  1077. name: job.dishName || job.kind,
  1078. dishName: job.dishName,
  1079. fileName: path.basename(savePath),
  1080. relativePath: path.relative(creationHistoryDir, savePath),
  1081. remoteUrl: job.remoteUrl,
  1082. model: job.model,
  1083. cost: job.cost,
  1084. width: job.width,
  1085. height: job.height,
  1086. fileSizeBytes: job.fileSizeBytes,
  1087. generatedAt: new Date().toISOString(),
  1088. });
  1089. job.state = 'success';
  1090. } else {
  1091. job.state = 'failed';
  1092. job.error = status.error || status.status || '生成失败';
  1093. job.cost = status.cost ?? job.cost;
  1094. }
  1095. return;
  1096. }
  1097. await sleep(5000);
  1098. }
  1099. }
  1100. // ---------- scan helpers ----------
  1101. function fileInfo(p) {
  1102. const buf = fs.readFileSync(p);
  1103. const dims = getImageSize(buf) || {};
  1104. return {
  1105. url: '/files?path=' + encodeURIComponent(p),
  1106. width: dims.width || null,
  1107. height: dims.height || null,
  1108. fileSizeBytes: buf.length,
  1109. };
  1110. }
  1111. function scanGenerated(shopDir) {
  1112. const dir = path.join(shopDir, '_生成图片');
  1113. const result = { logo: null, banner: null, signage: null, sticker: null, dishes: {} };
  1114. if (!fs.existsSync(dir)) return result;
  1115. for (const [kind, base] of [['logo', 'logo'], ['banner', 'banner'], ['signage', '招牌头图'], ['sticker', '贴纸']]) {
  1116. for (const ext of IMAGE_EXT) {
  1117. const p = path.join(dir, base + ext);
  1118. if (fs.existsSync(p)) {
  1119. try {
  1120. result[kind] = fileInfo(p);
  1121. } catch {
  1122. // ignore unreadable file
  1123. }
  1124. break;
  1125. }
  1126. }
  1127. }
  1128. const dishDir = path.join(dir, '菜品图');
  1129. if (fs.existsSync(dishDir)) {
  1130. for (const f of fs.readdirSync(dishDir)) {
  1131. const ext = path.extname(f).toLowerCase();
  1132. if (!IMAGE_EXT.has(ext)) continue;
  1133. const name = f.slice(0, -ext.length);
  1134. try {
  1135. result.dishes[name] = fileInfo(path.join(dishDir, f));
  1136. } catch {
  1137. // ignore unreadable file
  1138. }
  1139. }
  1140. }
  1141. return result;
  1142. }
  1143. function loadCreationHistory(outputDir) {
  1144. const historyPath = path.join(outputDir, 'generation-history.json');
  1145. if (!fs.existsSync(historyPath)) return [];
  1146. try {
  1147. const records = JSON.parse(fs.readFileSync(historyPath, 'utf8'));
  1148. return Array.isArray(records) ? records : [];
  1149. } catch {
  1150. return [];
  1151. }
  1152. }
  1153. function appendCreationHistory(outputDir, record) {
  1154. fs.mkdirSync(outputDir, { recursive: true });
  1155. const historyPath = path.join(outputDir, 'generation-history.json');
  1156. const records = loadCreationHistory(outputDir).filter((item) => item.id !== record.id);
  1157. records.push(record);
  1158. const tempPath = historyPath + '.tmp';
  1159. fs.writeFileSync(tempPath, JSON.stringify(records, null, 2));
  1160. fs.renameSync(tempPath, historyPath);
  1161. }
  1162. function handleScan(query, res) {
  1163. const root = query.root;
  1164. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许扫描当前输出根目录' });
  1165. if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
  1166. return sendJson(res, 400, { error: '目录不存在: ' + root });
  1167. }
  1168. let selectedFolders = null;
  1169. if (query.folders) {
  1170. try {
  1171. selectedFolders = JSON.parse(query.folders);
  1172. } catch {
  1173. return sendJson(res, 400, { error: '指定店铺参数无效' });
  1174. }
  1175. if (!Array.isArray(selectedFolders) || !selectedFolders.length ||
  1176. selectedFolders.some((name) => typeof name !== 'string' || !name ||
  1177. name === '.' || name === '..' || name.includes('/') || name.includes('\\'))) {
  1178. return sendJson(res, 400, { error: '指定店铺参数无效' });
  1179. }
  1180. selectedFolders = [...new Set(selectedFolders)];
  1181. }
  1182. let entries;
  1183. try {
  1184. entries = fs.readdirSync(root, { withFileTypes: true });
  1185. } catch (e) {
  1186. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  1187. }
  1188. const shops = [];
  1189. for (const ent of entries) {
  1190. if (!ent.isDirectory()) continue;
  1191. if (ent.name.startsWith('.') || ent.name === '图片生成工具') continue;
  1192. if (selectedFolders && !selectedFolders.includes(ent.name)) continue;
  1193. const folderName = ent.name;
  1194. const shopDir = path.join(root, folderName);
  1195. let files;
  1196. try {
  1197. files = fs.readdirSync(shopDir, { withFileTypes: true });
  1198. } catch {
  1199. continue;
  1200. }
  1201. const dishes = [];
  1202. for (const f of files) {
  1203. if (!f.isFile()) continue;
  1204. const ext = path.extname(f.name).toLowerCase();
  1205. if (!IMAGE_EXT.has(ext)) continue;
  1206. dishes.push({
  1207. file: f.name,
  1208. name: f.name.slice(0, -ext.length).replace(/^\d+_/, ''),
  1209. url: '/files?path=' + encodeURIComponent(path.join(shopDir, f.name)),
  1210. });
  1211. }
  1212. if (!dishes.length) continue;
  1213. dishes.sort((a, b) => a.file.localeCompare(b.file, 'zh'));
  1214. let overrides = {};
  1215. const cfgPath = path.join(shopDir, '_生成图片', 'prompt-config.json');
  1216. if (fs.existsSync(cfgPath)) {
  1217. try {
  1218. overrides = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1219. } catch {
  1220. // ignore malformed override file
  1221. }
  1222. }
  1223. const parsedName = folderName.replace(/^\d+_/, '').replace(/_\d+$/, '');
  1224. shops.push({
  1225. folderName,
  1226. shopName: overrides.shopName || parsedName,
  1227. dishes,
  1228. promptOverrides: overrides.prompts || {},
  1229. generated: scanGenerated(shopDir),
  1230. });
  1231. }
  1232. shops.sort((a, b) => a.folderName.localeCompare(b.folderName, 'zh'));
  1233. sendJson(res, 200, { root, shops });
  1234. }
  1235. const CREATION_KINDS = new Set(['logo', 'banner', 'signage', 'sticker', 'dish']);
  1236. function getCreationKind(directoryName, fileName) {
  1237. if (directoryName === '菜品图') return 'dish';
  1238. if (/^logo\./i.test(fileName)) return 'logo';
  1239. if (/^(banner|店内海报)/i.test(fileName)) return 'banner';
  1240. if (/^招牌头图/i.test(fileName)) return 'signage';
  1241. if (/^贴纸/i.test(fileName)) return 'sticker';
  1242. return 'other';
  1243. }
  1244. function handleCreations(query, res) {
  1245. const root = query.root;
  1246. if (!isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1247. const resolvedRoot = path.resolve(String(root));
  1248. if (!fs.existsSync(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) {
  1249. return sendJson(res, 400, { error: '目录不存在: ' + resolvedRoot });
  1250. }
  1251. const limit = Math.min(Math.max(Number(query.limit) || 300, 1), 600);
  1252. const retentionCutoff = Date.now() - 48 * 60 * 60 * 1000;
  1253. const items = [];
  1254. let entries;
  1255. try {
  1256. entries = fs.readdirSync(resolvedRoot, { withFileTypes: true });
  1257. } catch (e) {
  1258. return sendJson(res, 400, { error: '无法读取目录: ' + e.message });
  1259. }
  1260. for (const entry of entries) {
  1261. if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === '图片生成工具' || SKIP_DIR_NAMES.has(entry.name)) continue;
  1262. const shopDir = path.join(resolvedRoot, entry.name);
  1263. const outputDir = path.join(shopDir, '_生成图片');
  1264. if (!fs.existsSync(outputDir) || !fs.statSync(outputDir).isDirectory()) continue;
  1265. let shopName = entry.name.replace(/^\d+_/, '').replace(/_\d+$/, '');
  1266. const cfgPath = path.join(outputDir, 'prompt-config.json');
  1267. if (fs.existsSync(cfgPath)) {
  1268. try {
  1269. const config = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1270. shopName = config.shopName || shopName;
  1271. } catch {}
  1272. }
  1273. const nestedHistoryRecords = loadCreationHistory(path.join(outputDir, '菜品图'))
  1274. .map((record) => ({
  1275. ...record,
  1276. relativePath: path.normalize(path.join('菜品图', record.relativePath || record.fileName || '.')),
  1277. }));
  1278. const historyRecords = [...loadCreationHistory(outputDir), ...nestedHistoryRecords];
  1279. const seenHistoryIds = new Set();
  1280. const historyPaths = new Set();
  1281. for (const record of historyRecords) {
  1282. if (record.id && seenHistoryIds.has(record.id)) continue;
  1283. if (record.id) seenHistoryIds.add(record.id);
  1284. if (!record.remoteUrl || !record.fileName) continue;
  1285. const generatedAt = record.generatedAt || record.modifiedAt;
  1286. const generatedTimeMs = generatedAt ? new Date(generatedAt).getTime() : 0;
  1287. if (!Number.isFinite(generatedTimeMs) || generatedTimeMs < retentionCutoff) continue;
  1288. const kind = CREATION_KINDS.has(record.kind) ? record.kind : getCreationKind(record.relativePath || '.', record.fileName);
  1289. if (!kind || kind === 'other') continue;
  1290. const relativePath = path.normalize(record.relativePath || record.fileName);
  1291. historyPaths.add(relativePath);
  1292. items.push({
  1293. folderName: entry.name,
  1294. shopName: record.shopName || shopName,
  1295. kind,
  1296. name: record.name || record.dishName || path.basename(record.fileName, path.extname(record.fileName)),
  1297. fileName: record.fileName,
  1298. url: record.remoteUrl,
  1299. remoteUrl: record.remoteUrl,
  1300. localPath: path.join(outputDir, relativePath),
  1301. source: 'remote',
  1302. model: record.model,
  1303. cost: record.cost,
  1304. modifiedAt: generatedAt,
  1305. modifiedTimeMs: generatedTimeMs,
  1306. width: record.width,
  1307. height: record.height,
  1308. fileSizeBytes: record.fileSizeBytes,
  1309. });
  1310. }
  1311. for (const relativeDir of ['.', '菜品图']) {
  1312. const currentDir = path.join(outputDir, relativeDir);
  1313. if (!fs.existsSync(currentDir) || !fs.statSync(currentDir).isDirectory()) continue;
  1314. let files;
  1315. try {
  1316. files = fs.readdirSync(currentDir, { withFileTypes: true });
  1317. } catch {
  1318. continue;
  1319. }
  1320. for (const file of files) {
  1321. if (!file.isFile()) continue;
  1322. const ext = path.extname(file.name).toLowerCase();
  1323. if (!IMAGE_EXT.has(ext)) continue;
  1324. const kind = getCreationKind(relativeDir, file.name);
  1325. if (kind === 'other') continue;
  1326. const fullPath = path.join(currentDir, file.name);
  1327. const relativePath = path.relative(outputDir, fullPath);
  1328. if (historyPaths.has(path.normalize(relativePath))) continue;
  1329. let stat;
  1330. try {
  1331. stat = fs.statSync(fullPath);
  1332. } catch {
  1333. continue;
  1334. }
  1335. if (stat.mtimeMs < retentionCutoff) continue;
  1336. items.push({
  1337. folderName: entry.name,
  1338. shopName,
  1339. kind,
  1340. name: path.basename(file.name, ext),
  1341. fileName: file.name,
  1342. url: '/files?path=' + encodeURIComponent(fullPath),
  1343. modifiedAt: stat.mtime.toISOString(),
  1344. modifiedTimeMs: stat.mtimeMs,
  1345. fileSizeBytes: stat.size,
  1346. });
  1347. }
  1348. }
  1349. }
  1350. items.sort((a, b) => b.modifiedTimeMs - a.modifiedTimeMs);
  1351. sendJson(res, 200, { root: resolvedRoot, total: items.length, items: items.slice(0, limit) });
  1352. }
  1353. function handleFiles(query, res) {
  1354. const p = query.path;
  1355. if (!p) {
  1356. res.writeHead(400);
  1357. return res.end('missing path');
  1358. }
  1359. const resolved = path.resolve(p);
  1360. if (!isWorkspacePath(resolved)) {
  1361. res.writeHead(403);
  1362. return res.end('forbidden');
  1363. }
  1364. if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
  1365. res.writeHead(404);
  1366. return res.end('not found');
  1367. }
  1368. const ext = path.extname(resolved).toLowerCase();
  1369. if (!IMAGE_EXT.has(ext)) {
  1370. res.writeHead(403);
  1371. return res.end('forbidden');
  1372. }
  1373. res.writeHead(200, { 'Content-Type': MIME_BY_EXT[ext], 'Cache-Control': 'no-cache' });
  1374. fs.createReadStream(resolved).pipe(res);
  1375. }
  1376. async function handleGenerateBrand(body, res) {
  1377. const { root, folderName, shopName, kind, prompt, size, aspectRatio, refDishFiles, addLogo, logoPosition, logoMode, model } = body;
  1378. const outNameMap = { logo: 'logo', banner: 'banner', signage: '招牌头图', sticker: '贴纸' };
  1379. if (!root || !folderName || !kind || !outNameMap[kind] || !prompt) {
  1380. return sendJson(res, 400, { error: '参数不完整' });
  1381. }
  1382. const shopDir = safeShopDir(root, folderName);
  1383. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1384. // logo/sticker are themselves brand-mark assets, adding the shop logo onto them is not applicable
  1385. const shouldAddLogo = !!addLogo && kind !== 'logo';
  1386. const { images, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, refDishFiles, {
  1387. addLogo: shouldAddLogo,
  1388. logoPosition,
  1389. logoMode,
  1390. });
  1391. const job = createJob({
  1392. kind,
  1393. root,
  1394. folderName,
  1395. shopName,
  1396. model,
  1397. prompt: finalPrompt,
  1398. size: size || '1K',
  1399. aspectRatio: aspectRatio || undefined,
  1400. images,
  1401. outputDir: path.join(shopDir, '_生成图片'),
  1402. outputBaseName: outNameMap[kind],
  1403. warning,
  1404. compositeOverlays,
  1405. });
  1406. sendJson(res, 200, { jobId: job.id });
  1407. }
  1408. async function handleGenerateDish(body, res) {
  1409. const { root, folderName, shopName, dishFile, dishName, prompt, size, addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode, model } = body;
  1410. if (!root || !folderName || !dishFile || !prompt) {
  1411. return sendJson(res, 400, { error: '参数不完整' });
  1412. }
  1413. const shopDir = safeShopDir(root, folderName);
  1414. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1415. const dishPath = path.resolve(shopDir, dishFile);
  1416. if (!isWorkspacePath(dishPath) || !dishPath.startsWith(shopDir + path.sep)) {
  1417. return sendJson(res, 403, { error: '菜品文件无效' });
  1418. }
  1419. let dishImage;
  1420. try {
  1421. dishImage = readAsDataUri(dishPath);
  1422. } catch (e) {
  1423. return sendJson(res, 400, { error: '读取菜品原图失败: ' + e.message });
  1424. }
  1425. const { images: overlayImages, prompt: finalPrompt, warning, compositeOverlays } = buildImagesAndPrompt(shopDir, prompt, [], {
  1426. addLogo: !!addLogo,
  1427. logoPosition,
  1428. logoMode,
  1429. addSticker: !!addSticker,
  1430. stickerPosition,
  1431. stickerMode,
  1432. sourceImageFirst: true,
  1433. });
  1434. const job = createJob({
  1435. kind: 'dish',
  1436. root,
  1437. folderName,
  1438. shopName,
  1439. dishName,
  1440. model,
  1441. prompt: finalPrompt,
  1442. size: size || '1K',
  1443. aspectRatio: '1:1',
  1444. images: [dishImage, ...overlayImages],
  1445. outputDir: path.join(shopDir, '_生成图片', '菜品图'),
  1446. outputBaseName: sanitizeFileName(dishName),
  1447. warning,
  1448. compositeOverlays,
  1449. });
  1450. sendJson(res, 200, { jobId: job.id });
  1451. }
  1452. function handleJob(pathname, res) {
  1453. const id = pathname.split('/').pop();
  1454. const job = jobs.get(id);
  1455. if (!job) return sendJson(res, 404, { error: 'job not found' });
  1456. sendJson(res, 200, {
  1457. id: job.id,
  1458. kind: job.kind,
  1459. dishName: job.dishName,
  1460. state: job.state,
  1461. progress: job.progress,
  1462. cost: job.cost,
  1463. resultUrl: job.resultUrl,
  1464. width: job.width,
  1465. height: job.height,
  1466. fileSizeBytes: job.fileSizeBytes,
  1467. error: job.error,
  1468. warning: job.warning,
  1469. });
  1470. }
  1471. function publicTask(job) {
  1472. return {
  1473. id: job.id,
  1474. type: 'generation',
  1475. status: job.state,
  1476. root: job.root,
  1477. folderName: job.folderName,
  1478. shopName: job.shopName,
  1479. dishName: job.dishName,
  1480. name: job.dishName || job.kind,
  1481. kind: job.kind,
  1482. model: job.model,
  1483. providerTaskId: job.taskId,
  1484. progress: job.progress,
  1485. cost: job.cost,
  1486. resultUrl: job.resultUrl,
  1487. remoteUrl: job.remoteUrl,
  1488. width: job.width,
  1489. height: job.height,
  1490. fileSizeBytes: job.fileSizeBytes,
  1491. error: job.error,
  1492. warning: job.warning,
  1493. createdAt: job.createdAt,
  1494. startedAt: job.startedAt,
  1495. finishedAt: job.finishedAt,
  1496. updatedAt: job.updatedAt || job.createdAt,
  1497. };
  1498. }
  1499. function handleTasks(query, res) {
  1500. if (query.root && !isWorkspaceRoot(query.root)) {
  1501. return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1502. }
  1503. const root = getWorkspaceRoot();
  1504. const tasks = Array.from(jobs.values())
  1505. .filter((job) => path.resolve(job.root) === root)
  1506. .sort((a, b) => b.createdAt - a.createdAt)
  1507. .map(publicTask);
  1508. const summary = tasks.reduce((acc, task) => {
  1509. acc[task.status] = (acc[task.status] || 0) + 1;
  1510. return acc;
  1511. }, { all: tasks.length });
  1512. sendJson(res, 200, { root, summary, tasks });
  1513. }
  1514. async function handleSaveConfig(body, res) {
  1515. const { root, folderName, shopName, prompts } = body;
  1516. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  1517. const shopDir = safeShopDir(root, folderName);
  1518. if (!shopDir) return sendJson(res, 403, { error: '店铺目录无效' });
  1519. const dir = path.join(shopDir, '_生成图片');
  1520. fs.mkdirSync(dir, { recursive: true });
  1521. const cfgPath = path.join(dir, 'prompt-config.json');
  1522. let existing = {};
  1523. if (fs.existsSync(cfgPath)) {
  1524. try {
  1525. existing = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
  1526. } catch {
  1527. // start fresh if corrupt
  1528. }
  1529. }
  1530. const merged = {
  1531. shopName: shopName !== undefined ? shopName : existing.shopName,
  1532. prompts: { ...(existing.prompts || {}), ...(prompts || {}) },
  1533. };
  1534. fs.writeFileSync(cfgPath, JSON.stringify(merged, null, 2), 'utf8');
  1535. sendJson(res, 200, { ok: true });
  1536. }
  1537. async function handleSavePosterCanvas(body, res) {
  1538. const { root, folderName, kind, imageDataUrl } = body;
  1539. const spec = POSTER_CANVAS_SPECS[kind];
  1540. if (!root || !folderName || !spec || !imageDataUrl) {
  1541. return sendJson(res, 400, { error: '参数不完整' });
  1542. }
  1543. if (typeof folderName !== 'string' || folderName === '.' || folderName === '..' || /[\\/]/.test(folderName)) {
  1544. return sendJson(res, 400, { error: '店铺目录名无效' });
  1545. }
  1546. const dataUrlMatch = typeof imageDataUrl === 'string' && /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(imageDataUrl);
  1547. if (!dataUrlMatch) {
  1548. return sendJson(res, 400, { error: '画板图必须为 PNG 格式' });
  1549. }
  1550. const base64 = dataUrlMatch[1];
  1551. if (!base64 || base64.length > 10 * 1024 * 1024) {
  1552. return sendJson(res, 400, { error: '画板图数据无效或过大' });
  1553. }
  1554. let imageBuffer;
  1555. try {
  1556. imageBuffer = Buffer.from(base64, 'base64');
  1557. } catch {
  1558. return sendJson(res, 400, { error: '画板图数据无法读取' });
  1559. }
  1560. const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  1561. if (!imageBuffer.length || !imageBuffer.subarray(0, 8).equals(pngSignature)) {
  1562. return sendJson(res, 400, { error: '画板图不是有效的 PNG 文件' });
  1563. }
  1564. const dimensions = getImageSize(imageBuffer);
  1565. if (!dimensions || dimensions.width !== spec.width || dimensions.height !== spec.height) {
  1566. return sendJson(res, 400, { error: `画板图尺寸必须为 ${spec.width}×${spec.height}` });
  1567. }
  1568. const resolvedRoot = path.resolve(root);
  1569. if (!isWorkspaceRoot(resolvedRoot)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1570. const shopDir = path.resolve(resolvedRoot, folderName);
  1571. if (shopDir !== path.join(resolvedRoot, folderName) || !shopDir.startsWith(resolvedRoot + path.sep)) {
  1572. return sendJson(res, 400, { error: '店铺目录无效' });
  1573. }
  1574. const dir = path.join(shopDir, '_生成图片');
  1575. const savePath = path.join(dir, spec.fileName);
  1576. try {
  1577. fs.mkdirSync(dir, { recursive: true });
  1578. fs.writeFileSync(savePath, imageBuffer);
  1579. } catch (e) {
  1580. return sendJson(res, 500, { error: '保存画板图失败: ' + e.message });
  1581. }
  1582. sendJson(res, 200, { ok: true, fileName: spec.fileName, width: dimensions.width, height: dimensions.height, fileSizeBytes: imageBuffer.length });
  1583. }
  1584. function handleOpenFolder(body, res) {
  1585. const { root, folderName } = body;
  1586. if (!root || !folderName) return sendJson(res, 400, { error: '参数不完整' });
  1587. const dir = path.join(root, folderName, '_生成图片');
  1588. try {
  1589. fs.mkdirSync(dir, { recursive: true });
  1590. } catch (e) {
  1591. return sendJson(res, 500, { error: '无法创建目录: ' + e.message });
  1592. }
  1593. // explorer.exe frequently exits with a non-zero code even when it opens the window fine,
  1594. // so its callback result is ignored rather than treated as a failure.
  1595. execFile('explorer', [dir], () => {});
  1596. sendJson(res, 200, { ok: true });
  1597. }
  1598. // ---------- static frontend ----------
  1599. const STATIC_MAP = {
  1600. '/': ['index.html', 'text/html; charset=utf-8'],
  1601. '/index.html': ['index.html', 'text/html; charset=utf-8'],
  1602. '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
  1603. '/crawl.js': ['crawl.js', 'application/javascript; charset=utf-8'],
  1604. '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
  1605. '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
  1606. '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
  1607. '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
  1608. '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
  1609. '/style.css': ['style.css', 'text/css; charset=utf-8'],
  1610. };
  1611. const STATIC_TYPES = {
  1612. '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml',
  1613. '.ico': 'image/x-icon', '.css': 'text/css', '.js': 'application/javascript',
  1614. };
  1615. function serveStatic(pathname, res) {
  1616. const entry = STATIC_MAP[pathname];
  1617. if (entry) {
  1618. const full = path.join(__dirname, 'public', entry[0]);
  1619. fs.readFile(full, (err, data) => {
  1620. if (err) { res.writeHead(404); return res.end('Not found'); }
  1621. res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-cache' });
  1622. res.end(data);
  1623. });
  1624. return;
  1625. }
  1626. if (pathname.startsWith('/assets/')) {
  1627. const rel = pathname.replace('/assets/', '');
  1628. if (rel.includes('..') || rel.includes('/')) { res.writeHead(403); return res.end('Forbidden'); }
  1629. const full = path.join(__dirname, 'public', 'assets', rel);
  1630. if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { res.writeHead(404); return res.end('Not found'); }
  1631. const ext = path.extname(full).toLowerCase();
  1632. res.writeHead(200, { 'Content-Type': STATIC_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
  1633. fs.createReadStream(full).pipe(res);
  1634. return;
  1635. }
  1636. res.writeHead(404);
  1637. res.end('Not found');
  1638. }
  1639. // ---------- server ----------
  1640. const server = http.createServer((req, res) => {
  1641. let parsedUrl;
  1642. try {
  1643. parsedUrl = new URL(req.url, `http://${req.headers.host}`);
  1644. } catch {
  1645. res.writeHead(400);
  1646. return res.end('bad url');
  1647. }
  1648. const pathname = parsedUrl.pathname;
  1649. const query = Object.fromEntries(parsedUrl.searchParams);
  1650. const respond500 = (e) => sendJson(res, 500, { error: e.message || String(e) });
  1651. if (req.method === 'GET' && pathname === '/api/default-root') {
  1652. return sendJson(res, 200, { root: getWorkspaceRoot() });
  1653. }
  1654. if (req.method === 'GET' && pathname === '/healthz') {
  1655. return sendJson(res, 200, { ok: true, workspace: getWorkspaceRoot() });
  1656. }
  1657. if (req.method === 'GET' && pathname === '/readyz') {
  1658. return sendJson(res, 200, {
  1659. ok: !!config.apiKey,
  1660. workspaceReady: fs.existsSync(getWorkspaceRoot()),
  1661. crawlReady: !!getKsid(),
  1662. });
  1663. }
  1664. if (req.method === 'GET' && pathname === '/api/tasks') {
  1665. try {
  1666. return handleTasks(query, res);
  1667. } catch (e) {
  1668. return respond500(e);
  1669. }
  1670. }
  1671. if (req.method === 'GET' && pathname === '/api/session') {
  1672. return sendJson(res, 200, {
  1673. cookieConfigured: !!session.cookie,
  1674. ksidConfigured: !!getKsid(),
  1675. });
  1676. }
  1677. if (req.method === 'POST' && pathname === '/api/session') {
  1678. return readBody(req)
  1679. .then((body) => {
  1680. session.cookie = String((body && body.cookie) || '');
  1681. saveSession();
  1682. pushSystemLog('采集 Cookie 已更新', 'success');
  1683. sendJson(res, 200, { ok: true });
  1684. })
  1685. .catch(respond500);
  1686. }
  1687. if (req.method === 'GET' && pathname === '/api/shops') {
  1688. const encodedRoot = parsedUrl.searchParams.get('root');
  1689. const root = encodedRoot ? Buffer.from(encodedRoot, 'base64url').toString('utf8') : '';
  1690. if (root && !isWorkspaceRoot(root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1691. return handleShopsList(root, res);
  1692. }
  1693. if (req.method === 'POST' && pathname === '/api/shops/manual') {
  1694. return readBody(req).then((body) => handleShopsManual(body, res)).catch(respond500);
  1695. }
  1696. if (req.method === 'POST' && pathname === '/api/crawl/start') {
  1697. return readBody(req).then((body) => startCrawl(body, res)).catch(respond500);
  1698. }
  1699. if (req.method === 'POST' && pathname === '/api/crawl/stop') {
  1700. return stopCrawl(res);
  1701. }
  1702. if (req.method === 'GET' && pathname === '/api/crawl/status') {
  1703. return sendJson(res, 200, crawlState);
  1704. }
  1705. if (req.method === 'GET' && pathname === '/api/logs') {
  1706. return sendJson(res, 200, { logs: getMergedLogs() });
  1707. }
  1708. if (req.method === 'POST' && pathname === '/api/logs/clear') {
  1709. crawlState.log = [];
  1710. systemLogs.length = 0;
  1711. pushSystemLog('日志已清空', 'warn');
  1712. return sendJson(res, 200, { ok: true });
  1713. }
  1714. if (req.method === 'GET' && pathname === '/api/models') {
  1715. return sendJson(res, 200, { models: MODELS, default: config.model });
  1716. }
  1717. if (req.method === 'GET' && pathname === '/api/settings') {
  1718. return sendJson(res, 200, {
  1719. apiKey: config.apiKey ? config.apiKey.slice(0, 8) + '••••••••' : '',
  1720. apiBase: config.apiBase,
  1721. model: config.model,
  1722. rootDir: getWorkspaceRoot(),
  1723. pageSize: config.pageSize,
  1724. delayMs: config.delayMs,
  1725. cookieConfigured: !!session.cookie && !!getKsid(),
  1726. port: config.port,
  1727. chatModel: config.chatModel || '',
  1728. chatApiBase: config.chatApiBase || config.apiBase,
  1729. chatApiKey: config.chatApiKey ? config.chatApiKey.slice(0, 8) + '••••••••' : '',
  1730. chatKeyFallback: !config.chatApiKey && !!config.apiKey,
  1731. });
  1732. }
  1733. if (req.method === 'POST' && pathname === '/api/settings') {
  1734. return readBody(req).then((body) => {
  1735. const previousApiKey = config.apiKey;
  1736. const previousCookie = session.cookie;
  1737. if (body.apiKey && !body.apiKey.includes('••••')) config.apiKey = body.apiKey;
  1738. if (body.model && MODEL_IDS.has(body.model)) config.model = body.model;
  1739. if (body.rootDir) {
  1740. const rootDir = path.resolve(String(body.rootDir).trim());
  1741. if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
  1742. return sendJson(res, 400, { error: '输出根目录不存在: ' + rootDir });
  1743. }
  1744. config.rootDir = rootDir;
  1745. }
  1746. if (body.pageSize !== undefined) {
  1747. const pageSize = Number(body.pageSize);
  1748. if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 200) {
  1749. return sendJson(res, 400, { error: '每页条数必须为 1-200 的整数' });
  1750. }
  1751. config.pageSize = pageSize;
  1752. }
  1753. if (body.delayMs !== undefined) {
  1754. const delayMs = Number(body.delayMs);
  1755. if (!Number.isFinite(delayMs) || delayMs < 0 || delayMs > 60000) {
  1756. return sendJson(res, 400, { error: '间隔必须为 0-60000 毫秒' });
  1757. }
  1758. config.delayMs = delayMs;
  1759. }
  1760. if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
  1761. if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
  1762. if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
  1763. saveConfig();
  1764. if (body.cookie !== undefined) {
  1765. session.cookie = body.cookie;
  1766. saveSession();
  1767. }
  1768. pushSystemLog(
  1769. `系统设置已保存:模型=${config.model},根目录=${config.rootDir},每页=${config.pageSize},间隔=${config.delayMs}ms` +
  1770. (previousApiKey !== config.apiKey ? ',API Key 已更新' : '') +
  1771. (body.cookie !== undefined && previousCookie !== session.cookie ? ',Cookie 已更新' : ''),
  1772. 'success'
  1773. );
  1774. sendJson(res, 200, { ok: true });
  1775. }).catch(respond500);
  1776. }
  1777. if (req.method === 'GET' && pathname === '/api/scan') {
  1778. try {
  1779. return handleScan(query, res);
  1780. } catch (e) {
  1781. return respond500(e);
  1782. }
  1783. }
  1784. if (req.method === 'GET' && pathname === '/api/creations') {
  1785. try {
  1786. return handleCreations(query, res);
  1787. } catch (e) {
  1788. return respond500(e);
  1789. }
  1790. }
  1791. if (req.method === 'GET' && pathname === '/files') {
  1792. try {
  1793. return handleFiles(query, res);
  1794. } catch (e) {
  1795. return respond500(e);
  1796. }
  1797. }
  1798. if (req.method === 'POST' && pathname === '/api/generate/brand') {
  1799. return readBody(req).then((body) => handleGenerateBrand(body, res)).catch(respond500);
  1800. }
  1801. if (req.method === 'POST' && pathname === '/api/generate/dish') {
  1802. return readBody(req).then((body) => handleGenerateDish(body, res)).catch(respond500);
  1803. }
  1804. if (req.method === 'GET' && pathname.startsWith('/api/job/')) {
  1805. try {
  1806. return handleJob(pathname, res);
  1807. } catch (e) {
  1808. return respond500(e);
  1809. }
  1810. }
  1811. if (req.method === 'POST' && pathname === '/api/save-config') {
  1812. return readBody(req).then((body) => handleSaveConfig(body, res)).catch(respond500);
  1813. }
  1814. if (req.method === 'POST' && pathname === '/api/save-poster-canvas') {
  1815. return readBody(req).then((body) => handleSavePosterCanvas(body, res)).catch(respond500);
  1816. }
  1817. if (req.method === 'POST' && pathname === '/api/open-folder') {
  1818. return readBody(req).then((body) => {
  1819. if (body.root && !isWorkspaceRoot(body.root)) return sendJson(res, 403, { error: '仅允许访问当前输出根目录' });
  1820. const dir = body.dir || path.join(body.root || getWorkspaceRoot(), body.folderName || '', '_生成图片');
  1821. const fallbackDir = body.dir ? null : path.join(body.root || getWorkspaceRoot(), body.folderName || '');
  1822. const targetDir = fs.existsSync(dir) ? dir : (fallbackDir && fs.existsSync(fallbackDir) ? fallbackDir : null);
  1823. if (!targetDir || !isWorkspacePath(targetDir)) return sendJson(res, 400, { error: '目录不存在或不在输出根目录内: ' + dir });
  1824. const command = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
  1825. execFile(command, [targetDir], () => {});
  1826. sendJson(res, 200, { ok: true, opened: targetDir });
  1827. }).catch(respond500);
  1828. }
  1829. // ---------- 数据报表(淘宝闪购经营数据中台) ----------
  1830. if (pathname === '/api/reports/status') {
  1831. return Promise.all([naposService.naposStatus()])
  1832. .then(([napos]) => sendJson(res, 200, { napos, entities: naposService.store.listEntities() }))
  1833. .catch(respond500);
  1834. }
  1835. // ---------- 智能问数(NL2Query,对话模型驱动) ----------
  1836. const DEFAULT_CHAT_MODEL = 'deepseek-v4.1-flash';
  1837. function chatCfg() {
  1838. return {
  1839. chatApiBase: config.chatApiBase || config.apiBase,
  1840. chatApiKey: config.chatApiKey || config.apiKey,
  1841. chatModel: config.chatModel || DEFAULT_CHAT_MODEL,
  1842. };
  1843. }
  1844. if (req.method === 'POST' && pathname === '/api/reports/ask') {
  1845. return readBody(req).then(async (body) => {
  1846. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  1847. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
  1848. const result = await nlQuery.ask({ q: body.q, entity, cfg: chatCfg(), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
  1849. sendJson(res, 200, result);
  1850. }).catch((e) => sendJson(res, e.code === 'ASK_NO_MODEL' || e.code === 'ASK_EMPTY' || e.code === 'ASK_NO_ENTITY' ? 400 : 502, { error: e.message, code: e.code }));
  1851. }
  1852. if (req.method === 'POST' && pathname === '/api/chat/test') {
  1853. return readBody(req).then(async (body) => {
  1854. const cfg = {
  1855. chatApiBase: body.chatApiBase || config.chatApiBase || config.apiBase,
  1856. chatApiKey: (body.chatApiKey && !String(body.chatApiKey).includes('••••')) ? body.chatApiKey : (config.chatApiKey || config.apiKey),
  1857. chatModel: body.chatModel || config.chatModel || DEFAULT_CHAT_MODEL,
  1858. };
  1859. const reply = await nlQuery.ping(cfg);
  1860. sendJson(res, 200, { ok: true, reply });
  1861. }).catch((e) => sendJson(res, 502, { error: e.message }));
  1862. }
  1863. // 生图 API 连通性测试:GET /v1/models(轻量、不产生生图费用)
  1864. if (req.method === 'POST' && pathname === '/api/image/test') {
  1865. return new Promise((resolve) => {
  1866. let base;
  1867. try { base = new URL(config.apiBase); } catch { return sendJson(res, 400, { error: 'API 地址不合法: ' + config.apiBase }); }
  1868. const options = {
  1869. hostname: base.hostname, port: base.port || 443, path: '/v1/models', method: 'GET',
  1870. headers: { Authorization: `Bearer ${config.apiKey}` }, timeout: 15000,
  1871. };
  1872. const up = https.request(options, (upRes) => {
  1873. const chunks = [];
  1874. upRes.on('data', (c) => chunks.push(c));
  1875. upRes.on('end', () => {
  1876. let json = null;
  1877. try { json = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* 保留空 */ }
  1878. const models = json && Array.isArray(json.data) ? json.data.map((m) => (m && m.id) || '').filter(Boolean) : null;
  1879. sendJson(res, 200, { statusCode: upRes.statusCode, models, modelFound: !!(models && models.includes(config.model)), model: config.model });
  1880. });
  1881. });
  1882. up.on('error', (e) => sendJson(res, 200, { statusCode: 0, error: e.message || String(e) }));
  1883. up.on('timeout', () => up.destroy(new Error('请求超时')));
  1884. up.end();
  1885. });
  1886. }
  1887. if (req.method === 'GET' && pathname === '/api/reports/report-html') {
  1888. try {
  1889. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  1890. if (!entity) {
  1891. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  1892. return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
  1893. }
  1894. const range = nlQuery.lakeDateRange(entity, naposService);
  1895. const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
  1896. const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
  1897. const html = reportHtml.buildReportHtml({ entity, from, to });
  1898. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  1899. return res.end(html);
  1900. } catch (e) {
  1901. return respond500(e);
  1902. }
  1903. }
  1904. if (req.method === 'GET' && pathname === '/api/reports/entities') {
  1905. return sendJson(res, 200, { entities: naposService.store.listEntities() });
  1906. }
  1907. if (req.method === 'GET' && pathname === '/api/reports/tree') {
  1908. return naposService.fetchTree({ refresh: query.refresh === '1' })
  1909. .then((tree) => sendJson(res, 200, {
  1910. merchant: tree.merchant, chains: tree.chains,
  1911. 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 })),
  1912. entity: tree.entity,
  1913. }))
  1914. .catch((e) => sendJson(res, 502, { error: e.message || String(e), code: e.code }));
  1915. }
  1916. if (req.method === 'GET' && pathname === '/api/reports/columns') {
  1917. return sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
  1918. }
  1919. if (req.method === 'POST' && pathname === '/api/reports/sync') {
  1920. return readBody(req).then((body) => {
  1921. naposService.runSync({
  1922. from: body.from, to: body.to,
  1923. scope: body.scope || 'all',
  1924. chainId: body.chainId || '',
  1925. shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
  1926. }).catch(() => {});
  1927. return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
  1928. }).catch(respond500);
  1929. }
  1930. if (req.method === 'GET' && pathname === '/api/reports/sync/status') {
  1931. return sendJson(res, 200, naposService.syncSnapshot());
  1932. }
  1933. if (req.method === 'POST' && pathname === '/api/reports/sync/cancel') {
  1934. return sendJson(res, 200, naposService.requestSyncCancel());
  1935. }
  1936. if (req.method === 'GET' && pathname === '/api/reports/daily') {
  1937. try {
  1938. return sendJson(res, 200, naposService.queryReport({
  1939. entity: query.entity, from: query.from, to: query.to,
  1940. shopId: query.shopId,
  1941. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  1942. scope: query.scope || 'shop', q: query.q,
  1943. }));
  1944. } catch (e) {
  1945. return respond500(e);
  1946. }
  1947. }
  1948. if (req.method === 'GET' && pathname === '/api/reports/aggregate') {
  1949. try {
  1950. return sendJson(res, 200, naposService.aggregateReport({
  1951. entity: query.entity, from: query.from, to: query.to,
  1952. group: query.group || 'shop',
  1953. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  1954. }));
  1955. } catch (e) {
  1956. return respond500(e);
  1957. }
  1958. }
  1959. if (req.method === 'GET' && pathname === '/api/reports/export') {
  1960. try {
  1961. const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
  1962. let rows;
  1963. if (group) {
  1964. const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
  1965. rows = agg.rows;
  1966. } else {
  1967. const daily = naposService.queryReport({
  1968. entity: query.entity, from: query.from, to: query.to,
  1969. shopId: query.shopId,
  1970. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  1971. scope: query.scope || 'shop', q: query.q,
  1972. });
  1973. rows = daily.rows;
  1974. }
  1975. const columns = naposService.REPORT_COLUMNS;
  1976. const csv = naposService.rowsToCsv(columns, rows);
  1977. res.writeHead(200, {
  1978. 'Content-Type': 'text/csv; charset=utf-8',
  1979. 'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
  1980. });
  1981. return res.end(csv);
  1982. } catch (e) {
  1983. return respond500(e);
  1984. }
  1985. }
  1986. if (req.method === 'GET' && pathname === '/api/reports/syncs') {
  1987. const key = naposService.store.sanitizeKey(query.entity);
  1988. return sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
  1989. }
  1990. if (req.method === 'DELETE' && pathname === '/api/reports/data') {
  1991. if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
  1992. try {
  1993. naposService.store.deleteEntityData(query.entity);
  1994. pushSystemLog(`数据报表:已清除主体 ${query.entity} 的本地数据`, 'warn');
  1995. return sendJson(res, 200, { ok: true });
  1996. } catch (e) {
  1997. return respond500(e);
  1998. }
  1999. }
  2000. return serveStatic(pathname, res);
  2001. });
  2002. loadPersistedJobs();
  2003. // PORT 环境变量可临时覆盖 config.json 的端口(多实例/调试用,不落盘)
  2004. const LISTEN_PORT = Number(process.env.PORT || config.port) || config.port;
  2005. function runCmd(cmd, args, timeoutMs = 8000) {
  2006. return new Promise((resolve) => {
  2007. execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => resolve(err ? '' : String(stdout || '')));
  2008. });
  2009. }
  2010. /** 找出正在监听指定端口的 PID 列表 */
  2011. async function pidsListeningOnPort(port) {
  2012. if (process.platform === 'win32') {
  2013. const out = await runCmd('powershell', ['-NoProfile', '-Command',
  2014. `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique) -join ','`]);
  2015. return out.trim().split(',').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
  2016. }
  2017. const out = await runCmd('lsof', ['-ti', `tcp:${port}`, '-s', 'TCP:LISTEN']);
  2018. return out.trim().split('\n').map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
  2019. }
  2020. /** 校验该 PID 是否是「本项目的 server.js」(命令行 + 工作目录双重确认,避免误杀无关进程) */
  2021. async function isOurServerProcess(pid) {
  2022. try {
  2023. if (process.platform === 'win32') {
  2024. const out = await runCmd('powershell', ['-NoProfile', '-Command',
  2025. `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`]);
  2026. return /server\.js/i.test(out) && !!out;
  2027. }
  2028. const cmd = await runCmd('ps', ['-p', String(pid), '-o', 'command=']);
  2029. if (!/server\.js/i.test(cmd)) return false;
  2030. const cwdOut = await runCmd('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn']);
  2031. const cwdLine = cwdOut.split('\n').find((l) => l.startsWith('n')) || '';
  2032. const cwdPath = cwdLine.slice(1);
  2033. return !cwdPath || cwdPath === __dirname || cwdPath.startsWith(__dirname + path.sep) || cmd.includes(__dirname);
  2034. } catch {
  2035. return false;
  2036. }
  2037. }
  2038. /** 端口被占时,自动退出本项目遗留的旧实例并等待端口释放 */
  2039. async function reclaimPortFromStaleInstance(port) {
  2040. const pids = await pidsListeningOnPort(port);
  2041. let killedAny = false;
  2042. for (const pid of pids) {
  2043. if (pid === process.pid) continue;
  2044. if (!(await isOurServerProcess(pid))) continue;
  2045. try {
  2046. process.kill(pid, 'SIGTERM');
  2047. killedAny = true;
  2048. console.log(`已自动退出旧实例(PID ${pid}),正在重新启动...`);
  2049. } catch { /* 无权限等,忽略 */ }
  2050. }
  2051. if (!killedAny) return false;
  2052. for (let i = 0; i < 20; i++) {
  2053. await new Promise((r) => setTimeout(r, 200));
  2054. if (!(await pidsListeningOnPort(port)).length) return true;
  2055. }
  2056. // SIGTERM 没退干净 → 强杀兜底
  2057. for (const pid of await pidsListeningOnPort(port)) {
  2058. try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
  2059. }
  2060. await new Promise((r) => setTimeout(r, 400));
  2061. return !(await pidsListeningOnPort(port)).length;
  2062. }
  2063. function printPortBusyHelp() {
  2064. console.error(`启动失败:端口 ${LISTEN_PORT} 已被其他程序占用(非本工具实例),无法自动处理。`);
  2065. console.error('可任选其一:');
  2066. console.error(` · 退出占用该端口的程序:macOS/Linux 执行 lsof -ti tcp:${LISTEN_PORT} 查 PID,Windows 执行 netstat -ano | findstr :${LISTEN_PORT}`);
  2067. console.error(` · 或修改 config.json 里的 port(或以 PORT=xxxx 环境变量临时指定)后再启动。`);
  2068. }
  2069. let listenAttempts = 0;
  2070. const MAX_LISTEN_ATTEMPTS = 3;
  2071. server.on('error', async (err) => {
  2072. if (err && err.code === 'EADDRINUSE' && listenAttempts < MAX_LISTEN_ATTEMPTS) {
  2073. listenAttempts += 1;
  2074. console.log(`端口 ${LISTEN_PORT} 被占用,正在检测是否为本工具的旧实例...`);
  2075. let reclaimed = false;
  2076. try {
  2077. reclaimed = await reclaimPortFromStaleInstance(LISTEN_PORT);
  2078. } catch { /* 检测失败按未回收处理 */ }
  2079. if (reclaimed) {
  2080. server.listen(LISTEN_PORT, HOST);
  2081. return;
  2082. }
  2083. printPortBusyHelp();
  2084. process.exit(1);
  2085. }
  2086. if (err && err.code === 'EADDRINUSE') {
  2087. printPortBusyHelp();
  2088. process.exit(1);
  2089. }
  2090. throw err;
  2091. });
  2092. server.listen(LISTEN_PORT, HOST, () => {
  2093. const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`;
  2094. pushSystemLog(`服务已启动:${url}`, 'success');
  2095. console.log(`智慧运营中台已启动: ${url}`);
  2096. // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
  2097. // the empty "" argument is the window-title placeholder start expects before the URL.
  2098. execFile('cmd', ['/c', 'start', '', url], () => {});
  2099. });