2026-09-29 19:01:36 +08:00
|
|
|
const crypto = require('crypto')
|
|
|
|
|
const { HttpError } = require('./http')
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
let tokenCache = null
|
|
|
|
|
|
|
|
|
|
function wxConfigured() {
|
|
|
|
|
return !!(process.env.WX_APPID && process.env.WX_SECRET)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 19:01:36 +08:00
|
|
|
async function code2session(code, deviceId) {
|
2026-10-08 13:06:43 +08:00
|
|
|
if (!wxConfigured()) {
|
2026-09-29 19:01:36 +08:00
|
|
|
const seed = deviceId || 'guest'
|
|
|
|
|
return {
|
|
|
|
|
openid: `demo_${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16)}`,
|
|
|
|
|
demo: true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = new URL('https://api.weixin.qq.com/sns/jscode2session')
|
|
|
|
|
url.search = new URLSearchParams({
|
2026-10-08 13:06:43 +08:00
|
|
|
appid: process.env.WX_APPID,
|
|
|
|
|
secret: process.env.WX_SECRET,
|
2026-09-29 19:01:36 +08:00
|
|
|
js_code: code,
|
|
|
|
|
grant_type: 'authorization_code'
|
|
|
|
|
}).toString()
|
|
|
|
|
|
|
|
|
|
let session
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
|
|
|
|
session = await res.json()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new HttpError(502, '微信登录服务不可用')
|
|
|
|
|
}
|
|
|
|
|
if (!session.openid) throw new HttpError(400, session.errmsg || '微信登录失败')
|
|
|
|
|
return { openid: session.openid, demo: false }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
async function getAccessToken() {
|
|
|
|
|
if (!wxConfigured()) throw new HttpError(400, '未配置 WX_APPID / WX_SECRET,无法发送订阅消息')
|
|
|
|
|
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token
|
|
|
|
|
|
|
|
|
|
const url = new URL('https://api.weixin.qq.com/cgi-bin/token')
|
|
|
|
|
url.search = new URLSearchParams({
|
|
|
|
|
grant_type: 'client_credential',
|
|
|
|
|
appid: process.env.WX_APPID,
|
|
|
|
|
secret: process.env.WX_SECRET
|
|
|
|
|
}).toString()
|
|
|
|
|
|
|
|
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
|
|
|
|
const body = await res.json()
|
|
|
|
|
if (!body.access_token) throw new HttpError(502, body.errmsg || '获取微信 access_token 失败')
|
|
|
|
|
tokenCache = {
|
|
|
|
|
token: body.access_token,
|
|
|
|
|
expiresAt: Date.now() + (Number(body.expires_in) || 7200) * 1000
|
|
|
|
|
}
|
|
|
|
|
return tokenCache.token
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clip(value, max) {
|
|
|
|
|
const text = value == null ? '' : String(value)
|
|
|
|
|
return text.length > max ? text.slice(0, max) : text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default field mapping for a typical “活动提醒” template (thing / time / thing).
|
|
|
|
|
function buildRemindData(activity) {
|
|
|
|
|
return {
|
|
|
|
|
thing1: { value: clip(activity.title || '卓米会活动', 20) },
|
|
|
|
|
time2: { value: clip(activity.startAt || '待定', 20) },
|
|
|
|
|
thing3: { value: clip(activity.address || '详见活动页', 20) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sendSubscribeMessage({ openid, templateId, page, data }) {
|
|
|
|
|
const token = await getAccessToken()
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(token)}`,
|
|
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
touser: openid,
|
|
|
|
|
template_id: templateId,
|
|
|
|
|
page: page || '',
|
|
|
|
|
data,
|
|
|
|
|
miniprogram_state: process.env.WX_MINI_STATE || 'formal'
|
|
|
|
|
}),
|
|
|
|
|
signal: AbortSignal.timeout(8000)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
const body = await res.json()
|
|
|
|
|
return { ok: body.errcode === 0, errcode: body.errcode, errmsg: body.errmsg || '' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
wxConfigured,
|
|
|
|
|
code2session,
|
|
|
|
|
getAccessToken,
|
|
|
|
|
buildRemindData,
|
|
|
|
|
sendSubscribeMessage
|
|
|
|
|
}
|