|
|
@@ -0,0 +1,295 @@
|
|
|
+'use strict';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 题号任务进度上报器(live 模式生命周期打点 + 七牛产物上传)。
|
|
|
+ *
|
|
|
+ * - 上报目标:FMode 云函数 tihao-task-progress(action=report),看板据此聚合「人×任务×进度」。
|
|
|
+ * - 产物:原始 brief(文件原样或文本转 .md)与最终结果 CSV,经 09-uploadManager 直传七牛,URL 落到任务记录。
|
|
|
+ * - 全程静默容错:拿不到 sessionToken、上传失败、上报失败都不抛错、不阻断出名单。
|
|
|
+ *
|
|
|
+ * 关闭:input.reportProgress === false / input.noProgress === true / 环境变量 TIHAO_PROGRESS_DISABLED=1。
|
|
|
+ */
|
|
|
+
|
|
|
+const fs = require('fs');
|
|
|
+const path = require('path');
|
|
|
+const { readTihaoToken } = require('../../core/credentials');
|
|
|
+
|
|
|
+const PARSE_HOST = (env('TIHAO_PROGRESS_HOST') || env('PARSE_BASE_URL') || 'https://server.fmode.cn').replace(/\/+$/, '');
|
|
|
+const FUNCTIONS_URL = `${PARSE_HOST}/api/functions`;
|
|
|
+const PARSE_APP_ID = env('TIHAO_PARSE_APP_ID') || env('PARSE_APP_ID') || 'ncloudmaster';
|
|
|
+const PROGRESS_FN_ID = env('TIHAO_PROGRESS_FN_ID') || 'WrdzmO5fqr';
|
|
|
+const UPLOAD_FN_ID = env('TIHAO_UPLOAD_FN_ID') || 'VHS0noRP7Q'; // 09-uploadManager
|
|
|
+const BRIEF_PREVIEW_LIMIT = 4000;
|
|
|
+const CALL_TIMEOUT_MS = 20000;
|
|
|
+const CALL_RETRIES = 6;
|
|
|
+
|
|
|
+function env(name) {
|
|
|
+ return typeof process !== 'undefined' && process.env ? process.env[name] : undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function isEnabled(input) {
|
|
|
+ if (input && (input.reportProgress === false || input.noProgress === true)) return false;
|
|
|
+ if (env('TIHAO_PROGRESS_DISABLED') === '1') return false;
|
|
|
+ return true;
|
|
|
+}
|
|
|
+
|
|
|
+function createReporter(input = {}, context = {}) {
|
|
|
+ const sessionToken = isEnabled(input) ? String(readTihaoToken(input) || '').trim() : '';
|
|
|
+ const outputDir = context.outputDir || input.output || input.outputDir || '';
|
|
|
+ const taskId = String(
|
|
|
+ input.taskId || input.progressTaskId ||
|
|
|
+ (outputDir ? path.basename(outputDir) : '') ||
|
|
|
+ `tihao-${Date.now()}`
|
|
|
+ ).trim();
|
|
|
+ const active = Boolean(sessionToken);
|
|
|
+ return new ProgressReporter({ sessionToken, taskId, active });
|
|
|
+}
|
|
|
+
|
|
|
+class ProgressReporter {
|
|
|
+ constructor({ sessionToken, taskId, active }) {
|
|
|
+ this.sessionToken = sessionToken;
|
|
|
+ this.taskId = taskId;
|
|
|
+ this.active = active;
|
|
|
+ this.title = '';
|
|
|
+ this.briefUploaded = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ async start(criteria = {}, input = {}) {
|
|
|
+ if (!this.active) return;
|
|
|
+ this.title = buildTitle(criteria, input, this.taskId);
|
|
|
+ const artifacts = {};
|
|
|
+ try {
|
|
|
+ const brief = await this.uploadBrief(criteria, input);
|
|
|
+ Object.assign(artifacts, brief);
|
|
|
+ } catch (_) { /* 静默 */ }
|
|
|
+ await this.report({
|
|
|
+ status: 'running',
|
|
|
+ progress: 5,
|
|
|
+ title: this.title,
|
|
|
+ type: 'tihao_sourcing',
|
|
|
+ step: { key: 'start', label: '开始提号', at: now() },
|
|
|
+ data: { artifacts },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async step(key, label, progress) {
|
|
|
+ if (!this.active) return;
|
|
|
+ await this.report({
|
|
|
+ status: 'running',
|
|
|
+ progress,
|
|
|
+ step: { key, label, at: now() },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async complete(report = {}) {
|
|
|
+ if (!this.active) return;
|
|
|
+ const artifacts = {};
|
|
|
+ try {
|
|
|
+ Object.assign(artifacts, await this.uploadResults(report.files || []));
|
|
|
+ } catch (_) { /* 静默 */ }
|
|
|
+ await this.report({
|
|
|
+ status: 'completed',
|
|
|
+ progress: 100,
|
|
|
+ step: { key: 'done', label: '已完成', at: now() },
|
|
|
+ data: {
|
|
|
+ artifacts,
|
|
|
+ summary: pickSummary(report.summary),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async blocked(status, message, progress) {
|
|
|
+ if (!this.active) return;
|
|
|
+ await this.report({
|
|
|
+ status: status || 'blocked',
|
|
|
+ progress: typeof progress === 'number' ? progress : 50,
|
|
|
+ step: { key: 'blocked', label: message || '已暂停', at: now() },
|
|
|
+ data: { note: message || '' },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async fail(error) {
|
|
|
+ if (!this.active) return;
|
|
|
+ const message = error && error.message ? error.message : String(error || '未知错误');
|
|
|
+ await this.report({
|
|
|
+ status: 'failed',
|
|
|
+ step: { key: 'failed', label: '执行失败', at: now() },
|
|
|
+ data: { error: message.slice(0, 500) },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /* --------------------------- 内部 --------------------------- */
|
|
|
+
|
|
|
+ async report(payload) {
|
|
|
+ try {
|
|
|
+ await callFunction({
|
|
|
+ id: PROGRESS_FN_ID,
|
|
|
+ action: 'report',
|
|
|
+ sessionToken: this.sessionToken,
|
|
|
+ taskId: this.taskId,
|
|
|
+ ...payload,
|
|
|
+ });
|
|
|
+ } catch (_) { /* 静默:上报失败不阻断 */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async uploadBrief(criteria = {}, input = {}) {
|
|
|
+ const source = criteria.source || input.brief || input.briefPath || '';
|
|
|
+ const rawText = String(criteria.rawText || '').trim();
|
|
|
+
|
|
|
+ // 1) 真实文件 brief:原样上传
|
|
|
+ if (source && source !== 'inline' && source !== 'input') {
|
|
|
+ const filePath = path.resolve(source);
|
|
|
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
|
+ const buffer = fs.readFileSync(filePath);
|
|
|
+ const name = path.basename(filePath);
|
|
|
+ const url = await this.uploadBuffer(buffer, name, mimeOf(name));
|
|
|
+ const out = { briefUrl: url, briefName: name };
|
|
|
+ if (rawText) out.briefPreview = truncate(rawText, BRIEF_PREVIEW_LIMIT);
|
|
|
+ this.briefUploaded = Boolean(url);
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 文本 brief:转 .md 上传 + 预览入库
|
|
|
+ if (rawText) {
|
|
|
+ const name = `brief-${this.taskId}.md`;
|
|
|
+ const url = await this.uploadBuffer(Buffer.from(rawText, 'utf8'), name, 'text/markdown');
|
|
|
+ this.briefUploaded = Boolean(url);
|
|
|
+ return { briefUrl: url, briefName: name, briefPreview: truncate(rawText, BRIEF_PREVIEW_LIMIT) };
|
|
|
+ }
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+
|
|
|
+ async uploadResults(files = []) {
|
|
|
+ const out = {};
|
|
|
+ const csv = files.find((f) => String(f).toLowerCase().endsWith('.csv'));
|
|
|
+ if (csv && fs.existsSync(csv)) {
|
|
|
+ const name = path.basename(csv);
|
|
|
+ const url = await this.uploadBuffer(fs.readFileSync(csv), name, 'text/csv');
|
|
|
+ if (url) { out.resultCsvUrl = url; out.resultCsvName = name; }
|
|
|
+ }
|
|
|
+ const md = files.find((f) => String(f).toLowerCase().endsWith('.md'));
|
|
|
+ if (md && fs.existsSync(md)) {
|
|
|
+ const name = path.basename(md);
|
|
|
+ const url = await this.uploadBuffer(fs.readFileSync(md), name, 'text/markdown');
|
|
|
+ if (url) { out.reportUrl = url; out.reportName = name; }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ async uploadBuffer(buffer, filename, mimeType) {
|
|
|
+ if (!buffer || !buffer.length) return '';
|
|
|
+ const tokenResp = await callFunction({
|
|
|
+ id: UPLOAD_FN_ID,
|
|
|
+ action: 'createUploadToken',
|
|
|
+ sessionToken: this.sessionToken,
|
|
|
+ filename,
|
|
|
+ mimeType,
|
|
|
+ kind: 'export',
|
|
|
+ size: buffer.length,
|
|
|
+ bizId: this.taskId,
|
|
|
+ });
|
|
|
+ const data = (tokenResp && tokenResp.data) || {};
|
|
|
+ if (!data.uploadUrl || !data.token || !data.key) return '';
|
|
|
+
|
|
|
+ const form = new FormData();
|
|
|
+ form.append('key', data.key);
|
|
|
+ form.append('token', data.token);
|
|
|
+ form.append('file', new Blob([buffer], { type: mimeType }), filename);
|
|
|
+
|
|
|
+ const controller = new AbortController();
|
|
|
+ const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
|
|
|
+ try {
|
|
|
+ const resp = await fetch(data.uploadUrl, { method: 'POST', body: form, signal: controller.signal });
|
|
|
+ if (!resp.ok) return '';
|
|
|
+ return data.url || '';
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function callFunction(body) {
|
|
|
+ let lastErr;
|
|
|
+ for (let attempt = 0; attempt < CALL_RETRIES; attempt += 1) {
|
|
|
+ const controller = new AbortController();
|
|
|
+ const timer = setTimeout(() => controller.abort(), CALL_TIMEOUT_MS);
|
|
|
+ try {
|
|
|
+ const resp = await fetch(FUNCTIONS_URL, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Accept': 'application/json',
|
|
|
+ 'X-Parse-Application-Id': PARSE_APP_ID,
|
|
|
+ 'X-Parse-Session-Token': body.sessionToken || '',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({ ...body, _ApplicationId: PARSE_APP_ID }),
|
|
|
+ signal: controller.signal,
|
|
|
+ });
|
|
|
+ const text = await resp.text();
|
|
|
+ const json = safeJson(text);
|
|
|
+ // FMode 偶发空响应 / fetch failed(云函数内部解析登录态抖动)→ 重试
|
|
|
+ const fetchFailed = json && typeof json.message === 'string' && /fetch failed/i.test(json.message);
|
|
|
+ const retryable = !resp.ok || !json || fetchFailed || Number(json.code) >= 500;
|
|
|
+ if (retryable) {
|
|
|
+ lastErr = new Error((json && (json.message || json.error)) || `empty response (http ${resp.status})`);
|
|
|
+ } else {
|
|
|
+ return json;
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ lastErr = err;
|
|
|
+ } finally {
|
|
|
+ clearTimeout(timer);
|
|
|
+ }
|
|
|
+ await sleep(800 * (attempt + 1));
|
|
|
+ }
|
|
|
+ throw lastErr || new Error('callFunction failed');
|
|
|
+}
|
|
|
+
|
|
|
+function buildTitle(criteria = {}, input = {}, taskId) {
|
|
|
+ const brand = String(criteria.brand || input.brand || '').trim();
|
|
|
+ const keywords = Array.isArray(criteria.keywords) ? criteria.keywords.filter(Boolean) : [];
|
|
|
+ const kw = keywords.slice(0, 3).join('、');
|
|
|
+ if (brand && kw) return `${brand} · ${kw}`;
|
|
|
+ if (brand) return brand;
|
|
|
+ if (kw) return kw;
|
|
|
+ return `提号任务 ${taskId}`;
|
|
|
+}
|
|
|
+
|
|
|
+function pickSummary(summary = {}) {
|
|
|
+ if (!summary || typeof summary !== 'object') return {};
|
|
|
+ const keys = ['brand', 'collectionMode', 'total', 'strong', 'backup', 'review', 'clientReadyCount', 'targetCount'];
|
|
|
+ const out = {};
|
|
|
+ for (const key of keys) if (summary[key] !== undefined) out[key] = summary[key];
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+function mimeOf(name) {
|
|
|
+ const ext = String(name || '').toLowerCase().split('.').pop();
|
|
|
+ return {
|
|
|
+ md: 'text/markdown', txt: 'text/plain', csv: 'text/csv', json: 'application/json',
|
|
|
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
+ xls: 'application/vnd.ms-excel',
|
|
|
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
+ doc: 'application/msword', pdf: 'application/pdf',
|
|
|
+ }[ext] || 'application/octet-stream';
|
|
|
+}
|
|
|
+
|
|
|
+function truncate(text, limit) {
|
|
|
+ const value = String(text || '');
|
|
|
+ return value.length > limit ? `${value.slice(0, limit)}\n…(已截断)` : value;
|
|
|
+}
|
|
|
+
|
|
|
+function safeJson(text) {
|
|
|
+ try { return JSON.parse(text); } catch { return null; }
|
|
|
+}
|
|
|
+
|
|
|
+function now() {
|
|
|
+ return new Date().toISOString();
|
|
|
+}
|
|
|
+
|
|
|
+function sleep(ms) {
|
|
|
+ return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = { createReporter, ProgressReporter };
|