添加微信分享功能
This commit is contained in:
156
server/api/wechat/wx-config.get.ts
Normal file
156
server/api/wechat/wx-config.get.ts
Normal 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}×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
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user