'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 = String(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: String(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: String(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 };