@@ -4641,6 +5047,7 @@ function DistributionTable({
| 实际发布账号 |
发布时间 |
数据状态 |
+ {showCompanion && 收录验证 | }
状态 |
{onRelease && 操作 | }
@@ -4675,6 +5082,25 @@ function DistributionTable({
{item.publish_url ? "等待D2" : "尚未发布"}
)}
+ {showCompanion && (
+
+
+ {(() => {
+ const match = companionByContent.get(item.content_id);
+ if (!match) return —;
+ if (match.result_submitted_at) {
+ return (
+ <>
+ 已提交
+ {formatDate(match.result_submitted_at, true)}
+ >
+ );
+ }
+ return 未提交;
+ })()}
+
+ |
+ )}
{statusLabel(item.status)} |
{onRelease && (
@@ -4795,6 +5221,7 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
| 搜索关键词 |
+ 对应笔记 |
领取合作方 |
领取时间 |
提交时间 |
@@ -4811,6 +5238,16 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
{item.content_title}{item.task_name}
+
+ {item.related_content_title ? (
+
+ 文
+ {item.related_content_title}
+
+ ) : (
+ —
+ )}
+ |
{item.partner_name} |
{formatDate(item.claimed_at, true)} |
{formatDate(item.result_submitted_at, true)} |
@@ -6056,6 +6493,21 @@ function RecoveryPage({
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
+ const companionTask = tasks.find(
+ (task) =>
+ task.task_type === "screenshot_collect" &&
+ task.related_task_id === selectedTask.id,
+ );
+ const companionDistributions = companionTask
+ ? distributions.filter((item) => item.task_id === companionTask.id)
+ : [];
+ const companionSubmitted = companionDistributions.filter(
+ (item) => item.result_submitted_at,
+ ).length;
+ const companionByContent = new Map();
+ for (const item of companionDistributions) {
+ companionByContent.set(item.related_content_id || item.content_id, item);
+ }
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
@@ -6189,6 +6641,7 @@ function RecoveryPage({
/>
采集状态 |
创作者截图 |
+ {companionTask && 收录验证 | }
操作 |
@@ -6296,6 +6749,25 @@ function RecoveryPage({
)}
|
+ {companionTask && (
+
+
+ {(() => {
+ const match = companionByContent.get(item.content_id);
+ if (!match) return —;
+ if (match.result_submitted_at) {
+ return (
+ <>
+ 已提交
+ {formatDate(match.result_submitted_at, true)}
+ >
+ );
+ }
+ return 未提交;
+ })()}
+
+ |
+ )}
{item.screenshot_key && !hasCreatorMetrics(item) ? (
@@ -6335,6 +6807,56 @@ function RecoveryPage({
)}
+ {companionTask && (
+ <>
+
+ 收录回收 · 已领取{companionDistributions.length}
+
+ 已提交截图{companionSubmitted}
+
+ 待提交{companionDistributions.length - companionSubmitted}
+ 截图任务不进入自动采集
+
+
+
+
+ 收录回收 · {companionTask.name}
+ 配对的截图回收任务,每一项对应上方一篇笔记,用于验证收录。
+
+
+
+
+
+
+
+
+ >
+ )}
);
}
diff --git a/app/api/action/route.ts b/app/api/action/route.ts
index d86a26d..4f1377e 100644
--- a/app/api/action/route.ts
+++ b/app/api/action/route.ts
@@ -68,9 +68,12 @@ import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import {
getOwnerScope,
isDistributionOwner,
+ isProjectOwner,
isTaskOwner,
+ isTaskProjectOwner,
ownerForbidden,
ownerScopeUserid,
+ projectForbidden,
} from "../../../lib/owner-scope";
import { extractPublishUrl } from "../../../lib/publish-url";
import {
@@ -126,10 +129,11 @@ export async function POST(request: Request) {
const scope = await getOwnerScope(request);
const managerGate = () =>
scope.kind === "owner" ? managerForbidden() : null;
- const taskOwnerGate = async (taskId: unknown) => {
+ const taskOwnerGate = async (taskId: unknown, options?: { strict?: boolean }) => {
if (scope.kind === "full") return null;
const id = String(taskId ?? "").trim();
- return id && (await isTaskOwner(db, id, scope.wecomUserId))
+ return id &&
+ (await isTaskOwner(db, id, scope.wecomUserId, options))
? null
: ownerForbidden();
};
@@ -140,10 +144,22 @@ export async function POST(request: Request) {
? null
: ownerForbidden();
};
+ const projectOwnerGate = async (projectId: unknown) => {
+ if (scope.kind === "full") return null;
+ const id = String(projectId ?? "").trim();
+ return id && (await isProjectOwner(db, id, scope.wecomUserId))
+ ? null
+ : projectForbidden();
+ };
+ const taskProjectOwnerGate = async (taskId: unknown) => {
+ if (scope.kind === "full") return null;
+ const id = String(taskId ?? "").trim();
+ return id && (await isTaskProjectOwner(db, id, scope.wecomUserId))
+ ? null
+ : projectForbidden();
+ };
if (body.action === "inspect_feishu") {
- const forbidden = managerGate();
- if (forbidden) return forbidden;
const source = await readFeishuSource(
String(body.feishuUrl ?? "").trim(),
env as unknown as FeishuBindings,
@@ -167,7 +183,7 @@ export async function POST(request: Request) {
}
if (body.action === "create_task") {
- const forbidden = managerGate();
+ const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
const name = String(body.name ?? "").trim();
const brand = String(body.brand ?? "").trim();
@@ -195,15 +211,13 @@ export async function POST(request: Request) {
);
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
} else if (body.action === "create_screenshot_task") {
- const forbidden = managerGate();
+ const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
const creation = await createScreenshotTask({
name: String(body.name ?? "").trim(),
- brand: String(body.brand ?? "").trim(),
+ relatedTaskId: String(body.relatedTaskId ?? "").trim(),
dueAt: String(body.dueAt ?? "").trim(),
- keyword: String(body.keyword ?? "").trim(),
instructions: String(body.instructions ?? "").trim(),
- quantity: numberValue(body.quantity),
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
projectId: String(body.projectId ?? "").trim(),
ownerUserid: String(body.ownerUserid ?? "").trim(),
@@ -765,8 +779,6 @@ export async function POST(request: Request) {
const contacts = await listExternalContacts(wecomConfig);
return Response.json({ contacts });
} else if (body.action === "wecom_list_internal_contacts") {
- const forbidden = managerGate();
- if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(
env as unknown as WecomBindings,
);
@@ -816,16 +828,27 @@ export async function POST(request: Request) {
const pushes = await listGroupPushes(db, taskId || undefined);
return Response.json({ pushes });
} else if (body.action === "project_list") {
- const forbidden = managerGate();
- if (forbidden) return forbidden;
- return Response.json({ projects: await listProjects(db) });
+ return Response.json({
+ projects: await listProjects(db, ownerScopeUserid(scope)),
+ });
} else if (body.action === "project_detail") {
- const forbidden = managerGate();
+ const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
} else if (body.action === "project_save") {
- const forbidden = managerGate();
+ const projectId = String(body.id ?? "").trim();
+ // 项目负责人只能更新自己负责的项目,且归属 ownerUserid 强制保留原值
+ if (scope.kind === "owner" && !projectId) return managerForbidden();
+ const forbidden = await projectOwnerGate(projectId);
if (forbidden) return forbidden;
+ let nextOwnerUserid = body.ownerUserid;
+ if (scope.kind === "owner") {
+ const current = await db
+ .prepare("SELECT owner_userid FROM projects WHERE id = ?")
+ .bind(projectId)
+ .first<{ owner_userid: string }>();
+ nextOwnerUserid = current?.owner_userid ?? "";
+ }
const saved = await saveProject(
db,
{
@@ -833,19 +856,19 @@ export async function POST(request: Request) {
name: body.name,
brand: body.brand,
mainGroupMemberIds: body.mainGroupMemberIds,
- ownerUserid: body.ownerUserid,
+ ownerUserid: nextOwnerUserid,
note: body.note,
},
resolveWecomConfig(env as unknown as WecomBindings),
);
return Response.json(saved);
} else if (body.action === "project_delete") {
- const forbidden = managerGate();
+ const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
await deleteProject(db, String(body.projectId ?? ""));
return Response.json({ ok: true });
} else if (body.action === "project_ensure_main_chat") {
- const forbidden = managerGate();
+ const forbidden = await projectOwnerGate(body.projectId);
if (forbidden) return forbidden;
const result = await ensureProjectMainChat(
db,
@@ -854,7 +877,7 @@ export async function POST(request: Request) {
);
return Response.json(result);
} else if (body.action === "task_set_owner") {
- const forbidden = managerGate();
+ const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
const taskId = String(body.taskId ?? "").trim();
const ownerUserid = String(body.ownerUserid ?? "").trim();
@@ -882,8 +905,16 @@ export async function POST(request: Request) {
.first<{ owner_userid: string; task_chat_id: string }>();
return Response.json({ ok: true, assignment, task });
} else if (body.action === "task_update") {
- const forbidden = managerGate();
+ const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
+ if (scope.kind === "owner") {
+ // 迁移目标也必须是本人负责的项目;置空(脱离项目)同样拒绝
+ const nextProjectId = String(body.projectId ?? "").trim();
+ const projectForbiddenResponse = nextProjectId
+ ? await projectOwnerGate(nextProjectId)
+ : projectForbidden();
+ if (projectForbiddenResponse) return projectForbiddenResponse;
+ }
const platform = body.platform === "抖音" || body.platform === "小红书"
? (body.platform as "小红书" | "抖音")
: undefined;
@@ -919,7 +950,7 @@ export async function POST(request: Request) {
if (forbidden) return forbidden;
return Response.json({ partners: await listGroupOwnerPartners(db) });
} else if (body.action === "project_push_task") {
- const forbidden = managerGate();
+ const forbidden = await taskProjectOwnerGate(body.taskId);
if (forbidden) return forbidden;
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
const outcome = await pushProjectTask(
@@ -1008,7 +1039,7 @@ export async function POST(request: Request) {
});
} else if (body.action === "task_customer_chats") {
const taskId = String(body.taskId ?? "").trim();
- const forbidden = await taskOwnerGate(taskId);
+ const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare(
@@ -1037,7 +1068,7 @@ export async function POST(request: Request) {
});
} else if (body.action === "task_bind_customer_chat") {
const taskId = String(body.taskId ?? "").trim();
- const forbidden = await taskOwnerGate(taskId);
+ const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare(
diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts
index 2841b47..bbfad0c 100644
--- a/app/api/partner/route.ts
+++ b/app/api/partner/route.ts
@@ -23,6 +23,7 @@ import {
} from "../../../lib/partner-utils";
import { extractPublishUrl } from "../../../lib/publish-url";
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
+import { refreshTaskStatus } from "../../../lib/task-service";
import {
resolveWecomClaimant,
verifyWecomIdentityTicket,
@@ -1187,6 +1188,7 @@ async function handlePost(request: Request) {
).catch(() => undefined);
runInBackground(feedback, "wecom publish feedback");
}
+ await refreshTaskStatus(task.id, db).catch(() => undefined);
return Response.json({ ok: true });
}
@@ -1237,6 +1239,7 @@ async function handlePost(request: Request) {
)
.bind(exposure, views, assignment.id)
.run();
+ await refreshTaskStatus(task.id, db).catch(() => undefined);
return Response.json({ submitted: true });
}
@@ -1280,6 +1283,7 @@ async function handlePost(request: Request) {
);
}
await db.batch(statements);
+ await refreshTaskStatus(task.id, db).catch(() => undefined);
if (isWecomFeedbackEnabled()) {
const feedback = notifyPartnerSubmitted(
db,
diff --git a/app/api/recovery-export/route.ts b/app/api/recovery-export/route.ts
index 05954ef..094cdaf 100644
--- a/app/api/recovery-export/route.ts
+++ b/app/api/recovery-export/route.ts
@@ -304,6 +304,34 @@ export async function GET(request: Request) {
.bind(taskId)
.all();
const rows = result.results;
+ const companionTaskRow = await db
+ .prepare(
+ "SELECT id FROM tasks WHERE related_task_id = ? AND task_type = 'screenshot_collect' LIMIT 1",
+ )
+ .bind(taskId)
+ .first<{ id: string }>();
+ let companionSubmittedByContent: Map | null = null;
+ if (companionTaskRow) {
+ const companionRows = await db
+ .prepare(
+ `SELECT c.related_content_id, d.result_submitted_at
+ FROM distributions d
+ JOIN contents c ON c.id = d.content_id
+ WHERE d.task_id = ? AND c.related_content_id != ''`,
+ )
+ .bind(companionTaskRow.id)
+ .all<{
+ related_content_id: string;
+ result_submitted_at: string | null;
+ }>();
+ companionSubmittedByContent = new Map();
+ for (const row of companionRows.results) {
+ companionSubmittedByContent.set(
+ row.related_content_id,
+ Boolean(row.result_submitted_at),
+ );
+ }
+ }
const assetsByContent = new Map(
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
);
@@ -356,6 +384,7 @@ export async function GET(request: Request) {
"采集状态",
"合作方",
"领取人微信号/手机号",
+ ...(companionSubmittedByContent ? ["收录截图"] : []),
];
const originalImageStart = 3;
const accountColumn = originalImageStart + maxContentImages;
@@ -398,6 +427,15 @@ export async function GET(request: Request) {
row.distribution_id ? collectionLabel(row) : "未领取",
row.partner_name || "",
row.claimant_name || "",
+ ...(companionSubmittedByContent
+ ? [
+ companionSubmittedByContent.has(row.content_id)
+ ? companionSubmittedByContent.get(row.content_id)
+ ? "已提交"
+ : "未提交"
+ : "—",
+ ]
+ : []),
];
const images: RecoveryWorkbookRow["images"] = [];
for (const asset of contentAssets) {
@@ -433,6 +471,7 @@ export async function GET(request: Request) {
24,
18,
22,
+ ...(companionSubmittedByContent ? [12] : []),
];
const workbook = buildRecoveryWorkbook({
sheetName: `${task.name}-数据回收`,
diff --git a/app/api/wecom-bind/route.ts b/app/api/wecom-bind/route.ts
new file mode 100644
index 0000000..e37ae0c
--- /dev/null
+++ b/app/api/wecom-bind/route.ts
@@ -0,0 +1,177 @@
+import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
+import { getRuntimeEnv } from "../../../lib/runtime-env";
+import {
+ buildWecomOAuthUrl,
+ exchangeWecomOAuthCode,
+ resolveWecomConfig,
+ WecomClientError,
+ type WecomBindings,
+} from "../../../lib/wecom-client";
+import { getRequestPrincipal } from "../../../lib/user-auth";
+import {
+ signWecomBindState,
+ verifyWecomBindState,
+} from "../../../lib/wecom-oauth-service";
+
+const env = getRuntimeEnv();
+
+function bindResultPage(
+ title: string,
+ message: string,
+ options: { ok?: boolean; status?: number; redirectHome?: boolean } = {},
+) {
+ const ok = options.ok ?? false;
+ const html = `
+
+
+
+
+${title}
+
+
+
+
+ ${ok ? "✓" : "!"}
+ ${title}
+ ${message}
+ 回到工作台
+
+${options.redirectHome ? "" : ""}
+
+`;
+ return new Response(html, {
+ status: options.status ?? 200,
+ headers: {
+ "content-type": "text/html; charset=utf-8",
+ "cache-control": "private, no-store",
+ },
+ });
+}
+
+function appOrigin(requestUrl: URL) {
+ const configured = String(env.APP_ORIGIN ?? "").trim().replace(/\/$/, "");
+ return configured || requestUrl.origin;
+}
+
+async function handleStart(request: Request, requestUrl: URL) {
+ const principal = await getRequestPrincipal(request);
+ if (!principal || principal.kind !== "user") {
+ return Response.redirect(new URL("/", requestUrl).toString(), 302);
+ }
+ const config = resolveWecomConfig(env as unknown as WecomBindings);
+ if (!config.corpId || !config.agentId || !config.secret) {
+ return bindResultPage(
+ "暂不可用",
+ "服务器未配置企业微信自建应用(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET),请联系管理员。",
+ { status: 503 },
+ );
+ }
+ const state = await signWecomBindState(principal.user.id);
+ const redirectUri = `${appOrigin(requestUrl)}/api/wecom-bind`;
+ return new Response(null, {
+ status: 302,
+ headers: {
+ location: buildWecomOAuthUrl({
+ corpId: config.corpId,
+ agentId: config.agentId,
+ redirectUri,
+ state,
+ }),
+ "cache-control": "private, no-store",
+ },
+ });
+}
+
+async function handleCallback(request: Request, requestUrl: URL) {
+ const code = String(requestUrl.searchParams.get("code") ?? "").trim();
+ const state = String(requestUrl.searchParams.get("state") ?? "").trim();
+ if (!code || !state) {
+ return bindResultPage("绑定失败", "缺少企业微信授权参数,请回到工作台重新发起绑定。", {
+ status: 400,
+ });
+ }
+ const verified = await verifyWecomBindState(state);
+ if (!verified) {
+ return bindResultPage(
+ "绑定链接已过期",
+ "本次授权会话已失效(10 分钟内有效),请回到工作台重新点击「企业微信授权绑定」。",
+ { status: 400 },
+ );
+ }
+ const principal = await getRequestPrincipal(request);
+ if (
+ !principal ||
+ principal.kind !== "user" ||
+ principal.user.id !== verified.userId
+ ) {
+ return bindResultPage(
+ "登录态校验失败",
+ "请先在本浏览器登录发起绑定的那个后台账号,再重新完成企业微信授权。",
+ { status: 403 },
+ );
+ }
+ const config = resolveWecomConfig(env as unknown as WecomBindings);
+ let identity: { userId?: string; externalUserId?: string };
+ try {
+ identity = await exchangeWecomOAuthCode(config, code);
+ } catch (error) {
+ const message =
+ error instanceof WecomClientError
+ ? error.message
+ : "企业微信授权身份换取失败,请稍后重试。";
+ return bindResultPage("绑定失败", message, { status: 502 });
+ }
+ if (!identity.userId) {
+ return bindResultPage(
+ "无法绑定",
+ "当前企微身份不是企业内部成员(外部联系人不能绑定后台账号),请用企业内部成员身份授权。",
+ { status: 400 },
+ );
+ }
+ await ensureSchema();
+ const db = getRawDb();
+ const conflict = await db
+ .prepare(
+ "SELECT id, username FROM users WHERE wecom_userid = ? AND id <> ? LIMIT 1",
+ )
+ .bind(identity.userId, principal.user.id)
+ .first<{ id: string; username: string }>();
+ if (conflict) {
+ return bindResultPage(
+ "该企微账号已被绑定",
+ `企微账号 ${identity.userId} 已绑定到后台账号「${conflict.username}」,如需换绑请联系管理员在「用户管理」中处理。`,
+ { status: 409 },
+ );
+ }
+ await db
+ .prepare(
+ "UPDATE users SET wecom_userid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ )
+ .bind(identity.userId, principal.user.id)
+ .run();
+ return bindResultPage(
+ "绑定成功",
+ `已将企微账号 ${identity.userId} 绑定到当前后台账号,正在返回工作台…`,
+ { ok: true, redirectHome: true },
+ );
+}
+
+export async function GET(request: Request) {
+ const requestUrl = new URL(request.url);
+ if (requestUrl.searchParams.has("code")) {
+ return handleCallback(request, requestUrl);
+ }
+ return handleStart(request, requestUrl);
+}
diff --git a/app/globals.css b/app/globals.css
index 436b172..a309034 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -338,6 +338,13 @@ button:disabled {
margin: 0 !important;
}
+.related-task-meta {
+ color: #277a5d;
+ background: #e6f3ee;
+ border-radius: 9px;
+ padding: 2px 8px;
+}
+
a {
color: inherit;
text-decoration: none;
@@ -985,7 +992,9 @@ a {
.workflow-panel,
.task-mini-panel,
.table-panel,
-.wecom-status-panel {
+.wecom-status-panel,
+.project-progress-panel,
+.owner-unbound-banner {
padding: 21px;
}
@@ -1013,6 +1022,44 @@ a {
font-size: 10px;
}
+.owner-unbound-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.owner-unbound-banner h2 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 670;
+ letter-spacing: -0.02em;
+}
+
+.owner-unbound-banner p {
+ margin: 6px 0 0;
+ color: #7d8683;
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+.owner-bind-button {
+ flex-shrink: 0;
+ padding: 9px 18px;
+ border: 1px solid var(--green-deep);
+ border-radius: 10px;
+ color: white;
+ background: var(--green);
+ font-size: 12px;
+ font-weight: 600;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.owner-bind-button:hover {
+ background: var(--green-deep);
+}
+
.workflow-track {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto 1fr auto;
@@ -1334,7 +1381,8 @@ a {
background: #e9f3f6;
}
-.status-pill.complete {
+.status-pill.complete,
+.status-pill.completed {
color: #277a5d;
background: #e6f3ee;
}
@@ -1942,6 +1990,15 @@ a {
background: #e5e9e6;
}
+.inline-summary.related-summary {
+ border-color: #bfe3d6;
+ background: #f4faf7;
+}
+
+.inline-summary.related-summary strong {
+ font-size: 14px;
+}
+
.summary-note {
display: flex;
align-items: center;
@@ -2131,6 +2188,46 @@ a {
letter-spacing: -0.03em;
}
+.task-scope-companion {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 2px;
+ padding: 10px 12px;
+ border: 1px dashed #cfe0d8;
+ border-radius: 9px;
+ background: #f6faf8;
+ font-size: 10px;
+}
+
+.task-scope-companion .companion-label {
+ color: #277a5d;
+ font-weight: 650;
+}
+
+.task-scope-companion strong {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 10px;
+}
+
+.task-scope-companion .companion-progress {
+ color: #99a29f;
+ white-space: nowrap;
+}
+
+.tag-weak {
+ padding: 1px 7px;
+ border-radius: 8px;
+ color: #b3aba5;
+ background: #f6f4f2;
+ font-size: 8px;
+ white-space: nowrap;
+}
+
.task-scope-open {
display: flex;
width: 100%;
@@ -3331,6 +3428,98 @@ a {
gap: 6px;
}
+.collection-verify-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.collection-verify-cell small {
+ color: #99a29f;
+ font-size: 8px;
+}
+
+.project-progress-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.project-progress-row {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ padding: 13px 16px;
+ border: 1px solid #eef1ef;
+ border-radius: 11px;
+ background: #fbfcfb;
+}
+
+.project-progress-title {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ gap: 12px;
+}
+
+.project-progress-title .avatar {
+ width: 34px;
+ height: 34px;
+ flex-shrink: 0;
+}
+
+.project-progress-title strong {
+ display: block;
+ font-size: 13px;
+}
+
+.project-progress-title span {
+ display: block;
+ margin-top: 3px;
+ color: #7d8683;
+ font-size: 11px;
+}
+
+.project-progress-funnel {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ white-space: nowrap;
+}
+
+.project-progress-funnel i {
+ color: #c6cdc9;
+ font-size: 11px;
+ font-style: normal;
+}
+
+.funnel-chip {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 5px 12px;
+ border-radius: 9px;
+ color: #6b7470;
+ background: #f1f4f2;
+ font-size: 11px;
+}
+
+.funnel-chip b {
+ color: #37423d;
+ font-size: 13px;
+}
+
+.funnel-chip.done {
+ color: #277a5d;
+ background: #e6f3ee;
+}
+
+.funnel-chip.done b {
+ color: #277a5d;
+}
+
.creator-screenshot-link {
position: relative;
width: 72px;
diff --git a/db/schema.ts b/db/schema.ts
index 136ef5b..c75b205 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -70,6 +70,9 @@ export const tasks = mysqlTable(
.notNull()
.default(""),
joinQrCode: text("join_qr_code").notNull().default(""),
+ relatedTaskId: varchar("related_task_id", { length: 64 })
+ .notNull()
+ .default(""),
createdAt: timestamp("created_at"),
},
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
@@ -85,6 +88,9 @@ export const contents = mysqlTable("contents", {
status: varchar("status", { length: 32 }).notNull().default("available"),
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
sourceRow: int("source_row"),
+ relatedContentId: varchar("related_content_id", { length: 64 })
+ .notNull()
+ .default(""),
createdAt: timestamp("created_at"),
});
diff --git a/koc-portal/next.config.ts b/koc-portal/next.config.ts
index 45f28f1..45e7dea 100644
--- a/koc-portal/next.config.ts
+++ b/koc-portal/next.config.ts
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
trailingSlash: true,
images: { unoptimized: true },
turbopack: { root: path.resolve(process.cwd()) },
+ allowedDevOrigins: ["192.168.30.90", "localhost", "127.0.0.1"],
};
export default nextConfig;
diff --git a/lib/collection-service.ts b/lib/collection-service.ts
index d92ea56..6586e5d 100644
--- a/lib/collection-service.ts
+++ b/lib/collection-service.ts
@@ -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);
diff --git a/lib/distribution-release-service.ts b/lib/distribution-release-service.ts
index f4e3c42..5dff732 100644
--- a/lib/distribution-release-service.ts
+++ b/lib/distribution-release-service.ts
@@ -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;
}
diff --git a/lib/mvp-db.ts b/lib/mvp-db.ts
index ab37362..1586376 100644
--- a/lib/mvp-db.ts
+++ b/lib/mvp-db.ts
@@ -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();
return result.results;
diff --git a/lib/owner-scope.ts b/lib/owner-scope.ts
index 4760461..dd931dd 100644
--- a/lib/owner-scope.ts
+++ b/lib/owner-scope.ts
@@ -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 });
+}
diff --git a/lib/project-service.ts b/lib/project-service.ts
index 5bd0565..1598a33 100644
--- a/lib/project-service.ts
+++ b/lib/project-service.ts
@@ -224,15 +224,33 @@ export async function ensureProjectMainChat(
}
}
-export async function listProjects(db: DatabaseClient): Promise {
- 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();
+export async function listProjects(
+ db: DatabaseClient,
+ ownerUserid?: string,
+): Promise {
+ // 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();
return result.results.map((row) => ({
id: row.id,
name: row.name,
diff --git a/lib/task-report-csv.ts b/lib/task-report-csv.ts
index 159ea05..2c924f0 100644
--- a/lib/task-report-csv.ts
+++ b/lib/task-report-csv.ts
@@ -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(","),
diff --git a/lib/task-service.ts b/lib/task-service.ts
index a837f92..392a652 100644
--- a/lib/task-service.ts
+++ b/lib/task-service.ts
@@ -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 {
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();
+ 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();
+ 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();
@@ -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 {
+ const row = await db
+ .prepare("SELECT id, task_type, status FROM tasks WHERE id = ?")
+ .bind(taskId)
+ .first();
+ 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();
+ 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;
+}
diff --git a/lib/user-auth.ts b/lib/user-auth.ts
index 47a11e3..38c798f 100644
--- a/lib/user-auth.ts
+++ b/lib/user-auth.ts
@@ -267,7 +267,9 @@ export async function getUserFromSessionToken(token: string): Promise {
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 {
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) {
diff --git a/lib/wecom-notifier-service.ts b/lib/wecom-notifier-service.ts
index 7d5f1df..839b36e 100644
--- a/lib/wecom-notifier-service.ts
+++ b/lib/wecom-notifier-service.ts
@@ -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,
);
diff --git a/lib/wecom-oauth-service.ts b/lib/wecom-oauth-service.ts
index e161e92..764b5e2 100644
--- a/lib/wecom-oauth-service.ts
+++ b/lib/wecom-oauth-service.ts
@@ -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 {
+ 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,
diff --git a/mysql/0016_task_content_relation.sql b/mysql/0016_task_content_relation.sql
new file mode 100644
index 0000000..8ae0f64
--- /dev/null
+++ b/mysql/0016_task_content_relation.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `tasks` ADD COLUMN `related_task_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `join_qr_code`;
+-- statement-breakpoint
+ALTER TABLE `contents` ADD COLUMN `related_content_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER `source_row`;
+-- statement-breakpoint
+CREATE INDEX `contents_related_content_idx` ON `contents` (`related_content_id`);
diff --git a/tests/owner-permissions.test.mjs b/tests/owner-permissions.test.mjs
index 8b8f500..b213cc8 100644
--- a/tests/owner-permissions.test.mjs
+++ b/tests/owner-permissions.test.mjs
@@ -34,6 +34,10 @@ const mvpDb = readFileSync(
new URL("../lib/mvp-db.ts", import.meta.url),
"utf8",
);
+const ownerScope = readFileSync(
+ new URL("../lib/owner-scope.ts", import.meta.url),
+ "utf8",
+);
function section(source, action) {
const start = source.indexOf(`"${action}"`);
@@ -44,9 +48,6 @@ function section(source, action) {
test("manager-only actions gate on owner scope", () => {
const managerOnly = [
- "inspect_feishu",
- "create_task",
- "create_screenshot_task",
"claim",
"save_collection_schedule",
"run_due_collections",
@@ -57,18 +58,9 @@ test("manager-only actions gate on owner scope", () => {
"send_test_wecom",
"trigger_wecom_overdue_escalation",
"wecom_list_external_contacts",
- "wecom_list_internal_contacts",
"wecom_sync_group_chats",
- "project_list",
- "project_detail",
- "project_save",
- "project_delete",
- "project_ensure_main_chat",
- "task_set_owner",
- "task_update",
"partner_save",
"partner_group_owners",
- "project_push_task",
"refresh_account_fields",
"account_field_refresh_status",
];
@@ -82,6 +74,86 @@ test("manager-only actions gate on owner scope", () => {
}
});
+test("project-owner management actions gate on project ownership", () => {
+ for (const action of [
+ "project_detail",
+ "project_delete",
+ "project_ensure_main_chat",
+ "create_task",
+ "create_screenshot_task",
+ ]) {
+ const body = section(actionRoute, action);
+ assert.match(
+ body,
+ /projectOwnerGate\(/,
+ `${action} 必须校验项目负责人归属(owner 空 projectId 也 403)`,
+ );
+ }
+ for (const action of ["task_set_owner", "task_update", "project_push_task"]) {
+ const body = section(actionRoute, action);
+ assert.match(
+ body,
+ /taskProjectOwnerGate\(/,
+ `${action} 必须按任务→项目链校验项目负责人归属`,
+ );
+ }
+});
+
+test("project_save blocks creation and ownership transfer for project owners", () => {
+ const body = section(actionRoute, "project_save");
+ assert.match(body, /managerForbidden\(\)/, "项目负责人不可新建项目");
+ assert.match(body, /projectOwnerGate\(/, "项目负责人只能保存自己负责的项目");
+ assert.match(
+ body,
+ /SELECT owner_userid FROM projects WHERE id = \?/,
+ "owner 路径必须回填 DB 现值,堵死 ownerUserid 转移",
+ );
+ assert.match(body, /nextOwnerUserid/);
+});
+
+test("task_update blocks project migration by project owners", () => {
+ const body = section(actionRoute, "task_update");
+ assert.match(
+ body,
+ /projectOwnerGate\(nextProjectId\)/,
+ "迁移目标项目也必须本人负责",
+ );
+});
+
+test("read-only dependencies open to any logged-in user", () => {
+ for (const action of ["inspect_feishu", "wecom_list_internal_contacts"]) {
+ const body = section(actionRoute, action);
+ assert.ok(
+ !/managerGate\(\)/.test(body),
+ `${action} 不再是管理员专属(项目负责人建任务/选人依赖)`,
+ );
+ }
+});
+
+test("owner-scope exposes strict project ownership helpers", () => {
+ assert.match(ownerScope, /export async function isProjectOwner\(/);
+ const projectOwner = ownerScope.slice(
+ ownerScope.indexOf("export async function isProjectOwner"),
+ ownerScope.indexOf("export async function isTaskProjectOwner"),
+ );
+ assert.match(
+ projectOwner,
+ /SELECT id FROM projects WHERE id = \? AND owner_userid = \? LIMIT 1/,
+ );
+
+ assert.match(ownerScope, /export async function isTaskProjectOwner\(/);
+ const taskProjectOwner = ownerScope.slice(
+ ownerScope.indexOf("export async function isTaskProjectOwner"),
+ ownerScope.indexOf("export function ownerForbidden"),
+ );
+ assert.match(taskProjectOwner, /JOIN projects p/);
+ assert.ok(!/LEFT JOIN/.test(taskProjectOwner), "无项目归属的任务不放行");
+ assert.ok(
+ !/t\.owner_userid = \?/.test(taskProjectOwner),
+ "任务负责人本人不因此获得编辑权",
+ );
+});
+
test("collect actions stay manager-only", () => {
const body = section(actionRoute, "collect_now");
assert.match(body, /managerGate\(\)/);
@@ -122,6 +194,30 @@ test("task_report_list scopes reports by owner and drops the broken gate", () =>
);
});
+test("project_list opens to owner scope with server-side filtering", () => {
+ const body = section(actionRoute, "project_list");
+ assert.match(
+ body,
+ /listProjects\(db, ownerScopeUserid\(scope\)\)/,
+ "project_list 按负责人范围过滤,不再 managerGate",
+ );
+ assert.ok(
+ !/managerGate\(\)/.test(body),
+ "project_list 已放开给项目负责人(只读)",
+ );
+});
+
+test("customer chat actions keep strict task-owner gate", () => {
+ for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
+ const body = section(actionRoute, action);
+ assert.match(
+ body,
+ /strict: true/,
+ `${action} 保持严格任务负责人门,项目负责人不可代绑`,
+ );
+ }
+});
+
test("no unawaited isManagerRequest gates remain", () => {
assert.ok(
!actionRoute.includes("if (!isManagerRequest(request))"),
@@ -181,11 +277,14 @@ test("getDashboardData filters tasks and distributions by owner", () => {
mvpDb.indexOf("export async function getDashboardData"),
mvpDb.indexOf("export type TaskReportRow"),
);
- const occurrences = dashboard.match(/WHERE t\.owner_userid = \?/g) ?? [];
+ const occurrences =
+ dashboard.match(
+ /WHERE \(t\.owner_userid = \? OR \(t\.project_id != '' AND pj\.owner_userid = \?\)\)/g,
+ ) ?? [];
assert.equal(
occurrences.length,
2,
- "tasks 与 distributions 两条查询都要按 owner 过滤",
+ "tasks 与 distributions 两条查询都要按 union scope 过滤(任务负责人 OR 项目负责人)",
);
});
diff --git a/tests/owner-scope.test.mjs b/tests/owner-scope.test.mjs
index 0f3d3cc..f2666b5 100644
--- a/tests/owner-scope.test.mjs
+++ b/tests/owner-scope.test.mjs
@@ -40,7 +40,29 @@ function captureDb(rowsByPattern) {
return { db, captured };
}
-test("isTaskOwner matches task owner_userid binding", async () => {
+test("isTaskOwner union-matches task owner or project owner", async () => {
+ const { db, captured } = captureDb([
+ [
+ /FROM tasks t\s+LEFT JOIN projects p ON p\.id = t\.project_id/,
+ (values) =>
+ values[0] === "task-1" &&
+ values[1] === "zhangsan" &&
+ values[2] === "zhangsan"
+ ? { id: "task-1" }
+ : null,
+ ],
+ ]);
+ assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true);
+ assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false);
+ assert.equal(captured.length, 2);
+ assert.match(
+ captured[0].sql,
+ /t\.owner_userid = \? OR p\.owner_userid = \?/,
+ );
+ assert.deepEqual(captured[0].values, ["task-1", "zhangsan", "zhangsan"]);
+});
+
+test("isTaskOwner strict mode only matches task owner_userid", async () => {
const { db, captured } = captureDb([
[
/FROM tasks WHERE id = \? AND owner_userid = \?/,
@@ -50,9 +72,18 @@ test("isTaskOwner matches task owner_userid binding", async () => {
: null,
],
]);
- assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true);
- assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false);
+ assert.equal(await isTaskOwner(db, "task-1", "zhangsan", { strict: true }), true);
+ assert.equal(
+ await isTaskOwner(db, "task-1", "project-owner", { strict: true }),
+ false,
+ "strict 门不允许项目负责人代操作任务负责人的客户群",
+ );
assert.equal(captured.length, 2);
+ assert.ok(
+ !/LEFT JOIN projects/.test(captured[0].sql),
+ "strict 查询不应 JOIN projects",
+ );
+ assert.deepEqual(captured[0].values, ["task-1", "zhangsan"]);
});
test("isTaskOwner guards empty taskId / wecomUserId without querying", async () => {
@@ -63,12 +94,14 @@ test("isTaskOwner guards empty taskId / wecomUserId without querying", async ()
assert.equal(captured.length, 0);
});
-test("isDistributionOwner resolves distribution → task owner", async () => {
- const { db } = captureDb([
+test("isDistributionOwner union-matches via task or project owner", async () => {
+ const { db, captured } = captureDb([
[
- /FROM distributions d\s+JOIN tasks t ON t\.id = d\.task_id/,
+ /FROM distributions d\s+JOIN tasks t ON t\.id = d\.task_id\s+LEFT JOIN projects p ON p\.id = t\.project_id/,
(values) =>
- values[0] === "dist-1" && values[1] === "zhangsan"
+ values[0] === "dist-1" &&
+ values[1] === "zhangsan" &&
+ values[2] === "zhangsan"
? { id: "dist-1" }
: null,
],
@@ -76,6 +109,11 @@ test("isDistributionOwner resolves distribution → task owner", async () => {
assert.equal(await isDistributionOwner(db, "dist-1", "zhangsan"), true);
assert.equal(await isDistributionOwner(db, "dist-9", "zhangsan"), false);
assert.equal(await isDistributionOwner(db, "dist-1", ""), false);
+ assert.match(
+ captured[0].sql,
+ /t\.owner_userid = \? OR p\.owner_userid = \?/,
+ );
+ assert.deepEqual(captured[0].values, ["dist-1", "zhangsan", "zhangsan"]);
});
test("ownerScopeUserid maps scope kinds to filter value", () => {
@@ -88,8 +126,11 @@ test("getTaskReportData filters by owner when ownerUserid given", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
await getTaskReportData(db, "zhangsan");
assert.equal(captured.length, 1);
- assert.match(captured[0].sql, /WHERE t\.owner_userid = \?/);
- assert.deepEqual(captured[0].values, ["zhangsan"]);
+ assert.match(
+ captured[0].sql,
+ /WHERE \(t\.owner_userid = \? OR p\.owner_userid = \?\)/,
+ );
+ assert.deepEqual(captured[0].values, ["zhangsan", "zhangsan"]);
});
test("getTaskReportData keeps global aggregation without ownerUserid", async () => {
diff --git a/tests/project-service.test.mjs b/tests/project-service.test.mjs
index 0604f6c..eab0497 100644
--- a/tests/project-service.test.mjs
+++ b/tests/project-service.test.mjs
@@ -190,8 +190,12 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
async all() {
executed.push({ sql, params });
if (/FROM projects p/.test(sql)) {
+ const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
+ const rows = ownerFiltered
+ ? projects.filter((item) => item.owner_userid === params[0])
+ : projects;
return {
- results: projects.map((item) => ({
+ results: rows.map((item) => ({
...item,
task_count: tasks.filter((task) => task.project_id === item.id).length,
})),
@@ -360,6 +364,29 @@ test("listProjects aggregates task counts and exposes mainChatId", async () => {
assert.deepEqual(projects[0].mainGroupMemberIds, ["u1", "u2"]);
});
+test("listProjects filters by project owner when ownerUserid given", async () => {
+ const db = fakeProjectDb({
+ projects: [
+ projectRow({ id: "proj-1", owner_userid: "owner-a" }),
+ projectRow({ id: "proj-2", owner_userid: "owner-b" }),
+ projectRow({ id: "proj-3", owner_userid: "" }),
+ ],
+ });
+ const mine = await listProjects(db, "owner-a");
+ assert.deepEqual(
+ mine.map((item) => item.id),
+ ["proj-1"],
+ );
+ const unbound = await listProjects(db, "");
+ assert.deepEqual(
+ unbound,
+ [],
+ "已登录未绑定企微的空范围不应漏出无主项目",
+ );
+ const all = await listProjects(db);
+ assert.equal(all.length, 3);
+});
+
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
const db = fakeProjectDb({
projects: [
diff --git a/tests/task-report.test.mjs b/tests/task-report.test.mjs
index bfa536f..5b71fe1 100644
--- a/tests/task-report.test.mjs
+++ b/tests/task-report.test.mjs
@@ -32,6 +32,11 @@ const reportRow = {
latest_comments: 40,
latest_collects: 60,
latest_shares: 10,
+ related_task_id: "",
+ related_task_name: null,
+ reverse_related_task_id: null,
+ reverse_related_task_name: null,
+ submitted_count: 0,
};
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
@@ -71,6 +76,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
"最新评论",
"最新收藏",
"最新转发",
+ "关联任务",
+ "截图提交数",
].join(","),
);
assert.equal(
@@ -105,6 +112,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
40,
60,
10,
+ "",
+ "",
].join(","),
);
});
@@ -136,9 +145,26 @@ test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () =>
const rowLine = csv.slice(1).split("\r\n")[1];
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
const cells = rowLine.split(",");
- assert.equal(cells.at(-1), "", "null latest_shares becomes empty");
- assert.equal(cells.at(-18), "", "no claims means empty completion rate");
- assert.equal(cells.at(-16), "2026-08-01 10:00:00");
+ assert.equal(cells.at(-1), "", "content publish row leaves screenshot count empty");
+ assert.equal(cells.at(-2), "", "no related task leaves empty");
+ assert.equal(cells.at(-20), "", "no claims means empty completion rate");
+ assert.equal(cells.at(-18), "2026-08-01 10:00:00");
+});
+
+test("buildTaskReportCsv writes related batch and screenshot submission for collect tasks", () => {
+ const csv = buildTaskReportCsv([
+ {
+ ...reportRow,
+ task_type: "screenshot_collect",
+ related_task_id: "task-B",
+ related_task_name: "夏日种草计划",
+ submitted_count: 7,
+ claimed_count: 12,
+ },
+ ]);
+ const cells = csv.slice(1).split("\r\n")[1].split(",");
+ assert.equal(cells.at(-2), "夏日种草计划");
+ assert.equal(cells.at(-1), "7");
});
test("getTaskReportData aggregates per task via distributions join", async () => {
@@ -161,4 +187,6 @@ test("getTaskReportData aggregates per task via distributions join", async () =>
assert.match(capturedSql, /GROUP BY t\.id/);
assert.match(capturedSql, /publish_url/);
assert.match(capturedSql, /latest_shares/);
+ assert.match(capturedSql, /related_task_id/);
+ assert.match(capturedSql, /result_submitted_at/);
});
diff --git a/tests/wecom-bind.test.mjs b/tests/wecom-bind.test.mjs
new file mode 100644
index 0000000..21c45f4
--- /dev/null
+++ b/tests/wecom-bind.test.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+import {
+ signWecomBindState,
+ verifyWecomBindState,
+ signWecomIdentityTicket,
+ WECOM_BIND_STATE_TTL_MS,
+} from "../lib/wecom-oauth-service.ts";
+
+const SECRET = "internal-token-test";
+
+const bindRoute = readFileSync(
+ new URL("../app/api/wecom-bind/route.ts", import.meta.url),
+ "utf8",
+);
+const adminApp = readFileSync(
+ new URL("../app/admin-app.tsx", import.meta.url),
+ "utf8",
+);
+
+test("bind state sign → verify roundtrip", async () => {
+ const state = await signWecomBindState("user-abc", { secret: SECRET });
+ const verified = await verifyWecomBindState(state, { secret: SECRET });
+ assert.deepEqual(verified, { userId: "user-abc" });
+});
+
+test("bind state rejects expiry, tampering, wrong secret", async () => {
+ const state = await signWecomBindState("user-abc", {
+ secret: SECRET,
+ nowMs: Date.now() - WECOM_BIND_STATE_TTL_MS - 120_000,
+ });
+ assert.equal(await verifyWecomBindState(state, { secret: SECRET }), null);
+
+ const fresh = await signWecomBindState("user-abc", { secret: SECRET });
+ const [payload, signature] = fresh.split(".");
+ assert.ok(payload.length > 8);
+ const flip = payload[5] === "a" ? "b" : "a";
+ const tampered = `${payload.slice(0, 5)}${flip}${payload.slice(6)}.${signature}`;
+ assert.notEqual(tampered, fresh);
+ assert.equal(await verifyWecomBindState(tampered, { secret: SECRET }), null);
+ assert.equal(
+ await verifyWecomBindState(fresh, { secret: "other-secret" }),
+ null,
+ );
+ assert.equal(await verifyWecomBindState("garbage", { secret: SECRET }), null);
+ assert.equal(await verifyWecomBindState("", { secret: SECRET }), null);
+});
+
+test("identity ticket cannot be replayed as bind state", async () => {
+ const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", {
+ secret: SECRET,
+ });
+ assert.equal(await verifyWecomBindState(ticket, { secret: SECRET }), null);
+});
+
+test("bind state requires a user id", async () => {
+ await assert.rejects(
+ () => signWecomBindState("", { secret: SECRET }),
+ /后台账号 ID/,
+ );
+});
+
+test("bind route: start redirects logged-in users to wecom oauth", () => {
+ assert.ok(bindRoute.includes("buildWecomOAuthUrl"));
+ assert.ok(bindRoute.includes("signWecomBindState"));
+ assert.ok(bindRoute.includes("redirectUri"));
+ assert.ok(/APP_ORIGIN/.test(bindRoute));
+ assert.ok(/principal\.kind !== "user"/.test(bindRoute));
+});
+
+test("bind route: callback verifies state, session and conflicts", () => {
+ assert.ok(bindRoute.includes("verifyWecomBindState"));
+ assert.ok(/principal\.user\.id !== verified\.userId/.test(bindRoute));
+ assert.ok(bindRoute.includes("exchangeWecomOAuthCode"));
+ assert.ok(bindRoute.includes("!identity.userId"));
+ assert.ok(
+ /SELECT id, username FROM users WHERE wecom_userid = \? AND id <> \?/.test(
+ bindRoute,
+ ),
+ );
+ assert.ok(
+ /UPDATE users SET wecom_userid = \?, updated_at = CURRENT_TIMESTAMP WHERE id = \?/.test(
+ bindRoute,
+ ),
+ );
+});
+
+test("admin banner exposes self-service bind entry", () => {
+ assert.ok(adminApp.includes('href="/api/wecom-bind"'));
+ assert.ok(adminApp.includes("owner-bind-button"));
+});
|