import { ensureSchema, getRawDb, uid } from "./mvp-db"; import type { DatabaseClient } from "./database"; import { readFeishuSource, type FeishuBindings, type FeishuSource, } from "./feishu-client"; export type CreateDistributionTaskInput = { feishuUrl: string; name: string; brand: string; dueAt: string; platform?: "小红书" | "抖音"; contentFormat?: "image_text" | "video"; projectId?: string; ownerUserid?: string; recoveryEnabled?: boolean; recoveryDueAt?: string; recoveryInstructions?: string; recoveryExampleImageKey?: string; }; ; export type DistributionTaskCreation = { created: boolean; taskId: string; shareToken: string; name: string; brand: string; dueAt: string; noteCount: number; sheetId: string; sheetName: string; sourceUrl: string; platform: "小红书" | "抖音"; contentFormat: "image_text" | "video"; }; type TaskRow = { id: string; share_token: string | null; name: string; brand: string; due_at: string; quantity: number; source_url: string; source_sheet_id: string; source_sheet_name: string; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; }; function normalizedValue(value: string) { return String(value ?? "").trim(); } function normalizedDueDate(value: string) { const dueAt = normalizedValue(value); if (!/^\d{4}-\d{2}-\d{2}$/.test(dueAt)) { throw new Error("截止日期必须使用 YYYY-MM-DD 格式"); } const [year, month, day] = dueAt.split("-").map(Number); const parsed = new Date(Date.UTC(year, month - 1, day)); if ( Number.isNaN(parsed.getTime()) || parsed.getUTCFullYear() !== year || parsed.getUTCMonth() + 1 !== month || parsed.getUTCDate() !== day ) { throw new Error("截止日期无效"); } return dueAt; } function normalizedFeishuUrl(value: string) { const input = normalizedValue(value); try { const url = new URL(input); url.searchParams.delete("from"); url.searchParams.sort(); return url.toString(); } catch { return input; } } async function findExistingTask( sourceUrl: string, input: CreateDistributionTaskInput, ) { const db = getRawDb(); return db .prepare( `SELECT id, share_token, name, brand, due_at, quantity, source_url, source_sheet_id, source_sheet_name, platform, content_format FROM tasks WHERE name = ? AND brand = ? AND due_at = ? AND source_url = ? AND platform = ? AND content_format = ? AND status IN ('active', 'importing') ORDER BY created_at DESC LIMIT 1`, ) .bind( input.name, input.brand, input.dueAt, sourceUrl, input.platform ?? "小红书", input.contentFormat ?? "image_text", ) .first(); } type RecoveryConfig = { enabled: boolean; dueAt: string; instructions: string; exampleImages: string; }; function recoveryExampleImagesJson(exampleImageKey: string) { return exampleImageKey.startsWith("task-assets/") ? JSON.stringify([ { index: 1, key: exampleImageKey, width: null, height: null }, ]) : "[]"; } async function insertTaskFromSource( source: FeishuSource, input: CreateDistributionTaskInput, recovery: RecoveryConfig, ) { const db = getRawDb(); const taskId = uid("task"); const shareToken = crypto.randomUUID().replaceAll("-", ""); await db .prepare( `INSERT INTO tasks (id, name, brand, quantity, claimed_quantity, due_at, status, platform, content_format, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name, source_synced_at, share_token, join_qr_code, recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images) VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?)`, ) .bind( taskId, input.name, input.brand, source.rows.length, input.dueAt, input.platform ?? "小红书", input.contentFormat ?? "image_text", input.projectId, input.ownerUserid, source.url, source.sheetId, source.sheetName, source.syncedAt, shareToken, recovery.enabled ? 1 : 0, recovery.dueAt, recovery.instructions, recovery.exampleImages, ) .run(); try { const contentStatements = source.rows.map((row) => { const contentId = uid("content"); const imageAssets = row.images.map((image) => ({ ...image, key: `content-assets/${taskId}/${contentId}/${image.index}`, })); const videoAssets = row.videos.map((video) => ({ ...video, key: `content-videos/${taskId}/${contentId}/${video.index}`, })); return db .prepare( `INSERT INTO contents (id, task_id, title, body, image_assets, video_assets, status, source, source_row) VALUES (?, ?, ?, ?, ?, ?, 'available', ?, ?)`, ) .bind( contentId, taskId, row.title, row.body, JSON.stringify(imageAssets), JSON.stringify(videoAssets), `飞书 · ${source.sheetName}`, row.sourceRow, ); }); for (let index = 0; index < contentStatements.length; index += 100) { await db.batch(contentStatements.slice(index, index + 100)); } await db .prepare("UPDATE tasks SET status = 'active' WHERE id = ?") .bind(taskId) .run(); } catch (error) { await db.batch([ db.prepare("DELETE FROM contents WHERE task_id = ?").bind(taskId), db.prepare("DELETE FROM tasks WHERE id = ?").bind(taskId), ]); throw error; } return { taskId, shareToken }; } export async function createDistributionTask( rawInput: CreateDistributionTaskInput, bindings: FeishuBindings, options: { deduplicate?: boolean } = {}, ): Promise { await ensureSchema(); const input: Required = { feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl), name: normalizedValue(rawInput.name), brand: normalizedValue(rawInput.brand), dueAt: normalizedDueDate(rawInput.dueAt), platform: rawInput.platform === "抖音" ? "抖音" : "小红书", contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text", projectId: normalizedValue(rawInput.projectId ?? ""), ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64), recoveryEnabled: rawInput.recoveryEnabled !== false, recoveryDueAt: normalizedValue(rawInput.recoveryDueAt ?? ""), recoveryInstructions: normalizedValue(rawInput.recoveryInstructions ?? ""), recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""), }; if (!input.feishuUrl || !input.name || !input.brand) { throw new Error("请补全飞书链接、任务名称和品牌/项目"); } if (options.deduplicate) { const existing = await findExistingTask(input.feishuUrl, input); if (existing?.share_token) { return { created: false, taskId: existing.id, shareToken: existing.share_token, name: existing.name, brand: existing.brand, dueAt: existing.due_at, noteCount: Number(existing.quantity), sheetId: existing.source_sheet_id, sheetName: existing.source_sheet_name, sourceUrl: existing.source_url, platform: existing.platform, contentFormat: existing.content_format, }; } } const source = await readFeishuSource(input.feishuUrl, bindings); if ( input.contentFormat === "video" && source.rows.some((row) => row.videos.length === 0) ) { throw new Error("视频任务中存在未识别到视频的内容行,请检查飞书“视频”列"); } const recoveryDueAt = normalizedValue(rawInput.recoveryDueAt ?? ""); const inserted = await insertTaskFromSource(source, input, { enabled: rawInput.recoveryEnabled !== false, dueAt: recoveryDueAt ? normalizedDueDate(recoveryDueAt) : input.dueAt, instructions: normalizedValue(rawInput.recoveryInstructions ?? "") || "请提交发布后的笔记截图", exampleImages: recoveryExampleImagesJson( normalizedValue(rawInput.recoveryExampleImageKey ?? ""), ), }); return { created: true, taskId: inserted.taskId, shareToken: inserted.shareToken, name: input.name, brand: input.brand, dueAt: input.dueAt, noteCount: source.rows.length, sheetId: source.sheetId, sheetName: source.sheetName, sourceUrl: source.url, platform: input.platform, contentFormat: input.contentFormat, }; } export type UpdateTaskInput = { taskId: string; name: string; brand: string; dueAt: string; projectId?: string; platform?: "小红书" | "抖音"; contentFormat?: "image_text" | "video"; recoveryEnabled?: boolean; recoveryDueAt?: string; recoveryInstructions?: string; recoveryExampleImageKey?: string; }; export type TaskUpdateResult = { taskId: string; quantity: number; claimedQuantity: number; }; type UpdatableTaskRow = { id: string; quantity: number; claimed_quantity: number; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; recovery_enabled: number; recovery_due_at: string; recovery_instructions: string | null; recovery_example_images: string; }; export async function updateTask( rawInput: UpdateTaskInput, db: DatabaseClient = getRawDb(), ): Promise { const taskId = normalizedValue(rawInput.taskId); if (!taskId) throw new Error("缺少 taskId"); const input = { name: normalizedValue(rawInput.name), brand: normalizedValue(rawInput.brand), dueAt: normalizedDueDate(rawInput.dueAt), projectId: normalizedValue(rawInput.projectId ?? ""), platform: rawInput.platform === "抖音" || rawInput.platform === "小红书" ? rawInput.platform : undefined, contentFormat: rawInput.contentFormat === "video" || rawInput.contentFormat === "image_text" ? rawInput.contentFormat : undefined, recoveryExampleImageKey: normalizedValue(rawInput.recoveryExampleImageKey ?? ""), }; if (!input.name || !input.brand) { throw new Error("请补全任务名称和品牌"); } const row = await db .prepare( `SELECT id, quantity, claimed_quantity, platform, content_format, recovery_enabled, recovery_due_at, recovery_instructions, recovery_example_images FROM tasks WHERE id = ?`, ) .bind(taskId) .first(); if (!row) throw new Error("任务不存在"); const quantity = Number(row.quantity); const claimed = Number(row.claimed_quantity); const recoveryEnabled = rawInput.recoveryEnabled === undefined ? row.recovery_enabled : rawInput.recoveryEnabled ? 1 : 0; const recoveryDueAtInput = normalizedValue(rawInput.recoveryDueAt ?? ""); const recoveryDueAt = recoveryDueAtInput ? normalizedDueDate(recoveryDueAtInput) : row.recovery_due_at || input.dueAt; const recoveryInstructions = normalizedValue(rawInput.recoveryInstructions ?? "") || row.recovery_instructions || ""; const recoveryExampleImages = input.recoveryExampleImageKey ? recoveryExampleImagesJson(input.recoveryExampleImageKey) : row.recovery_example_images; await db .prepare( `UPDATE tasks SET name = ?, brand = ?, due_at = ?, project_id = ?, platform = ?, content_format = ?, quantity = ?, recovery_enabled = ?, recovery_due_at = ?, recovery_instructions = ?, recovery_example_images = ? WHERE id = ?`, ) .bind( input.name, input.brand, input.dueAt, input.projectId, input.platform ?? row.platform, input.contentFormat ?? row.content_format, quantity, recoveryEnabled, recoveryDueAt, recoveryInstructions, recoveryExampleImages, taskId, ) .run(); await refreshTaskStatus(taskId, db).catch(() => undefined); return { taskId, quantity, claimedQuantity: claimed }; } export type EnableRecoveryInput = { taskId: string; recoveryDueAt?: string; recoveryInstructions?: string; recoveryExampleImageKey?: string; }; export async function enableTaskRecovery( rawInput: EnableRecoveryInput, db: DatabaseClient = getRawDb(), ): Promise<{ taskId: string; recoveryDueAt: string }> { const taskId = normalizedValue(rawInput.taskId); if (!taskId) throw new Error("缺少 taskId"); const row = await db .prepare( "SELECT id, due_at, recovery_example_images FROM tasks WHERE id = ?", ) .bind(taskId) .first<{ id: string; due_at: string; recovery_example_images: string; }>(); if (!row) throw new Error("任务不存在"); const dueAtInput = normalizedValue(rawInput.recoveryDueAt ?? ""); const dueAt = dueAtInput ? normalizedDueDate(dueAtInput) : row.due_at; const exampleImageKey = normalizedValue(rawInput.recoveryExampleImageKey ?? ""); await db .prepare( `UPDATE tasks SET recovery_enabled = 1, recovery_due_at = ?, recovery_instructions = ?, recovery_example_images = ? WHERE id = ?`, ) .bind( dueAt, normalizedValue(rawInput.recoveryInstructions ?? "") || "请提交发布后的笔记截图", exampleImageKey ? recoveryExampleImagesJson(exampleImageKey) : row.recovery_example_images, taskId, ) .run(); return { taskId, recoveryDueAt: dueAt }; } export function buildClaimUrl(portalOrigin: string, shareToken: string) { const origin = normalizedValue(portalOrigin).replace(/\/$/, ""); if (!origin) throw new Error("KOC 领取站点地址尚未配置"); const url = new URL(origin); url.searchParams.set("task", shareToken); return url.toString(); } type TaskStatusRow = { id: string; status: string; }; type DistributionProgressRow = { total: number; complete_count: number; }; // 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景); // importing 是导入中间态,不参与推导。完成判定取发布阶段口径(complete_count) export async function refreshTaskStatus( taskId: string, db: DatabaseClient = getRawDb(), ): Promise { const row = await db .prepare("SELECT id, status FROM tasks WHERE id = ?") .bind(taskId) .first(); if (!row || row.status === "importing") return row?.status ?? ""; const progress = await db .prepare( `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END), 0) AS complete_count FROM distributions WHERE task_id = ?`, ) .bind(taskId) .first(); const available = await db .prepare( "SELECT COUNT(*) AS available_count FROM contents WHERE task_id = ? AND status = 'available'", ) .bind(taskId) .first<{ available_count: number }>(); const total = Number(progress?.total ?? 0); const availableCount = Number(available?.available_count ?? 0); const completed = total > 0 && availableCount === 0 && Number(progress?.complete_count ?? 0) === total; const next = completed ? "completed" : "active"; if (next !== row.status) { await db .prepare("UPDATE tasks SET status = ? WHERE id = ?") .bind(next, taskId) .run(); } return next; }