Files
koc-loop/lib/task-service.ts
ABAPPLO e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00

521 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TaskRow>();
}
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<DistributionTaskCreation> {
await ensureSchema();
const input: Required<CreateDistributionTaskInput> = {
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<TaskUpdateResult> {
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<UpdatableTaskRow>();
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<string> {
const row = await db
.prepare("SELECT id, status FROM tasks WHERE id = ?")
.bind(taskId)
.first<TaskStatusRow>();
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<DistributionProgressRow>();
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;
}