157 lines
5.3 KiB
TypeScript
157 lines
5.3 KiB
TypeScript
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=<encoded_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<string> {
|
||
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<AccessTokenResponse>(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<string> {
|
||
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<JsapiTicketResponse>(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<WxConfigPayload> => {
|
||
// 避免代理缓存
|
||
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
|
||
}
|
||
})
|