feat: 门户接入企微网页授权——企微内静默免输身份领取,浏览器外保留手动输入

This commit is contained in:
ABAPPLO
2026-08-31 17:17:00 +08:00
parent 6e590e3b08
commit ca3810a3d0
4 changed files with 326 additions and 30 deletions

View File

@@ -547,6 +547,40 @@ input:focus {
line-height: 1.65;
}
.wecom-identity-chip {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--green-soft);
}
.wecom-identity-chip b {
color: var(--green-deep);
font-size: 11px;
}
.wecom-identity-chip span {
color: var(--ink);
font-size: 11px;
font-weight: 600;
}
.wecom-identity-chip button {
margin-left: auto;
border: none;
background: none;
color: var(--muted);
font-size: 10px;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.recovery-list {
display: flex;
flex-direction: column;

View File

@@ -107,11 +107,135 @@ function partnerApi(
path:
| "/api/partner"
| "/api/partner-upload"
| "/api/partner-batch-workbook",
| "/api/partner-batch-workbook"
| "/api/wecom-oauth",
) {
return `${resolveAdminOrigin()}${path}`;
}
const WECOM_CORP_ID = process.env.NEXT_PUBLIC_WECOM_CORP_ID ?? "";
const WECOM_AGENT_ID = process.env.NEXT_PUBLIC_WECOM_AGENT_ID ?? "";
const WECOM_OAUTH_ENABLED = Boolean(WECOM_CORP_ID && WECOM_AGENT_ID);
const WECOM_PORTAL_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "/koc";
const WECOM_IDENTITY_SESSION_KEY = "koc-wecom-identity";
const WECOM_AUTH_FAILED_KEY = "koc-wecom-auth-failed";
const WECOM_OAUTH_SID_PREFIX = "koc-wecom-oauth-sid";
const WECOM_OAUTH_STATE_LIMIT = 120;
type WecomPortalIdentity = {
ticket: string;
name: string;
expiresAt: number;
};
type PortalParams = {
task: string;
claim: string;
share: string;
note: string;
};
function readSessionValue<T>(key: string): T | null {
try {
const raw = window.sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function writeSessionValue(key: string, value: unknown): boolean {
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
function markSessionFlag(key: string) {
try {
window.sessionStorage.setItem(key, "1");
} catch {
// 隐私模式等场景下忽略,最多少一次自动授权
}
}
function hasSessionFlag(key: string) {
try {
return window.sessionStorage.getItem(key) === "1";
} catch {
return false;
}
}
function loadWecomIdentity(): WecomPortalIdentity | null {
const identity = readSessionValue<WecomPortalIdentity>(
WECOM_IDENTITY_SESSION_KEY,
);
if (!identity?.ticket || typeof identity.expiresAt !== "number") return null;
if (Date.now() > identity.expiresAt) return null;
return identity;
}
function isWecomEmbeddedBrowser() {
const userAgent = window.navigator.userAgent;
return /wxwork/i.test(userAgent) || /MicroMessenger/i.test(userAgent);
}
function buildWecomAuthorizeUrl(state: string) {
const query = new URLSearchParams({
appid: WECOM_CORP_ID,
redirect_uri: `${window.location.origin}${WECOM_PORTAL_BASE_PATH}/`,
response_type: "code",
scope: "snsapi_base",
state,
agentid: WECOM_AGENT_ID,
});
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query}#wechat_redirect`;
}
function buildWecomOAuthState(params: PortalParams): string {
const compact = new URLSearchParams();
if (params.task) compact.set("t", params.task);
if (params.claim) compact.set("c", params.claim);
if (params.share) compact.set("s", params.share);
if (params.note) compact.set("n", params.note);
if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) {
return compact.toString();
}
const sid = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
if (writeSessionValue(`${WECOM_OAUTH_SID_PREFIX}:${sid}`, params)) {
return `sid=${sid}`;
}
const fallback = new URLSearchParams();
if (params.task) fallback.set("t", params.task);
if (params.share) fallback.set("s", params.share);
return fallback.toString();
}
function restoreParamsFromWecomState(state: string): PortalParams {
const parsed = new URLSearchParams(state);
const sid = parsed.get("sid");
if (sid) {
const key = `${WECOM_OAUTH_SID_PREFIX}:${sid}`;
const stored = readSessionValue<PortalParams>(key);
try {
window.sessionStorage.removeItem(key);
} catch {
// 忽略清理失败
}
if (stored?.task || stored?.share) return stored;
return { task: "", claim: "", share: "", note: "" };
}
return {
task: parsed.get("t") ?? "",
claim: parsed.get("c") ?? "",
share: parsed.get("s") ?? "",
note: parsed.get("n") ?? "",
};
}
function isMobilePortalViewport() {
return (
typeof window !== "undefined" &&
@@ -325,6 +449,10 @@ export default function Home() {
const [creatorViews, setCreatorViews] = useState("");
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>(
null,
);
const [manualEntry, setManualEntry] = useState(false);
const [adminOrigin, setAdminOrigin] = useState("");
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
const [batchDownloading, setBatchDownloading] = useState(false);
@@ -401,10 +529,79 @@ export default function Home() {
}
}, []);
const exchangeWecomIdentity = useCallback(
async (code: string, restored: PortalParams) => {
try {
const response = await fetch(partnerApi("/api/wecom-oauth"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const result = (await response.json()) as {
userType?: string;
identityTicket?: string;
displayName?: string;
expiresIn?: number;
error?: string;
};
if (
response.ok &&
result.userType === "external" &&
result.identityTicket
) {
const identity: WecomPortalIdentity = {
ticket: result.identityTicket,
name: result.displayName || "企微客户",
expiresAt:
Date.now() +
Math.max(60, (result.expiresIn ?? 1800) - 60) * 1_000,
};
writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity);
setWecomIdentity(identity);
setManualEntry(false);
setToast(`企微身份已确认:${identity.name}`);
} else {
markSessionFlag(WECOM_AUTH_FAILED_KEY);
setToast(
result.userType === "internal"
? "当前为企业内部成员身份,请手动输入微信号领取"
: result.error || "企微授权失败,请手动输入微信号",
);
}
} catch {
markSessionFlag(WECOM_AUTH_FAILED_KEY);
setToast("企微授权失败,请手动输入微信号");
}
setTaskToken(restored.task);
setClaimToken(restored.claim);
setDelegationToken(restored.share);
setSelectedId(restored.note);
if (!restored.task && !restored.share) {
setLoading(false);
setError("任务链接不完整,请向运营重新获取");
return;
}
await loadTask(restored.task, restored.claim, restored.share);
},
[loadTask],
);
useEffect(() => {
const timer = window.setTimeout(() => {
setAdminOrigin(resolveAdminOrigin());
const params = new URLSearchParams(window.location.search);
const oauthCode = params.get("code") ?? "";
if (oauthCode) {
const restored = restoreParamsFromWecomState(params.get("state") ?? "");
const cleaned = new URL(window.location.href);
cleaned.searchParams.delete("code");
cleaned.searchParams.delete("state");
window.history.replaceState({}, "", cleaned);
void exchangeWecomIdentity(oauthCode, restored);
return;
}
const cachedIdentity = loadWecomIdentity();
if (cachedIdentity) setWecomIdentity(cachedIdentity);
const task = params.get("task") ?? "";
const claim = params.get("claim") ?? "";
const delegation = params.get("share") ?? "";
@@ -418,10 +615,28 @@ export default function Home() {
setError("任务链接不完整,请向运营重新获取");
return;
}
if (
WECOM_OAUTH_ENABLED &&
!cachedIdentity &&
!hasSessionFlag(WECOM_AUTH_FAILED_KEY) &&
isWecomEmbeddedBrowser()
) {
window.location.replace(
buildWecomAuthorizeUrl(
buildWecomOAuthState({
task,
claim,
share: delegation,
note,
}),
),
);
return;
}
void loadTask(task, claim, delegation);
}, 0);
return () => window.clearTimeout(timer);
}, [loadTask]);
}, [loadTask, exchangeWecomIdentity]);
useEffect(() => {
if (!toast) return;
@@ -464,6 +679,9 @@ export default function Home() {
action: "claim",
taskToken,
claimantIdentifier: claimantName,
...(wecomIdentity && !manualEntry
? { identityTicket: wecomIdentity.ticket }
: {}),
quantity,
}),
});
@@ -512,6 +730,9 @@ export default function Home() {
action: "recover",
taskToken,
claimantIdentifier: claimantName,
...(wecomIdentity && !manualEntry
? { identityTicket: wecomIdentity.ticket }
: {}),
}),
});
const result = (await response.json()) as {
@@ -2031,12 +2252,22 @@ export default function Home() {
找回领取记录
</button>
</div>
{wecomIdentity && !manualEntry && (
<div className="wecom-identity-chip">
<b>企微身份已确认</b>
<span>{wecomIdentity.name}</span>
<button type="button" onClick={() => setManualEntry(true)}>
切换为手动输入
</button>
</div>
)}
{entryMode === "claim" ? (
<form onSubmit={claimNotes}>
<div>
<p className="micro">01 · 确认领取</p>
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
</div>
{(!wecomIdentity || manualEntry) && (
<label>
<span>微信号 / 手机号</span>
<input
@@ -2050,6 +2281,7 @@ export default function Home() {
required
/>
</label>
)}
<div className="quantity-control">
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
@@ -2080,9 +2312,12 @@ export default function Home() {
<p className="micro">找回历史记录</p>
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
<p className="recover-intro">
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
{wecomIdentity && !manualEntry
? "已用企微身份查找,同一任务多次领取会分批展示。"
: "输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。"}
</p>
</div>
{(!wecomIdentity || manualEntry) && (
<label>
<span>领取时的微信号 / 手机号</span>
<input
@@ -2096,6 +2331,7 @@ export default function Home() {
required
/>
</label>
)}
<button className="claim-primary" disabled={working}>
{working ? "正在查找…" : "查找我的领取记录"}
</button>

View File

@@ -9,7 +9,7 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "npx serve out -l 3001",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"test": "npm run build && node --import tsx --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern out"
},
"dependencies": {

View File

@@ -170,3 +170,29 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
assert.match(styles, /\.share-checkbox/);
assert.match(styles, /\.delegation-history/);
});
test("wires silent wecom oauth with full manual fallback", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /NEXT_PUBLIC_WECOM_CORP_ID/);
assert.match(page, /NEXT_PUBLIC_WECOM_AGENT_ID/);
assert.match(page, /snsapi_base/);
assert.match(page, /connect\/oauth2\/authorize/);
assert.match(page, /wxwork\/i\.test\(userAgent\)/);
assert.match(page, /MicroMessenger\/i\.test\(userAgent\)/);
assert.match(page, /"koc-wecom-identity"/);
assert.match(page, /"koc-wecom-auth-failed"/);
assert.match(page, /\/api\/wecom-oauth/);
assert.match(page, /restoreParamsFromWecomState/);
assert.match(page, /buildWecomOAuthState/);
assert.match(page, /identityTicket: wecomIdentity\.ticket/);
assert.match(page, /企微身份已确认/);
assert.match(page, /切换为手动输入/);
assert.match(page, /window\.location\.replace\(/);
assert.match(styles, /\.wecom-identity-chip/);
assert.match(page, /\(!wecomIdentity \|\| manualEntry\)/);
assert.match(page, /claimantIdentifier:\s*claimantName/);
});