progress-reporter.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. 'use strict';
  2. /**
  3. * 题号任务进度上报器(live 模式生命周期打点 + 七牛产物上传)。
  4. *
  5. * - 上报目标:FMode 云函数 tihao-task-progress(action=report),看板据此聚合「人×任务×进度」。
  6. * - 产物:原始 brief(文件原样或文本转 .md)与最终结果 CSV,经 09-uploadManager 直传七牛,URL 落到任务记录。
  7. * - 全程静默容错:拿不到 sessionToken、上传失败、上报失败都不抛错、不阻断出名单。
  8. *
  9. * 关闭:input.reportProgress === false / input.noProgress === true / 环境变量 TIHAO_PROGRESS_DISABLED=1。
  10. */
  11. const fs = require('fs');
  12. const path = require('path');
  13. const { readTihaoToken } = require('../../core/credentials');
  14. const PARSE_HOST = (env('TIHAO_PROGRESS_HOST') || env('PARSE_BASE_URL') || 'https://server.fmode.cn').replace(/\/+$/, '');
  15. const FUNCTIONS_URL = `${PARSE_HOST}/api/functions`;
  16. const PARSE_APP_ID = env('TIHAO_PARSE_APP_ID') || env('PARSE_APP_ID') || 'ncloudmaster';
  17. const PROGRESS_FN_ID = env('TIHAO_PROGRESS_FN_ID') || 'WrdzmO5fqr';
  18. const UPLOAD_FN_ID = env('TIHAO_UPLOAD_FN_ID') || 'VHS0noRP7Q'; // 09-uploadManager
  19. const BRIEF_PREVIEW_LIMIT = 4000;
  20. const CALL_TIMEOUT_MS = 20000;
  21. const CALL_RETRIES = 6;
  22. function env(name) {
  23. return typeof process !== 'undefined' && process.env ? process.env[name] : undefined;
  24. }
  25. function isEnabled(input) {
  26. if (input && (input.reportProgress === false || input.noProgress === true)) return false;
  27. if (env('TIHAO_PROGRESS_DISABLED') === '1') return false;
  28. return true;
  29. }
  30. function createReporter(input = {}, context = {}) {
  31. const sessionToken = isEnabled(input) ? String(readTihaoToken(input) || '').trim() : '';
  32. const outputDir = context.outputDir || input.output || input.outputDir || '';
  33. const taskId = String(
  34. input.taskId || input.progressTaskId ||
  35. (outputDir ? path.basename(outputDir) : '') ||
  36. `tihao-${Date.now()}`
  37. ).trim();
  38. const active = Boolean(sessionToken);
  39. return new ProgressReporter({ sessionToken, taskId, active });
  40. }
  41. class ProgressReporter {
  42. constructor({ sessionToken, taskId, active }) {
  43. this.sessionToken = String(sessionToken || '');
  44. this.taskId = taskId;
  45. this.active = active;
  46. this.title = '';
  47. this.briefUploaded = false;
  48. }
  49. async start(criteria = {}, input = {}) {
  50. if (!this.active) return;
  51. this.title = buildTitle(criteria, input, this.taskId);
  52. const artifacts = {};
  53. try {
  54. const brief = await this.uploadBrief(criteria, input);
  55. Object.assign(artifacts, brief);
  56. } catch (_) { /* 静默 */ }
  57. await this.report({
  58. status: 'running',
  59. progress: 5,
  60. title: this.title,
  61. type: 'tihao_sourcing',
  62. step: { key: 'start', label: '开始提号', at: now() },
  63. data: { artifacts },
  64. });
  65. }
  66. async step(key, label, progress) {
  67. if (!this.active) return;
  68. await this.report({
  69. status: 'running',
  70. progress,
  71. step: { key, label, at: now() },
  72. });
  73. }
  74. async complete(report = {}) {
  75. if (!this.active) return;
  76. const artifacts = {};
  77. try {
  78. Object.assign(artifacts, await this.uploadResults(report.files || []));
  79. } catch (_) { /* 静默 */ }
  80. await this.report({
  81. status: 'completed',
  82. progress: 100,
  83. step: { key: 'done', label: '已完成', at: now() },
  84. data: {
  85. artifacts,
  86. summary: pickSummary(report.summary),
  87. },
  88. });
  89. }
  90. async blocked(status, message, progress) {
  91. if (!this.active) return;
  92. await this.report({
  93. status: status || 'blocked',
  94. progress: typeof progress === 'number' ? progress : 50,
  95. step: { key: 'blocked', label: message || '已暂停', at: now() },
  96. data: { note: message || '' },
  97. });
  98. }
  99. async fail(error) {
  100. if (!this.active) return;
  101. const message = error && error.message ? error.message : String(error || '未知错误');
  102. await this.report({
  103. status: 'failed',
  104. step: { key: 'failed', label: '执行失败', at: now() },
  105. data: { error: message.slice(0, 500) },
  106. });
  107. }
  108. /* --------------------------- 内部 --------------------------- */
  109. async report(payload) {
  110. try {
  111. await callFunction({
  112. id: PROGRESS_FN_ID,
  113. action: 'report',
  114. sessionToken: String(this.sessionToken || ''),
  115. taskId: this.taskId,
  116. ...payload,
  117. });
  118. } catch (_) { /* 静默:上报失败不阻断 */ }
  119. }
  120. async uploadBrief(criteria = {}, input = {}) {
  121. const source = criteria.source || input.brief || input.briefPath || '';
  122. const rawText = String(criteria.rawText || '').trim();
  123. // 1) 真实文件 brief:原样上传
  124. if (source && source !== 'inline' && source !== 'input') {
  125. const filePath = path.resolve(source);
  126. if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
  127. const buffer = fs.readFileSync(filePath);
  128. const name = path.basename(filePath);
  129. const url = await this.uploadBuffer(buffer, name, mimeOf(name));
  130. const out = { briefUrl: url, briefName: name };
  131. if (rawText) out.briefPreview = truncate(rawText, BRIEF_PREVIEW_LIMIT);
  132. this.briefUploaded = Boolean(url);
  133. return out;
  134. }
  135. }
  136. // 2) 文本 brief:转 .md 上传 + 预览入库
  137. if (rawText) {
  138. const name = `brief-${this.taskId}.md`;
  139. const url = await this.uploadBuffer(Buffer.from(rawText, 'utf8'), name, 'text/markdown');
  140. this.briefUploaded = Boolean(url);
  141. return { briefUrl: url, briefName: name, briefPreview: truncate(rawText, BRIEF_PREVIEW_LIMIT) };
  142. }
  143. return {};
  144. }
  145. async uploadResults(files = []) {
  146. const out = {};
  147. const csv = files.find((f) => String(f).toLowerCase().endsWith('.csv'));
  148. if (csv && fs.existsSync(csv)) {
  149. const name = path.basename(csv);
  150. const url = await this.uploadBuffer(fs.readFileSync(csv), name, 'text/csv');
  151. if (url) { out.resultCsvUrl = url; out.resultCsvName = name; }
  152. }
  153. const md = files.find((f) => String(f).toLowerCase().endsWith('.md'));
  154. if (md && fs.existsSync(md)) {
  155. const name = path.basename(md);
  156. const url = await this.uploadBuffer(fs.readFileSync(md), name, 'text/markdown');
  157. if (url) { out.reportUrl = url; out.reportName = name; }
  158. }
  159. return out;
  160. }
  161. async uploadBuffer(buffer, filename, mimeType) {
  162. if (!buffer || !buffer.length) return '';
  163. const tokenResp = await callFunction({
  164. id: UPLOAD_FN_ID,
  165. action: 'createUploadToken',
  166. sessionToken: String(this.sessionToken || ''),
  167. filename,
  168. mimeType,
  169. kind: 'export',
  170. size: buffer.length,
  171. bizId: this.taskId,
  172. });
  173. const data = (tokenResp && tokenResp.data) || {};
  174. if (!data.uploadUrl || !data.token || !data.key) return '';
  175. const form = new FormData();
  176. form.append('key', data.key);
  177. form.append('token', data.token);
  178. form.append('file', new Blob([buffer], { type: mimeType }), filename);
  179. const controller = new AbortController();
  180. const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
  181. try {
  182. const resp = await fetch(data.uploadUrl, { method: 'POST', body: form, signal: controller.signal });
  183. if (!resp.ok) return '';
  184. return data.url || '';
  185. } finally {
  186. clearTimeout(timer);
  187. }
  188. }
  189. }
  190. async function callFunction(body) {
  191. let lastErr;
  192. for (let attempt = 0; attempt < CALL_RETRIES; attempt += 1) {
  193. const controller = new AbortController();
  194. const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
  195. try {
  196. const resp = await fetch(FUNCTIONS_URL, {
  197. method: 'POST',
  198. headers: {
  199. 'Content-Type': 'application/json',
  200. 'Accept': 'application/json',
  201. 'X-Parse-Application-Id': PARSE_APP_ID,
  202. 'X-Parse-Session-Token': body.sessionToken || '',
  203. },
  204. body: JSON.stringify({ ...body, _ApplicationId: PARSE_APP_ID }),
  205. signal: controller.signal,
  206. });
  207. const text = await resp.text();
  208. const json = safeJson(text);
  209. // FMode 偶发空响应 / fetch failed(云函数内部解析登录态抖动)→ 重试
  210. const fetchFailed = json && typeof json.message === 'string' && /fetch failed/i.test(json.message);
  211. const retryable = !resp.ok || !json || fetchFailed || Number(json.code) >= 500;
  212. if (retryable) {
  213. lastErr = new Error((json && (json.message || json.error)) || `empty response (http ${resp.status})`);
  214. } else {
  215. return json;
  216. }
  217. } catch (err) {
  218. lastErr = err;
  219. } finally {
  220. clearTimeout(timer);
  221. }
  222. await sleep(800 * (attempt + 1));
  223. }
  224. throw lastErr || new Error('callFunction failed');
  225. }
  226. function buildTitle(criteria = {}, input = {}, taskId) {
  227. const brand = String(criteria.brand || input.brand || '').trim();
  228. const keywords = Array.isArray(criteria.keywords) ? criteria.keywords.filter(Boolean) : [];
  229. const kw = keywords.slice(0, 3).join('、');
  230. if (brand && kw) return `${brand} · ${kw}`;
  231. if (brand) return brand;
  232. if (kw) return kw;
  233. return `提号任务 ${taskId}`;
  234. }
  235. function pickSummary(summary = {}) {
  236. if (!summary || typeof summary !== 'object') return {};
  237. const keys = ['brand', 'collectionMode', 'total', 'strong', 'backup', 'review', 'clientReadyCount', 'targetCount'];
  238. const out = {};
  239. for (const key of keys) if (summary[key] !== undefined) out[key] = summary[key];
  240. return out;
  241. }
  242. function mimeOf(name) {
  243. const ext = String(name || '').toLowerCase().split('.').pop();
  244. return {
  245. md: 'text/markdown', txt: 'text/plain', csv: 'text/csv', json: 'application/json',
  246. xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  247. xls: 'application/vnd.ms-excel',
  248. docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  249. doc: 'application/msword', pdf: 'application/pdf',
  250. }[ext] || 'application/octet-stream';
  251. }
  252. function truncate(text, limit) {
  253. const value = String(text || '');
  254. return value.length > limit ? `${value.slice(0, limit)}\n…(已截断)` : value;
  255. }
  256. function safeJson(text) {
  257. try { return JSON.parse(text); } catch { return null; }
  258. }
  259. function now() {
  260. return new Date().toISOString();
  261. }
  262. function sleep(ms) {
  263. return new Promise((resolve) => setTimeout(resolve, ms));
  264. }
  265. module.exports = { createReporter, ProgressReporter };