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:
@@ -68,9 +68,12 @@ import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
||||
import {
|
||||
getOwnerScope,
|
||||
isDistributionOwner,
|
||||
isProjectOwner,
|
||||
isTaskOwner,
|
||||
isTaskProjectOwner,
|
||||
ownerForbidden,
|
||||
ownerScopeUserid,
|
||||
projectForbidden,
|
||||
} from "../../../lib/owner-scope";
|
||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||
import {
|
||||
@@ -126,10 +129,11 @@ export async function POST(request: Request) {
|
||||
const scope = await getOwnerScope(request);
|
||||
const managerGate = () =>
|
||||
scope.kind === "owner" ? managerForbidden() : null;
|
||||
const taskOwnerGate = async (taskId: unknown) => {
|
||||
const taskOwnerGate = async (taskId: unknown, options?: { strict?: boolean }) => {
|
||||
if (scope.kind === "full") return null;
|
||||
const id = String(taskId ?? "").trim();
|
||||
return id && (await isTaskOwner(db, id, scope.wecomUserId))
|
||||
return id &&
|
||||
(await isTaskOwner(db, id, scope.wecomUserId, options))
|
||||
? null
|
||||
: ownerForbidden();
|
||||
};
|
||||
@@ -140,10 +144,22 @@ export async function POST(request: Request) {
|
||||
? null
|
||||
: ownerForbidden();
|
||||
};
|
||||
const projectOwnerGate = async (projectId: unknown) => {
|
||||
if (scope.kind === "full") return null;
|
||||
const id = String(projectId ?? "").trim();
|
||||
return id && (await isProjectOwner(db, id, scope.wecomUserId))
|
||||
? null
|
||||
: projectForbidden();
|
||||
};
|
||||
const taskProjectOwnerGate = async (taskId: unknown) => {
|
||||
if (scope.kind === "full") return null;
|
||||
const id = String(taskId ?? "").trim();
|
||||
return id && (await isTaskProjectOwner(db, id, scope.wecomUserId))
|
||||
? null
|
||||
: projectForbidden();
|
||||
};
|
||||
|
||||
if (body.action === "inspect_feishu") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
const source = await readFeishuSource(
|
||||
String(body.feishuUrl ?? "").trim(),
|
||||
env as unknown as FeishuBindings,
|
||||
@@ -167,7 +183,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (body.action === "create_task") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
if (forbidden) return forbidden;
|
||||
const name = String(body.name ?? "").trim();
|
||||
const brand = String(body.brand ?? "").trim();
|
||||
@@ -195,15 +211,13 @@ export async function POST(request: Request) {
|
||||
);
|
||||
ownerWarning = await assignOwnerQuietly(creation.taskId, body.ownerUserid);
|
||||
} else if (body.action === "create_screenshot_task") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
if (forbidden) return forbidden;
|
||||
const creation = await createScreenshotTask({
|
||||
name: String(body.name ?? "").trim(),
|
||||
brand: String(body.brand ?? "").trim(),
|
||||
relatedTaskId: String(body.relatedTaskId ?? "").trim(),
|
||||
dueAt: String(body.dueAt ?? "").trim(),
|
||||
keyword: String(body.keyword ?? "").trim(),
|
||||
instructions: String(body.instructions ?? "").trim(),
|
||||
quantity: numberValue(body.quantity),
|
||||
exampleImageKey: String(body.exampleImageKey ?? "").trim(),
|
||||
projectId: String(body.projectId ?? "").trim(),
|
||||
ownerUserid: String(body.ownerUserid ?? "").trim(),
|
||||
@@ -765,8 +779,6 @@ export async function POST(request: Request) {
|
||||
const contacts = await listExternalContacts(wecomConfig);
|
||||
return Response.json({ contacts });
|
||||
} else if (body.action === "wecom_list_internal_contacts") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
const wecomConfig = resolveWecomConfig(
|
||||
env as unknown as WecomBindings,
|
||||
);
|
||||
@@ -816,16 +828,27 @@ export async function POST(request: Request) {
|
||||
const pushes = await listGroupPushes(db, taskId || undefined);
|
||||
return Response.json({ pushes });
|
||||
} else if (body.action === "project_list") {
|
||||
const forbidden = managerGate();
|
||||
if (forbidden) return forbidden;
|
||||
return Response.json({ projects: await listProjects(db) });
|
||||
return Response.json({
|
||||
projects: await listProjects(db, ownerScopeUserid(scope)),
|
||||
});
|
||||
} else if (body.action === "project_detail") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
if (forbidden) return forbidden;
|
||||
return Response.json(await getProjectDetail(db, String(body.projectId ?? "")));
|
||||
} else if (body.action === "project_save") {
|
||||
const forbidden = managerGate();
|
||||
const projectId = String(body.id ?? "").trim();
|
||||
// 项目负责人只能更新自己负责的项目,且归属 ownerUserid 强制保留原值
|
||||
if (scope.kind === "owner" && !projectId) return managerForbidden();
|
||||
const forbidden = await projectOwnerGate(projectId);
|
||||
if (forbidden) return forbidden;
|
||||
let nextOwnerUserid = body.ownerUserid;
|
||||
if (scope.kind === "owner") {
|
||||
const current = await db
|
||||
.prepare("SELECT owner_userid FROM projects WHERE id = ?")
|
||||
.bind(projectId)
|
||||
.first<{ owner_userid: string }>();
|
||||
nextOwnerUserid = current?.owner_userid ?? "";
|
||||
}
|
||||
const saved = await saveProject(
|
||||
db,
|
||||
{
|
||||
@@ -833,19 +856,19 @@ export async function POST(request: Request) {
|
||||
name: body.name,
|
||||
brand: body.brand,
|
||||
mainGroupMemberIds: body.mainGroupMemberIds,
|
||||
ownerUserid: body.ownerUserid,
|
||||
ownerUserid: nextOwnerUserid,
|
||||
note: body.note,
|
||||
},
|
||||
resolveWecomConfig(env as unknown as WecomBindings),
|
||||
);
|
||||
return Response.json(saved);
|
||||
} else if (body.action === "project_delete") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
if (forbidden) return forbidden;
|
||||
await deleteProject(db, String(body.projectId ?? ""));
|
||||
return Response.json({ ok: true });
|
||||
} else if (body.action === "project_ensure_main_chat") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await projectOwnerGate(body.projectId);
|
||||
if (forbidden) return forbidden;
|
||||
const result = await ensureProjectMainChat(
|
||||
db,
|
||||
@@ -854,7 +877,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
return Response.json(result);
|
||||
} else if (body.action === "task_set_owner") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||
if (forbidden) return forbidden;
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const ownerUserid = String(body.ownerUserid ?? "").trim();
|
||||
@@ -882,8 +905,16 @@ export async function POST(request: Request) {
|
||||
.first<{ owner_userid: string; task_chat_id: string }>();
|
||||
return Response.json({ ok: true, assignment, task });
|
||||
} else if (body.action === "task_update") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||
if (forbidden) return forbidden;
|
||||
if (scope.kind === "owner") {
|
||||
// 迁移目标也必须是本人负责的项目;置空(脱离项目)同样拒绝
|
||||
const nextProjectId = String(body.projectId ?? "").trim();
|
||||
const projectForbiddenResponse = nextProjectId
|
||||
? await projectOwnerGate(nextProjectId)
|
||||
: projectForbidden();
|
||||
if (projectForbiddenResponse) return projectForbiddenResponse;
|
||||
}
|
||||
const platform = body.platform === "抖音" || body.platform === "小红书"
|
||||
? (body.platform as "小红书" | "抖音")
|
||||
: undefined;
|
||||
@@ -919,7 +950,7 @@ export async function POST(request: Request) {
|
||||
if (forbidden) return forbidden;
|
||||
return Response.json({ partners: await listGroupOwnerPartners(db) });
|
||||
} else if (body.action === "project_push_task") {
|
||||
const forbidden = managerGate();
|
||||
const forbidden = await taskProjectOwnerGate(body.taskId);
|
||||
if (forbidden) return forbidden;
|
||||
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
|
||||
const outcome = await pushProjectTask(
|
||||
@@ -1008,7 +1039,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} else if (body.action === "task_customer_chats") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const forbidden = await taskOwnerGate(taskId);
|
||||
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||
if (forbidden) return forbidden;
|
||||
const task = await db
|
||||
.prepare(
|
||||
@@ -1037,7 +1068,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} else if (body.action === "task_bind_customer_chat") {
|
||||
const taskId = String(body.taskId ?? "").trim();
|
||||
const forbidden = await taskOwnerGate(taskId);
|
||||
const forbidden = await taskOwnerGate(taskId, { strict: true });
|
||||
if (forbidden) return forbidden;
|
||||
const task = await db
|
||||
.prepare(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "../../../lib/partner-utils";
|
||||
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
||||
import { refreshTaskStatus } from "../../../lib/task-service";
|
||||
import {
|
||||
resolveWecomClaimant,
|
||||
verifyWecomIdentityTicket,
|
||||
@@ -1187,6 +1188,7 @@ async function handlePost(request: Request) {
|
||||
).catch(() => undefined);
|
||||
runInBackground(feedback, "wecom publish feedback");
|
||||
}
|
||||
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1237,6 +1239,7 @@ async function handlePost(request: Request) {
|
||||
)
|
||||
.bind(exposure, views, assignment.id)
|
||||
.run();
|
||||
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||
return Response.json({ submitted: true });
|
||||
}
|
||||
|
||||
@@ -1280,6 +1283,7 @@ async function handlePost(request: Request) {
|
||||
);
|
||||
}
|
||||
await db.batch(statements);
|
||||
await refreshTaskStatus(task.id, db).catch(() => undefined);
|
||||
if (isWecomFeedbackEnabled()) {
|
||||
const feedback = notifyPartnerSubmitted(
|
||||
db,
|
||||
|
||||
@@ -304,6 +304,34 @@ export async function GET(request: Request) {
|
||||
.bind(taskId)
|
||||
.all<ExportRow>();
|
||||
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(
|
||||
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
|
||||
);
|
||||
@@ -356,6 +384,7 @@ export async function GET(request: Request) {
|
||||
"采集状态",
|
||||
"合作方",
|
||||
"领取人微信号/手机号",
|
||||
...(companionSubmittedByContent ? ["收录截图"] : []),
|
||||
];
|
||||
const originalImageStart = 3;
|
||||
const accountColumn = originalImageStart + maxContentImages;
|
||||
@@ -398,6 +427,15 @@ export async function GET(request: Request) {
|
||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||
row.partner_name || "",
|
||||
row.claimant_name || "",
|
||||
...(companionSubmittedByContent
|
||||
? [
|
||||
companionSubmittedByContent.has(row.content_id)
|
||||
? companionSubmittedByContent.get(row.content_id)
|
||||
? "已提交"
|
||||
: "未提交"
|
||||
: "—",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const images: RecoveryWorkbookRow["images"] = [];
|
||||
for (const asset of contentAssets) {
|
||||
@@ -433,6 +471,7 @@ export async function GET(request: Request) {
|
||||
24,
|
||||
18,
|
||||
22,
|
||||
...(companionSubmittedByContent ? [12] : []),
|
||||
];
|
||||
const workbook = buildRecoveryWorkbook({
|
||||
sheetName: `${task.name}-数据回收`,
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user