| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- '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 };
|