Files
estel_docs/server/api/wechat/wx-config.get.ts
2025-08-09 13:27:47 +08:00

157 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}&timestamp=${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
}
})