6ee6fd64dd
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
14 KiB
JavaScript
439 lines
14 KiB
JavaScript
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', 'scan']);
|
|
|
|
function isPollScanAuthMode(authMode) {
|
|
return authMode === 'open' || authMode === 'scan';
|
|
}
|
|
|
|
function resolveCallbackUrl(env = process.env) {
|
|
const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? '';
|
|
if (explicit) return explicit;
|
|
const base = env.H5_PUBLIC_BASE_URL?.trim() ?? '';
|
|
if (base) return `${base.replace(/\/$/, '')}/auth/wechat/callback`;
|
|
return '';
|
|
}
|
|
|
|
export function isWechatUserAgent(userAgent = '') {
|
|
const ua = userAgent || '';
|
|
return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
export function sanitizeWechatReturnTo(returnTo, req) {
|
|
if (!returnTo || typeof returnTo !== 'string') return '/';
|
|
const host = req.get('host');
|
|
try {
|
|
const url = new URL(returnTo, `http://${host}`);
|
|
if (url.host !== host) return '/';
|
|
if (!url.pathname.startsWith('/')) return '/';
|
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
} catch {
|
|
if (returnTo.startsWith('/') && !returnTo.startsWith('//')) return returnTo;
|
|
return '/';
|
|
}
|
|
}
|
|
|
|
function normalizeIntent(intent) {
|
|
const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login';
|
|
return VALID_INTENTS.has(value) ? value : 'login';
|
|
}
|
|
|
|
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 useMp = authMode === 'mp' || authMode === 'scan';
|
|
const params = new URLSearchParams({
|
|
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',
|
|
});
|
|
const res = await fetch(`${OAUTH_ACCESS_TOKEN_URL}?${params.toString()}`);
|
|
const data = await res.json();
|
|
if (data.errcode) {
|
|
throw new Error(data.errmsg || `微信授权失败 (${data.errcode})`);
|
|
}
|
|
if (!data.openid) {
|
|
throw new Error('微信授权未返回 openid');
|
|
}
|
|
return {
|
|
accessToken: data.access_token,
|
|
openid: data.openid,
|
|
unionid: data.unionid ?? null,
|
|
refreshToken: data.refresh_token ?? null,
|
|
appId: isOpen ? config.openAppId : config.appId,
|
|
};
|
|
}
|
|
|
|
async function fetchUserInfo(accessToken, openid) {
|
|
const params = new URLSearchParams({
|
|
access_token: accessToken,
|
|
openid,
|
|
lang: 'zh_CN',
|
|
});
|
|
const res = await fetch(`${OAUTH_USERINFO_URL}?${params.toString()}`);
|
|
const data = await res.json();
|
|
if (data.errcode) {
|
|
throw new Error(data.errmsg || `获取微信用户信息失败 (${data.errcode})`);
|
|
}
|
|
return {
|
|
nickname: typeof data.nickname === 'string' ? data.nickname.slice(0, 128) : null,
|
|
avatarUrl: typeof data.headimgurl === 'string' ? data.headimgurl.slice(0, 512) : null,
|
|
unionid: data.unionid ?? null,
|
|
};
|
|
}
|
|
|
|
export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
|
if (!config?.enabled) {
|
|
return {
|
|
enabled: false,
|
|
publicConfig() {
|
|
return { enabled: false, inWechat: false, scanEnabled: false };
|
|
},
|
|
};
|
|
}
|
|
|
|
const pruneExpiredStates = async (now = Date.now()) => {
|
|
if (!pool) return;
|
|
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]);
|
|
};
|
|
|
|
const createState = async ({
|
|
returnTo = '/',
|
|
utmSource = null,
|
|
utmMedium = null,
|
|
utmCampaign = null,
|
|
intent = 'login',
|
|
bindUserId = null,
|
|
authMode = 'mp',
|
|
now = Date.now(),
|
|
} = {}) => {
|
|
const state = crypto.randomBytes(24).toString('base64url');
|
|
if (pool) {
|
|
await pruneExpiredStates(now);
|
|
await pool.query(
|
|
`INSERT INTO h5_wechat_oauth_states
|
|
(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 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, 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];
|
|
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 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) => {
|
|
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
|
|
const utmSource = typeof req.query?.utm_source === 'string' ? req.query.utm_source : null;
|
|
const utmMedium = typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null;
|
|
const utmCampaign = typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null;
|
|
const common = {
|
|
returnTo,
|
|
utmSource,
|
|
utmMedium,
|
|
utmCampaign,
|
|
intent: 'login',
|
|
};
|
|
|
|
if (config.scanEnabled) {
|
|
const state = await createState({ ...common, authMode: 'open' });
|
|
return {
|
|
mode: 'open',
|
|
state,
|
|
openAppId: config.openAppId,
|
|
redirectUri: config.callbackUrl,
|
|
expiresInMs: STATE_TTL_MS,
|
|
};
|
|
}
|
|
|
|
const state = await createState({ ...common, authMode: 'scan' });
|
|
return {
|
|
mode: 'mp',
|
|
state,
|
|
qrUrl: buildAuthorizeUrl(config, { state, authMode: 'scan' }),
|
|
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 loadState(state);
|
|
if (!stateRow) {
|
|
throw new Error('授权状态无效或已过期,请重新登录');
|
|
}
|
|
|
|
const authMode = stateRow.auth_mode || 'mp';
|
|
const tokenInfo = await exchangeCode(config, code, authMode);
|
|
let nickname = null;
|
|
let avatarUrl = null;
|
|
let unionid = tokenInfo.unionid;
|
|
|
|
if ((authMode === 'mp' || authMode === 'scan') && 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?.resolveWechatAuth) {
|
|
throw new Error('用户系统未就绪');
|
|
}
|
|
|
|
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,
|
|
});
|
|
if (!result.ok) {
|
|
if (isPollScanAuthMode(authMode)) {
|
|
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 (isPollScanAuthMode(authMode)) {
|
|
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 (isPollScanAuthMode(authMode)) {
|
|
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,
|
|
bound: result.bound,
|
|
returnTo: stateRow.return_to || '/',
|
|
utmSource: stateRow.utm_source,
|
|
utmMedium: stateRow.utm_medium,
|
|
utmCampaign: stateRow.utm_campaign,
|
|
};
|
|
};
|
|
|
|
return {
|
|
enabled: true,
|
|
publicConfig(req) {
|
|
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
|
|
return {
|
|
enabled: true,
|
|
inWechat,
|
|
scanEnabled: true,
|
|
openScanEnabled: Boolean(config.scanEnabled),
|
|
appId: config.appId,
|
|
openAppId: config.scanEnabled ? config.openAppId : undefined,
|
|
oauthCallbackUrl: config.scanEnabled ? config.callbackUrl : undefined,
|
|
};
|
|
},
|
|
buildAuthorizeRedirect,
|
|
startScanLogin,
|
|
pollScanLogin,
|
|
handleCallback,
|
|
};
|
|
}
|