添加微信分享功能

This commit is contained in:
2025-08-09 13:27:47 +08:00
parent f57e410393
commit 597339b666
7 changed files with 315 additions and 3 deletions

View File

@@ -0,0 +1,156 @@
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
}
})