import { ensureSchema, getRawDb, uid } from "./mvp-db"; import { readFeishuSource, type FeishuBindings, type FeishuSource, } from "./feishu-client"; export type CreateDistributionTaskInput = { feishuUrl: string; name: string; brand: string; dueAt: string; }; export type DistributionTaskCreation = { created: boolean; taskId: string; shareToken: string; name: string; brand: string; dueAt: string; noteCount: number; sheetId: string; sheetName: string; sourceUrl: string; }; 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; }; 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 FROM tasks WHERE name = ? AND brand = ? AND due_at = ? AND source_url = ? AND status IN ('active', 'importing') ORDER BY created_at DESC LIMIT 1`, ) .bind(input.name, input.brand, input.dueAt, sourceUrl) .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, 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, 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}`, })); return db .prepare( `INSERT INTO contents (id, task_id, title, body, image_assets, status, source, source_row) VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`, ) .bind( contentId, taskId, row.title, row.body, JSON.stringify(imageAssets), `飞书 · ${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 = { feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl), name: normalizedValue(rawInput.name), brand: normalizedValue(rawInput.brand), dueAt: normalizedDueDate(rawInput.dueAt), }; 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, }; } } const source = await readFeishuSource(input.feishuUrl, bindings); 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, }; } 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(); }