Ship signup sheet, subscribe reminders, cover crop, and theme updates.

Wire real WeChat app config, prefer activity cover over posters, and add admin cover cropping for the home banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michelle Zan
2026-10-08 13:06:43 +08:00
co-authored by Cursor
parent cd05113755
commit 0ca2e87bf2
35 changed files with 968 additions and 184 deletions
+72 -6
View File
@@ -1,10 +1,14 @@
const crypto = require('crypto')
const { HttpError } = require('./http')
let tokenCache = null
function wxConfigured() {
return !!(process.env.WX_APPID && process.env.WX_SECRET)
}
async function code2session(code, deviceId) {
const appid = process.env.WX_APPID
const secret = process.env.WX_SECRET
if (!appid || !secret) {
if (!wxConfigured()) {
const seed = deviceId || 'guest'
return {
openid: `demo_${crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16)}`,
@@ -14,8 +18,8 @@ async function code2session(code, deviceId) {
const url = new URL('https://api.weixin.qq.com/sns/jscode2session')
url.search = new URLSearchParams({
appid,
secret,
appid: process.env.WX_APPID,
secret: process.env.WX_SECRET,
js_code: code,
grant_type: 'authorization_code'
}).toString()
@@ -31,4 +35,66 @@ async function code2session(code, deviceId) {
return { openid: session.openid, demo: false }
}
module.exports = { code2session }
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
}