178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
|
|
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);
|
|||
|
|
}
|