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

178 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ensureSchema, getRawDb } 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);
}