diff --git a/koc-portal/app/globals.css b/koc-portal/app/globals.css index ef33a0f..95ce82c 100644 --- a/koc-portal/app/globals.css +++ b/koc-portal/app/globals.css @@ -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; diff --git a/koc-portal/app/page.tsx b/koc-portal/app/page.tsx index f11ef1d..763d3e2 100644 --- a/koc-portal/app/page.tsx +++ b/koc-portal/app/page.tsx @@ -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(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( + 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(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([]); + const [wecomIdentity, setWecomIdentity] = useState( + null, + ); + const [manualEntry, setManualEntry] = useState(false); const [adminOrigin, setAdminOrigin] = useState(""); const [downloadingImage, setDownloadingImage] = useState(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,25 +2252,36 @@ export default function Home() { 找回领取记录 + {wecomIdentity && !manualEntry && ( +
+ 企微身份已确认 + {wecomIdentity.name} + +
+ )} {entryMode === "claim" ? (

01 · 确认领取

你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?

- + {(!wecomIdentity || manualEntry) && ( + + )}
{quantity}{payload.task.type === "screenshot_collect" ? "份" : "篇"}
@@ -2080,22 +2312,26 @@ export default function Home() {

找回历史记录

继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}

- 输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。 + {wecomIdentity && !manualEntry + ? "已用企微身份查找,同一任务多次领取会分批展示。" + : "输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。"}

- + {(!wecomIdentity || manualEntry) && ( + + )} diff --git a/koc-portal/package.json b/koc-portal/package.json index cf50847..367459e 100644 --- a/koc-portal/package.json +++ b/koc-portal/package.json @@ -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": { diff --git a/koc-portal/tests/rendered-html.test.mjs b/koc-portal/tests/rendered-html.test.mjs index 8042caf..91f637c 100644 --- a/koc-portal/tests/rendered-html.test.mjs +++ b/koc-portal/tests/rendered-html.test.mjs @@ -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/); +});