context.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. 'use strict';
  2. // 共享状态装配:config / session / 手动店铺 / workspace 路径守卫 / 日志。
  3. // 由 server.js 在启动时调用一次 createContext(),得到的 ctx 贯穿所有服务与路由。
  4. // 语义与拆分前一致:config 为同一个可变对象;getWorkspaceRoot() 每次重读 config.rootDir。
  5. const fs = require('fs');
  6. const path = require('path');
  7. const ROOT_DIR = path.resolve(__dirname, '..');
  8. const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
  9. // GENPIC_CONFIG:把配置文件指到别处(smoke/多实例隔离用),避免触碰本机真实 config.json
  10. const CONFIG_PATH = process.env.GENPIC_CONFIG
  11. ? path.resolve(process.env.GENPIC_CONFIG)
  12. : path.join(ROOT_DIR, 'config.json');
  13. const CONFIG_EXAMPLE_PATH = path.join(ROOT_DIR, 'config.example.json');
  14. const SESSION_PATH = path.join(ROOT_DIR, 'session.json');
  15. const MANUAL_SHOPS_PATH = path.join(ROOT_DIR, 'manual-shops.json');
  16. const CONFIG_DEFAULTS = {
  17. port: 5177,
  18. apiKey: '',
  19. apiBase: 'https://api.lk888.ai',
  20. model: 'gpt-image-2',
  21. maxConcurrentJobs: 4,
  22. pageSize: 50,
  23. delayMs: 300,
  24. requestTimeoutMs: 20000,
  25. chatModel: '',
  26. chatApiBase: '',
  27. chatApiKey: '',
  28. // 深度分析模型(问数智能体专用,缺省回落 chatModel;快问始终用 chatModel)
  29. chatModelAgent: '',
  30. // 问数动作协议走 JSON mode(response_format=json_object):网关支持时解析更稳,默认关
  31. chatJsonMode: false,
  32. accessToken: '',
  33. // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印)
  34. httpLog: false,
  35. // 钉钉 AI 表格(在线主档案/共享层):同步完成后自动推送,其他机器可拉取
  36. // 非敏感项给组织级默认值(appKey/baseId/三张表名/operatorId),新机器只需填 AppSecret;
  37. // appSecret 按规矩不进源码(gitignored config.json 各机自存)。
  38. // 三张表按维度拆分(每张表内粒度唯一,AI 问数/人工聚合不会跨维度算重);
  39. // 旧单表 sheetName 已废弃不读,读取处对缺省键兜底(老 config.json 零迁移)。
  40. aitable: {
  41. enabled: false,
  42. readOnly: false,
  43. appKey: 'ding5vlpq3yeu7h6c6kj',
  44. appSecret: '',
  45. baseId: '7QG4Yx2JpLkaBl7EIpXNx0aGJ9dEq3XD',
  46. sheetDetail: '门店明细',
  47. sheetChain: '连锁汇总',
  48. sheetCompany: '公司汇总',
  49. operatorId: 'zkWXDRXbxDlUiPgRPI3XjpwiEiE',
  50. },
  51. // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭
  52. autoSync: { enabled: false, time: '06:00' },
  53. };
  54. function writeConfigFile(cfg) {
  55. const temp = CONFIG_PATH + '.tmp';
  56. fs.writeFileSync(temp, JSON.stringify(cfg, null, 2), 'utf8');
  57. fs.renameSync(temp, CONFIG_PATH);
  58. }
  59. // 嵌套默认值按键合并(一层足够):config.json 只写 aitable:{enabled:true} 时,
  60. // 组织级默认的 appKey/baseId/operatorId 不应被浅合并整块清掉
  61. function mergeDefaults(defaults, saved) {
  62. const out = { ...defaults };
  63. for (const [k, v] of Object.entries(saved || {})) {
  64. out[k] = (v && typeof v === 'object' && !Array.isArray(v)
  65. && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k]))
  66. ? { ...out[k], ...v }
  67. : v;
  68. }
  69. return out;
  70. }
  71. // 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
  72. function loadConfig() {
  73. try {
  74. return mergeDefaults(CONFIG_DEFAULTS, JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
  75. } catch (e) {
  76. if (e.code !== 'ENOENT') {
  77. console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
  78. }
  79. let seeded = {};
  80. try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
  81. const cfg = mergeDefaults(CONFIG_DEFAULTS, seeded);
  82. try {
  83. writeConfigFile(cfg);
  84. console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
  85. } catch { /* 只读目录等场景忽略 */ }
  86. return cfg;
  87. }
  88. }
  89. const MODELS = [
  90. { id: 'gpt-image-2', label: 'GPT Image 2(默认)' },
  91. { id: 'gpt-image-2-guan', label: 'GPT Image 2 官转' },
  92. { id: 'doubao-seedream-5-0-pro-260628', label: '豆包 Seedream 5.0 Pro' },
  93. { id: 'doubao-seedream-5-0-260128', label: '即梦 5.0(Seedream 5.0)' },
  94. { id: 'doubao-seedream-4-5-251128', label: '即梦 4.5(Seedream 4.5)' },
  95. { id: 'wan2.6-image', label: '通义万相 wan2.6-image' },
  96. ];
  97. const MODEL_IDS = new Set(MODELS.map((m) => m.id));
  98. // These seedream variants don't support the '1K' tier the UI's size dropdown offers by
  99. // default (doubao-seedream-4-5-251128: 2K/4K only; doubao-seedream-5-0-260128: 2K/3K only) -
  100. // map it up to '2K' rather than sending an invalid enum value.
  101. const NO_1K_SUPPORT = new Set(['doubao-seedream-4-5-251128', 'doubao-seedream-5-0-260128']);
  102. // ---------- workspace 路径守卫 ----------
  103. function createWorkspaceService(config) {
  104. const WORKSPACE_ROOT = path.join(path.dirname(ROOT_DIR), 'workspace');
  105. function getWorkspaceRoot() {
  106. const configuredRoot = String(config.rootDir || WORKSPACE_ROOT).trim() || WORKSPACE_ROOT;
  107. return path.resolve(configuredRoot);
  108. }
  109. function isWorkspaceRoot(candidate) {
  110. try {
  111. return !!candidate && path.resolve(String(candidate)) === getWorkspaceRoot();
  112. } catch {
  113. return false;
  114. }
  115. }
  116. function isWorkspacePath(candidate) {
  117. try {
  118. const resolved = path.resolve(String(candidate));
  119. const root = getWorkspaceRoot();
  120. return resolved === root || resolved.startsWith(root + path.sep);
  121. } catch {
  122. return false;
  123. }
  124. }
  125. function safeShopDir(root, folderName) {
  126. if (!isWorkspaceRoot(root) || typeof folderName !== 'string' ||
  127. !folderName || folderName === '.' || folderName === '..' ||
  128. folderName.includes('/') || folderName.includes('\\')) return null;
  129. const rootDir = path.resolve(root);
  130. const shopDir = path.resolve(rootDir, folderName);
  131. return shopDir.startsWith(rootDir + path.sep) ? shopDir : null;
  132. }
  133. return { WORKSPACE_ROOT, getWorkspaceRoot, isWorkspaceRoot, isWorkspacePath, safeShopDir };
  134. }
  135. // ---------- 采集 Cookie 会话 ----------
  136. function extractCookieValue(cookie, name) {
  137. const match = String(cookie || '').match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
  138. if (!match) return '';
  139. try { return decodeURIComponent(match[1]); } catch { return match[1]; }
  140. }
  141. function createSessionService() {
  142. const session = { cookie: '' };
  143. function loadSession() {
  144. try {
  145. const saved = JSON.parse(fs.readFileSync(SESSION_PATH, 'utf8'));
  146. session.cookie = String(saved.cookie || '');
  147. } catch { /* no saved session */ }
  148. }
  149. function saveSession() {
  150. fs.writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2), 'utf8');
  151. }
  152. function getKsid() { return extractCookieValue(session.cookie, 'ksid'); }
  153. loadSession();
  154. return {
  155. get cookie() { return session.cookie; },
  156. set cookie(v) { session.cookie = v; },
  157. saveSession,
  158. getKsid,
  159. };
  160. }
  161. // ---------- 手动店铺清单 ----------
  162. function createShopsService() {
  163. let shopList = [];
  164. function loadManualShops() {
  165. try {
  166. const data = JSON.parse(fs.readFileSync(MANUAL_SHOPS_PATH, 'utf8'));
  167. if (data.version !== 1 || !Array.isArray(data.shops)) throw new Error('清单格式无效');
  168. const shopsById = new Map();
  169. for (const shop of data.shops) {
  170. if (!shop || !Number.isInteger(shop.shopId) || shop.shopId <= 0) continue;
  171. shopsById.set(shop.shopId, { shopId: shop.shopId, shopName: String(shop.shopName || shop.shopId), source: 'manual' });
  172. }
  173. shopList = Array.from(shopsById.values());
  174. } catch (e) {
  175. if (e.code !== 'ENOENT') console.warn(`读取手动店铺清单失败: ${e.message || e}`);
  176. }
  177. }
  178. function saveManualShops(shops) {
  179. const temp = `${MANUAL_SHOPS_PATH}.${process.pid}.tmp`;
  180. fs.writeFileSync(temp, JSON.stringify({ version: 1, shops }, null, 2), 'utf8');
  181. fs.renameSync(temp, MANUAL_SHOPS_PATH);
  182. }
  183. function mergeShops(newOnes) {
  184. const shopsById = new Map(shopList.map((shop) => [shop.shopId, shop]));
  185. for (const shop of newOnes) shopsById.set(shop.shopId, shop);
  186. return Array.from(shopsById.values());
  187. }
  188. loadManualShops();
  189. return {
  190. getList: () => shopList,
  191. setList: (v) => { shopList = v; },
  192. mergeShops,
  193. saveManualShops,
  194. };
  195. }
  196. // ---------- 日志(系统日志 + 统一序号;采集任务日志由 crawl 服务持有,这里负责合并) ----------
  197. function createLogsService() {
  198. const systemLogs = [];
  199. let logSeq = 0;
  200. function detectLogLevel(message) {
  201. if (/失败|错误|异常|过期|error|鉴权/i.test(message)) return 'error';
  202. if (/停止|跳过|警告|warn/i.test(message)) return 'warn';
  203. if (/完成|成功|已保存|已启动|done/i.test(message)) return 'success';
  204. return 'info';
  205. }
  206. function createLogEntry(message, source = 'task', level = 'info') {
  207. const now = new Date();
  208. return {
  209. seq: ++logSeq,
  210. id: `${source}-${logSeq}`,
  211. source,
  212. level,
  213. time: now.toTimeString().slice(0, 8),
  214. timestamp: now.getTime(),
  215. message: String(message),
  216. };
  217. }
  218. function pushSystemLog(msg, level = 'info') {
  219. systemLogs.push(createLogEntry(msg, 'system', level));
  220. if (systemLogs.length > 500) systemLogs.shift();
  221. }
  222. function mergeLogs(crawlLog) {
  223. return [...crawlLog, ...systemLogs]
  224. .sort((a, b) => b.seq - a.seq)
  225. .slice(0, 500);
  226. }
  227. function clearAll(crawlLog) {
  228. if (crawlLog) crawlLog.length = 0;
  229. systemLogs.length = 0;
  230. }
  231. return { detectLogLevel, createLogEntry, pushSystemLog, mergeLogs, clearAll };
  232. }
  233. function createContext() {
  234. const config = loadConfig();
  235. // 局域网访问口令:环境变量可临时覆盖(不落盘),便于临时开启/测试
  236. if (process.env.ACCESS_TOKEN) config.accessToken = process.env.ACCESS_TOKEN;
  237. const workspace = createWorkspaceService(config);
  238. try {
  239. fs.mkdirSync(workspace.getWorkspaceRoot(), { recursive: true });
  240. } catch (e) {
  241. // 历史配置的 rootDir 在本机不可创建(权限/目录被移动)时不要崩,允许进设置页修改
  242. console.warn(`输出根目录不可用:${workspace.getWorkspaceRoot()}(${e.code || e.message})。请到「系统设置」修改输出根目录。`);
  243. }
  244. const session = createSessionService();
  245. const shops = createShopsService();
  246. const logs = createLogsService();
  247. function saveConfig() {
  248. writeConfigFile(config);
  249. }
  250. return {
  251. ROOT_DIR,
  252. PUBLIC_DIR,
  253. config,
  254. saveConfig,
  255. models: { MODELS, MODEL_IDS, NO_1K_SUPPORT },
  256. workspace,
  257. session,
  258. shops,
  259. logs,
  260. };
  261. }
  262. // 智能问数的对话模型:未在设置中选择时使用的兜底模型,以及「生图 Key 复用」推导。
  263. // 不放入 CONFIG_DEFAULTS —— 避免落盘到 config.json(保持拆分前的持久化内容)。
  264. const DEFAULT_CHAT_MODEL = 'deepseek-v4.1-flash';
  265. function makeChatCfg(ctx) {
  266. return {
  267. chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
  268. chatApiKey: ctx.config.chatApiKey || ctx.config.apiKey,
  269. chatModel: ctx.config.chatModel || DEFAULT_CHAT_MODEL,
  270. // 智能体深度分析模型(可选):缺省回落主对话模型
  271. chatModelAgent: ctx.config.chatModelAgent || ctx.config.chatModel || DEFAULT_CHAT_MODEL,
  272. chatJsonMode: !!ctx.config.chatJsonMode,
  273. };
  274. }
  275. module.exports = {
  276. createContext,
  277. CONFIG_DEFAULTS,
  278. MODELS,
  279. MODEL_IDS,
  280. NO_1K_SUPPORT,
  281. DEFAULT_CHAT_MODEL,
  282. makeChatCfg,
  283. mergeDefaults,
  284. };