'use strict'; // 共享状态装配:config / session / 手动店铺 / workspace 路径守卫 / 日志。 // 由 server.js 在启动时调用一次 createContext(),得到的 ctx 贯穿所有服务与路由。 // 语义与拆分前一致:config 为同一个可变对象;getWorkspaceRoot() 每次重读 config.rootDir。 const fs = require('fs'); const path = require('path'); const ROOT_DIR = path.resolve(__dirname, '..'); const PUBLIC_DIR = path.join(ROOT_DIR, 'public'); const CONFIG_PATH = path.join(ROOT_DIR, 'config.json'); const CONFIG_EXAMPLE_PATH = path.join(ROOT_DIR, 'config.example.json'); const SESSION_PATH = path.join(ROOT_DIR, 'session.json'); const MANUAL_SHOPS_PATH = path.join(ROOT_DIR, 'manual-shops.json'); const CONFIG_DEFAULTS = { port: 5177, apiKey: '', apiBase: 'https://api.lk888.ai', model: 'gpt-image-2', maxConcurrentJobs: 4, pageSize: 50, delayMs: 300, requestTimeoutMs: 20000, chatModel: '', chatApiBase: '', chatApiKey: '', accessToken: '', // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印) httpLog: false, // 钉钉 AI 表格(在线主档案/共享层):同步完成后自动推送,其他机器可拉取 // 非敏感项给组织级默认值(appKey/baseId/三张表名/operatorId),新机器只需填 AppSecret; // appSecret 按规矩不进源码(gitignored config.json 各机自存)。 // 三张表按维度拆分(每张表内粒度唯一,AI 问数/人工聚合不会跨维度算重); // 旧单表 sheetName 已废弃不读,读取处对缺省键兜底(老 config.json 零迁移)。 aitable: { enabled: false, appKey: 'ding5vlpq3yeu7h6c6kj', appSecret: '', baseId: '7QG4Yx2JpLkaBl7EIpXNx0aGJ9dEq3XD', sheetDetail: '门店明细', sheetChain: '连锁汇总', sheetCompany: '公司汇总', operatorId: 'zkWXDRXbxDlUiPgRPI3XjpwiEiE', }, // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭 autoSync: { enabled: false, time: '06:00' }, }; function writeConfigFile(cfg) { const temp = CONFIG_PATH + '.tmp'; fs.writeFileSync(temp, JSON.stringify(cfg, null, 2), 'utf8'); fs.renameSync(temp, CONFIG_PATH); } // 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用 function loadConfig() { try { return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) }; } catch (e) { if (e.code !== 'ENOENT') { console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`); } let seeded = {}; try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ } const cfg = { ...CONFIG_DEFAULTS, ...seeded }; try { writeConfigFile(cfg); console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。'); } catch { /* 只读目录等场景忽略 */ } return cfg; } } const MODELS = [ { id: 'gpt-image-2', label: 'GPT Image 2(默认)' }, { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' }, { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' }, { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' }, { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' }, { id: 'wan2.6-image', label: '通义万相 wan2.6-image' }, ]; const MODEL_IDS = new Set(MODELS.map((m) => m.id)); // These seedream variants don't support the '1K' tier the UI's size dropdown offers by // default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) - // map it up to '2K' rather than sending an invalid enum value. const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']); // ---------- workspace 路径守卫 ---------- function createWorkspaceService(config) { const WORKSPACE_ROOT = path.join(path.dirname(ROOT_DIR), 'workspace'); function getWorkspaceRoot() { const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT; return path.resolve(configuredRoot); } function isWorkspaceRoot(candidate) { try { return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot(); } catch { return false; } } function isWorkspacePath(candidate) { try { const resolved = path.resolve(String(candidate)); const root = getWorkspaceRoot(); return resolved === root || resolved.startsWith(root + path.sep); } catch { return false; } } function safeShopDir(root, folderName) { if (!isWorkspaceRoot(root) || typeof folderName !== 'string' || !folderName || folderName === '.' || folderName === '..' || folderName.includes('/') || folderName.includes('\\')) return null; const rootDir = path.resolve(root); const shopDir = path.resolve(rootDir, folderName); return shopDir.startsWith(rootDir + path.sep) ? shopDir : null; } return { WORKSPACE_ROOT, getWorkspaceRoot, isWorkspaceRoot, isWorkspacePath, safeShopDir }; } // ---------- 采集 Cookie 会话 ---------- function extractCookieValue(cookie, name) { const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); if (!match) return ''; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } function createSessionService() { const session = { cookie: '' }; function loadSession() { try { const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8')); session.cookie = String(saved.cookie || ''); } catch { /* no saved session */ } } function saveSession() { fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8'); } function getKsid() { return extractCookieValue(session.cookie, 'ksid'); } loadSession(); return { get cookie() { return session.cookie; }, set cookie(v) { session.cookie = v; }, saveSession, getKsid, }; } // ---------- 手动店铺清单 ---------- function createShopsService() { let shopList = []; function loadManualShops() { try { const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8')); if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效'); const shopsById = new Map(); for (const shop of data.shops) { if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue; shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' }); } shopList = Array.from(shopsById.values()); } catch (e) { if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`); } } function saveManualShops(shops) { const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8'); fs.renameSync(temp, MANUAL_SHOPS_PATH); } function mergeShops(newOnes) { const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop])); for (const shop of newOnes) shopsById.set(shop.shopId, shop); return Array.from(shopsById.values()); } loadManualShops(); return { getList: () => shopList, setList: (v) => { shopList = v; }, mergeShops, saveManualShops, }; } // ---------- 日志(系统日志 + 统一序号;采集任务日志由 crawl 服务持有,这里负责合并) ---------- function createLogsService() { const systemLogs = []; let logSeq = 0; function detectLogLevel(message) { if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error'; if (/停止|跳过|警告|warn/i.test(message)) return 'warn'; if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success'; return 'info'; } function createLogEntry(message, source = 'task', level = 'info') { const now = new Date(); return { seq: ++logSeq, id: `${source}-${logSeq}`, source, level, time: now.toTimeString().slice(0, 8), timestamp: now.getTime(), message: String(message), }; } function pushSystemLog(msg, level = 'info') { systemLogs.push(createLogEntry(msg, 'system', level)); if (systemLogs.length > 500) systemLogs.shift(); } function mergeLogs(crawlLog) { return [...crawlLog, ...systemLogs] .sort((a, b) => b.seq - a.seq) .slice(0, 500); } function clearAll(crawlLog) { if (crawlLog) crawlLog.length = 0; systemLogs.length = 0; } return { detectLogLevel, createLogEntry, pushSystemLog, mergeLogs, clearAll }; } function createContext() { const config = loadConfig(); // 局域网访问口令:环境变量可临时覆盖(不落盘),便于临时开启/测试 if (process.env.ACCESS_TOKEN) config.accessToken = process.env.ACCESS_TOKEN; const workspace = createWorkspaceService(config); try { fs.mkdirSync(workspace.getWorkspaceRoot(), { recursive: true }); } catch (e) { // 历史配置的 rootDir 在本机不可创建(权限/目录被移动)时不要崩,允许进设置页修改 console.warn(`输出根目录不可用:${workspace.getWorkspaceRoot()}(${e.code || e.message})。请到「系统设置」修改输出根目录。`); } const session = createSessionService(); const shops = createShopsService(); const logs = createLogsService(); function saveConfig() { writeConfigFile(config); } return { ROOT_DIR, PUBLIC_DIR, config, saveConfig, models: { MODELS, MODEL_IDS, NO_1K_SUPPORT }, workspace, session, shops, logs, }; } // 智能问数的对话模型:未在设置中选择时使用的兜底模型,以及「生图 Key 复用」推导。 // 不放入 CONFIG_DEFAULTS —— 避免落盘到 config.json(保持拆分前的持久化内容)。 const DEFAULT_CHAT_MODEL = 'deepseek-v4.1-flash'; function makeChatCfg(ctx) { return { chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase, chatApiKey: ctx.config.chatApiKey || ctx.config.apiKey, chatModel: ctx.config.chatModel || DEFAULT_CHAT_MODEL, }; } module.exports = { createContext, CONFIG_DEFAULTS, MODELS, MODEL_IDS, NO_1K_SUPPORT, DEFAULT_CHAT_MODEL, makeChatCfg, };