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:
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,12 @@ import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
|||||||
import {
|
import {
|
||||||
getOwnerScope,
|
getOwnerScope,
|
||||||
isDistributionOwner,
|
isDistributionOwner,
|
||||||
|
isProjectOwner,
|
||||||
isTaskOwner,
|
isTaskOwner,
|
||||||
|
isTaskProjectOwner,
|
||||||
ownerForbidden,
|
ownerForbidden,
|
||||||
ownerScopeUserid,
|
ownerScopeUserid,
|
||||||
|
projectForbidden,
|
||||||
} from "../../../lib/owner-scope";
|
} from "../../../lib/owner-scope";
|
||||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||||
import {
|
import {
|
||||||
@@ -126,10 +129,11 @@ export async function POST(request: Request) {
|
|||||||
const scope = await getOwnerScope(request);
|
const scope = await getOwnerScope(request);
|
||||||
const managerGate = () =>
|
const managerGate = () =>
|
||||||
scope.kind === "owner" ? managerForbidden() : null;
|
scope.kind === "owner" ? managerForbidden() : null;
|
||||||
const taskOwnerGate = async (taskId: unknown) => {
|
const taskOwnerGate = async (taskId: unknown, options?: { strict?: boolean }) => {
|
||||||
if (scope.kind === "full") return null;
|
if (scope.kind === "full") return null;
|
||||||
const id = String(taskId ?? "").trim();
|
const id = String(taskId ?? "").trim();
|
||||||
return id && (await isTaskOwner(db, id, scope.wecomUserId))
|
return id &&
|
||||||
|
(await isTaskOwner(db, id, scope.wecomUserId, options))
|
||||||
? null
|
? null
|
||||||
: ownerForbidden();
|
: ownerForbidden();
|
||||||
};
|
};
|
||||||
@@ -140,10 +144,22 @@ export async function POST(request: Request) {
|
|||||||
? null
|
? null
|
||||||
: ownerForbidden();
|
: 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") {
|
if (body.action === "inspect_feishu") {
|
||||||
const forbidden = managerGate();
|
|
||||||
if (forbidden) return forbidden;
|
|
||||||
const source = await readFeishuSource(
|
const source = await readFeishuSource(
|
||||||
String(body.feishuUrl ?? "").trim(),
|
String(body.feishuUrl ?? "").trim(),
|
||||||
env as unknown as FeishuBindings,
|
env as unknown as FeishuBindings,
|
||||||
@@ -167,7 +183,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.action === "create_task") {
|
if (body.action === "create_task") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await projectOwnerGate(body.projectId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const name = String(body.name ?? "").trim();
|
const name = String(body.name ?? "").trim();
|
||||||
const brand = String(body.brand ?? "").trim();
|
const brand = String(body.brand ?? "").trim();
|
||||||
@@ -195,15 +211,13 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
|
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
|
||||||
} else if (body.action === "create_screenshot_task") {
|
} else if (body.action === "create_screenshot_task") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await projectOwnerGate(body.projectId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const creation = await createScreenshotTask({
|
const creation = await createScreenshotTask({
|
||||||
name: String(body.name ?? "").trim(),
|
name: String(body.name ?? "").trim(),
|
||||||
brand: String(body.brand ?? "").trim(),
|
relatedTaskId: String(body.relatedTaskId ?? "").trim(),
|
||||||
dueAt: String(body.dueAt ?? "").trim(),
|
dueAt: String(body.dueAt ?? "").trim(),
|
||||||
keyword: String(body.keyword ?? "").trim(),
|
|
||||||
instructions: String(body.instructions ?? "").trim(),
|
instructions: String(body.instructions ?? "").trim(),
|
||||||
quantity: numberValue(body.quantity),
|
|
||||||
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
||||||
projectId: String(body.projectId ?? "").trim(),
|
projectId: String(body.projectId ?? "").trim(),
|
||||||
ownerUserid: String(body.ownerUserid ?? "").trim(),
|
ownerUserid: String(body.ownerUserid ?? "").trim(),
|
||||||
@@ -765,8 +779,6 @@ export async function POST(request: Request) {
|
|||||||
const contacts = await listExternalContacts(wecomConfig);
|
const contacts = await listExternalContacts(wecomConfig);
|
||||||
return Response.json({ contacts });
|
return Response.json({ contacts });
|
||||||
} else if (body.action === "wecom_list_internal_contacts") {
|
} else if (body.action === "wecom_list_internal_contacts") {
|
||||||
const forbidden = managerGate();
|
|
||||||
if (forbidden) return forbidden;
|
|
||||||
const wecomConfig = resolveWecomConfig(
|
const wecomConfig = resolveWecomConfig(
|
||||||
env as unknown as WecomBindings,
|
env as unknown as WecomBindings,
|
||||||
);
|
);
|
||||||
@@ -816,16 +828,27 @@ export async function POST(request: Request) {
|
|||||||
const pushes = await listGroupPushes(db, taskId || undefined);
|
const pushes = await listGroupPushes(db, taskId || undefined);
|
||||||
return Response.json({ pushes });
|
return Response.json({ pushes });
|
||||||
} else if (body.action === "project_list") {
|
} else if (body.action === "project_list") {
|
||||||
const forbidden = managerGate();
|
return Response.json({
|
||||||
if (forbidden) return forbidden;
|
projects: await listProjects(db, ownerScopeUserid(scope)),
|
||||||
return Response.json({ projects: await listProjects(db) });
|
});
|
||||||
} else if (body.action === "project_detail") {
|
} else if (body.action === "project_detail") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await projectOwnerGate(body.projectId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
|
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
|
||||||
} else if (body.action === "project_save") {
|
} 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;
|
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(
|
const saved = await saveProject(
|
||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
@@ -833,19 +856,19 @@ export async function POST(request: Request) {
|
|||||||
name: body.name,
|
name: body.name,
|
||||||
brand: body.brand,
|
brand: body.brand,
|
||||||
mainGroupMemberIds: body.mainGroupMemberIds,
|
mainGroupMemberIds: body.mainGroupMemberIds,
|
||||||
ownerUserid: body.ownerUserid,
|
ownerUserid: nextOwnerUserid,
|
||||||
note: body.note,
|
note: body.note,
|
||||||
},
|
},
|
||||||
resolveWecomConfig(env as unknown as WecomBindings),
|
resolveWecomConfig(env as unknown as WecomBindings),
|
||||||
);
|
);
|
||||||
return Response.json(saved);
|
return Response.json(saved);
|
||||||
} else if (body.action === "project_delete") {
|
} else if (body.action === "project_delete") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await projectOwnerGate(body.projectId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
await deleteProject(db, String(body.projectId ?? ""));
|
await deleteProject(db, String(body.projectId ?? ""));
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
} else if (body.action === "project_ensure_main_chat") {
|
} else if (body.action === "project_ensure_main_chat") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await projectOwnerGate(body.projectId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const result = await ensureProjectMainChat(
|
const result = await ensureProjectMainChat(
|
||||||
db,
|
db,
|
||||||
@@ -854,7 +877,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
} else if (body.action === "task_set_owner") {
|
} else if (body.action === "task_set_owner") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const ownerUserid = String(body.ownerUserid ?? "").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 }>();
|
.first<{ owner_userid: string; task_chat_id: string }>();
|
||||||
return Response.json({ ok: true, assignment, task });
|
return Response.json({ ok: true, assignment, task });
|
||||||
} else if (body.action === "task_update") {
|
} else if (body.action === "task_update") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||||
if (forbidden) return forbidden;
|
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 === "小红书"
|
const platform = body.platform === "抖音" || body.platform === "小红书"
|
||||||
? (body.platform as "小红书" | "抖音")
|
? (body.platform as "小红书" | "抖音")
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -919,7 +950,7 @@ export async function POST(request: Request) {
|
|||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
return Response.json({ partners: await listGroupOwnerPartners(db) });
|
return Response.json({ partners: await listGroupOwnerPartners(db) });
|
||||||
} else if (body.action === "project_push_task") {
|
} else if (body.action === "project_push_task") {
|
||||||
const forbidden = managerGate();
|
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||||
const outcome = await pushProjectTask(
|
const outcome = await pushProjectTask(
|
||||||
@@ -1008,7 +1039,7 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} else if (body.action === "task_customer_chats") {
|
} else if (body.action === "task_customer_chats") {
|
||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const forbidden = await taskOwnerGate(taskId);
|
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const task = await db
|
const task = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -1037,7 +1068,7 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} else if (body.action === "task_bind_customer_chat") {
|
} else if (body.action === "task_bind_customer_chat") {
|
||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const forbidden = await taskOwnerGate(taskId);
|
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||||
if (forbidden) return forbidden;
|
if (forbidden) return forbidden;
|
||||||
const task = await db
|
const task = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../../../lib/partner-utils";
|
} from "../../../lib/partner-utils";
|
||||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||||
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
||||||
|
import { refreshTaskStatus } from "../../../lib/task-service";
|
||||||
import {
|
import {
|
||||||
resolveWecomClaimant,
|
resolveWecomClaimant,
|
||||||
verifyWecomIdentityTicket,
|
verifyWecomIdentityTicket,
|
||||||
@@ -1187,6 +1188,7 @@ async function handlePost(request: Request) {
|
|||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
runInBackground(feedback, "wecom publish feedback");
|
runInBackground(feedback, "wecom publish feedback");
|
||||||
}
|
}
|
||||||
|
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1239,7 @@ async function handlePost(request: Request) {
|
|||||||
)
|
)
|
||||||
.bind(exposure, views, assignment.id)
|
.bind(exposure, views, assignment.id)
|
||||||
.run();
|
.run();
|
||||||
|
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||||
return Response.json({ submitted: true });
|
return Response.json({ submitted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,6 +1283,7 @@ async function handlePost(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await db.batch(statements);
|
await db.batch(statements);
|
||||||
|
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||||
if (isWecomFeedbackEnabled()) {
|
if (isWecomFeedbackEnabled()) {
|
||||||
const feedback = notifyPartnerSubmitted(
|
const feedback = notifyPartnerSubmitted(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -304,6 +304,34 @@ export async function GET(request: Request) {
|
|||||||
.bind(taskId)
|
.bind(taskId)
|
||||||
.all<ExportRow>();
|
.all<ExportRow>();
|
||||||
const rows = result.results;
|
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<string, boolean> | 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<string, boolean>();
|
||||||
|
for (const row of companionRows.results) {
|
||||||
|
companionSubmittedByContent.set(
|
||||||
|
row.related_content_id,
|
||||||
|
Boolean(row.result_submitted_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const assetsByContent = new Map(
|
const assetsByContent = new Map(
|
||||||
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
|
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 originalImageStart = 3;
|
||||||
const accountColumn = originalImageStart + maxContentImages;
|
const accountColumn = originalImageStart + maxContentImages;
|
||||||
@@ -398,6 +427,15 @@ export async function GET(request: Request) {
|
|||||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||||
row.partner_name || "",
|
row.partner_name || "",
|
||||||
row.claimant_name || "",
|
row.claimant_name || "",
|
||||||
|
...(companionSubmittedByContent
|
||||||
|
? [
|
||||||
|
companionSubmittedByContent.has(row.content_id)
|
||||||
|
? companionSubmittedByContent.get(row.content_id)
|
||||||
|
? "已提交"
|
||||||
|
: "未提交"
|
||||||
|
: "—",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
const images: RecoveryWorkbookRow["images"] = [];
|
const images: RecoveryWorkbookRow["images"] = [];
|
||||||
for (const asset of contentAssets) {
|
for (const asset of contentAssets) {
|
||||||
@@ -433,6 +471,7 @@ export async function GET(request: Request) {
|
|||||||
24,
|
24,
|
||||||
18,
|
18,
|
||||||
22,
|
22,
|
||||||
|
...(companionSubmittedByContent ? [12] : []),
|
||||||
];
|
];
|
||||||
const workbook = buildRecoveryWorkbook({
|
const workbook = buildRecoveryWorkbook({
|
||||||
sheetName: `${task.name}-数据回收`,
|
sheetName: `${task.name}-数据回收`,
|
||||||
|
|||||||
177
app/api/wecom-bind/route.ts
Normal file
177
app/api/wecom-bind/route.ts
Normal file
@@ -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 = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:#f4f6f5; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; color:#1f2a26; }
|
||||||
|
.card { width:min(420px, calc(100% - 40px)); padding:32px 28px; border:1px solid #e3e8e5; border-radius:16px;
|
||||||
|
background:#fff; text-align:center; }
|
||||||
|
.icon { width:44px; height:44px; margin:0 auto 14px; border-radius:50%; display:flex; align-items:center;
|
||||||
|
justify-content:center; font-size:22px; font-weight:700; color:#fff; }
|
||||||
|
.ok .icon { background:#2f9e75; } .fail .icon { background:#c9503f; }
|
||||||
|
h1 { margin:0 0 10px; font-size:17px; }
|
||||||
|
p { margin:0 0 18px; font-size:13px; line-height:1.8; color:#5f6a65; word-break:break-all; }
|
||||||
|
a { display:inline-block; padding:9px 22px; border-radius:10px; background:#2f9e75; color:#fff;
|
||||||
|
font-size:13px; text-decoration:none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card ${ok ? "ok" : "fail"}">
|
||||||
|
<div class="icon">${ok ? "✓" : "!"}</div>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<p>${message}</p>
|
||||||
|
<a href="/">回到工作台</a>
|
||||||
|
</div>
|
||||||
|
${options.redirectHome ? "<script>setTimeout(function(){location.href = '/';},1500)</script>" : ""}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
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);
|
||||||
|
}
|
||||||
193
app/globals.css
193
app/globals.css
@@ -338,6 +338,13 @@ button:disabled {
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.related-task-meta {
|
||||||
|
color: #277a5d;
|
||||||
|
background: #e6f3ee;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -985,7 +992,9 @@ a {
|
|||||||
.workflow-panel,
|
.workflow-panel,
|
||||||
.task-mini-panel,
|
.task-mini-panel,
|
||||||
.table-panel,
|
.table-panel,
|
||||||
.wecom-status-panel {
|
.wecom-status-panel,
|
||||||
|
.project-progress-panel,
|
||||||
|
.owner-unbound-banner {
|
||||||
padding: 21px;
|
padding: 21px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,6 +1022,44 @@ a {
|
|||||||
font-size: 10px;
|
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 {
|
.workflow-track {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto 1fr auto 1fr auto;
|
grid-template-columns: auto 1fr auto 1fr auto 1fr auto;
|
||||||
@@ -1334,7 +1381,8 @@ a {
|
|||||||
background: #e9f3f6;
|
background: #e9f3f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill.complete {
|
.status-pill.complete,
|
||||||
|
.status-pill.completed {
|
||||||
color: #277a5d;
|
color: #277a5d;
|
||||||
background: #e6f3ee;
|
background: #e6f3ee;
|
||||||
}
|
}
|
||||||
@@ -1942,6 +1990,15 @@ a {
|
|||||||
background: #e5e9e6;
|
background: #e5e9e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-summary.related-summary {
|
||||||
|
border-color: #bfe3d6;
|
||||||
|
background: #f4faf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-summary.related-summary strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-note {
|
.summary-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2131,6 +2188,46 @@ a {
|
|||||||
letter-spacing: -0.03em;
|
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 {
|
.task-scope-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -3331,6 +3428,98 @@ a {
|
|||||||
gap: 6px;
|
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 {
|
.creator-screenshot-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 72px;
|
width: 72px;
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const tasks = mysqlTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(""),
|
.default(""),
|
||||||
joinQrCode: text("join_qr_code").notNull().default(""),
|
joinQrCode: text("join_qr_code").notNull().default(""),
|
||||||
|
relatedTaskId: varchar("related_task_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.default(""),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("tasks_share_token_idx").on(table.shareToken)],
|
(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"),
|
status: varchar("status", { length: 32 }).notNull().default("available"),
|
||||||
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
|
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
|
||||||
sourceRow: int("source_row"),
|
sourceRow: int("source_row"),
|
||||||
|
relatedContentId: varchar("related_content_id", { length: 64 })
|
||||||
|
.notNull()
|
||||||
|
.default(""),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
|||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
images: { unoptimized: true },
|
images: { unoptimized: true },
|
||||||
turbopack: { root: path.resolve(process.cwd()) },
|
turbopack: { root: path.resolve(process.cwd()) },
|
||||||
|
allowedDevOrigins: ["192.168.30.90", "localhost", "127.0.0.1"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from "./mcp-collection-client";
|
} from "./mcp-collection-client";
|
||||||
import { uid } from "./mvp-db";
|
import { uid } from "./mvp-db";
|
||||||
import type { DatabaseClient, DatabaseStatement } from "./database";
|
import type { DatabaseClient, DatabaseStatement } from "./database";
|
||||||
|
import { refreshTaskStatus } from "./task-service";
|
||||||
|
|
||||||
type DistributionForCollection = {
|
type DistributionForCollection = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -336,6 +337,7 @@ export async function collectDistributionMetrics(
|
|||||||
distributionId,
|
distributionId,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
await refreshTaskStatus(current.task_id, db).catch(() => undefined);
|
||||||
return { skipped: false, likes, comments, collects, shares };
|
return { skipped: false, likes, comments, collects, shares };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DatabaseClient } from "./database";
|
import type { DatabaseClient } from "./database";
|
||||||
|
import { refreshTaskStatus } from "./task-service";
|
||||||
|
|
||||||
type ReleasableDistribution = {
|
type ReleasableDistribution = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,7 +43,7 @@ export async function releaseUnfinishedDistribution(
|
|||||||
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
|
throw new DistributionReleaseError("请选择需要释放的领取记录", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return database.transaction(async (db) => {
|
const result = await database.transaction(async (db) => {
|
||||||
const distribution = await db
|
const distribution = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT
|
`SELECT
|
||||||
@@ -150,4 +151,7 @@ export async function releaseUnfinishedDistribution(
|
|||||||
partnerName: distribution.partner_name,
|
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",
|
||||||
"join_qr_code TEXT NOT NULL DEFAULT ''",
|
"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
|
const tasksWithoutShare = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -555,8 +565,8 @@ export async function seedIfEmpty() {
|
|||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
"task-summer",
|
"task-summer",
|
||||||
@@ -570,8 +580,8 @@ export async function seedIfEmpty() {
|
|||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
(id, name, brand, quantity, claimed_quantity, due_at, status, join_qr_code)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, '')`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
"task-living",
|
"task-living",
|
||||||
@@ -853,11 +863,24 @@ export async function getDashboardData(ownerUserid?: string) {
|
|||||||
});
|
});
|
||||||
const tasksBase = `SELECT t.*,
|
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.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
|
(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 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
|
const distributionsBase = `SELECT
|
||||||
d.*,
|
d.*,
|
||||||
c.title AS content_title,
|
c.title AS content_title,
|
||||||
|
c.related_content_id AS related_content_id,
|
||||||
|
rc.title AS related_content_title,
|
||||||
p.name AS partner_name,
|
p.name AS partner_name,
|
||||||
cl.claimant_name AS claimant_name,
|
cl.claimant_name AS claimant_name,
|
||||||
a.nickname AS account_nickname,
|
a.nickname AS account_nickname,
|
||||||
@@ -870,9 +893,11 @@ export async function getDashboardData(ownerUserid?: string) {
|
|||||||
t.due_at AS due_at
|
t.due_at AS due_at
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN contents c ON c.id = d.content_id
|
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
|
JOIN partners p ON p.id = d.partner_id
|
||||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||||
JOIN tasks t ON t.id = d.task_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`;
|
LEFT JOIN accounts a ON a.id = d.account_id`;
|
||||||
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
|
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -882,9 +907,9 @@ export async function getDashboardData(ownerUserid?: string) {
|
|||||||
ownerFilter
|
ownerFilter
|
||||||
? db
|
? db
|
||||||
.prepare(
|
.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()
|
.all()
|
||||||
: ownerFilter === null
|
: ownerFilter === null
|
||||||
? db.prepare(`${tasksBase} ORDER BY t.created_at DESC`).all()
|
? db.prepare(`${tasksBase} ORDER BY t.created_at DESC`).all()
|
||||||
@@ -913,9 +938,9 @@ export async function getDashboardData(ownerUserid?: string) {
|
|||||||
ownerFilter
|
ownerFilter
|
||||||
? db
|
? db
|
||||||
.prepare(
|
.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()
|
.all()
|
||||||
: ownerFilter === null
|
: ownerFilter === null
|
||||||
? db
|
? db
|
||||||
@@ -964,6 +989,11 @@ export type TaskReportRow = {
|
|||||||
latest_comments: number | null;
|
latest_comments: number | null;
|
||||||
latest_collects: number | null;
|
latest_collects: number | null;
|
||||||
latest_shares: 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) {
|
export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string) {
|
||||||
@@ -982,10 +1012,15 @@ export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string
|
|||||||
t.due_at,
|
t.due_at,
|
||||||
t.created_at,
|
t.created_at,
|
||||||
t.quantity,
|
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,
|
p.name AS project_name,
|
||||||
COUNT(d.id) AS claimed_count,
|
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 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.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.exposure) AS exposure_total,
|
||||||
SUM(d.views) AS views_total,
|
SUM(d.views) AS views_total,
|
||||||
SUM(d.d2_likes) AS d2_likes,
|
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
|
SUM(d.latest_shares) AS latest_shares
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN distributions d ON d.task_id = t.id
|
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
|
const statement = ownerFilter
|
||||||
? db
|
? db
|
||||||
.prepare(
|
.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`);
|
: db.prepare(`${base} GROUP BY t.id ORDER BY t.created_at DESC`);
|
||||||
const result = await statement.all<TaskReportRow>();
|
const result = await statement.all<TaskReportRow>();
|
||||||
return result.results;
|
return result.results;
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ export async function isTaskOwner(
|
|||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
wecomUserId: string,
|
wecomUserId: string,
|
||||||
|
options?: { strict?: boolean },
|
||||||
) {
|
) {
|
||||||
const id = String(taskId ?? "").trim();
|
const id = String(taskId ?? "").trim();
|
||||||
if (!id || !wecomUserId) return false;
|
if (!id || !wecomUserId) return false;
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare("SELECT id FROM tasks WHERE id = ? AND owner_userid = ? LIMIT 1")
|
.prepare(
|
||||||
.bind(id, wecomUserId)
|
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 }>();
|
.first<{ id: string }>();
|
||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
@@ -48,7 +57,42 @@ export async function isDistributionOwner(
|
|||||||
`SELECT d.id
|
`SELECT d.id
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN tasks t ON t.id = d.task_id
|
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`,
|
LIMIT 1`,
|
||||||
)
|
)
|
||||||
.bind(id, wecomUserId)
|
.bind(id, wecomUserId)
|
||||||
@@ -59,3 +103,7 @@ export async function isDistributionOwner(
|
|||||||
export function ownerForbidden() {
|
export function ownerForbidden() {
|
||||||
return Response.json({ error: "该任务未分配给您,无权操作" }, { status: 403 });
|
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[]> {
|
export async function listProjects(
|
||||||
const result = await db
|
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(
|
.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 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
|
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
|
||||||
FROM projects p
|
FROM projects p
|
||||||
ORDER BY p.created_at DESC`,
|
ORDER BY p.created_at DESC`,
|
||||||
)
|
)
|
||||||
.all<ProjectRow & { task_count: number }>();
|
).all<ProjectRow & { task_count: number }>();
|
||||||
return result.results.map((row) => ({
|
return result.results.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const CSV_HEADERS = [
|
|||||||
"最新评论",
|
"最新评论",
|
||||||
"最新收藏",
|
"最新收藏",
|
||||||
"最新转发",
|
"最新转发",
|
||||||
|
"关联任务",
|
||||||
|
"截图提交数",
|
||||||
];
|
];
|
||||||
|
|
||||||
function taskTypeLabel(value: string) {
|
function taskTypeLabel(value: string) {
|
||||||
@@ -85,6 +87,8 @@ export function buildTaskReportCsv(rows: TaskReportRow[]) {
|
|||||||
row.latest_comments,
|
row.latest_comments,
|
||||||
row.latest_collects,
|
row.latest_collects,
|
||||||
row.latest_shares,
|
row.latest_shares,
|
||||||
|
row.related_task_name ?? row.reverse_related_task_name ?? "",
|
||||||
|
row.task_type === "screenshot_collect" ? row.submitted_count : "",
|
||||||
]
|
]
|
||||||
.map(csvCell)
|
.map(csvCell)
|
||||||
.join(","),
|
.join(","),
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ export type CreateDistributionTaskInput = {
|
|||||||
|
|
||||||
export type CreateScreenshotTaskInput = {
|
export type CreateScreenshotTaskInput = {
|
||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
relatedTaskId: string;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
keyword: string;
|
|
||||||
instructions: string;
|
instructions: string;
|
||||||
quantity: number;
|
|
||||||
exampleImageKey?: string;
|
exampleImageKey?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
ownerUserid?: string;
|
ownerUserid?: string;
|
||||||
@@ -52,6 +50,7 @@ export type ScreenshotTaskCreation = {
|
|||||||
brand: string;
|
brand: string;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
relatedTaskId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TaskRow = {
|
type TaskRow = {
|
||||||
@@ -147,8 +146,8 @@ async function insertTaskFromSource(
|
|||||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
platform, content_format, project_id, owner_userid,
|
platform, content_format, project_id, owner_userid,
|
||||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||||
share_token)
|
share_token, join_qr_code)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?, ?, ?, '')`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
taskId,
|
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(
|
export async function createScreenshotTask(
|
||||||
rawInput: CreateScreenshotTaskInput,
|
rawInput: CreateScreenshotTaskInput,
|
||||||
): Promise<ScreenshotTaskCreation> {
|
): Promise<ScreenshotTaskCreation> {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const input = {
|
const input = {
|
||||||
name: normalizedValue(rawInput.name),
|
name: normalizedValue(rawInput.name),
|
||||||
brand: normalizedValue(rawInput.brand),
|
relatedTaskId: normalizedValue(rawInput.relatedTaskId),
|
||||||
dueAt: normalizedDueDate(rawInput.dueAt),
|
dueAt: normalizedDueDate(rawInput.dueAt),
|
||||||
keyword: normalizedValue(rawInput.keyword),
|
|
||||||
instructions: normalizedValue(rawInput.instructions),
|
instructions: normalizedValue(rawInput.instructions),
|
||||||
quantity: Math.floor(Number(rawInput.quantity)),
|
|
||||||
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
|
exampleImageKey: normalizedValue(rawInput.exampleImageKey ?? ""),
|
||||||
projectId: normalizedValue(rawInput.projectId ?? ""),
|
projectId: normalizedValue(rawInput.projectId ?? ""),
|
||||||
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
|
ownerUserid: normalizedValue(rawInput.ownerUserid ?? "").slice(0, 64),
|
||||||
};
|
};
|
||||||
if (!input.name || !input.brand || !input.keyword || !input.instructions) {
|
if (!input.name || !input.relatedTaskId || !input.instructions) {
|
||||||
throw new Error("请补全任务名称、品牌/项目、搜索关键词和任务说明");
|
throw new Error("请补全任务名称、关联分发任务和任务说明");
|
||||||
}
|
|
||||||
if (!Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 500) {
|
|
||||||
throw new Error("任务数量需为 1—500 份");
|
|
||||||
}
|
}
|
||||||
if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) {
|
if (input.exampleImageKey && !input.exampleImageKey.startsWith("task-assets/")) {
|
||||||
throw new Error("示例截图无效,请重新上传");
|
throw new Error("示例截图无效,请重新上传");
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getRawDb();
|
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 taskId = uid("task");
|
||||||
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
const shareToken = crypto.randomUUID().replaceAll("-", "");
|
||||||
const imageAssets = input.exampleImageKey
|
const imageAssets = input.exampleImageKey
|
||||||
@@ -311,41 +348,45 @@ export async function createScreenshotTask(
|
|||||||
{ index: 1, key: input.exampleImageKey, width: null, height: null },
|
{ 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
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
task_type, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name,
|
task_type, project_id, owner_userid, source_url, source_sheet_id, source_sheet_name,
|
||||||
share_token, collection_days)
|
share_token, collection_days, join_qr_code, related_task_id)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]')`,
|
VALUES (?, ?, ?, ?, 0, ?, 'active', 'screenshot_collect', ?, ?, '', '', '', ?, '[]', '', ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
taskId,
|
taskId,
|
||||||
input.name,
|
input.name,
|
||||||
input.brand,
|
source.brand,
|
||||||
input.quantity,
|
sourceContents.results.length,
|
||||||
input.dueAt,
|
input.dueAt,
|
||||||
input.projectId,
|
projectId,
|
||||||
input.ownerUserid,
|
ownerUserid,
|
||||||
shareToken,
|
shareToken,
|
||||||
|
input.relatedTaskId,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statements = Array.from({ length: input.quantity }, (_, index) =>
|
const statements = sourceContents.results.map((row) =>
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO contents
|
`INSERT INTO contents
|
||||||
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
|
(id, task_id, title, body, image_assets, video_assets, status, source, source_row, related_content_id)
|
||||||
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
|
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?, ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
uid("content"),
|
uid("content"),
|
||||||
taskId,
|
taskId,
|
||||||
input.keyword,
|
row.title,
|
||||||
input.instructions,
|
input.instructions,
|
||||||
imageAssets,
|
imageAssets,
|
||||||
index + 1,
|
row.source_row,
|
||||||
|
row.id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (let index = 0; index < statements.length; index += 100) {
|
for (let index = 0; index < statements.length; index += 100) {
|
||||||
@@ -364,9 +405,10 @@ export async function createScreenshotTask(
|
|||||||
taskId,
|
taskId,
|
||||||
shareToken,
|
shareToken,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
brand: input.brand,
|
brand: source.brand,
|
||||||
dueAt: input.dueAt,
|
dueAt: input.dueAt,
|
||||||
quantity: input.quantity,
|
quantity: sourceContents.results.length,
|
||||||
|
relatedTaskId: input.relatedTaskId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +439,7 @@ type UpdatableTaskRow = {
|
|||||||
claimed_quantity: number;
|
claimed_quantity: number;
|
||||||
platform: "小红书" | "抖音";
|
platform: "小红书" | "抖音";
|
||||||
content_format: "image_text" | "video";
|
content_format: "image_text" | "video";
|
||||||
|
related_task_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应
|
// 截图任务的 keyword/instructions 存在 contents 每行(title/body),数量与行数一一对应
|
||||||
@@ -492,7 +535,7 @@ export async function updateTask(
|
|||||||
|
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.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)
|
.bind(taskId)
|
||||||
.first<UpdatableTaskRow>();
|
.first<UpdatableTaskRow>();
|
||||||
@@ -501,7 +544,44 @@ export async function updateTask(
|
|||||||
const claimed = Number(row.claimed_quantity);
|
const claimed = Number(row.claimed_quantity);
|
||||||
let quantity = Number(row.quantity);
|
let quantity = Number(row.quantity);
|
||||||
if (row.task_type === "screenshot_collect") {
|
if (row.task_type === "screenshot_collect") {
|
||||||
if (!input.keyword || !input.instructions) {
|
const related = Boolean(row.related_task_id);
|
||||||
|
if (!input.instructions) {
|
||||||
|
throw new Error(related ? "请补全任务说明" : "请补全搜索关键词和任务说明");
|
||||||
|
}
|
||||||
|
if (related) {
|
||||||
|
if (
|
||||||
|
rawInput.quantity !== undefined &&
|
||||||
|
rawInput.quantity !== null &&
|
||||||
|
input.quantity !== quantity
|
||||||
|
) {
|
||||||
|
throw new Error("关联任务的份数由分发任务决定,不可修改");
|
||||||
|
}
|
||||||
|
if (input.keyword) {
|
||||||
|
const firstTitle = await db
|
||||||
|
.prepare(
|
||||||
|
"SELECT title FROM contents WHERE task_id = ? ORDER BY source_row, id LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(taskId)
|
||||||
|
.first<{ title: string }>();
|
||||||
|
if (firstTitle && input.keyword !== firstTitle.title) {
|
||||||
|
throw new Error("关联任务的搜索关键词由分发任务笔记决定,不可修改");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE contents SET body = ? WHERE task_id = ?")
|
||||||
|
.bind(input.instructions, taskId)
|
||||||
|
.run();
|
||||||
|
if (input.exampleImageKey.startsWith("task-assets/")) {
|
||||||
|
const imageAssets = JSON.stringify([
|
||||||
|
{ index: 1, key: input.exampleImageKey, width: null, height: null },
|
||||||
|
]);
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE contents SET image_assets = ? WHERE task_id = ?")
|
||||||
|
.bind(imageAssets, taskId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!input.keyword) {
|
||||||
throw new Error("请补全搜索关键词和任务说明");
|
throw new Error("请补全搜索关键词和任务说明");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -523,6 +603,7 @@ export async function updateTask(
|
|||||||
exampleImageKey: input.exampleImageKey,
|
exampleImageKey: input.exampleImageKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -542,6 +623,7 @@ export async function updateTask(
|
|||||||
taskId,
|
taskId,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
await refreshTaskStatus(taskId, db).catch(() => undefined);
|
||||||
|
|
||||||
return { taskId, quantity, claimedQuantity: claimed };
|
return { taskId, quantity, claimedQuantity: claimed };
|
||||||
}
|
}
|
||||||
@@ -553,3 +635,60 @@ export function buildClaimUrl(portalOrigin: string, shareToken: string) {
|
|||||||
url.searchParams.set("task", shareToken);
|
url.searchParams.set("task", shareToken);
|
||||||
return url.toString();
|
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));
|
return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,12 +279,18 @@ function hasInternalToken(request: Request) {
|
|||||||
return Boolean(expected && received && safeEqual(expected, received));
|
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(
|
export async function getRequestPrincipal(
|
||||||
request: Request,
|
request: Request,
|
||||||
): Promise<RequestPrincipal | null> {
|
): Promise<RequestPrincipal | null> {
|
||||||
if (hasInternalToken(request)) return { kind: "internal" };
|
if (hasInternalToken(request)) return { kind: "internal" };
|
||||||
const user = await getUserFromCookieHeader(request.headers.get("cookie"));
|
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) {
|
export async function isAppRequest(request: Request) {
|
||||||
|
|||||||
@@ -27,10 +27,23 @@ type DueSoonRow = {
|
|||||||
wecom_external_user_id: string | null;
|
wecom_external_user_id: string | null;
|
||||||
task_id: string;
|
task_id: string;
|
||||||
task_name: string;
|
task_name: string;
|
||||||
|
task_type: string;
|
||||||
due_at: string;
|
due_at: string;
|
||||||
content_title: 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) {
|
export function computeDueCutoff(today: string, dueDays: number) {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(today);
|
||||||
if (!match) return today;
|
if (!match) return today;
|
||||||
@@ -60,6 +73,7 @@ export async function runDueSoonWecomNotifications(
|
|||||||
p.wecom_external_user_id,
|
p.wecom_external_user_id,
|
||||||
t.id AS task_id,
|
t.id AS task_id,
|
||||||
t.name AS task_name,
|
t.name AS task_name,
|
||||||
|
t.task_type,
|
||||||
t.due_at,
|
t.due_at,
|
||||||
c.title AS content_title
|
c.title AS content_title
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
@@ -115,13 +129,13 @@ export async function runDueSoonWecomNotifications(
|
|||||||
? [entry.externalUserId]
|
? [entry.externalUserId]
|
||||||
: [];
|
: [];
|
||||||
const lines = entry.rows.slice(0, 5).map((row) => {
|
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 =
|
const overflow =
|
||||||
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
|
entry.rows.length > 5 ? `\n…还有 ${entry.rows.length - 5} 条` : "";
|
||||||
const link = portalUrl ? `\n领取链接:${portalUrl}` : "";
|
const link = portalUrl ? `\n领取链接:${portalUrl}` : "";
|
||||||
const content =
|
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(
|
const appResult = await sendWecomAppMessage(
|
||||||
external,
|
external,
|
||||||
content,
|
content,
|
||||||
@@ -166,7 +180,7 @@ export async function runDueSoonWecomNotifications(
|
|||||||
await sendTaskChatMessage(
|
await sendTaskChatMessage(
|
||||||
db,
|
db,
|
||||||
groupTaskId,
|
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,
|
config,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,6 +156,71 @@ export function wecomClaimantPartnerId(externalUserId: string): string {
|
|||||||
return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`;
|
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(
|
export async function resolveWecomClaimant(
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
externalUserId: string,
|
externalUserId: string,
|
||||||
|
|||||||
5
mysql/0016_task_content_relation.sql
Normal file
5
mysql/0016_task_content_relation.sql
Normal file
@@ -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`);
|
||||||
@@ -34,6 +34,10 @@ const mvpDb = readFileSync(
|
|||||||
new URL("../lib/mvp-db.ts", import.meta.url),
|
new URL("../lib/mvp-db.ts", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
const ownerScope = readFileSync(
|
||||||
|
new URL("../lib/owner-scope.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
function section(source, action) {
|
function section(source, action) {
|
||||||
const start = source.indexOf(`"${action}"`);
|
const start = source.indexOf(`"${action}"`);
|
||||||
@@ -44,9 +48,6 @@ function section(source, action) {
|
|||||||
|
|
||||||
test("manager-only actions gate on owner scope", () => {
|
test("manager-only actions gate on owner scope", () => {
|
||||||
const managerOnly = [
|
const managerOnly = [
|
||||||
"inspect_feishu",
|
|
||||||
"create_task",
|
|
||||||
"create_screenshot_task",
|
|
||||||
"claim",
|
"claim",
|
||||||
"save_collection_schedule",
|
"save_collection_schedule",
|
||||||
"run_due_collections",
|
"run_due_collections",
|
||||||
@@ -57,18 +58,9 @@ test("manager-only actions gate on owner scope", () => {
|
|||||||
"send_test_wecom",
|
"send_test_wecom",
|
||||||
"trigger_wecom_overdue_escalation",
|
"trigger_wecom_overdue_escalation",
|
||||||
"wecom_list_external_contacts",
|
"wecom_list_external_contacts",
|
||||||
"wecom_list_internal_contacts",
|
|
||||||
"wecom_sync_group_chats",
|
"wecom_sync_group_chats",
|
||||||
"project_list",
|
|
||||||
"project_detail",
|
|
||||||
"project_save",
|
|
||||||
"project_delete",
|
|
||||||
"project_ensure_main_chat",
|
|
||||||
"task_set_owner",
|
|
||||||
"task_update",
|
|
||||||
"partner_save",
|
"partner_save",
|
||||||
"partner_group_owners",
|
"partner_group_owners",
|
||||||
"project_push_task",
|
|
||||||
"refresh_account_fields",
|
"refresh_account_fields",
|
||||||
"account_field_refresh_status",
|
"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", () => {
|
test("collect actions stay manager-only", () => {
|
||||||
const body = section(actionRoute, "collect_now");
|
const body = section(actionRoute, "collect_now");
|
||||||
assert.match(body, /managerGate\(\)/);
|
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", () => {
|
test("no unawaited isManagerRequest gates remain", () => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!actionRoute.includes("if (!isManagerRequest(request))"),
|
!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 async function getDashboardData"),
|
||||||
mvpDb.indexOf("export type TaskReportRow"),
|
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(
|
assert.equal(
|
||||||
occurrences.length,
|
occurrences.length,
|
||||||
2,
|
2,
|
||||||
"tasks 与 distributions 两条查询都要按 owner 过滤",
|
"tasks 与 distributions 两条查询都要按 union scope 过滤(任务负责人 OR 项目负责人)",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,29 @@ function captureDb(rowsByPattern) {
|
|||||||
return { db, captured };
|
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([
|
const { db, captured } = captureDb([
|
||||||
[
|
[
|
||||||
/FROM tasks WHERE id = \? AND owner_userid = \?/,
|
/FROM tasks WHERE id = \? AND owner_userid = \?/,
|
||||||
@@ -50,9 +72,18 @@ test("isTaskOwner matches task owner_userid binding", async () => {
|
|||||||
: null,
|
: null,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
assert.equal(await isTaskOwner(db, "task-1", "zhangsan"), true);
|
assert.equal(await isTaskOwner(db, "task-1", "zhangsan", { strict: true }), true);
|
||||||
assert.equal(await isTaskOwner(db, "task-2", "zhangsan"), false);
|
assert.equal(
|
||||||
|
await isTaskOwner(db, "task-1", "project-owner", { strict: true }),
|
||||||
|
false,
|
||||||
|
"strict 门不允许项目负责人代操作任务负责人的客户群",
|
||||||
|
);
|
||||||
assert.equal(captured.length, 2);
|
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 () => {
|
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);
|
assert.equal(captured.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isDistributionOwner resolves distribution → task owner", async () => {
|
test("isDistributionOwner union-matches via task or project owner", async () => {
|
||||||
const { db } = captureDb([
|
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) =>
|
||||||
values[0] === "dist-1" && values[1] === "zhangsan"
|
values[0] === "dist-1" &&
|
||||||
|
values[1] === "zhangsan" &&
|
||||||
|
values[2] === "zhangsan"
|
||||||
? { id: "dist-1" }
|
? { id: "dist-1" }
|
||||||
: null,
|
: 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-1", "zhangsan"), true);
|
||||||
assert.equal(await isDistributionOwner(db, "dist-9", "zhangsan"), false);
|
assert.equal(await isDistributionOwner(db, "dist-9", "zhangsan"), false);
|
||||||
assert.equal(await isDistributionOwner(db, "dist-1", ""), 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", () => {
|
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/, () => []]]);
|
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
|
||||||
await getTaskReportData(db, "zhangsan");
|
await getTaskReportData(db, "zhangsan");
|
||||||
assert.equal(captured.length, 1);
|
assert.equal(captured.length, 1);
|
||||||
assert.match(captured[0].sql, /WHERE t\.owner_userid = \?/);
|
assert.match(
|
||||||
assert.deepEqual(captured[0].values, ["zhangsan"]);
|
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 () => {
|
test("getTaskReportData keeps global aggregation without ownerUserid", async () => {
|
||||||
|
|||||||
@@ -190,8 +190,12 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
|
|||||||
async all() {
|
async all() {
|
||||||
executed.push({ sql, params });
|
executed.push({ sql, params });
|
||||||
if (/FROM projects p/.test(sql)) {
|
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 {
|
return {
|
||||||
results: projects.map((item) => ({
|
results: rows.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
task_count: tasks.filter((task) => task.project_id === item.id).length,
|
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"]);
|
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 () => {
|
test("getProjectDetail returns tasks with owner and mainChat members", async () => {
|
||||||
const db = fakeProjectDb({
|
const db = fakeProjectDb({
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ const reportRow = {
|
|||||||
latest_comments: 40,
|
latest_comments: 40,
|
||||||
latest_collects: 60,
|
latest_collects: 60,
|
||||||
latest_shares: 10,
|
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", () => {
|
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
||||||
@@ -71,6 +76,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
|||||||
"最新评论",
|
"最新评论",
|
||||||
"最新收藏",
|
"最新收藏",
|
||||||
"最新转发",
|
"最新转发",
|
||||||
|
"关联任务",
|
||||||
|
"截图提交数",
|
||||||
].join(","),
|
].join(","),
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -105,6 +112,8 @@ test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
|
|||||||
40,
|
40,
|
||||||
60,
|
60,
|
||||||
10,
|
10,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
].join(","),
|
].join(","),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -136,9 +145,26 @@ test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () =>
|
|||||||
const rowLine = csv.slice(1).split("\r\n")[1];
|
const rowLine = csv.slice(1).split("\r\n")[1];
|
||||||
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
|
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
|
||||||
const cells = rowLine.split(",");
|
const cells = rowLine.split(",");
|
||||||
assert.equal(cells.at(-1), "", "null latest_shares becomes empty");
|
assert.equal(cells.at(-1), "", "content publish row leaves screenshot count empty");
|
||||||
assert.equal(cells.at(-18), "", "no claims means empty completion rate");
|
assert.equal(cells.at(-2), "", "no related task leaves empty");
|
||||||
assert.equal(cells.at(-16), "2026-08-01 10:00:00");
|
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 () => {
|
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, /GROUP BY t\.id/);
|
||||||
assert.match(capturedSql, /publish_url/);
|
assert.match(capturedSql, /publish_url/);
|
||||||
assert.match(capturedSql, /latest_shares/);
|
assert.match(capturedSql, /latest_shares/);
|
||||||
|
assert.match(capturedSql, /related_task_id/);
|
||||||
|
assert.match(capturedSql, /result_submitted_at/);
|
||||||
});
|
});
|
||||||
|
|||||||
92
tests/wecom-bind.test.mjs
Normal file
92
tests/wecom-bind.test.mjs
Normal file
@@ -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"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user