import { defineEventHandler, getQuery, setResponseHeader, createError } from 'h3' import { createHash, randomBytes } from 'node:crypto' type AccessTokenResponse = { access_token: string expires_in: number errcode?: number errmsg?: string } type JsapiTicketResponse = { errcode: number errmsg: string ticket: string expires_in: number } type WxConfigPayload = { appId: string timestamp: number nonceStr: string signature: string } /** * GET /api/wechat/jssdk-config?url= * * 仅新增本文件,不修改其他任何代码。 * 从环境变量读取: * - WECHAT_APP_ID * - WECHAT_APP_SECRET * * 按微信官方文档生成 JS-SDK 所需签名配置:appId、timestamp、nonceStr、signature。 * 会在内存中缓存 access_token 与 jsapi_ticket,缓存有效期比官方返回略短(预留安全缓冲)。 * * 参考文档: * - JS-SDK 使用步骤(签名计算与 ticket 缓存):https://developers.weixin.qq.com/doc/service/guide/h5/jssdk.html#JSSDK%E4%BD%BF%E7%94%A8%E6%AD%A5%E9%AA%A4 */ const TOKEN_SAFETY_BUFFER_SECONDS = 300 // 5 分钟安全缓冲 let cachedAccessToken: { value: string, expiresAt: number } | null = null let cachedJsapiTicket: { value: string, expiresAt: number } | null = null function isExpired(cache: { expiresAt: number } | null): boolean { if (!cache) return true return Date.now() >= cache.expiresAt } async function fetchAccessToken(appId: string, appSecret: string): Promise { if (!isExpired(cachedAccessToken)) { return cachedAccessToken!.value } const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}` const response = await $fetch(url, { method: 'GET' }) if (!response || !response.access_token) { throw createError({ statusCode: 502, statusMessage: `Failed to obtain access_token: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim() }) } const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS) cachedAccessToken = { value: response.access_token, expiresAt: Date.now() + expiresInSeconds * 1000 } return response.access_token } async function fetchJsapiTicket(accessToken: string): Promise { if (!isExpired(cachedJsapiTicket)) { return cachedJsapiTicket!.value } const url = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${encodeURIComponent(accessToken)}&type=jsapi` const response = await $fetch(url, { method: 'GET' }) if (!response || response.errcode !== 0 || !response.ticket) { throw createError({ statusCode: 502, statusMessage: `Failed to obtain jsapi_ticket: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim() }) } const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS) cachedJsapiTicket = { value: response.ticket, expiresAt: Date.now() + expiresInSeconds * 1000 } return response.ticket } function generateNonceStr(bytes: number = 16): string { // 生成由 [a-z0-9] 组成的随机字符串 return randomBytes(bytes).toString('hex') } function generateTimestamp(): number { return Math.floor(Date.now() / 1000) } function buildSignature(jsapiTicket: string, nonceStr: string, timestamp: number, url: string): string { // 注意:参数名必须为全小写,且按字典序构造字符串 // 官方示例顺序:jsapi_ticket, noncestr, timestamp, url const rawString = `jsapi_ticket=${jsapiTicket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}` return createHash('sha1').update(rawString).digest('hex') } export default defineEventHandler(async (event): Promise => { // 避免代理缓存 setResponseHeader(event, 'Cache-Control', 'no-store') const appId = process.env.WECHAT_APP_ID || '' const appSecret = process.env.WECHAT_APP_SECRET || '' if (!appId || !appSecret) { throw createError({ statusCode: 500, statusMessage: 'Missing WECHAT_APP_ID or WECHAT_APP_SECRET in environment.' }) } const query = getQuery(event) let pageUrlRaw = typeof query.url === 'string' ? query.url : '' // 若未传入 url,则使用当前请求完整地址(去除 hash),通常建议前端传入当前页面 URL if (!pageUrlRaw) { const requestUrl = event.node.req.headers['x-forwarded-proto'] && event.node.req.headers['x-forwarded-host'] ? `${event.node.req.headers['x-forwarded-proto']}://${event.node.req.headers['x-forwarded-host']}${event.node.req.url ?? ''}` : new URL(event.node.req.url ?? '/', `http://${event.node.req.headers.host}`).toString() pageUrlRaw = requestUrl } const pageUrlNoHash = pageUrlRaw.includes('#') ? pageUrlRaw.slice(0, pageUrlRaw.indexOf('#')) : pageUrlRaw const pageUrl = decodeURIComponent(pageUrlNoHash) if (!/^https?:\/\//i.test(pageUrl)) { throw createError({ statusCode: 400, statusMessage: 'Invalid url parameter. Expecting an absolute http/https URL.' }) } const accessToken = await fetchAccessToken(appId, appSecret) const jsapiTicket = await fetchJsapiTicket(accessToken) const timestamp = generateTimestamp() const nonceStr = generateNonceStr(16) const signature = buildSignature(jsapiTicket, nonceStr, timestamp, pageUrl) return { appId, timestamp, nonceStr, signature } })