feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列 - 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移 - 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts - 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口 - 工作台项目进度卡与未绑定 banner 排版修复 - 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
} from "./mcp-collection-client";
|
||||
import { uid } from "./mvp-db";
|
||||
import type { DatabaseClient, DatabaseStatement } from "./database";
|
||||
import { refreshTaskStatus } from "./task-service";
|
||||
|
||||
type DistributionForCollection = {
|
||||
id: string;
|
||||
@@ -336,6 +337,7 @@ export async function collectDistributionMetrics(
|
||||
distributionId,
|
||||
),
|
||||
]);
|
||||
await refreshTaskStatus(current.task_id, db).catch(() => undefined);
|
||||
return { skipped: false, likes, comments, collects, shares };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseClient } from "./database";
|
||||
import { refreshTaskStatus } from "./task-service";
|
||||
|
||||
type ReleasableDistribution = {
|
||||
id: string;
|
||||
@@ -42,7 +43,7 @@ export async function releaseUnfinishedDistribution(
|
||||
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
|
||||
}
|
||||
|
||||
return database.transaction(async (db) => {
|
||||
const result = await database.transaction(async (db) => {
|
||||
const distribution = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
@@ -150,4 +151,7 @@ export async function releaseUnfinishedDistribution(
|
||||
partnerName: distribution.partner_name,
|
||||
};
|
||||
});
|
||||
// 释放会让任务从 completed 回到 active(content 重新 available)
|
||||
await refreshTaskStatus(result.taskId, database).catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -400,6 +400,16 @@ export async function ensureSchema(database?: DatabaseClient) {
|
||||
"join_qr_code",
|
||||
"join_qr_code TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"related_task_id",
|
||||
"related_task_id TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
await ensureColumn(
|
||||
"contents",
|
||||
"related_content_id",
|
||||
"related_content_id TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
|
||||
const tasksWithoutShare = await db
|
||||
.prepare(
|
||||
@@ -555,8 +565,8 @@ export async function seedIfEmpty() {
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
)
|
||||
.bind(
|
||||
"task-summer",
|
||||
@@ -570,8 +580,8 @@ export async function seedIfEmpty() {
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
)
|
||||
.bind(
|
||||
"task-living",
|
||||
@@ -853,11 +863,24 @@ export async function getDashboardData(ownerUserid?: string) {
|
||||
});
|
||||
const tasksBase = `SELECT t.*,
|
||||
(SELECT c.title FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS keyword,
|
||||
(SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions
|
||||
FROM tasks t`;
|
||||
(SELECT c.body FROM contents c WHERE c.task_id = t.id ORDER BY c.source_row LIMIT 1) AS instructions,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS dist_total,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.status = 'complete') AS dist_complete,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS dist_submitted,
|
||||
(SELECT rt.name FROM tasks rt WHERE rt.id = t.related_task_id) AS related_task_name,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.related_task_id) AS related_dist_total,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.related_task_id AND d.status = 'complete') AS related_dist_complete,
|
||||
(SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AS reverse_related_task_id,
|
||||
(SELECT rt2.name FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AS reverse_related_task_name,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = (SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1)) AS reverse_dist_total,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = (SELECT rt2.id FROM tasks rt2 WHERE rt2.related_task_id = t.id LIMIT 1) AND d.result_submitted_at IS NOT NULL) AS reverse_dist_submitted
|
||||
FROM tasks t
|
||||
LEFT JOIN projects pj ON pj.id = t.project_id`;
|
||||
const distributionsBase = `SELECT
|
||||
d.*,
|
||||
c.title AS content_title,
|
||||
c.related_content_id AS related_content_id,
|
||||
rc.title AS related_content_title,
|
||||
p.name AS partner_name,
|
||||
cl.claimant_name AS claimant_name,
|
||||
a.nickname AS account_nickname,
|
||||
@@ -870,9 +893,11 @@ export async function getDashboardData(ownerUserid?: string) {
|
||||
t.due_at AS due_at
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
LEFT JOIN contents rc ON rc.id = c.related_content_id
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
LEFT JOIN projects pj ON pj.id = t.project_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id`;
|
||||
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
|
||||
await Promise.all([
|
||||
@@ -882,9 +907,9 @@ export async function getDashboardData(ownerUserid?: string) {
|
||||
ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`${tasksBase} WHERE t.owner_userid = ? ORDER BY t.created_at DESC`,
|
||||
`${tasksBase} WHERE (t.owner_userid = ? OR (t.project_id != '' AND pj.owner_userid = ?)) ORDER BY t.created_at DESC`,
|
||||
)
|
||||
.bind(ownerFilter)
|
||||
.bind(ownerFilter, ownerFilter)
|
||||
.all()
|
||||
: ownerFilter === null
|
||||
? db.prepare(`${tasksBase} ORDER BY t.created_at DESC`).all()
|
||||
@@ -913,9 +938,9 @@ export async function getDashboardData(ownerUserid?: string) {
|
||||
ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`${distributionsBase} WHERE t.owner_userid = ? ORDER BY d.updated_at DESC, d.claimed_at DESC`,
|
||||
`${distributionsBase} WHERE (t.owner_userid = ? OR (t.project_id != '' AND pj.owner_userid = ?)) ORDER BY d.updated_at DESC, d.claimed_at DESC`,
|
||||
)
|
||||
.bind(ownerFilter)
|
||||
.bind(ownerFilter, ownerFilter)
|
||||
.all()
|
||||
: ownerFilter === null
|
||||
? db
|
||||
@@ -964,6 +989,11 @@ export type TaskReportRow = {
|
||||
latest_comments: number | null;
|
||||
latest_collects: number | null;
|
||||
latest_shares: number | null;
|
||||
related_task_id: string | null;
|
||||
related_task_name: string | null;
|
||||
reverse_related_task_id: string | null;
|
||||
reverse_related_task_name: string | null;
|
||||
submitted_count: number;
|
||||
};
|
||||
|
||||
export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string) {
|
||||
@@ -982,10 +1012,15 @@ export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string
|
||||
t.due_at,
|
||||
t.created_at,
|
||||
t.quantity,
|
||||
t.related_task_id,
|
||||
rt.name AS related_task_name,
|
||||
(SELECT rrt.id FROM tasks rrt WHERE rrt.related_task_id = t.id LIMIT 1) AS reverse_related_task_id,
|
||||
(SELECT rrt.name FROM tasks rrt WHERE rrt.related_task_id = t.id LIMIT 1) AS reverse_related_task_name,
|
||||
p.name AS project_name,
|
||||
COUNT(d.id) AS claimed_count,
|
||||
SUM(CASE WHEN TRIM(COALESCE(d.publish_url, '')) != '' THEN 1 ELSE 0 END) AS published_count,
|
||||
SUM(CASE WHEN d.status = 'complete' THEN 1 ELSE 0 END) AS completed_count,
|
||||
SUM(CASE WHEN d.result_submitted_at IS NOT NULL THEN 1 ELSE 0 END) AS submitted_count,
|
||||
SUM(d.exposure) AS exposure_total,
|
||||
SUM(d.views) AS views_total,
|
||||
SUM(d.d2_likes) AS d2_likes,
|
||||
@@ -1003,13 +1038,14 @@ export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string
|
||||
SUM(d.latest_shares) AS latest_shares
|
||||
FROM tasks t
|
||||
LEFT JOIN distributions d ON d.task_id = t.id
|
||||
LEFT JOIN projects p ON p.id = t.project_id`;
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
LEFT JOIN tasks rt ON rt.id = t.related_task_id`;
|
||||
const statement = ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`${base} WHERE t.owner_userid = ? GROUP BY t.id ORDER BY t.created_at DESC`,
|
||||
`${base} WHERE (t.owner_userid = ? OR p.owner_userid = ?) GROUP BY t.id ORDER BY t.created_at DESC`,
|
||||
)
|
||||
.bind(ownerFilter)
|
||||
.bind(ownerFilter, ownerFilter)
|
||||
: db.prepare(`${base} GROUP BY t.id ORDER BY t.created_at DESC`);
|
||||
const result = await statement.all<TaskReportRow>();
|
||||
return result.results;
|
||||
|
||||
@@ -26,12 +26,21 @@ export async function isTaskOwner(
|
||||
db: DatabaseClient,
|
||||
taskId: string,
|
||||
wecomUserId: string,
|
||||
options?: { strict?: boolean },
|
||||
) {
|
||||
const id = String(taskId ?? "").trim();
|
||||
if (!id || !wecomUserId) return false;
|
||||
const row = await db
|
||||
.prepare("SELECT id FROM tasks WHERE id = ? AND owner_userid = ? LIMIT 1")
|
||||
.bind(id, wecomUserId)
|
||||
.prepare(
|
||||
options?.strict
|
||||
? "SELECT id FROM tasks WHERE id = ? AND owner_userid = ? LIMIT 1"
|
||||
: `SELECT t.id
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.id = ? AND (t.owner_userid = ? OR p.owner_userid = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.bind(...(options?.strict ? [id, wecomUserId] : [id, wecomUserId, wecomUserId]))
|
||||
.first<{ id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
@@ -48,7 +57,42 @@ export async function isDistributionOwner(
|
||||
`SELECT d.id
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
WHERE d.id = ? AND t.owner_userid = ?
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE d.id = ? AND (t.owner_userid = ? OR p.owner_userid = ?)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.bind(id, wecomUserId, wecomUserId)
|
||||
.first<{ id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function isProjectOwner(
|
||||
db: DatabaseClient,
|
||||
projectId: string,
|
||||
wecomUserId: string,
|
||||
) {
|
||||
const id = String(projectId ?? "").trim();
|
||||
if (!id || !wecomUserId) return false;
|
||||
const row = await db
|
||||
.prepare("SELECT id FROM projects WHERE id = ? AND owner_userid = ? LIMIT 1")
|
||||
.bind(id, wecomUserId)
|
||||
.first<{ id: string }>();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function isTaskProjectOwner(
|
||||
db: DatabaseClient,
|
||||
taskId: string,
|
||||
wecomUserId: string,
|
||||
) {
|
||||
const id = String(taskId ?? "").trim();
|
||||
if (!id || !wecomUserId) return false;
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT t.id
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.id = ? AND p.owner_userid = ?
|
||||
LIMIT 1`,
|
||||
)
|
||||
.bind(id, wecomUserId)
|
||||
@@ -59,3 +103,7 @@ export async function isDistributionOwner(
|
||||
export function ownerForbidden() {
|
||||
return Response.json({ error: "该任务未分配给您,无权操作" }, { status: 403 });
|
||||
}
|
||||
|
||||
export function projectForbidden() {
|
||||
return Response.json({ error: "您不是该项目负责人,无权操作" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -224,15 +224,33 @@ export async function ensureProjectMainChat(
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
ORDER BY p.created_at DESC`,
|
||||
)
|
||||
.all<ProjectRow & { task_count: number }>();
|
||||
export async function listProjects(
|
||||
db: DatabaseClient,
|
||||
ownerUserid?: string,
|
||||
): Promise<ProjectSummary[]> {
|
||||
// undefined=全量;""=已登录未绑定企微(空范围,不能绑定 '' 否则漏出无主项目)
|
||||
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
|
||||
return [];
|
||||
}
|
||||
const ownerFilter = String(ownerUserid ?? "").trim();
|
||||
const result = await (ownerFilter
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
WHERE p.owner_userid = ?
|
||||
ORDER BY p.created_at DESC`,
|
||||
)
|
||||
.bind(ownerFilter)
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||
FROM projects p
|
||||
ORDER BY p.created_at DESC`,
|
||||
)
|
||||
).all<ProjectRow & { task_count: number }>();
|
||||
return result.results.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
|
||||
@@ -30,6 +30,8 @@ const CSV_HEADERS = [
|
||||
"最新评论",
|
||||
"最新收藏",
|
||||
"最新转发",
|
||||
"关联任务",
|
||||
"截图提交数",
|
||||
];
|
||||
|
||||
function taskTypeLabel(value: string) {
|
||||
@@ -85,6 +87,8 @@ export function buildTaskReportCsv(rows: TaskReportRow[]) {
|
||||
row.latest_comments,
|
||||
row.latest_collects,
|
||||
row.latest_shares,
|
||||
row.related_task_name ?? row.reverse_related_task_name ?? "",
|
||||
row.task_type === "screenshot_collect" ? row.submitted_count : "",
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(","),
|
||||
|
||||
@@ -19,11 +19,9 @@ export type CreateDistributionTaskInput = {
|
||||
|
||||
export type CreateScreenshotTaskInput = {
|
||||
name: string;
|
||||
brand: string;
|
||||
relatedTaskId: string;
|
||||
dueAt: string;
|
||||
keyword: string;
|
||||
instructions: string;
|
||||
quantity: number;
|
||||
exampleImageKey?: string;
|
||||
projectId?: string;
|
||||
ownerUserid?: string;
|
||||
@@ -52,6 +50,7 @@ export type ScreenshotTaskCreation = {
|
||||
brand: string;
|
||||
dueAt: string;
|
||||
quantity: number;
|
||||
relatedTaskId: string;
|
||||
};
|
||||
|
||||
type TaskRow = {
|
||||
@@ -147,8 +146,8 @@ async function insertTaskFromSource(
|
||||
(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', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
share_token, join_qr_code)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '')`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
@@ -278,32 +277,70 @@ export async function createDistributionTask(
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
brand: normalizedValue(rawInput.brand),
|
||||
relatedTaskId: normalizedValue(rawInput.relatedTaskId),
|
||||
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.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
|
||||
@@ -311,41 +348,45 @@ export async function createScreenshotTask(
|
||||
{ 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)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]')`,
|
||||
share_token, collection_days, join_qr_code, related_task_id)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]', '', ?)`,
|
||||
)
|
||||
.bind(
|
||||
taskId,
|
||||
input.name,
|
||||
input.brand,
|
||||
input.quantity,
|
||||
source.brand,
|
||||
sourceContents.results.length,
|
||||
input.dueAt,
|
||||
input.projectId,
|
||||
input.ownerUserid,
|
||||
projectId,
|
||||
ownerUserid,
|
||||
shareToken,
|
||||
input.relatedTaskId,
|
||||
)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const statements = Array.from({ length: input.quantity }, (_, index) =>
|
||||
const statements = sourceContents.results.map((row) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO contents
|
||||
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
|
||||
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
|
||||
(id, task_id, title, body, image_assets, video_assets, status, source, source_row, related_content_id)
|
||||
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
uid("content"),
|
||||
taskId,
|
||||
input.keyword,
|
||||
row.title,
|
||||
input.instructions,
|
||||
imageAssets,
|
||||
index + 1,
|
||||
row.source_row,
|
||||
row.id,
|
||||
),
|
||||
);
|
||||
for (let index = 0; index < statements.length; index += 100) {
|
||||
@@ -364,9 +405,10 @@ export async function createScreenshotTask(
|
||||
taskId,
|
||||
shareToken,
|
||||
name: input.name,
|
||||
brand: input.brand,
|
||||
brand: source.brand,
|
||||
dueAt: input.dueAt,
|
||||
quantity: input.quantity,
|
||||
quantity: sourceContents.results.length,
|
||||
relatedTaskId: input.relatedTaskId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,6 +439,7 @@ type UpdatableTaskRow = {
|
||||
claimed_quantity: number;
|
||||
platform: "小红书" | "抖音";
|
||||
content_format: "image_text" | "video";
|
||||
related_task_id: string;
|
||||
};
|
||||
|
||||
// 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应
|
||||
@@ -492,7 +535,7 @@ export async function updateTask(
|
||||
|
||||
const row = await db
|
||||
.prepare(
|
||||
"SELECT id, task_type, quantity, claimed_quantity, platform, content_format FROM tasks WHERE id = ?",
|
||||
"SELECT id, task_type, quantity, claimed_quantity, platform, content_format, related_task_id FROM tasks WHERE id = ?",
|
||||
)
|
||||
.bind(taskId)
|
||||
.first<UpdatableTaskRow>();
|
||||
@@ -501,27 +544,65 @@ export async function updateTask(
|
||||
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("请补全搜索关键词和任务说明");
|
||||
const related = Boolean(row.related_task_id);
|
||||
if (!input.instructions) {
|
||||
throw new Error(related ? "请补全任务说明" : "请补全搜索关键词和任务说明");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(input.quantity) ||
|
||||
input.quantity < 1 ||
|
||||
input.quantity > 500
|
||||
) {
|
||||
throw new Error("任务数量需为 1—500 份");
|
||||
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,
|
||||
});
|
||||
}
|
||||
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
|
||||
@@ -542,6 +623,7 @@ export async function updateTask(
|
||||
taskId,
|
||||
)
|
||||
.run();
|
||||
await refreshTaskStatus(taskId, db).catch(() => undefined);
|
||||
|
||||
return { taskId, quantity, claimedQuantity: claimed };
|
||||
}
|
||||
@@ -553,3 +635,60 @@ export function buildClaimUrl(portalOrigin: string, shareToken: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -267,7 +267,9 @@ export async function getUserFromSessionToken(token: string): Promise<AuthUser |
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserFromCookieHeader(cookieHeader: string | null) {
|
||||
export async function getUserFromCookieHeader(
|
||||
cookieHeader: string | null,
|
||||
): Promise<AuthUser | null> {
|
||||
return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader));
|
||||
}
|
||||
|
||||
@@ -277,12 +279,18 @@ function hasInternalToken(request: Request) {
|
||||
return Boolean(expected && received && safeEqual(expected, received));
|
||||
}
|
||||
|
||||
// Turbopack dev 会把 `!user` 误判为编译期常量并删掉判空分支,这里用不透明函数调用兜住
|
||||
function isNullish(value: unknown): value is null | undefined {
|
||||
return value === null || value === undefined;
|
||||
}
|
||||
|
||||
export async function getRequestPrincipal(
|
||||
request: Request,
|
||||
): Promise<RequestPrincipal | null> {
|
||||
if (hasInternalToken(request)) return { kind: "internal" };
|
||||
const user = await getUserFromCookieHeader(request.headers.get("cookie"));
|
||||
return user ? { kind: "user", user } : null;
|
||||
if (isNullish(user)) return null;
|
||||
return { kind: "user", user };
|
||||
}
|
||||
|
||||
export async function isAppRequest(request: Request) {
|
||||
|
||||
@@ -27,10 +27,23 @@ type DueSoonRow = {
|
||||
wecom_external_user_id: string | null;
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
task_type: string;
|
||||
due_at: string;
|
||||
content_title: string;
|
||||
};
|
||||
|
||||
function pendingActionLabel(taskType: string) {
|
||||
return taskType === "screenshot_collect" ? "截图待提交" : "待发布";
|
||||
}
|
||||
|
||||
function pendingSummary(rows: DueSoonRow[]) {
|
||||
const shot = rows.filter((row) => row.task_type === "screenshot_collect").length;
|
||||
const publish = rows.length - shot;
|
||||
if (shot === 0) return `${rows.length} 条笔记待发布`;
|
||||
if (publish === 0) return `${rows.length} 份截图待提交`;
|
||||
return `${publish} 条笔记待发布、${shot} 份截图待提交`;
|
||||
}
|
||||
|
||||
export function computeDueCutoff(today: string, dueDays: number) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
|
||||
if (!match) return today;
|
||||
@@ -60,6 +73,7 @@ export async function runDueSoonWecomNotifications(
|
||||
p.wecom_external_user_id,
|
||||
t.id AS task_id,
|
||||
t.name AS task_name,
|
||||
t.task_type,
|
||||
t.due_at,
|
||||
c.title AS content_title
|
||||
FROM distributions d
|
||||
@@ -115,13 +129,13 @@ export async function runDueSoonWecomNotifications(
|
||||
? [entry.externalUserId]
|
||||
: [];
|
||||
const lines = entry.rows.slice(0, 5).map((row) => {
|
||||
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(截止 ${row.due_at})`;
|
||||
return `· 《${truncate(row.task_name, 24)}》— ${truncate(row.content_title, 24)}(${pendingActionLabel(row.task_type)},截止 ${row.due_at})`;
|
||||
});
|
||||
const overflow =
|
||||
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
|
||||
const link = portalUrl ? `\n领取链接:${portalUrl}` : "";
|
||||
const content =
|
||||
`${entry.partnerName},你有 ${entry.rows.length} 条内容待发布:\n${lines.join("\n")}${overflow}${link}`;
|
||||
`${entry.partnerName},你有 ${pendingSummary(entry.rows)}:\n${lines.join("\n")}${overflow}${link}`;
|
||||
const appResult = await sendWecomAppMessage(
|
||||
external,
|
||||
content,
|
||||
@@ -166,7 +180,7 @@ export async function runDueSoonWecomNotifications(
|
||||
await sendTaskChatMessage(
|
||||
db,
|
||||
groupTaskId,
|
||||
`【催办】《${truncate(entry.taskName, 24)}》有 ${entry.rows.length} 条内容待发布(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
|
||||
`【催办】《${truncate(entry.taskName, 24)}》有 ${pendingSummary(entry.rows)}(截止 ${entry.dueAt}):\n${partners.slice(0, 5).join("、")}${overflow}`,
|
||||
config,
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
@@ -156,6 +156,71 @@ export function wecomClaimantPartnerId(externalUserId: string): string {
|
||||
return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`;
|
||||
}
|
||||
|
||||
export const WECOM_BIND_STATE_TTL_MS = 10 * 60 * 1_000;
|
||||
|
||||
type WecomBindStatePayload = {
|
||||
v: 2;
|
||||
uid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export async function signWecomBindState(
|
||||
userId: string,
|
||||
options: { secret?: string; nowMs?: number; ttlMs?: number } = {},
|
||||
): Promise<string> {
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const issuedAt = options.nowMs ?? Date.now();
|
||||
const ttl = options.ttlMs ?? WECOM_BIND_STATE_TTL_MS;
|
||||
const uid = String(userId ?? "").trim().slice(0, 64);
|
||||
if (!uid) throw new Error("缺少后台账号 ID,无法发起企微绑定");
|
||||
const payload: WecomBindStatePayload = {
|
||||
v: 2,
|
||||
uid,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + ttl,
|
||||
jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))),
|
||||
};
|
||||
const payloadSegment = toBase64Url(
|
||||
new TextEncoder().encode(JSON.stringify(payload)),
|
||||
);
|
||||
const signature = await hmacSha256(secret, payloadSegment);
|
||||
return `${payloadSegment}.${toBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
export async function verifyWecomBindState(
|
||||
state: string,
|
||||
options: { secret?: string; nowMs?: number } = {},
|
||||
): Promise<{ userId: string } | null> {
|
||||
const raw = String(state ?? "").trim();
|
||||
const dotIndex = raw.indexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === raw.length - 1) return null;
|
||||
const payloadSegment = raw.slice(0, dotIndex);
|
||||
const signatureSegment = raw.slice(dotIndex + 1);
|
||||
if (/[^A-Za-z0-9_-]/.test(payloadSegment) || /[^A-Za-z0-9_-]/.test(signatureSegment)) {
|
||||
return null;
|
||||
}
|
||||
let payload: WecomBindStatePayload;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
new TextDecoder().decode(fromBase64Url(payloadSegment)),
|
||||
) as WecomBindStatePayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (payload?.v !== 2 || typeof payload.uid !== "string") return null;
|
||||
const uid = payload.uid.trim().slice(0, 64);
|
||||
if (!uid) return null;
|
||||
const secret = options.secret ?? getWecomOAuthSecret();
|
||||
const expected = await hmacSha256(secret, payloadSegment);
|
||||
const provided = fromBase64Url(signatureSegment);
|
||||
if (!constantTimeEquals(expected, provided)) return null;
|
||||
const now = options.nowMs ?? Date.now();
|
||||
if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null;
|
||||
return { userId: uid };
|
||||
}
|
||||
|
||||
export async function resolveWecomClaimant(
|
||||
db: DatabaseClient,
|
||||
externalUserId: string,
|
||||
|
||||
Reference in New Issue
Block a user