Files
koc-loop/lib/task-service.ts
ABAPPLO 8f1ebf7697 feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列
- 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移
- 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts
- 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口
- 工作台项目进度卡与未绑定 banner 排版修复
- 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
2026-09-17 15:21:24 +08:00

695 lines
20 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;
};
export type CreateScreenshotTaskInput = {
name: string;
relatedTaskId: string;
dueAt: string;
instructions: string;
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;
relatedTaskId: 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;
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>();
}
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, join_qr_code)
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<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),
};
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,
};
}
type ScreenshotSourceTaskRow = {
id: string;
name: string;
brand: string;
quantity: number;
project_id: string;
owner_userid: string;
task_type: "content_publish" | "screenshot_collect";
};
type ScreenshotSourceContentRow = {
id: string;
title: string;
source_row: number | null;
};
export async function createScreenshotTask(
rawInput: CreateScreenshotTaskInput,
): Promise<ScreenshotTaskCreation> {
await ensureSchema();
const input = {
name: normalizedValue(rawInput.name),
relatedTaskId: normalizedValue(rawInput.relatedTaskId),
dueAt: normalizedDueDate(rawInput.dueAt),
instructions: normalizedValue(rawInput.instructions),
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
projectId: normalizedValue(rawInput.projectId ?? ""),
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
};
if (!input.name || !input.relatedTaskId || !input.instructions) {
throw new Error("请补全任务名称、关联分发任务和任务说明");
}
if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) {
throw new Error("示例截图无效,请重新上传");
}
const db = getRawDb();
const source = await db
.prepare(
`SELECT id, name, brand, quantity, project_id, owner_userid, task_type
FROM tasks WHERE id = ?`,
)
.bind(input.relatedTaskId)
.first<ScreenshotSourceTaskRow>();
if (!source) throw new Error("关联的分发任务不存在");
if (source.task_type !== "content_publish") {
throw new Error("请选择内容发布类型的分发任务");
}
const existing = await db
.prepare("SELECT id FROM tasks WHERE related_task_id = ?")
.bind(input.relatedTaskId)
.first<{ id: string }>();
if (existing) throw new Error("该分发任务已有关联回收任务");
const sourceContents = await db
.prepare(
"SELECT id, title, source_row FROM contents WHERE task_id = ? ORDER BY source_row, id",
)
.bind(input.relatedTaskId)
.all<ScreenshotSourceContentRow>();
if (sourceContents.results.length === 0) {
throw new Error("分发任务还没有可回收的笔记");
}
const taskId = uid("task");
const shareToken = crypto.randomUUID().replaceAll("-", "");
const imageAssets = input.exampleImageKey
? JSON.stringify([
{ index: 1, key: input.exampleImageKey, width: null, height: null },
])
: "[]";
const projectId = input.projectId || source.project_id;
const ownerUserid = input.ownerUserid || source.owner_userid;
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, join_qr_code, related_task_id)
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]', '', ?)`,
)
.bind(
taskId,
input.name,
source.brand,
sourceContents.results.length,
input.dueAt,
projectId,
ownerUserid,
shareToken,
input.relatedTaskId,
)
.run();
try {
const statements = sourceContents.results.map((row) =>
db
.prepare(
`INSERT INTO contents
(id, task_id, title, body, image_assets, video_assets, status, source, source_row, related_content_id)
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?, ?)`,
)
.bind(
uid("content"),
taskId,
row.title,
input.instructions,
imageAssets,
row.source_row,
row.id,
),
);
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: source.brand,
dueAt: input.dueAt,
quantity: sourceContents.results.length,
relatedTaskId: input.relatedTaskId,
};
}
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";
related_task_id: string;
};
// 截图任务的 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<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,
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, related_task_id FROM tasks WHERE id = ?",
)
.bind(taskId)
.first<UpdatableTaskRow>();
if (!row) throw new Error("任务不存在");
const claimed = Number(row.claimed_quantity);
let quantity = Number(row.quantity);
if (row.task_type === "screenshot_collect") {
const related = Boolean(row.related_task_id);
if (!input.instructions) {
throw new Error(related ? "请补全任务说明" : "请补全搜索关键词和任务说明");
}
if (related) {
if (
rawInput.quantity !== undefined &&
rawInput.quantity !== null &&
input.quantity !== quantity
) {
throw new Error("关联任务的份数由分发任务决定,不可修改");
}
if (input.keyword) {
const firstTitle = await db
.prepare(
"SELECT title FROM contents WHERE task_id = ? ORDER BY source_row, id LIMIT 1",
)
.bind(taskId)
.first<{ title: string }>();
if (firstTitle && input.keyword !== firstTitle.title) {
throw new Error("关联任务的搜索关键词由分发任务笔记决定,不可修改");
}
}
await db
.prepare("UPDATE contents SET body = ? WHERE task_id = ?")
.bind(input.instructions, taskId)
.run();
if (input.exampleImageKey.startsWith("task-assets/")) {
const imageAssets = JSON.stringify([
{ index: 1, key: input.exampleImageKey, width: null, height: null },
]);
await db
.prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?")
.bind(imageAssets, taskId)
.run();
}
} else {
if (!input.keyword) {
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();
await refreshTaskStatus(taskId, db).catch(() => undefined);
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();
}
type TaskStatusRow = {
id: string;
task_type: "content_publish" | "screenshot_collect";
status: string;
};
type DistributionProgressRow = {
total: number;
complete_count: number;
submitted_count: number;
};
// 纯重算任务状态:条件满足置 completed,不满足回 active(释放重开等场景);
// importing 是导入中间态,不参与推导
export async function refreshTaskStatus(
taskId: string,
db: DatabaseClient = getRawDb(),
): Promise<string> {
const row = await db
.prepare("SELECT id, task_type, 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,
COALESCE(SUM(CASE WHEN result_submitted_at IS NOT NULL THEN 1 ELSE 0 END), 0) AS submitted_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);
let completed = false;
if (total > 0 && availableCount === 0) {
completed =
row.task_type === "screenshot_collect"
? Number(progress?.submitted_count ?? 0) === total
: 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;
}