| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641 |
- /**
- * 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
- * actions:
- * analysisCreate | analysisList | analysisGet | analysisUpdate
- * topicCreate | topicList | topicUpdate | topicArchive
- * dailyReportCreate | dailyReportList | dailyReportGet
- * transcriptStart | transcriptGet
- *
- * 说明:本函数只负责业务资产的持久化和账号隔离。抖音原始数据抓取仍由 12-douyinManager 负责。
- */
- const PARSE_API_HOST = readEnv('PARSE_API_HOST') || 'https://server.fmode.cn';
- const PARSE_APP_ID = readEnv('PARSE_APP_ID') || 'ncloudmaster';
- const DOUYIN_API_BASE_URL = (readEnv('DOUYIN_API_BASE_URL') || readEnv('VOC_SOCIAL_BASE_URL') || 'https://server.fmode.cn/api/voc-social').replace(/\/+$/, '');
- const IS_TIKHUB_DIRECT = /api\.tikhub\.io/i.test(DOUYIN_API_BASE_URL);
- const LOCAL_VOC_TOKEN_FALLBACK = 'r:33c57d404c8fffc9b19199a4da0bb663';
- const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
- const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
- const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
- const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
- const VOC_TOKEN = readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
- async function handler(request, response) {
- try {
- const action = pickParam(request, 'action') || '';
- if (action === 'diagnose') {
- return diagnose(request, response);
- }
- await ensureTables();
- const session = await requireSession(request);
- const requestedUserId = pickParam(request, 'userId') || '';
- if (requestedUserId && requestedUserId !== session.userId) {
- return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
- }
- const userId = session.userId;
- if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
- if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
- if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'));
- if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
- if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
- if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
- if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
- if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), { status: 'archived' });
- if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
- if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
- if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'id'));
- if (action === 'transcriptStart') return startTranscript(request, response, userId);
- if (action === 'transcriptGet') return getTranscript(request, response, userId);
- response.json({ code: 400, success: false, error: `未知 action: ${action}` });
- } catch (error) {
- console.error('douyinInsightManager failed:', error.message);
- response.json({ code: 500, success: false, error: error.message });
- }
- }
- async function ensureTables() {
- for (const table of ['VideoflowViralAnalysis', 'VideoflowTopicIdea', 'VideoflowDailyReport', 'VideoflowTranscriptJob']) {
- await Psql.query(`
- CREATE TABLE IF NOT EXISTS "${table}" (
- "objectId" VARCHAR(50) PRIMARY KEY,
- "bizId" VARCHAR(255) NOT NULL,
- "userId" VARCHAR(255) NOT NULL,
- "data" JSONB NOT NULL DEFAULT '{}',
- "status" VARCHAR(50) DEFAULT '',
- "createdAt" TIMESTAMPTZ DEFAULT NOW(),
- "updatedAt" TIMESTAMPTZ DEFAULT NOW()
- )
- `);
- await Psql.query(`DROP INDEX IF EXISTS idx_${table.toLowerCase()}_biz`);
- await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
- await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
- }
- await Psql.query(`
- CREATE TABLE IF NOT EXISTS "AppSession" (
- "token" VARCHAR(120) PRIMARY KEY,
- "userId" VARCHAR(50) NOT NULL,
- "expiresAt" TIMESTAMPTZ NOT NULL,
- "createdAt" TIMESTAMPTZ DEFAULT NOW()
- )
- `);
- }
- async function createRow(response, table, userId, data) {
- const now = new Date().toISOString();
- const bizId = data.id || data.bizId || generateId();
- const merged = { ...data, id: bizId, userId, createdAt: data.createdAt || now, updatedAt: now };
- const existing = await Psql.query(
- `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
- [bizId, userId]
- );
- if (existing.length) {
- await Psql.query(
- `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
- [JSON.stringify(merged), merged.status || '', bizId, userId]
- );
- return response.json({ code: 200, success: true, data: merged });
- }
- await Psql.query(
- `INSERT INTO "${table}" ("objectId","bizId","userId","data","status")
- VALUES ($1,$2,$3,$4,$5)`,
- [generateId(), bizId, userId, JSON.stringify(merged), merged.status || '']
- );
- response.json({ code: 200, success: true, data: merged });
- }
- async function listRows(response, table, userId, limit) {
- const parsedLimit = parseInt(limit || '100', 10);
- const safeLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 1000) : 100;
- const rows = await Psql.query(
- `SELECT * FROM "${table}" WHERE "userId"=$1 ORDER BY "updatedAt" DESC LIMIT $2`,
- [userId, safeLimit]
- );
- response.json({ code: 200, success: true, data: rows.map(rowToObj) });
- }
- async function getRow(response, table, userId, id) {
- if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
- const rows = await Psql.query(
- `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
- [id, userId]
- );
- if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
- response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
- }
- async function updateRow(response, table, userId, id, patch) {
- if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
- const rows = await Psql.query(
- `SELECT * FROM "${table}" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
- [id, userId]
- );
- if (!rows.length) return response.json({ code: 404, success: false, error: '未找到记录' });
- const merged = { ...rowToObj(rows[0]), ...patch, id, userId, updatedAt: new Date().toISOString() };
- await Psql.query(
- `UPDATE "${table}" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
- [JSON.stringify(merged), merged.status || '', id, userId]
- );
- response.json({ code: 200, success: true, data: merged });
- }
- async function startTranscript(request, response, userId) {
- const awemeId = clean(pickParam(request, 'awemeId'));
- const analysisId = clean(pickParam(request, 'analysisId'));
- if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
- const now = new Date().toISOString();
- const job = {
- id: `transcript_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
- awemeId,
- analysisId,
- provider: 'iflytek-gateway',
- status: 'pending',
- warnings: [],
- createdAt: now,
- updatedAt: now,
- };
- if (!VOC_TOKEN) {
- job.status = 'needs_provider_config';
- job.warnings.push('云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。');
- await upsertTranscriptJob(userId, job);
- return response.json({ code: 200, success: true, data: job });
- }
- try {
- const detail = await fetchDouyinDetail(awemeId);
- const media = selectAudioCandidate(detail);
- if (!media.url) {
- job.status = 'needs_media';
- job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
- await upsertTranscriptJob(userId, job);
- return response.json({ code: 200, success: true, data: job });
- }
- const durationMs = media.durationMs || extractDurationMs(detail);
- if (!durationMs) {
- job.status = 'needs_media';
- job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
- await upsertTranscriptJob(userId, job);
- return response.json({ code: 200, success: true, data: job });
- }
- const uploaded = await uploadGatewayAudio(media.url, durationMs);
- job.orderId = uploaded.orderId;
- job.estimateTime = uploaded.estimateTime || 0;
- job.mediaUrl = media.url;
- job.durationMs = durationMs;
- job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
- await upsertTranscriptJob(userId, job);
- response.json({ code: 200, success: true, data: job });
- } catch (error) {
- job.status = 'failed';
- job.errorMessage = error.message || '提交逐字稿任务失败';
- job.warnings.push(job.errorMessage);
- await upsertTranscriptJob(userId, job);
- response.json({ code: 200, success: true, data: job });
- }
- }
- async function getTranscript(request, response, userId) {
- const id = clean(pickParam(request, 'id'));
- if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
- const rows = await Psql.query(
- `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
- [id, userId]
- );
- if (!rows.length) return response.json({ code: 404, success: false, error: '未找到逐字稿任务' });
- const job = rowToObj(rows[0]);
- if (!job.orderId || job.status === 'completed' || job.status === 'failed') {
- return response.json({ code: 200, success: true, data: job });
- }
- if (!VOC_TOKEN) {
- job.status = 'needs_provider_config';
- job.warnings = [...(job.warnings || []), '云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'];
- await upsertTranscriptJob(userId, job);
- return response.json({ code: 200, success: true, data: job });
- }
- try {
- const data = await queryGateway(job.orderId);
- const status = String(gatewayValue(data, 'status') || '').toLowerCase();
- const text = clean(gatewayValue(data, 'text'));
- const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
- if (status === 'completed' || text) {
- job.status = 'completed';
- job.text = text || segments.map(s => s.text).join('\n');
- job.segments = segments;
- } else if (status === 'failed' || status === 'error') {
- job.status = 'failed';
- job.errorMessage = gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写失败';
- job.warnings = [...(job.warnings || []), job.errorMessage];
- } else {
- job.status = 'pending';
- job.warnings = [...new Set([...(job.warnings || []), '转写任务仍在处理中。'])];
- }
- job.updatedAt = new Date().toISOString();
- await upsertTranscriptJob(userId, job);
- response.json({ code: 200, success: true, data: job });
- } catch (error) {
- job.status = 'failed';
- job.errorMessage = error.message || '查询逐字稿任务失败';
- job.warnings = [...(job.warnings || []), job.errorMessage];
- job.updatedAt = new Date().toISOString();
- await upsertTranscriptJob(userId, job);
- response.json({ code: 200, success: true, data: job });
- }
- }
- async function upsertTranscriptJob(userId, job) {
- const existing = await Psql.query(
- `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
- [job.id, userId]
- );
- if (existing.length) {
- await Psql.query(
- `UPDATE "VideoflowTranscriptJob" SET "data"=$1, "status"=$2, "updatedAt"=NOW() WHERE "bizId"=$3 AND "userId"=$4`,
- [JSON.stringify(job), job.status || '', job.id, userId]
- );
- return;
- }
- await Psql.query(
- `INSERT INTO "VideoflowTranscriptJob" ("objectId","bizId","userId","data","status")
- VALUES ($1,$2,$3,$4,$5)`,
- [generateId(), job.id, userId, JSON.stringify(job), job.status || '']
- );
- }
- function rowToObj(row) {
- const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
- return { ...data, objectId: row.objectId, createdAt: row.createdAt, updatedAt: row.updatedAt };
- }
- async function fetchDouyinDetail(awemeId) {
- if (!DOUYIN_API_TOKEN) {
- throw new Error('抖音数据网关未配置有效 token:FMode voc-social 请配置 DOUYIN_API_TOKEN、VOC_TOKEN 或 VOC_SOCIAL_TOKEN;TikHub 直连才使用 TIKHUB_TOKEN。');
- }
- const url = new URL(`${DOUYIN_API_BASE_URL}${normalizeDouyinPath('/douyin/app/v3/fetch_one_video_v3')}`);
- url.searchParams.set('aweme_id', awemeId);
- let resp;
- try {
- resp = await fetch(url.toString(), {
- method: 'GET',
- headers: {
- 'Accept': 'application/json',
- 'Authorization': bearerAuth(DOUYIN_API_TOKEN),
- },
- });
- } catch (error) {
- throw new Error(`抖音数据网关网络请求失败:${formatFetchError(error)}。base=${maskBaseUrl(DOUYIN_API_BASE_URL)} route=/douyin/app/v3/fetch_one_video_v3`);
- }
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `抖音详情获取失败 HTTP ${resp.status}`);
- return data;
- }
- async function diagnose(request, response) {
- const awemeId = String(pickParam(request, 'awemeId') || '7592116912205630761').trim();
- const result = {
- code: 200,
- success: true,
- data: {
- parseApiHost: PARSE_API_HOST,
- douyinBaseUrl: maskBaseUrl(DOUYIN_API_BASE_URL),
- isTikhubDirect: IS_TIKHUB_DIRECT,
- douyinTokenConfigured: !!DOUYIN_API_TOKEN,
- douyinTokenSource: douyinTokenSource(),
- transcriptTokenConfigured: !!VOC_TOKEN,
- transcriptTokenSource: transcriptTokenSource(),
- transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
- probe: null,
- }
- };
- if (String(pickParam(request, 'probe') || '') === '1') {
- try {
- const detail = await fetchDouyinDetail(awemeId);
- result.data.probe = {
- code: detail?.code,
- success: detail?.success !== false,
- hasData: !!detail?.data,
- keys: detail && typeof detail === 'object' ? Object.keys(detail).slice(0, 10) : [],
- };
- } catch (error) {
- result.data.probe = {
- code: 502,
- success: false,
- error: error && error.message ? error.message : String(error || 'probe failed'),
- };
- }
- }
- if (String(pickParam(request, 'probeDb') || '') === '1') {
- try {
- await ensureTables();
- result.data.database = { success: true };
- } catch (error) {
- result.data.database = {
- success: false,
- error: error && error.message ? error.message : String(error || 'database probe failed'),
- };
- }
- }
- const sessionToken = clean(pickParam(request, 'sessionToken'));
- if (sessionToken) {
- try {
- const user = await verifyParseSessionFromDb(sessionToken) || await verifyParseSession(sessionToken);
- result.data.session = {
- success: !!user?.objectId,
- userId: user?.objectId || '',
- source: user?.source || '',
- };
- } catch (error) {
- result.data.session = {
- success: false,
- error: error && error.message ? error.message : String(error || 'session probe failed'),
- };
- }
- }
- return response.json(result);
- }
- function normalizeDouyinPath(path) {
- if (IS_TIKHUB_DIRECT && !path.startsWith('/api/v1/')) {
- return `/api/v1${path}`;
- }
- return path;
- }
- function selectAudioCandidate(detail) {
- const candidates = [];
- collectMediaUrls(detail, candidates, []);
- const audio = candidates.find(item => item.kind === 'audio') || candidates.find(item => /audio|mp4a|music|sound/i.test(item.url));
- return audio || { url: '', durationMs: extractDurationMs(detail) };
- }
- function collectMediaUrls(value, out, path) {
- if (!value) return;
- if (Array.isArray(value)) {
- value.forEach((item, index) => collectMediaUrls(item, out, [...path, String(index)]));
- return;
- }
- if (typeof value !== 'object') return;
- for (const [key, raw] of Object.entries(value)) {
- const keyPath = [...path, key].join('.');
- if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) {
- const joined = keyPath.toLowerCase();
- const kind = /audio|music|sound|mp4a/.test(joined) ? 'audio' : /video|play|download|media/.test(joined) ? 'video' : '';
- if (kind) out.push({ url: raw, kind, keyPath, durationMs: extractDurationMs(value) });
- } else if (raw && typeof raw === 'object') {
- collectMediaUrls(raw, out, [...path, key]);
- }
- }
- }
- function extractDurationMs(value) {
- const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
- if (!found) return 0;
- return found > 1000 ? Math.round(found) : Math.round(found * 1000);
- }
- function findFirstNumber(value, keys, depth = 0) {
- if (!value || depth > 5) return 0;
- if (Array.isArray(value)) {
- for (const item of value) {
- const hit = findFirstNumber(item, keys, depth + 1);
- if (hit) return hit;
- }
- return 0;
- }
- if (typeof value !== 'object') return 0;
- for (const [key, raw] of Object.entries(value)) {
- if (keys.includes(key)) {
- const number = Number(raw);
- if (Number.isFinite(number) && number > 0) return number;
- }
- const hit = findFirstNumber(raw, keys, depth + 1);
- if (hit) return hit;
- }
- return 0;
- }
- async function uploadGatewayAudio(mediaUrl, durationMs) {
- let mediaResp;
- try {
- mediaResp = await fetch(mediaUrl);
- } catch (error) {
- throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
- }
- if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
- const blob = await mediaResp.blob();
- const form = new FormData();
- form.append('audio', blob, `douyin-audio-${Date.now()}.m4a`);
- form.append('durationMs', String(durationMs));
- form.append('roleType', '1');
- form.append('roleNum', '0');
- let resp;
- try {
- resp = await fetch(`${TRANSCRIPTION_GATEWAY}/upload`, {
- method: 'POST',
- headers: {
- Authorization: bearerAuth(VOC_TOKEN),
- Accept: 'application/json',
- },
- body: form,
- });
- } catch (error) {
- throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
- }
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok || data.success === false) throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${resp.status}`);
- const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
- if (!orderId) throw new Error('转写网关未返回 orderId');
- return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
- }
- async function queryGateway(orderId) {
- let resp;
- try {
- resp = await fetch(`${TRANSCRIPTION_GATEWAY}/result`, {
- method: 'POST',
- headers: {
- Authorization: bearerAuth(VOC_TOKEN),
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ orderId }),
- });
- } catch (error) {
- throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
- }
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok) throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${resp.status}`);
- return data;
- }
- function gatewayValue(data, key) {
- return data?.[key] ?? data?.data?.[key] ?? data?.result?.[key] ?? data?.content?.[key];
- }
- function readGatewayError(data) {
- const detail = data?.detail;
- if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
- const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || '';
- if (/company或用户信息不存在/.test(String(message))) {
- return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
- }
- return message;
- }
- function normalizeGatewaySegments(segments) {
- const arr = Array.isArray(segments) ? segments : [];
- return arr.map(segment => ({
- start: normalizeTime(segment.start ?? segment.begin ?? segment.bg),
- end: normalizeTime(segment.end ?? segment.ed),
- text: clean(segment.text || segment.onebest || segment.content),
- })).filter(segment => segment.text);
- }
- function normalizeTime(value) {
- const number = Number(value);
- if (!Number.isFinite(number)) return null;
- return number > 1000 ? number / 1000 : number;
- }
- function bearerAuth(token) {
- const value = clean(token);
- return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
- }
- function douyinTokenSource() {
- if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
- if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
- if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
- if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
- if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
- if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
- if (IS_TIKHUB_DIRECT && readEnv('TIKHUB_TOKEN')) return 'TIKHUB_TOKEN';
- if (!IS_TIKHUB_DIRECT && LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
- return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
- }
- function transcriptTokenSource() {
- if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
- if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
- if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
- if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
- if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
- if (LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
- return '';
- }
- function formatFetchError(error) {
- const message = error && error.message ? error.message : String(error || 'fetch failed');
- const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
- return `${message}${cause}`;
- }
- function maskBaseUrl(value) {
- return String(value || '').replace(/(token=)[^&]+/ig, '$1***');
- }
- function pickParam(request, ...names) {
- const sources = [request.params, request.body, request];
- for (const src of sources) {
- if (!src || typeof src !== 'object') continue;
- for (const name of names) {
- const value = src[name];
- if (value !== undefined && value !== null && value !== '') return value;
- }
- }
- return null;
- }
- async function requireSession(request) {
- const token = clean(pickParam(request, 'sessionToken'));
- if (!token) throw new Error('请先登录');
- const rows = await Psql.query(
- `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
- [token]
- );
- if (rows.length) return rows[0];
- const parseUser = await verifyParseSessionFromDb(token) || await verifyParseSession(token);
- if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
- throw new Error('登录已过期,请重新登录');
- }
- async function verifyParseSessionFromDb(sessionToken) {
- try {
- const rows = await Psql.query(
- `SELECT s."objectId" AS "sessionObjectId",
- s."sessionToken" AS "sessionToken",
- s."expiresAt" AS "expiresAt",
- s."_p_user" AS "userPointer",
- u."objectId" AS "objectId",
- u."username" AS "username"
- FROM "_Session" s
- LEFT JOIN "_User" u ON s."_p_user" = CONCAT('_User$', u."objectId")
- WHERE s."sessionToken"=$1
- AND (s."expiresAt" IS NULL OR s."expiresAt" > NOW())
- LIMIT 1`,
- [sessionToken]
- );
- if (!rows.length) return null;
- const row = rows[0];
- const pointerUserId = String(row.userPointer || '').replace(/^_User\$/, '');
- const objectId = row.objectId || pointerUserId;
- return objectId ? { objectId, username: row.username || '', source: 'parse-db' } : null;
- } catch (error) {
- console.warn('verifyParseSessionFromDb skipped:', error.message);
- return null;
- }
- }
- async function verifyParseSession(sessionToken) {
- if (typeof fetch !== 'function') return null;
- try {
- const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
- method: 'GET',
- headers: {
- 'X-Parse-Application-Id': PARSE_APP_ID,
- 'X-Parse-Session-Token': sessionToken,
- },
- });
- const data = await resp.json().catch(() => ({}));
- if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
- return data.objectId ? { ...data, source: 'parse-rest' } : null;
- } catch (error) {
- throw new Error(`Parse 会话网络校验失败:${formatFetchError(error)}`);
- }
- }
- function clean(value) {
- return String(value || '').trim();
- }
- function generateId() {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
- let s = '';
- for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
- return s;
- }
- function readEnv(name) {
- if (typeof process !== 'undefined' && process.env && process.env[name]) {
- return process.env[name];
- }
- return '';
- }
|