From 3cd322ccfe2067d939a07c20e61f5d18301bd2df Mon Sep 17 00:00:00 2001 From: John Date: Mon, 15 Jun 2026 15:47:19 -0700 Subject: [PATCH] Add WeChat login binding gate and context-aware auth UX. Stop auto-creating duplicate accounts on OAuth, add bind-or-register gate, PC scan login, mobile open-in-WeChat guide, and fix localhost session cookies. Co-authored-by: Cursor --- .env.example | 3 + db.mjs | 35 +++ schema.sql | 26 +- server.mjs | 174 ++++++++++- src/App.tsx | 4 +- src/api/client.ts | 149 +++++++++ src/components/AuthView.tsx | 288 ++++++++++++----- src/components/ChatView.tsx | 6 +- src/components/WechatAccountButton.tsx | 44 +++ src/components/WechatBindGate.tsx | 184 +++++++++++ src/components/WechatBindPrompt.tsx | 51 +++ src/components/WechatDesktopLogin.tsx | 54 ++++ src/components/WechatIcon.tsx | 15 + src/components/WechatOpenGuide.tsx | 87 ++++++ src/components/WechatScanLogin.tsx | 104 +++++++ src/index.css | 337 +++++++++++++++++++- src/utils/wechat.ts | 67 ++++ user-auth.mjs | 409 +++++++++++++++++++++++-- wechat-oauth.mjs | 260 +++++++++++++--- 19 files changed, 2141 insertions(+), 156 deletions(-) create mode 100644 src/components/WechatAccountButton.tsx create mode 100644 src/components/WechatBindGate.tsx create mode 100644 src/components/WechatBindPrompt.tsx create mode 100644 src/components/WechatDesktopLogin.tsx create mode 100644 src/components/WechatIcon.tsx create mode 100644 src/components/WechatOpenGuide.tsx create mode 100644 src/components/WechatScanLogin.tsx diff --git a/.env.example b/.env.example index 7d7d278..ba2bc0d 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,9 @@ H5_ACCESS_PASSWORD=change-me # H5_WECHAT_OAUTH_SCOPE=snsapi_userinfo # 默认回调:{H5_PUBLIC_BASE_URL}/auth/wechat/callback # H5_WECHAT_OAUTH_CALLBACK_URL=https://goo.tkmind.cn/auth/wechat/callback +# PC 扫码登录(微信开放平台网站应用,可选;未配置时用手机扫页面链接二维码) +# H5_WECHAT_OPEN_APP_ID= +# H5_WECHAT_OPEN_APP_SECRET= # 本地开发:pnpm dev 会自动启动 server.mjs + vite # 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server) diff --git a/db.mjs b/db.mjs index a70cd20..801b899 100644 --- a/db.mjs +++ b/db.mjs @@ -215,6 +215,41 @@ export async function migrateSchema(pool) { `ALTER TABLE h5_users ADD COLUMN signup_source VARCHAR(32) NULL DEFAULT 'password' AFTER workspace_root`, ); } + + const oauthStateColumns = [ + ['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"], + ['bind_user_id', 'CHAR(36) NULL AFTER intent'], + ['auth_mode', "VARCHAR(8) NOT NULL DEFAULT 'mp' AFTER bind_user_id"], + ['status', "VARCHAR(16) NOT NULL DEFAULT 'pending' AFTER auth_mode"], + ['result_kind', 'VARCHAR(32) NULL AFTER status'], + ['result_token', 'VARCHAR(512) NULL AFTER result_kind'], + ['result_message', 'VARCHAR(255) NULL AFTER result_token'], + ]; + for (const [column, definition] of oauthStateColumns) { + if (!(await columnExists(pool, 'h5_wechat_oauth_states', column))) { + await pool.query( + `ALTER TABLE h5_wechat_oauth_states ADD COLUMN \`${column}\` ${definition}`, + ); + } + } + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds ( + token VARCHAR(64) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(128) NULL, + avatar_url VARCHAR(512) NULL, + return_to VARCHAR(512) NULL, + utm_source VARCHAR(64) NULL, + utm_medium VARCHAR(64) NULL, + utm_campaign VARCHAR(64) NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_pending_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); } export async function initSchema(pool) { diff --git a/schema.sql b/schema.sql index 94e6702..4561d11 100644 --- a/schema.sql +++ b/schema.sql @@ -668,7 +668,31 @@ CREATE TABLE IF NOT EXISTS h5_wechat_oauth_states ( utm_source VARCHAR(64) NULL, utm_medium VARCHAR(64) NULL, utm_campaign VARCHAR(64) NULL, + intent VARCHAR(16) NOT NULL DEFAULT 'login', + bind_user_id CHAR(36) NULL, + auth_mode VARCHAR(8) NOT NULL DEFAULT 'mp', + status VARCHAR(16) NOT NULL DEFAULT 'pending', + result_kind VARCHAR(32) NULL, + result_token VARCHAR(512) NULL, + result_message VARCHAR(255) NULL, expires_at BIGINT NOT NULL, created_at BIGINT NOT NULL, - KEY idx_wechat_oauth_state_expires (expires_at) + KEY idx_wechat_oauth_state_expires (expires_at), + KEY idx_wechat_oauth_state_status (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds ( + token VARCHAR(64) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + unionid VARCHAR(64) NULL, + nickname VARCHAR(128) NULL, + avatar_url VARCHAR(512) NULL, + return_to VARCHAR(512) NULL, + utm_source VARCHAR(64) NULL, + utm_medium VARCHAR(64) NULL, + utm_campaign VARCHAR(64) NULL, + expires_at BIGINT NOT NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_pending_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server.mjs b/server.mjs index 11e181d..b85d079 100644 --- a/server.mjs +++ b/server.mjs @@ -15,6 +15,7 @@ import { createTkmindProxy } from './tkmind-proxy.mjs'; import { clearUserSessionCookie, createUserAuth, + resolveCookieDomainForRequest, USER_COOKIE, userLoginCookies, userSessionCookie, @@ -340,6 +341,20 @@ function userToken(req) { return parseCookies(req.get('cookie'))[USER_COOKIE]; } +function setUserLoginCookies(res, req, token) { + res.set( + 'Set-Cookie', + userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)), + ); +} + +function clearUserLoginCookies(res, req) { + res.set( + 'Set-Cookie', + clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)), + ); +} + async function attachUserSession(req, _res, next) { if (!userAuth) return next(); const token = userToken(req); @@ -394,7 +409,7 @@ app.post('/auth/login', jsonBody, async (req, res) => { } return res.status(401).json({ message: result.message }); } - res.set('Set-Cookie', userLoginCookies(result.token, secure)); + setUserLoginCookies(res, req, result.token); return res.json({ authenticated: true, user: result.user, mode: 'user' }); } @@ -460,18 +475,145 @@ app.post('/auth/reset-password', jsonBody, async (req, res) => { app.get('/auth/wechat/config', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { - return res.json({ enabled: false, inWechat: isWechatUserAgent(req.get('user-agent') || '') }); + return res.json({ + enabled: false, + inWechat: isWechatUserAgent(req.get('user-agent') || ''), + scanEnabled: false, + }); } return res.json(wechatOAuthService.publicConfig(req)); }); +app.get('/auth/wechat/status', async (req, res) => { + await userAuthReady; + if (!userAuth || !wechatOAuthService?.enabled) { + return res.json({ enabled: false, bound: false }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '未登录' }); + const config = loadWechatOAuthConfig(); + const status = await userAuth.getWechatBindingStatus(me.id, config.appId); + return res.json({ enabled: true, ...status }); +}); + +app.get('/auth/wechat/pending/:token', async (req, res) => { + await userAuthReady; + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const pending = await userAuth.getWechatPendingBind(req.params.token); + if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' }); + return res.json({ + nickname: pending.nickname, + avatarUrl: pending.avatar_url, + returnTo: pending.return_to || '/', + }); +}); + +app.post('/auth/wechat/register', jsonBody, async (req, res) => { + await userAuthReady; + const secure = isSecureRequest(req); + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; + if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); + const result = await userAuth.completeWechatRegister({ pendingToken }); + if (!result.ok) return res.status(400).json({ message: result.message }); + if (result.isNewUser && plazaSeo) { + void plazaSeo + .recordAttribution( + { + event_type: 'signup', + utm_source: result.utmSource || 'wechat', + utm_medium: result.utmMedium, + utm_campaign: result.utmCampaign, + user_id: result.user?.id ?? null, + }, + plazaClientIp(req), + ) + .catch(() => {}); + } + setUserLoginCookies(res, req, result.token); + return res.json({ + authenticated: true, + user: result.user, + returnTo: result.returnTo || '/', + }); +}); + +app.post('/auth/wechat/bind', jsonBody, async (req, res) => { + await userAuthReady; + const secure = isSecureRequest(req); + if (!userAuth) return res.status(503).json({ message: '未启用用户系统' }); + const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : ''; + const username = typeof req.body?.username === 'string' ? req.body.username : ''; + const password = typeof req.body?.password === 'string' ? req.body.password : ''; + if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' }); + if (!username || !password) { + return res.status(400).json({ message: '用户名和密码不能为空' }); + } + const result = await userAuth.completeWechatBindAccount({ + pendingToken, + username, + password, + ip: req.ip, + }); + if (!result.ok) { + const status = result.retryAfterMs > 0 ? 429 : 401; + if (result.retryAfterMs > 0) { + res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))); + } + return res.status(status).json({ message: result.message }); + } + setUserLoginCookies(res, req, result.token); + return res.json({ + authenticated: true, + user: result.user, + bound: true, + returnTo: result.returnTo || '/', + }); +}); + +app.post('/auth/wechat/scan/start', async (req, res) => { + await userAuthReady; + if (!wechatOAuthService?.enabled) { + return res.status(503).json({ message: '微信登录未启用' }); + } + try { + const payload = await wechatOAuthService.startScanLogin(req); + return res.json(payload); + } catch (err) { + return res.status(503).json({ + message: err instanceof Error ? err.message : '微信扫码登录不可用', + }); + } +}); + +app.get('/auth/wechat/scan/poll', async (req, res) => { + await userAuthReady; + if (!wechatOAuthService?.enabled) { + return res.status(503).json({ message: '微信登录未启用' }); + } + const state = typeof req.query?.state === 'string' ? req.query.state : ''; + if (!state) return res.status(400).json({ message: '缺少扫码状态' }); + const result = await wechatOAuthService.pollScanLogin(state); + if (result.status === 'complete' && result.token) { + setUserLoginCookies(res, req, result.token); + } + return res.json(result); +}); + app.get('/auth/wechat/authorize', async (req, res) => { await userAuthReady; if (!wechatOAuthService?.enabled) { return res.status(503).json({ message: '微信登录未启用' }); } try { - const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req); + let bindUserId = null; + const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login'; + if (intent === 'bind' && userAuth) { + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' }); + bindUserId = me.id; + } + const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId }); return res.redirect(302, redirectUrl); } catch (err) { console.error('WeChat authorize failed:', err); @@ -491,6 +633,23 @@ app.get('/auth/wechat/callback', async (req, res) => { state: typeof req.query?.state === 'string' ? req.query.state : '', ip: req.ip, }); + + if (result.action === 'binding_gate') { + const params = new URLSearchParams(); + params.set('wechat_pending', result.pendingToken); + if (result.returnTo && result.returnTo !== '/') { + params.set('return_to', result.returnTo); + } + return res.redirect(302, `/?${params.toString()}`); + } + + if (result.action === 'poll_error') { + return res.redirect( + 302, + `/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`, + ); + } + if (result.isNewUser && plazaSeo) { void plazaSeo .recordAttribution( @@ -505,7 +664,12 @@ app.get('/auth/wechat/callback', async (req, res) => { ) .catch(() => {}); } - res.set('Set-Cookie', userLoginCookies(result.token, secure)); + + if (result.authMode === 'open') { + return res.send(`微信登录

扫码登录成功,请返回电脑继续操作。

`); + } + + setUserLoginCookies(res, req, result.token); return res.redirect(302, result.returnTo || '/'); } catch (err) { console.error('WeChat callback failed:', err); @@ -537,7 +701,7 @@ app.post('/auth/logout', async (req, res) => { const secure = isSecureRequest(req); if (userAuth) { await userAuth.revoke(userToken(req)); - res.set('Set-Cookie', clearUserSessionCookie(secure)); + clearUserLoginCookies(res, req); } if (legacyAuth) { legacyAuth.revoke(legacySessionToken(req)); diff --git a/src/App.tsx b/src/App.tsx index 9aadc65..687d6c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -183,12 +183,10 @@ export function App() { if (returnTo) { try { const url = new URL(returnTo, window.location.origin); - if (url.origin !== window.location.origin) { + if (url.origin === window.location.origin) { window.location.href = url.toString(); return; } - window.location.href = url.toString(); - return; } catch { // ignore invalid URLs } diff --git a/src/api/client.ts b/src/api/client.ts index b2025f2..613d715 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -222,6 +222,20 @@ export async function rememberProjectContext( export type WechatAuthConfig = { enabled: boolean; inWechat?: boolean; + scanEnabled?: boolean; +}; + +export type WechatPendingProfile = { + nickname: string | null; + avatarUrl: string | null; + returnTo: string; +}; + +export type WechatBindingStatus = { + enabled: boolean; + bound: boolean; + nickname?: string | null; + avatarUrl?: string | null; }; export async function getWechatAuthConfig(): Promise { @@ -1133,6 +1147,141 @@ export async function resetPassword( } } +export async function getWechatPendingProfile(token: string): Promise { + let response: Response; + try { + response = await fetch(`/auth/wechat/pending/${encodeURIComponent(token)}`); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + const body = (await response.json().catch(() => null)) as WechatPendingProfile & { + message?: string; + }; + if (!response.ok) { + throw new ApiError(response.status, body?.message ?? '绑定会话无效'); + } + return body; +} + +export async function completeWechatRegister(pendingToken: string): Promise<{ + user: PortalUser; + returnTo: string; +}> { + let response: Response; + try { + response = await fetch('/auth/wechat/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pendingToken }), + }); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + const body = (await response.json().catch(() => null)) as { + message?: string; + user?: PortalUser; + returnTo?: string; + }; + if (!response.ok || !body?.user) { + throw new ApiError(response.status, body?.message ?? '微信注册失败'); + } + return { user: body.user, returnTo: body.returnTo ?? '/' }; +} + +export async function completeWechatBind( + pendingToken: string, + username: string, + password: string, +): Promise<{ user: PortalUser; returnTo: string }> { + let response: Response; + try { + response = await fetch('/auth/wechat/bind', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pendingToken, username, password }), + }); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + const body = (await response.json().catch(() => null)) as { + message?: string; + user?: PortalUser; + returnTo?: string; + }; + if (!response.ok || !body?.user) { + throw new ApiError(response.status, body?.message ?? '绑定失败'); + } + return { user: body.user, returnTo: body.returnTo ?? '/' }; +} + +export async function getWechatBindingStatus(): Promise { + try { + const response = await fetch('/auth/wechat/status'); + if (!response.ok) return { enabled: false, bound: false }; + return (await response.json()) as WechatBindingStatus; + } catch { + return { enabled: false, bound: false }; + } +} + +export async function startWechatScanLogin(returnTo?: string): Promise<{ + state: string; + qrUrl: string; + expiresInMs: number; +}> { + const params = new URLSearchParams(); + if (returnTo) params.set('return_to', returnTo); + const query = params.toString(); + let response: Response; + try { + response = await fetch(`/auth/wechat/scan/start${query ? `?${query}` : ''}`, { + method: 'POST', + }); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + const body = (await response.json().catch(() => null)) as { + message?: string; + state?: string; + qrUrl?: string; + expiresInMs?: number; + }; + if (!response.ok || !body?.state || !body?.qrUrl) { + throw new ApiError(response.status, body?.message ?? '无法启动扫码登录'); + } + return { + state: body.state, + qrUrl: body.qrUrl, + expiresInMs: body.expiresInMs ?? 600000, + }; +} + +export async function pollWechatScanLogin(state: string): Promise<{ + status: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired'; + pendingToken?: string; + message?: string; +}> { + let response: Response; + try { + response = await fetch(`/auth/wechat/scan/poll?state=${encodeURIComponent(state)}`); + } catch (err) { + throw new ApiError(0, formatNetworkError(err)); + } + const body = (await response.json().catch(() => null)) as { + status?: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired'; + pendingToken?: string; + message?: string; + }; + if (!response.ok) { + throw new ApiError(response.status, body?.message ?? '扫码状态查询失败'); + } + return { + status: body?.status ?? 'error', + pendingToken: body?.pendingToken, + message: body?.message, + }; +} + export async function register( username: string, password: string, diff --git a/src/components/AuthView.tsx b/src/components/AuthView.tsx index aadb0bb..0e49636 100644 --- a/src/components/AuthView.tsx +++ b/src/components/AuthView.tsx @@ -1,9 +1,26 @@ import { useEffect, useState } from 'react'; -import { getMe, getWechatAuthConfig, login, loginLegacy, register, resetPassword } from '../api/client'; +import { + getMe, + getWechatAuthConfig, + login, + loginLegacy, + register, + resetPassword, +} from '../api/client'; import type { CapabilityMap, PortalUser } from '../types'; -import { buildWechatAuthorizeUrl, isWechatContext, readWechatAuthError } from '../utils/wechat'; +import { + buildWechatAuthorizeUrl, + isMobileExternalBrowser, + isWechatContext, + readWechatAuthError, + readWechatPendingToken, +} from '../utils/wechat'; import { TKMindAvatar } from './TKMindAvatar'; import { resolvePlazaHomeUrl } from '../utils/publicUrl'; +import { WechatBindGate } from './WechatBindGate'; +import { WechatDesktopLogin } from './WechatDesktopLogin'; +import { WechatIcon } from './WechatIcon'; +import { WechatOpenGuide } from './WechatOpenGuide'; type Mode = 'login' | 'register' | 'reset'; @@ -34,9 +51,16 @@ export function AuthView({ const [success, setSuccess] = useState(null); const [submitting, setSubmitting] = useState(false); const [wechatEnabled, setWechatEnabled] = useState(false); + const [scanEnabled, setScanEnabled] = useState(false); const [wechatContext, setWechatContext] = useState(() => isWechatContext({ search: window.location.search }), ); + const [mobileExternal, setMobileExternal] = useState(() => + isMobileExternalBrowser({ search: window.location.search }), + ); + const [showPasswordLogin, setShowPasswordLogin] = useState(false); + const [showScanLogin, setShowScanLogin] = useState(false); + const [pendingToken, setPendingToken] = useState(() => readWechatPendingToken()); const searchParams = new URLSearchParams(window.location.search); const fromPlaza = (searchParams.get('utm_source') ?? '').toLowerCase() === 'plaza'; const returnTo = searchParams.get('return_to'); @@ -52,22 +76,54 @@ export function AuthView({ if (legacyMode) return; void getWechatAuthConfig().then((config) => { setWechatEnabled(config.enabled); + setScanEnabled(Boolean(config.scanEnabled)); setWechatContext((prev) => isWechatContext({ search: window.location.search, serverInWechat: config.inWechat, }) || prev, ); + setMobileExternal( + isMobileExternalBrowser({ + search: window.location.search, + serverInWechat: config.inWechat, + }), + ); }); }, [legacyMode]); - const showWechatLogin = !legacyMode && mode === 'login' && wechatContext && wechatEnabled; + const isLogin = mode === 'login' && !legacyMode; + const showWechatOneClick = isLogin && wechatContext && wechatEnabled; + const showMobileWechatGuide = isLogin && mobileExternal && wechatEnabled; + const showDesktopWechat = isLogin && wechatEnabled && !wechatContext && !mobileExternal; + const isDesktopScanMode = showDesktopWechat && showScanLogin; + const showAccountForm = + !isDesktopScanMode && + (legacyMode || + mode !== 'login' || + mobileExternal || + showPasswordLogin || + !showWechatOneClick || + showDesktopWechat); + + const switchToPasswordLogin = () => { + setShowScanLogin(false); + setError(null); + setSuccess(null); + }; + + const switchToScanLogin = () => { + setShowScanLogin(true); + setError(null); + setSuccess(null); + }; const switchMode = (next: Mode) => { setMode(next); setError(null); setSuccess(null); setConfirmPassword(''); + setShowScanLogin(false); }; const handleWechatLogin = () => { @@ -77,6 +133,7 @@ export function AuthView({ utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat', utmMedium: params.get('utm_medium') ?? undefined, utmCampaign: params.get('utm_campaign') ?? undefined, + intent: 'login', }); }; @@ -119,6 +176,7 @@ export function AuthView({ ); const user = await login(username, password); const me = await getMe().catch(() => null); + sessionStorage.setItem('tkmind_suggest_wechat_bind', '1'); onAuth(user, me?.capabilities, me?.grantedSkills); } catch (err) { setError(err instanceof Error ? err.message : '注册失败'); @@ -149,6 +207,16 @@ export function AuthView({ } }; + if (pendingToken) { + return ( + + ); + } + const meta = legacyMode ? { title: 'TKMind', desc: '内部访问入口' } : fromPlaza @@ -201,6 +269,9 @@ export function AuthView({ !password.trim() || !confirmPassword.trim()); + const showAltLogin = + isLogin && wechatEnabled && (showMobileWechatGuide || (showDesktopWechat && !showScanLogin)); + return (
@@ -236,7 +307,24 @@ export function AuthView({

{meta.desc}

- {showWechatLogin && ( + {isDesktopScanMode && ( + <> + setPendingToken(token)} + onComplete={() => window.location.reload()} + onError={(message) => setError(message)} + /> + {error &&

{error}

} + {success &&

{success}

} + + + )} + + {showWechatOneClick && ( <> - + {!showPasswordLogin ? ( + + ) : ( + + )} )} -
- {!legacyMode && ( - setUsername(e.target.value)} - /> - )} + {showAccountForm && ( + <> +
+ {!legacyMode && ( + setUsername(e.target.value)} + /> + )} + + {mode === 'register' && !legacyMode && ( + <> + setEmail(e.target.value)} + /> + setDisplayName(e.target.value)} + /> + + )} + + {mode === 'reset' && !legacyMode && ( + setEmail(e.target.value)} + /> + )} - {mode === 'register' && !legacyMode && ( - <> setEmail(e.target.value)} + className={`login-input${error ? ' login-input-error' : ''}`} + type="password" + placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'} + value={password} + autoComplete={ + mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password' + } + autoFocus={legacyMode} + onChange={(e) => setPassword(e.target.value)} /> - setDisplayName(e.target.value)} + + {mode === 'reset' && !legacyMode && ( + setConfirmPassword(e.target.value)} + /> + )} +
+ + {error &&

{error}

} + {success &&

{success}

} + + + + )} + + {showAltLogin && ( +
+ + + {showDesktopWechat && ( + + )} + + {showMobileWechatGuide && ( + setSuccess('链接已复制,请在微信中打开')} /> - - )} - - {mode === 'reset' && !legacyMode && ( - setEmail(e.target.value)} - /> - )} - - setPassword(e.target.value)} - /> - - {mode === 'reset' && !legacyMode && ( - setConfirmPassword(e.target.value)} - /> - )} -
- - {error &&

{error}

} - {success &&

{success}

} - - + )} +
+ )}
diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 3ce134b..949f6a9 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -7,7 +7,8 @@ import { openAvatarPicker } from '../utils/userAvatar'; import { HistorySidebar } from './HistorySidebar'; import { TKMindAvatar } from './TKMindAvatar'; import { ChatPanel } from './ChatPanel'; -import { BalanceRing } from './BalanceRing'; +import { WechatBindPrompt } from './WechatBindPrompt'; +import { WechatAccountButton } from './WechatAccountButton'; import type { MindSpaceSaveCategory } from '../types'; export function ChatView({ @@ -144,6 +145,7 @@ export function ChatView({ + {user && } {onLogout && ( + ); +} diff --git a/src/components/WechatBindGate.tsx b/src/components/WechatBindGate.tsx new file mode 100644 index 0000000..316883b --- /dev/null +++ b/src/components/WechatBindGate.tsx @@ -0,0 +1,184 @@ +import { useEffect, useState } from 'react'; +import { + completeWechatBind, + completeWechatRegister, + getMe, + getWechatPendingProfile, +} from '../api/client'; +import type { CapabilityMap, PortalUser } from '../types'; +import type { WechatPendingProfile } from '../api/client'; +import { TKMindAvatar } from './TKMindAvatar'; + +type Step = 'choose' | 'bind'; + +export function WechatBindGate({ + pendingToken, + returnTo, + onAuth, +}: { + pendingToken: string; + returnTo?: string | null; + onAuth: ( + user?: PortalUser | null, + capabilities?: CapabilityMap, + grantedSkills?: string[], + ) => void; +}) { + const [profile, setProfile] = useState(null); + const [step, setStep] = useState('choose'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + void getWechatPendingProfile(pendingToken) + .then((data) => setProfile(data)) + .catch((err) => { + setError(err instanceof Error ? err.message : '绑定会话无效'); + }) + .finally(() => setLoading(false)); + }, [pendingToken]); + + const finishAuth = async (user: PortalUser) => { + const me = await getMe().catch(() => null); + onAuth(user, me?.capabilities, me?.grantedSkills); + const target = returnTo || profile?.returnTo; + if (target && target !== '/') { + try { + const url = new URL(target, window.location.origin); + if (url.origin === window.location.origin) { + window.location.href = url.toString(); + return; + } + } catch { + // ignore invalid return target + } + } + }; + + const handleNewUser = async () => { + setSubmitting(true); + setError(null); + try { + const result = await completeWechatRegister(pendingToken); + await finishAuth(result.user); + } catch (err) { + setError(err instanceof Error ? err.message : '注册失败'); + } finally { + setSubmitting(false); + } + }; + + const handleBindExisting = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + const result = await completeWechatBind(pendingToken, username, password); + await finishAuth(result.user); + } catch (err) { + setError(err instanceof Error ? err.message : '绑定失败'); + } finally { + setSubmitting(false); + } + }; + + const displayName = profile?.nickname?.trim() || '微信用户'; + + return ( +
+
+
+ {profile?.avatarUrl ? ( + + ) : ( + + )} +

你好,{displayName}

+

+ {step === 'choose' + ? '请选择如何使用这个微信账号' + : '验证已有账号后,微信将与该账号绑定'} +

+
+ + {loading &&

正在加载微信信息…

} + + {!loading && step === 'choose' && ( +
+ + +

+ 绑定后,微信登录将进入此账号,你的 MindSpace 内容不会丢失。 +

+
+ )} + + {!loading && step === 'bind' && ( +
+
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + + +
+ )} + + {error && step === 'choose' &&

{error}

} +
+
+ ); +} diff --git a/src/components/WechatBindPrompt.tsx b/src/components/WechatBindPrompt.tsx new file mode 100644 index 0000000..e53d2e8 --- /dev/null +++ b/src/components/WechatBindPrompt.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { getWechatBindingStatus } from '../api/client'; +import { buildWechatAuthorizeUrl } from '../utils/wechat'; + +export function WechatBindPrompt({ + returnTo, + onDismiss, +}: { + returnTo?: string; + onDismiss?: () => void; +}) { + const [visible, setVisible] = useState(false); + const [bound, setBound] = useState(false); + + useEffect(() => { + void getWechatBindingStatus().then((status) => { + const suggested = sessionStorage.getItem('tkmind_suggest_wechat_bind') === '1'; + if (suggested) sessionStorage.removeItem('tkmind_suggest_wechat_bind'); + if (status.enabled && !status.bound && suggested) { + setVisible(true); + } + setBound(status.bound); + }); + }, []); + + if (!visible || bound) return null; + + const handleBind = () => { + window.location.href = buildWechatAuthorizeUrl({ + intent: 'bind', + returnTo: returnTo ?? window.location.pathname, + }); + }; + + const handleDismiss = () => { + setVisible(false); + onDismiss?.(); + }; + + return ( +
+ 绑定微信,下次在微信里可一键登录 + + +
+ ); +} diff --git a/src/components/WechatDesktopLogin.tsx b/src/components/WechatDesktopLogin.tsx new file mode 100644 index 0000000..11e9b7c --- /dev/null +++ b/src/components/WechatDesktopLogin.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +import { buildPageUrlForWechatOpen } from '../utils/wechat'; +import { WechatScanLogin } from './WechatScanLogin'; + +/** PC 浏览器微信登录:优先开放平台扫码,否则展示「页面链接」二维码供手机微信打开 */ +export function WechatDesktopLogin({ + scanEnabled, + returnTo, + onBindingGate, + onComplete, + onError, +}: { + scanEnabled: boolean; + returnTo?: string | null; + onBindingGate: (pendingToken: string) => void; + onComplete: () => void; + onError: (message: string) => void; +}) { + const [pageQrDataUrl, setPageQrDataUrl] = useState(null); + + useEffect(() => { + if (scanEnabled) return; + const url = buildPageUrlForWechatOpen(); + void QRCode.toDataURL(url, { margin: 1, width: 180 }).then(setPageQrDataUrl).catch(() => { + onError('无法生成微信登录二维码'); + }); + }, [scanEnabled, onError]); + + return ( +
+
+ 微信扫码登录 +
+ {scanEnabled ? ( + + ) : ( +
+ {pageQrDataUrl ? ( + 微信扫码登录 + ) : ( +
正在生成二维码…
+ )} +

请使用微信扫一扫

+
+ )} +
+ ); +} diff --git a/src/components/WechatIcon.tsx b/src/components/WechatIcon.tsx new file mode 100644 index 0000000..fdd2bab --- /dev/null +++ b/src/components/WechatIcon.tsx @@ -0,0 +1,15 @@ +export function WechatIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/WechatOpenGuide.tsx b/src/components/WechatOpenGuide.tsx new file mode 100644 index 0000000..6e223d0 --- /dev/null +++ b/src/components/WechatOpenGuide.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { buildPageUrlForWechatOpen, copyWechatOpenLink, isIosBrowser } from '../utils/wechat'; + +export function WechatOpenGuide({ + disabled, + onCopied, +}: { + disabled?: boolean; + onCopied?: () => void; +}) { + const [guideOpen, setGuideOpen] = useState(false); + const [copied, setCopied] = useState(false); + const ios = isIosBrowser(); + + const handleOpen = async () => { + const ok = await copyWechatOpenLink(); + setCopied(ok); + if (ok) onCopied?.(); + setGuideOpen(true); + }; + + return ( + <> + + + {guideOpen && ( +
setGuideOpen(false)} + > +
e.stopPropagation()}> + +

请在微信中打开

+ {copied ? ( +

链接已复制

+ ) : ( +

未能自动复制,请手动复制下方链接

+ )} + {ios ? ( +
    +
  1. 点击浏览器右上角 ···
  2. +
  3. 选择 在微信中打开
  4. +
  5. 打开后即可使用微信一键登录
  6. +
+ ) : ( +
    +
  1. 打开微信,将链接发给「文件传输助手」或任意聊天
  2. +
  3. 在微信里点击该链接
  4. +
  5. 打开后即可使用微信一键登录
  6. +
+ )} + e.currentTarget.select()} + /> + +
+
+ )} + + ); +} diff --git a/src/components/WechatScanLogin.tsx b/src/components/WechatScanLogin.tsx new file mode 100644 index 0000000..6c1415c --- /dev/null +++ b/src/components/WechatScanLogin.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; +import { pollWechatScanLogin, startWechatScanLogin } from '../api/client'; + +export function WechatScanLogin({ + returnTo, + onBindingGate, + onComplete, + onError, +}: { + returnTo?: string | null; + onBindingGate: (pendingToken: string) => void; + onComplete: () => void; + onError: (message: string) => void; +}) { + const [qrDataUrl, setQrDataUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [expired, setExpired] = useState(false); + const stateRef = useRef(null); + const pollRef = useRef(null); + + const stopPolling = () => { + if (pollRef.current !== null) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + const start = async () => { + stopPolling(); + setLoading(true); + setExpired(false); + setQrDataUrl(null); + try { + const session = await startWechatScanLogin(returnTo ?? undefined); + stateRef.current = session.state; + const dataUrl = await QRCode.toDataURL(session.qrUrl, { + margin: 1, + width: 180, + }); + setQrDataUrl(dataUrl); + const expiresAt = Date.now() + session.expiresInMs; + pollRef.current = window.setInterval(() => { + const state = stateRef.current; + if (!state) return; + if (Date.now() > expiresAt) { + stopPolling(); + setExpired(true); + return; + } + void pollWechatScanLogin(state) + .then((result) => { + if (result.status === 'pending') return; + stopPolling(); + if (result.status === 'complete') { + onComplete(); + return; + } + if (result.status === 'binding_gate' && result.pendingToken) { + onBindingGate(result.pendingToken); + return; + } + if (result.status === 'expired') { + setExpired(true); + return; + } + onError(result.message ?? '微信扫码登录失败'); + }) + .catch((err) => { + stopPolling(); + onError(err instanceof Error ? err.message : '微信扫码登录失败'); + }); + }, 2000); + } catch (err) { + onError(err instanceof Error ? err.message : '无法启动扫码登录'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void start(); + return () => stopPolling(); + }, [returnTo]); + + return ( +
+ {loading &&
正在生成二维码…
} + {!loading && qrDataUrl && !expired && ( + 微信扫码登录 + )} + {expired &&
二维码已过期
} +

+ {expired ? ( + + ) : ( + '请使用微信扫一扫,在手机上确认登录' + )} +

+
+ ); +} diff --git a/src/index.css b/src/index.css index e4a0079..b9f3d53 100644 --- a/src/index.css +++ b/src/index.css @@ -1765,7 +1765,7 @@ body, display: flex; flex-direction: column; width: 100%; - gap: var(--space-lg); + gap: var(--space-md); } .login-brand { @@ -1837,6 +1837,10 @@ body, } .login-wechat-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; width: 100%; border: none; border-radius: var(--radius-lg); @@ -1849,6 +1853,127 @@ body, transition: opacity 0.2s; } +.login-wechat-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.login-wechat-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 12px 14px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + background: var(--color-bg-surface); + color: var(--color-text-primary); + transition: border-color 0.2s, background 0.2s; +} + +.login-wechat-secondary:hover:not(:disabled) { + border-color: rgba(7, 193, 96, 0.45); + background: rgba(7, 193, 96, 0.05); +} + +.login-wechat-secondary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.login-wechat-secondary .login-wechat-icon { + color: #07c160; +} + +.login-alt-methods { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; +} + +.login-wechat-scan-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-elevated); + overflow: hidden; +} + +.login-wechat-scan-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid var(--color-border); + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.login-wechat-scan-close { + border: none; + background: none; + padding: 0; + font-size: 13px; + color: var(--color-text-link); + cursor: pointer; +} + +.login-wechat-scan-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 20px 16px 16px; +} + +.login-wechat-scan-qr { + width: 180px; + height: 180px; + border-radius: 8px; + background: #fff; + padding: 6px; +} + +.login-wechat-scan-loading { + width: 180px; + height: 180px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--color-bg-surface); + color: var(--color-text-muted); + font-size: 13px; +} + +.login-wechat-scan-tip { + margin: 0; + font-size: 13px; + color: var(--color-text-muted); + text-align: center; + line-height: 1.5; +} + +.login-mode-switch { + align-self: center; + border: none; + background: none; + padding: 4px 2px; + font-size: 14px; + color: var(--color-text-link); + cursor: pointer; +} + +.login-mode-switch:hover { + opacity: 0.8; +} + .login-wechat-btn:hover:not(:disabled) { opacity: 0.92; } @@ -1858,6 +1983,215 @@ body, cursor: not-allowed; } +.login-wechat-btn-outline { + background: transparent; + border: 1px solid #07c160; + color: #07c160; +} + +.login-wechat-btn-outline:hover:not(:disabled) { + background: rgba(7, 193, 96, 0.08); + opacity: 1; +} + +.login-password-toggle { + align-self: center; + margin-top: calc(-1 * var(--space-sm)); +} + +.wechat-bind-card { + max-width: 400px; +} + +.wechat-bind-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + margin-bottom: var(--space-xs); + object-fit: cover; +} + +.wechat-bind-options { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.wechat-bind-option { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + padding: 16px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + background: var(--color-bg-surface); + cursor: pointer; + text-align: left; + transition: border-color 0.2s, background 0.2s; +} + +.wechat-bind-option strong { + font-size: 16px; + color: var(--color-text-primary); +} + +.wechat-bind-option span { + font-size: 13px; + color: var(--color-text-muted); +} + +.wechat-bind-option:hover:not(:disabled) { + border-color: var(--color-border-input-focus); + background: var(--color-bg-elevated); +} + +.wechat-bind-option-primary { + border-color: rgba(7, 193, 96, 0.45); + background: rgba(7, 193, 96, 0.06); +} + +.wechat-bind-hint { + font-size: 13px; + color: var(--color-text-muted); + margin: 0; + line-height: 1.5; +} + +.wechat-bind-form { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.wechat-bind-back { + align-self: center; +} + +.wechat-scan-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) 0; +} + +.wechat-scan-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); +} + +.wechat-scan-qr { + width: 220px; + height: 220px; + border-radius: var(--radius-md); + background: white; + padding: 8px; +} + +.wechat-scan-hint { + margin: 0; + font-size: 13px; + color: var(--color-text-muted); + text-align: center; +} + +.wechat-account-bound { + cursor: default; + opacity: 0.85; + white-space: nowrap; +} + +.wechat-open-hint { + margin: calc(-1 * var(--space-sm)) 0 0; + font-size: 13px; + color: var(--color-text-muted); + text-align: center; + line-height: 1.5; +} + +.wechat-open-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 24px 16px; + background: rgba(0, 0, 0, 0.55); +} + +.wechat-open-guide { + position: relative; + width: 100%; + max-width: 400px; + padding: 24px 20px 20px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-bg-surface); + box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2); +} + +.wechat-open-guide h2 { + margin: 0 0 12px; + font-size: 18px; + color: var(--color-text-primary); +} + +.wechat-open-close { + position: absolute; + top: 12px; + right: 12px; + border: none; + background: none; + font-size: 24px; + line-height: 1; + color: var(--color-text-muted); + cursor: pointer; +} + +.wechat-open-success { + margin: 0 0 12px; + color: #07c160; + font-size: 14px; +} + +.wechat-open-warning { + margin: 0 0 12px; + color: var(--color-text-error); + font-size: 14px; +} + +.wechat-open-steps { + margin: 0 0 16px; + padding-left: 20px; + color: var(--color-text-primary); + font-size: 14px; + line-height: 1.6; +} + +.wechat-open-steps li + li { + margin-top: 8px; +} + +.wechat-open-url { + width: 100%; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-elevated); + color: var(--color-text-muted); + font-size: 12px; +} + +.wechat-bind-banner { + flex-wrap: wrap; + gap: 8px; +} + .login-divider { display: flex; align-items: center; @@ -1874,6 +2208,7 @@ body, background: var(--color-border); } + .balance-pill { align-self: center; padding: 6px 10px; diff --git a/src/utils/wechat.ts b/src/utils/wechat.ts index dbddaf2..9ec09ad 100644 --- a/src/utils/wechat.ts +++ b/src/utils/wechat.ts @@ -3,6 +3,58 @@ export function isWechatBrowser(userAgent = navigator.userAgent) { return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua); } +export function isMobileBrowser(userAgent = navigator.userAgent) { + return /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || ''); +} + +export function isIosBrowser(userAgent = navigator.userAgent) { + return /iPhone|iPad|iPod/i.test(userAgent || ''); +} + +/** 手机外部浏览器(Safari / Chrome 等),不在微信内 */ +export function isMobileExternalBrowser(options?: { + userAgent?: string; + search?: string; + serverInWechat?: boolean; +}) { + return isMobileBrowser(options?.userAgent) && !isWechatContext(options); +} + +/** 构建「在微信中打开」后应访问的链接(带上 from=wechat 以展示一键登录) */ +export function buildPageUrlForWechatOpen( + search = window.location.search, + pathname = window.location.pathname, +) { + const params = new URLSearchParams(search); + params.set('from', 'wechat'); + if (!params.get('utm_source')) params.set('utm_source', 'wechat'); + const query = params.toString(); + return `${window.location.origin}${pathname}${query ? `?${query}` : ''}${window.location.hash}`; +} + +export async function copyWechatOpenLink(): Promise { + const url = buildPageUrlForWechatOpen(); + try { + await navigator.clipboard.writeText(url); + return true; + } catch { + try { + const input = document.createElement('textarea'); + input.value = url; + input.setAttribute('readonly', 'true'); + input.style.position = 'fixed'; + input.style.left = '-9999px'; + document.body.appendChild(input); + input.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(input); + return ok; + } catch { + return false; + } + } +} + /** 从服务号链接进入时带的参数(即使 UA 识别失败也展示微信登录) */ export function isWechatEntryUrl(search = window.location.search) { const params = new URLSearchParams(search); @@ -26,6 +78,8 @@ export function buildWechatAuthorizeUrl(options?: { utmSource?: string; utmMedium?: string; utmCampaign?: string; + intent?: 'login' | 'register' | 'bind'; + authMode?: 'mp' | 'open'; }) { const params = new URLSearchParams(); const returnTo = @@ -37,10 +91,23 @@ export function buildWechatAuthorizeUrl(options?: { if (options?.utmSource) params.set('utm_source', options.utmSource); if (options?.utmMedium) params.set('utm_medium', options.utmMedium); if (options?.utmCampaign) params.set('utm_campaign', options.utmCampaign); + if (options?.intent) params.set('intent', options.intent); + if (options?.authMode) params.set('auth_mode', options.authMode); const query = params.toString(); return query ? `/auth/wechat/authorize?${query}` : '/auth/wechat/authorize'; } +export function readWechatPendingToken(): string | null { + const params = new URLSearchParams(window.location.search); + const pending = params.get('wechat_pending'); + if (!pending) return null; + params.delete('wechat_pending'); + const nextSearch = params.toString(); + const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`; + window.history.replaceState({}, '', nextUrl); + return pending; +} + export function readWechatAuthError(): string | null { const params = new URLSearchParams(window.location.search); const error = params.get('wechat_error'); diff --git a/user-auth.mjs b/user-auth.mjs index 9679181..930ebf3 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1519,6 +1519,194 @@ export function createUserAuth(pool, options = {}) { await repairAllUserPublishDirs(); }; + const PENDING_BIND_TTL_MS = 15 * 60 * 1000; + + const issueUserSession = async (userId, role, now = Date.now()) => { + const token = crypto.randomBytes(32).toString('base64url'); + await storeSession(userId, role, token, now); + return token; + }; + + const findBindingByOpenid = async (appId, openid) => { + const [rows] = await pool.query( + `SELECT wi.user_id, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.app_id = ? AND wi.openid = ? + LIMIT 1`, + [appId, openid], + ); + return rows[0] ?? null; + }; + + const findBindingByUnionid = async (unionid) => { + if (!unionid) return null; + const [rows] = await pool.query( + `SELECT wi.user_id, wi.app_id, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.unionid = ? + LIMIT 1`, + [unionid], + ); + return rows[0] ?? null; + }; + + const getWechatBindingForUser = async (userId, appId) => { + const [rows] = await pool.query( + `SELECT id, nickname, avatar_url, last_login_at, created_at + FROM h5_user_wechat_identities + WHERE user_id = ? AND app_id = ? + LIMIT 1`, + [userId, appId], + ); + return rows[0] ?? null; + }; + + const bindWechatToUser = async ({ + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now(), + }) => { + const existingOpenid = await findBindingByOpenid(appId, openid); + if (existingOpenid && existingOpenid.user_id !== userId) { + return { ok: false, message: '该微信已绑定其他账号,请先用该账号登录' }; + } + const existingUserBind = await getWechatBindingForUser(userId, appId); + if (existingUserBind) { + return { ok: false, message: '你的账号已绑定其他微信,需先解绑后再试' }; + } + + try { + await pool.query( + `INSERT INTO h5_user_wechat_identities + (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto.randomUUID(), + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + now, + now, + ], + ); + return { ok: true }; + } catch (err) { + if (err?.code === 'ER_DUP_ENTRY') { + return { ok: false, message: '微信绑定冲突,请重试' }; + } + throw err; + } + }; + + const touchWechatIdentity = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now(), + }) => { + await pool.query( + `UPDATE h5_user_wechat_identities + SET nickname = COALESCE(?, nickname), + avatar_url = COALESCE(?, avatar_url), + unionid = COALESCE(?, unionid), + last_login_at = ?, + updated_at = ? + WHERE app_id = ? AND openid = ?`, + [nickname, avatarUrl, unionid, now, now, appId, openid], + ); + }; + + const pruneWechatPendingBinds = async (now = Date.now()) => { + await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]); + }; + + const createWechatPendingBind = async ({ + appId, + openid, + unionid, + nickname, + avatarUrl, + returnTo = '/', + utmSource = null, + utmMedium = null, + utmCampaign = null, + now = Date.now(), + }) => { + await pruneWechatPendingBinds(now); + const token = crypto.randomBytes(24).toString('base64url'); + await pool.query( + `INSERT INTO h5_wechat_pending_binds + (token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + token, + appId, + openid, + unionid, + nickname, + avatarUrl, + returnTo, + utmSource, + utmMedium, + utmCampaign, + now + PENDING_BIND_TTL_MS, + now, + ], + ); + return token; + }; + + const getWechatPendingBind = async (token, now = Date.now()) => { + if (!token) return null; + await pruneWechatPendingBinds(now); + const [rows] = await pool.query( + `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at + FROM h5_wechat_pending_binds + WHERE token = ? + LIMIT 1`, + [token], + ); + const row = rows[0]; + if (!row || Number(row.expires_at) <= now) return null; + return row; + }; + + const consumeWechatPendingBind = async (token) => { + await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]); + }; + + const loginBoundWechatUser = async ({ + userId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now = Date.now(), + }) => { + await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now }); + const user = await getUserById(userId); + if (!user || user.status === 'disabled') { + return { ok: false, message: '账户已禁用,请联系管理员' }; + } + const token = await issueUserSession(user.id, user.role, now); + return { ok: true, token, user: publicUser(user), isNewUser: false }; + }; + const generateWechatUsername = async (openid) => { const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase(); const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex'); @@ -1621,70 +1809,223 @@ export function createUserAuth(pool, options = {}) { } }; - const loginByWechat = async ({ + const resolveWechatAuth = async ({ appId, openid, unionid, nickname, avatarUrl, - ip = 'unknown', + intent = 'login', + bindUserId = null, + returnTo = '/', + utmSource = null, + utmMedium = null, + utmCampaign = null, now = Date.now(), }) => { - const [bindings] = await pool.query( - `SELECT wi.user_id, u.status - FROM h5_user_wechat_identities wi - JOIN h5_users u ON u.id = wi.user_id - WHERE wi.app_id = ? AND wi.openid = ? - LIMIT 1`, - [appId, openid], - ); - const binding = bindings[0]; + let binding = await findBindingByOpenid(appId, openid); + + if (!binding && unionid) { + const unionBinding = await findBindingByUnionid(unionid); + if (unionBinding) { + const linked = await bindWechatToUser({ + userId: unionBinding.user_id, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + }); + if (!linked.ok) return linked; + binding = { user_id: unionBinding.user_id, status: unionBinding.status }; + } + } if (binding) { if (binding.status === 'disabled') { return { ok: false, message: '账户已禁用,请联系管理员' }; } - await pool.query( - `UPDATE h5_user_wechat_identities - SET nickname = COALESCE(?, nickname), - avatar_url = COALESCE(?, avatar_url), - unionid = COALESCE(?, unionid), - last_login_at = ?, - updated_at = ? - WHERE app_id = ? AND openid = ?`, - [nickname, avatarUrl, unionid, now, now, appId, openid], - ); - const user = await getUserById(binding.user_id); - const token = crypto.randomBytes(32).toString('base64url'); - await storeSession(user.id, user.role, token, now); - return { ok: true, token, user: publicUser(user), isNewUser: false }; + return loginBoundWechatUser({ + userId: binding.user_id, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + }).then((result) => (result.ok ? { ...result, action: 'login' } : result)); } - const registered = await registerViaWechat({ + if (intent === 'bind' && bindUserId) { + const user = await getUserById(bindUserId); + if (!user) return { ok: false, message: '用户不存在' }; + if (user.status === 'disabled') { + return { ok: false, message: '账户已禁用,请联系管理员' }; + } + const bound = await bindWechatToUser({ + userId: bindUserId, + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + }); + if (!bound.ok) return bound; + const token = await issueUserSession(user.id, user.role, now); + return { + ok: true, + action: 'login', + token, + user: publicUser(user), + isNewUser: false, + bound: true, + }; + } + + if (intent === 'register') { + const registered = await registerViaWechat({ + appId, + openid, + unionid, + nickname, + avatarUrl, + now, + }); + if (!registered.ok) return registered; + const token = await issueUserSession(registered.user.id, registered.user.role, now); + return { + ok: true, + action: 'login', + token, + user: registered.user, + isNewUser: true, + }; + } + + const pendingToken = await createWechatPendingBind({ appId, openid, unionid, nickname, avatarUrl, + returnTo, + utmSource, + utmMedium, + utmCampaign, + now, + }); + return { + ok: true, + action: 'binding_gate', + pendingToken, + wechatProfile: { + nickname: nickname ?? null, + avatarUrl: avatarUrl ?? null, + }, + returnTo, + utmSource, + utmMedium, + utmCampaign, + }; + }; + + const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => { + const pending = await getWechatPendingBind(pendingToken, now); + if (!pending) { + return { ok: false, message: '绑定会话已过期,请重新微信登录' }; + } + const registered = await registerViaWechat({ + appId: pending.app_id, + openid: pending.openid, + unionid: pending.unionid, + nickname: pending.nickname, + avatarUrl: pending.avatar_url, now, }); if (!registered.ok) return registered; - - const token = crypto.randomBytes(32).toString('base64url'); - await storeSession(registered.user.id, registered.user.role, token, now); + await consumeWechatPendingBind(pendingToken); + const token = await issueUserSession(registered.user.id, registered.user.role, now); return { ok: true, token, user: registered.user, isNewUser: true, + returnTo: pending.return_to || '/', + utmSource: pending.utm_source, + utmMedium: pending.utm_medium, + utmCampaign: pending.utm_campaign, }; }; + const completeWechatBindAccount = async ({ + pendingToken, + username, + password, + ip = 'unknown', + now = Date.now(), + }) => { + const pending = await getWechatPendingBind(pendingToken, now); + if (!pending) { + return { ok: false, message: '绑定会话已过期,请重新微信登录' }; + } + const loginResult = await login({ username, password, ip, now }); + if (!loginResult.ok) return loginResult; + + const bound = await bindWechatToUser({ + userId: loginResult.user.id, + appId: pending.app_id, + openid: pending.openid, + unionid: pending.unionid, + nickname: pending.nickname, + avatarUrl: pending.avatar_url, + now, + }); + if (!bound.ok) return bound; + + await consumeWechatPendingBind(pendingToken); + return { + ok: true, + token: loginResult.token, + user: loginResult.user, + isNewUser: false, + bound: true, + returnTo: pending.return_to || '/', + }; + }; + + const getWechatBindingStatus = async (userId, appId) => { + const row = await getWechatBindingForUser(userId, appId); + if (!row) return { bound: false }; + return { + bound: true, + nickname: row.nickname, + avatarUrl: row.avatar_url, + lastLoginAt: Number(row.last_login_at), + boundAt: Number(row.created_at), + }; + }; + + const loginByWechat = async (params) => { + const result = await resolveWechatAuth({ ...params, intent: 'login' }); + if (!result.ok) return result; + if (result.action === 'binding_gate') { + return { ok: false, message: '需要完成账号绑定' }; + } + return result; + }; + return { USER_COOKIE, register, login, loginByWechat, + resolveWechatAuth, + completeWechatRegister, + completeWechatBindAccount, + getWechatPendingBind, + getWechatBindingStatus, resetPassword, verify, revoke, @@ -1785,3 +2126,13 @@ export function resolveCookieDomain() { } return null; } + +/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */ +export function resolveCookieDomainForRequest(req) { + const hostHeader = req?.get?.('host') ?? req?.hostname ?? ''; + const hostname = String(hostHeader).split(':')[0].toLowerCase(); + if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { + return null; + } + return resolveCookieDomain(); +} diff --git a/wechat-oauth.mjs b/wechat-oauth.mjs index c397a2d..d460698 100644 --- a/wechat-oauth.mjs +++ b/wechat-oauth.mjs @@ -2,10 +2,13 @@ import crypto from 'node:crypto'; import { fetch } from 'undici'; const OAUTH_AUTHORIZE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize'; +const OAUTH_QRCONNECT_URL = 'https://open.weixin.qq.com/connect/qrconnect'; const OAUTH_ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token'; const OAUTH_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo'; const STATE_TTL_MS = 10 * 60 * 1000; +const VALID_INTENTS = new Set(['login', 'register', 'bind']); +const VALID_AUTH_MODES = new Set(['mp', 'open']); function resolveCallbackUrl(env = process.env) { const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? ''; @@ -24,13 +27,19 @@ export function loadWechatOAuthConfig(env = process.env) { const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === '1'; const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? ''; const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? ''; + const openAppId = env.H5_WECHAT_OPEN_APP_ID?.trim() ?? ''; + const openAppSecret = env.H5_WECHAT_OPEN_APP_SECRET?.trim() ?? ''; const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo'; const callbackUrl = resolveCallbackUrl(env); const configured = Boolean(appId && appSecret && callbackUrl); + const scanConfigured = Boolean(openAppId && openAppSecret && callbackUrl); return { enabled: enabledFlag && configured, appId, appSecret, + openAppId, + openAppSecret, + scanEnabled: enabledFlag && scanConfigured, scope, callbackUrl, }; @@ -50,21 +59,34 @@ export function sanitizeWechatReturnTo(returnTo, req) { } } -function buildAuthorizeUrl(config, { state }) { - const params = new URLSearchParams({ - appid: config.appId, - redirect_uri: config.callbackUrl, - response_type: 'code', - scope: config.scope, - state, - }); - return `${OAUTH_AUTHORIZE_URL}?${params.toString()}#wechat_redirect`; +function normalizeIntent(intent) { + const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login'; + return VALID_INTENTS.has(value) ? value : 'login'; } -async function exchangeCode(config, code) { +function normalizeAuthMode(authMode) { + const value = typeof authMode === 'string' ? authMode.trim().toLowerCase() : 'mp'; + return VALID_AUTH_MODES.has(value) ? value : 'mp'; +} + +function buildAuthorizeUrl(config, { state, authMode = 'mp' }) { + const isOpen = authMode === 'open'; const params = new URLSearchParams({ - appid: config.appId, - secret: config.appSecret, + appid: isOpen ? config.openAppId : config.appId, + redirect_uri: config.callbackUrl, + response_type: 'code', + scope: isOpen ? 'snsapi_login' : config.scope, + state, + }); + const base = isOpen ? OAUTH_QRCONNECT_URL : OAUTH_AUTHORIZE_URL; + return `${base}?${params.toString()}#wechat_redirect`; +} + +async function exchangeCode(config, code, authMode = 'mp') { + const isOpen = authMode === 'open'; + const params = new URLSearchParams({ + appid: isOpen ? config.openAppId : config.appId, + secret: isOpen ? config.openAppSecret : config.appSecret, code, grant_type: 'authorization_code', }); @@ -81,6 +103,7 @@ async function exchangeCode(config, code) { openid: data.openid, unionid: data.unionid ?? null, refreshToken: data.refresh_token ?? null, + appId: isOpen ? config.openAppId : config.appId, }; } @@ -107,7 +130,7 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) { return { enabled: false, publicConfig() { - return { enabled: false, inWechat: false }; + return { enabled: false, inWechat: false, scanEnabled: false }; }, }; } @@ -122,6 +145,9 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) { utmSource = null, utmMedium = null, utmCampaign = null, + intent = 'login', + bindUserId = null, + authMode = 'mp', now = Date.now(), } = {}) => { const state = crypto.randomBytes(24).toString('base64url'); @@ -129,93 +155,246 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) { await pruneExpiredStates(now); await pool.query( `INSERT INTO h5_wechat_oauth_states - (state, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [state, returnTo, utmSource, utmMedium, utmCampaign, now + STATE_TTL_MS, now], + (state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, + auth_mode, status, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + [ + state, + returnTo, + utmSource, + utmMedium, + utmCampaign, + normalizeIntent(intent), + bindUserId, + normalizeAuthMode(authMode), + now + STATE_TTL_MS, + now, + ], ); } return state; }; - const consumeState = async (state, now = Date.now()) => { - if (!state) return null; - if (!pool) return null; + const loadState = async (state, now = Date.now()) => { + if (!state || !pool) return null; const [rows] = await pool.query( - `SELECT state, return_to, utm_source, utm_medium, utm_campaign, expires_at + `SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, + auth_mode, status, result_kind, result_token, result_message, expires_at FROM h5_wechat_oauth_states WHERE state = ? LIMIT 1`, [state], ); const row = rows[0]; - await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); if (!row || Number(row.expires_at) <= now) return null; + return row; + }; + + const consumeState = async (state, now = Date.now()) => { + const row = await loadState(state, now); + if (!row) return null; + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); return { returnTo: row.return_to || '/', utmSource: row.utm_source, utmMedium: row.utm_medium, utmCampaign: row.utm_campaign, + intent: row.intent || 'login', + bindUserId: row.bind_user_id, + authMode: row.auth_mode || 'mp', }; }; - const buildAuthorizeRedirect = async (req) => { + const markStateComplete = async ( + state, + { resultKind, resultToken = null, resultMessage = null }, + now = Date.now(), + ) => { + await pool.query( + `UPDATE h5_wechat_oauth_states + SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ? + WHERE state = ?`, + [resultKind, resultToken, resultMessage, now + STATE_TTL_MS, state], + ); + }; + + const buildAuthorizeRedirect = async (req, { bindUserId = null } = {}) => { + const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req); + const intent = normalizeIntent(req.query?.intent); + const authMode = normalizeAuthMode(req.query?.auth_mode); + if (authMode === 'open' && !config.scanEnabled) { + throw new Error('微信扫码登录未配置'); + } + const state = await createState({ + returnTo, + utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null, + utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null, + utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null, + intent, + bindUserId, + authMode, + }); + return buildAuthorizeUrl(config, { state, authMode }); + }; + + const startScanLogin = async (req) => { + if (!config.scanEnabled) { + throw new Error('微信扫码登录未配置'); + } const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req); const state = await createState({ returnTo, utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null, utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null, utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null, + intent: 'login', + authMode: 'open', }); - return buildAuthorizeUrl(config, { state }); + return { + state, + qrUrl: buildAuthorizeUrl(config, { state, authMode: 'open' }), + expiresInMs: STATE_TTL_MS, + }; + }; + + const pollScanLogin = async (state, now = Date.now()) => { + const row = await loadState(state, now); + if (!row) return { status: 'expired' }; + if (row.status !== 'done') return { status: 'pending' }; + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + if (row.result_kind === 'error') { + return { status: 'error', message: row.result_message || '微信登录失败' }; + } + if (row.result_kind === 'binding_gate') { + return { status: 'binding_gate', pendingToken: row.result_token }; + } + if (row.result_kind === 'login') { + return { status: 'complete', token: row.result_token }; + } + return { status: 'error', message: '微信登录结果无效' }; }; const handleCallback = async ({ code, state, ip = 'unknown' }) => { if (!code) { throw new Error('缺少微信授权 code'); } - const stateRow = await consumeState(state); + const stateRow = await loadState(state); if (!stateRow) { throw new Error('授权状态无效或已过期,请重新登录'); } - const tokenInfo = await exchangeCode(config, code); + const authMode = stateRow.auth_mode || 'mp'; + const tokenInfo = await exchangeCode(config, code, authMode); let nickname = null; let avatarUrl = null; let unionid = tokenInfo.unionid; - if (config.scope === 'snsapi_userinfo') { + if (authMode === 'mp' && config.scope === 'snsapi_userinfo') { const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid); nickname = profile.nickname; avatarUrl = profile.avatarUrl; unionid = profile.unionid ?? unionid; } + if (authMode === 'open') { + try { + const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid); + nickname = profile.nickname; + avatarUrl = profile.avatarUrl; + unionid = profile.unionid ?? unionid; + } catch { + // snsapi_login may not always return userinfo; proceed with openid only + } + } - if (!userAuth?.loginByWechat) { + if (!userAuth?.resolveWechatAuth) { throw new Error('用户系统未就绪'); } - const result = await userAuth.loginByWechat({ - appId: config.appId, + const result = await userAuth.resolveWechatAuth({ + appId: tokenInfo.appId, openid: tokenInfo.openid, unionid, nickname, avatarUrl, + intent: stateRow.intent || 'login', + bindUserId: stateRow.bind_user_id, + returnTo: stateRow.return_to || '/', + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign, ip, - utmSource: stateRow.utmSource, - utmMedium: stateRow.utmMedium, - utmCampaign: stateRow.utmCampaign, }); if (!result.ok) { + if (authMode === 'open') { + await markStateComplete(state, { + resultKind: 'error', + resultMessage: result.message || '微信登录失败', + }); + return { + authMode, + action: 'poll_error', + message: result.message || '微信登录失败', + }; + } throw new Error(result.message || '微信登录失败'); } + + if (result.action === 'binding_gate') { + if (authMode === 'open') { + await markStateComplete(state, { + resultKind: 'binding_gate', + resultToken: result.pendingToken, + }); + return { + authMode, + action: 'binding_gate', + pendingToken: result.pendingToken, + wechatProfile: result.wechatProfile, + returnTo: result.returnTo, + }; + } + return { + authMode, + action: 'binding_gate', + pendingToken: result.pendingToken, + wechatProfile: result.wechatProfile, + returnTo: result.returnTo, + utmSource: result.utmSource, + utmMedium: result.utmMedium, + utmCampaign: result.utmCampaign, + }; + } + + if (authMode === 'open') { + await markStateComplete(state, { + resultKind: 'login', + resultToken: result.token, + }); + return { + authMode, + action: 'login', + token: result.token, + user: result.user, + isNewUser: result.isNewUser, + returnTo: stateRow.return_to || '/', + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign, + }; + } + + await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); return { + authMode, + action: 'login', token: result.token, user: result.user, isNewUser: result.isNewUser, - returnTo: stateRow.returnTo, - utmSource: stateRow.utmSource, - utmMedium: stateRow.utmMedium, - utmCampaign: stateRow.utmCampaign, + bound: result.bound, + returnTo: stateRow.return_to || '/', + utmSource: stateRow.utm_source, + utmMedium: stateRow.utm_medium, + utmCampaign: stateRow.utm_campaign, }; }; @@ -223,9 +402,16 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) { enabled: true, publicConfig(req) { const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false; - return { enabled: true, inWechat }; + return { + enabled: true, + inWechat, + scanEnabled: Boolean(config.scanEnabled), + appId: config.appId, + }; }, buildAuthorizeRedirect, + startScanLogin, + pollScanLogin, handleCallback, }; }