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; }; export type CreateScreenshotTaskInput = { name: string; brand: string; dueAt: string; keyword: string; instructions: string; quantity: number; exampleImageKey?: string; projectId?: string; ownerUserid?: 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"; }; export type ScreenshotTaskCreation = { created: true; taskId: string; shareToken: string; name: string; brand: string; dueAt: string; quantity: number; }; 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(); } async function insertTaskFromSource( source: FeishuSource, input: CreateDistributionTaskInput, ) { 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) 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, ) .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), }; 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 inserted = await insertTaskFromSource(source, input); 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 async function createScreenshotTask( rawInput: CreateScreenshotTaskInput, ): Promise { await ensureSchema(); const input = { name: normalizedValue(rawInput.name), brand: normalizedValue(rawInput.brand), dueAt: normalizedDueDate(rawInput.dueAt), keyword: normalizedValue(rawInput.keyword), instructions: normalizedValue(rawInput.instructions), quantity: Math.floor(Number(rawInput.quantity)), exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""), projectId: normalizedValue(rawInput.projectId ?? ""), ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64), }; if (!input.name || !input.brand || !input.keyword || !input.instructions) { throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明"); } if (!Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 500) { throw new Error("任务数量需为 1—500 份"); } if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) { throw new Error("示例截图无效,请重新上传"); } const db = getRawDb(); const taskId = uid("task"); const shareToken = crypto.randomUUID().replaceAll("-", ""); const imageAssets = input.exampleImageKey ? JSON.stringify([ { index: 1, key: input.exampleImageKey, width: null, height: null }, ]) : "[]"; await db .prepare( `INSERT INTO tasks (id, name, brand, quantity, claimed_quantity, due_at, status, task_type, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name, share_token, collection_days) VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]')`, ) .bind( taskId, input.name, input.brand, input.quantity, input.dueAt, input.projectId, input.ownerUserid, shareToken, ) .run(); try { const statements = Array.from({ length: input.quantity }, (_, index) => db .prepare( `INSERT INTO contents (id, task_id, title, body, image_assets, video_assets, status, source, source_row) VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`, ) .bind( uid("content"), taskId, input.keyword, input.instructions, imageAssets, index + 1, ), ); for (let index = 0; index < statements.length; index += 100) { await db.batch(statements.slice(index, index + 100)); } } 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 { created: true, taskId, shareToken, name: input.name, brand: input.brand, dueAt: input.dueAt, quantity: input.quantity, }; } export type UpdateTaskInput = { taskId: string; name: string; brand: string; dueAt: string; projectId?: string; platform?: "小红书" | "抖音"; contentFormat?: "image_text" | "video"; quantity?: number; keyword?: string; instructions?: string; exampleImageKey?: string; }; export type TaskUpdateResult = { taskId: string; quantity: number; claimedQuantity: number; }; type UpdatableTaskRow = { id: string; task_type: "content_publish" | "screenshot_collect"; quantity: number; claimed_quantity: number; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; }; // 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应 async function syncScreenshotContents( db: DatabaseClient, taskId: string, params: { oldQuantity: number; quantity: number; keyword: string; instructions: string; exampleImageKey: string; }, ) { const delta = params.quantity - params.oldQuantity; if (delta > 0) { const sample = await db .prepare( "SELECT image_assets FROM contents WHERE task_id = ? ORDER BY source_row LIMIT 1", ) .bind(taskId) .first<{ image_assets: string }>(); const imageAssets = sample?.image_assets ?? "[]"; const statements = Array.from({ length: delta }, (_, index) => db .prepare( `INSERT INTO contents (id, task_id, title, body, image_assets, video_assets, status, source, source_row) VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`, ) .bind( uid("content"), taskId, params.keyword, params.instructions, imageAssets, params.oldQuantity + index + 1, ), ); for (let index = 0; index < statements.length; index += 100) { await db.batch(statements.slice(index, index + 100)); } } else if (delta < 0) { // 只删未领取的尾部行,已领取(claimed)的保留 await db .prepare( `DELETE FROM contents WHERE task_id = ? AND status = 'available' ORDER BY source_row DESC LIMIT ?`, ) .bind(taskId, -delta) .run(); } await db .prepare("UPDATE contents SET title = ?, body = ? WHERE task_id = ?") .bind(params.keyword, params.instructions, taskId) .run(); if (params.exampleImageKey.startsWith("task-assets/")) { const imageAssets = JSON.stringify([ { index: 1, key: params.exampleImageKey, width: null, height: null }, ]); await db .prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?") .bind(imageAssets, taskId) .run(); } } 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, quantity: Math.floor(Number(rawInput.quantity ?? 0)), keyword: normalizedValue(rawInput.keyword ?? ""), instructions: normalizedValue(rawInput.instructions ?? ""), exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""), }; if (!input.name || !input.brand) { throw new Error("请补全任务名称和品牌"); } const row = await db .prepare( "SELECT id, task_type, quantity, claimed_quantity, platform, content_format FROM tasks WHERE id = ?", ) .bind(taskId) .first(); if (!row) throw new Error("任务不存在"); const claimed = Number(row.claimed_quantity); let quantity = Number(row.quantity); if (row.task_type === "screenshot_collect") { if (!input.keyword || !input.instructions) { throw new Error("请补全搜索关键词和任务说明"); } if ( !Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 500 ) { throw new Error("任务数量需为 1—500 份"); } if (input.quantity < claimed) { throw new Error(`任务数量不能低于已领取的 ${claimed} 份`); } quantity = input.quantity; await syncScreenshotContents(db, taskId, { oldQuantity: Number(row.quantity), quantity, keyword: input.keyword, instructions: input.instructions, exampleImageKey: input.exampleImageKey, }); } await db .prepare( `UPDATE tasks SET name = ?, brand = ?, due_at = ?, project_id = ?, platform = ?, content_format = ?, quantity = ? WHERE id = ?`, ) .bind( input.name, input.brand, input.dueAt, input.projectId, input.platform ?? row.platform, input.contentFormat ?? row.content_format, quantity, taskId, ) .run(); return { taskId, quantity, claimedQuantity: claimed }; } 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(); }