2026-09-29 19:01:36 +08:00
|
|
|
const crypto = require('crypto')
|
|
|
|
|
const express = require('express')
|
|
|
|
|
const QRCode = require('qrcode')
|
|
|
|
|
const db = require('../lib/db')
|
|
|
|
|
const passwords = require('../lib/passwords')
|
2026-10-08 13:06:43 +08:00
|
|
|
const wechat = require('../lib/wechat')
|
2026-09-29 19:01:36 +08:00
|
|
|
const { uploader } = require('../lib/upload')
|
|
|
|
|
const { adminAuth } = require('../lib/auth')
|
|
|
|
|
const { uid, token, ok, fail, wrap, str } = require('../lib/http')
|
|
|
|
|
const { activityPayload, setPinned } = require('../lib/serialize')
|
|
|
|
|
|
|
|
|
|
const router = express.Router()
|
|
|
|
|
const auth = adminAuth()
|
|
|
|
|
|
|
|
|
|
router.post('/login', (req, res) => {
|
|
|
|
|
const username = str(req.body && req.body.username)
|
|
|
|
|
const password = String((req.body && req.body.password) || '')
|
|
|
|
|
const session = db.update((data) => {
|
|
|
|
|
const admin = data.admins.find((item) => item.username === username)
|
|
|
|
|
if (!admin || !passwords.verify(password, admin.password)) return null
|
|
|
|
|
admin.token = token()
|
|
|
|
|
return { token: admin.token, username: admin.username }
|
|
|
|
|
})
|
|
|
|
|
if (!session) return fail(res, 401, '账号或密码错误')
|
|
|
|
|
ok(res, session)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/password', auth, (req, res) => {
|
|
|
|
|
const oldPassword = String((req.body && req.body.oldPassword) || '')
|
|
|
|
|
const nextPassword = String((req.body && req.body.newPassword) || '')
|
|
|
|
|
if (nextPassword.length < 6) return fail(res, 400, '新密码至少 6 位')
|
|
|
|
|
if (!passwords.verify(oldPassword, req.admin.password)) return fail(res, 400, '原密码不正确')
|
|
|
|
|
db.update(() => {
|
|
|
|
|
req.admin.password = passwords.hash(nextPassword)
|
|
|
|
|
req.admin.token = ''
|
|
|
|
|
})
|
|
|
|
|
ok(res, { relogin: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.get('/stats', auth, (req, res) => {
|
|
|
|
|
const data = db.read()
|
|
|
|
|
const byActivity = data.activities.map((activity) => {
|
|
|
|
|
const rows = data.signups.filter((item) => item.activityId === activity.id)
|
|
|
|
|
return {
|
|
|
|
|
id: activity.id,
|
|
|
|
|
title: activity.title,
|
|
|
|
|
signups: rows.length,
|
|
|
|
|
checkins: rows.filter((item) => item.checkedIn).length
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
const sharerMap = {}
|
|
|
|
|
data.signups.forEach((item) => {
|
|
|
|
|
const key = item.sourceSharer || '直接访问'
|
|
|
|
|
sharerMap[key] = (sharerMap[key] || 0) + 1
|
|
|
|
|
})
|
|
|
|
|
ok(res, {
|
|
|
|
|
activityCount: data.activities.length,
|
|
|
|
|
signupCount: data.signups.length,
|
|
|
|
|
checkinCount: data.signups.filter((item) => item.checkedIn).length,
|
|
|
|
|
byActivity,
|
|
|
|
|
bySharer: Object.keys(sharerMap).map((sharer) => ({ sharer, count: sharerMap[sharer] }))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.get('/users', auth, (req, res) => {
|
|
|
|
|
const data = db.read()
|
|
|
|
|
ok(
|
|
|
|
|
res,
|
|
|
|
|
data.users.map((user) => ({
|
|
|
|
|
openid: user.openid,
|
|
|
|
|
nickName: user.nickName || '',
|
|
|
|
|
name: user.name || '',
|
|
|
|
|
phone: user.phone || '',
|
|
|
|
|
allowed: data.adminAllowlist.includes(user.openid),
|
|
|
|
|
createdAt: user.createdAt || ''
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.get('/allowlist', auth, (req, res) => {
|
|
|
|
|
ok(res, db.read().adminAllowlist)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/allowlist', auth, (req, res) => {
|
|
|
|
|
const openid = str(req.body && req.body.openid)
|
|
|
|
|
if (!openid) return fail(res, 400, '缺少 openid')
|
|
|
|
|
ok(
|
|
|
|
|
res,
|
|
|
|
|
db.update((data) => {
|
|
|
|
|
if (!data.adminAllowlist.includes(openid)) data.adminAllowlist.push(openid)
|
|
|
|
|
return data.adminAllowlist
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.delete('/allowlist/:openid', auth, (req, res) => {
|
|
|
|
|
const openid = req.params.openid
|
|
|
|
|
ok(
|
|
|
|
|
res,
|
|
|
|
|
db.update((data) => {
|
|
|
|
|
data.adminAllowlist = data.adminAllowlist.filter((item) => item !== openid)
|
|
|
|
|
return data.adminAllowlist
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function clearOtherCurrent(data, keepId) {
|
|
|
|
|
data.activities.forEach((item) => {
|
|
|
|
|
if (item.id !== keepId) item.isCurrent = false
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get('/activities', auth, (req, res) => {
|
|
|
|
|
ok(res, db.read().activities)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/activities', auth, (req, res) => {
|
|
|
|
|
const created = db.update((data) => {
|
|
|
|
|
const payload = activityPayload(req.body || {}, null, data)
|
|
|
|
|
if (!payload.title) return null
|
|
|
|
|
if (payload.isCurrent) clearOtherCurrent(data, payload.id)
|
|
|
|
|
data.activities.push(payload)
|
|
|
|
|
return payload
|
|
|
|
|
})
|
|
|
|
|
if (!created) return fail(res, 400, '请填写活动名称')
|
|
|
|
|
ok(res, created)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.put('/activities/:id', auth, (req, res) => {
|
|
|
|
|
const result = db.update((data) => {
|
|
|
|
|
const index = data.activities.findIndex((item) => item.id === req.params.id)
|
|
|
|
|
if (index < 0) return { status: 404, error: '活动不存在' }
|
|
|
|
|
const payload = activityPayload(req.body || {}, data.activities[index], data)
|
|
|
|
|
if (!payload.title) return { status: 400, error: '请填写活动名称' }
|
|
|
|
|
if (payload.isCurrent) clearOtherCurrent(data, payload.id)
|
|
|
|
|
data.activities[index] = payload
|
|
|
|
|
return { activity: payload }
|
|
|
|
|
})
|
|
|
|
|
if (result.error) return fail(res, result.status, result.error)
|
|
|
|
|
ok(res, result.activity)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/activities/:id/pin', auth, (req, res) => {
|
|
|
|
|
const result = db.update((data) => {
|
|
|
|
|
const activity = data.activities.find((item) => item.id === req.params.id)
|
|
|
|
|
if (!activity) return { status: 404, error: '活动不存在' }
|
|
|
|
|
if (activity.status !== 'past') return { status: 400, error: '只能置顶往期活动' }
|
|
|
|
|
return { activity: setPinned(activity, !!(req.body && req.body.pinned)) }
|
|
|
|
|
})
|
|
|
|
|
if (result.error) return fail(res, result.status, result.error)
|
|
|
|
|
ok(res, result.activity)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.delete('/activities/:id', auth, (req, res) => {
|
|
|
|
|
const removed = db.update((data) => {
|
|
|
|
|
if (!data.activities.some((item) => item.id === req.params.id)) return false
|
|
|
|
|
data.activities = data.activities.filter((item) => item.id !== req.params.id)
|
|
|
|
|
data.posters = data.posters.filter((item) => item.activityId !== req.params.id)
|
|
|
|
|
data.checkinCodes = data.checkinCodes.filter((item) => item.activityId !== req.params.id)
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
if (!removed) return fail(res, 404, '活动不存在')
|
|
|
|
|
ok(res, { id: req.params.id })
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-08 13:06:43 +08:00
|
|
|
router.get('/settings', auth, (req, res) => {
|
|
|
|
|
const settings = db.read().settings || {}
|
|
|
|
|
ok(res, {
|
|
|
|
|
subscribeTmplId: settings.subscribeTmplId || '',
|
|
|
|
|
wxConfigured: wechat.wxConfigured()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.put('/settings', auth, (req, res) => {
|
|
|
|
|
const subscribeTmplId = str(req.body && req.body.subscribeTmplId, 80)
|
|
|
|
|
const settings = db.update((data) => {
|
|
|
|
|
if (!data.settings) data.settings = {}
|
|
|
|
|
data.settings.subscribeTmplId = subscribeTmplId
|
|
|
|
|
return data.settings
|
|
|
|
|
})
|
|
|
|
|
ok(res, settings)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post(
|
|
|
|
|
'/activities/:id/remind',
|
|
|
|
|
auth,
|
|
|
|
|
wrap(async (req, res) => {
|
|
|
|
|
const data = db.read()
|
|
|
|
|
const activity = data.activities.find((item) => item.id === req.params.id)
|
|
|
|
|
if (!activity) return fail(res, 404, '活动不存在')
|
|
|
|
|
const tmplId = str(data.settings && data.settings.subscribeTmplId)
|
|
|
|
|
if (!tmplId) return fail(res, 400, '请先在后台配置订阅消息模板 ID')
|
|
|
|
|
|
|
|
|
|
const targets = data.signups.filter((item) => item.activityId === activity.id && item.remindAccepted)
|
|
|
|
|
if (!targets.length) return fail(res, 400, '没有可提醒的报名用户(需用户报名时同意订阅)')
|
|
|
|
|
|
|
|
|
|
if (!wechat.wxConfigured()) {
|
|
|
|
|
return ok(res, {
|
|
|
|
|
demo: true,
|
|
|
|
|
total: targets.length,
|
|
|
|
|
sent: targets.length,
|
|
|
|
|
failed: 0,
|
|
|
|
|
message: '演示模式:未配置微信密钥,已模拟发送成功'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const page = `pages/detail/detail?id=${activity.id}`
|
|
|
|
|
const payload = wechat.buildRemindData(activity)
|
|
|
|
|
let sent = 0
|
|
|
|
|
const failures = []
|
|
|
|
|
for (const signup of targets) {
|
|
|
|
|
try {
|
|
|
|
|
const result = await wechat.sendSubscribeMessage({
|
|
|
|
|
openid: signup.openid,
|
|
|
|
|
templateId: tmplId,
|
|
|
|
|
page,
|
|
|
|
|
data: payload
|
|
|
|
|
})
|
|
|
|
|
if (result.ok) sent += 1
|
|
|
|
|
else failures.push({ openid: signup.openid, errmsg: result.errmsg || String(result.errcode) })
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failures.push({ openid: signup.openid, errmsg: error.message || '发送失败' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ok(res, { demo: false, total: targets.length, sent, failed: failures.length, failures: failures.slice(0, 20) })
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-29 19:01:36 +08:00
|
|
|
router.get('/posters', auth, (req, res) => {
|
|
|
|
|
ok(res, db.read().posters)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/posters', auth, (req, res) => {
|
|
|
|
|
const body = req.body || {}
|
|
|
|
|
if (!body.activityId) return fail(res, 400, '请选择活动')
|
|
|
|
|
const poster = {
|
|
|
|
|
id: uid('m'),
|
|
|
|
|
activityId: str(body.activityId),
|
|
|
|
|
image: str(body.image),
|
|
|
|
|
title: str(body.title, 60),
|
|
|
|
|
enabled: body.enabled !== false,
|
|
|
|
|
sort: Number(body.sort) || 0,
|
|
|
|
|
createdAt: new Date().toISOString()
|
|
|
|
|
}
|
|
|
|
|
db.update((data) => data.posters.push(poster))
|
|
|
|
|
ok(res, poster)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.put('/posters/:id', auth, (req, res) => {
|
|
|
|
|
const body = req.body || {}
|
|
|
|
|
const updated = db.update((data) => {
|
|
|
|
|
const poster = data.posters.find((item) => item.id === req.params.id)
|
|
|
|
|
if (!poster) return null
|
|
|
|
|
if (body.activityId != null) poster.activityId = str(body.activityId)
|
|
|
|
|
if (body.image != null) poster.image = str(body.image)
|
|
|
|
|
if (body.title != null) poster.title = str(body.title, 60)
|
|
|
|
|
if (body.enabled != null) poster.enabled = !!body.enabled
|
|
|
|
|
if (body.sort != null) poster.sort = Number(body.sort) || 0
|
|
|
|
|
return poster
|
|
|
|
|
})
|
|
|
|
|
if (!updated) return fail(res, 404, '海报不存在')
|
|
|
|
|
ok(res, updated)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.delete('/posters/:id', auth, (req, res) => {
|
|
|
|
|
const removed = db.update((data) => {
|
|
|
|
|
const before = data.posters.length
|
|
|
|
|
data.posters = data.posters.filter((item) => item.id !== req.params.id)
|
|
|
|
|
return data.posters.length !== before
|
|
|
|
|
})
|
|
|
|
|
if (!removed) return fail(res, 404, '海报不存在')
|
|
|
|
|
ok(res, { id: req.params.id })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.post('/upload-file', auth, uploader())
|
|
|
|
|
|
|
|
|
|
function signupRows(activityId) {
|
|
|
|
|
const rows = db.read().signups
|
|
|
|
|
return activityId ? rows.filter((item) => item.activityId === activityId) : rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get('/signups', auth, (req, res) => {
|
|
|
|
|
ok(res, signupRows(str(req.query.activityId)))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function csvCell(value) {
|
|
|
|
|
const text = value == null ? '' : String(value)
|
|
|
|
|
// Prefix formula-leading cells so spreadsheets don't execute them.
|
|
|
|
|
const safe = /^[=+\-@]/.test(text) ? `'${text}` : text
|
|
|
|
|
return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get('/signups/export', adminAuth({ allowQueryToken: true }), (req, res) => {
|
2026-10-08 13:06:43 +08:00
|
|
|
const header = ['姓名', '手机号', '公司', '职位', '活动', '分享人', '订阅提醒', '报名时间', '是否签到', '签到时间']
|
2026-09-29 19:01:36 +08:00
|
|
|
const lines = [header.join(',')]
|
|
|
|
|
signupRows(str(req.query.activityId)).forEach((item) => {
|
|
|
|
|
lines.push(
|
|
|
|
|
[
|
|
|
|
|
item.name,
|
|
|
|
|
item.phone,
|
|
|
|
|
item.company,
|
2026-10-08 13:06:43 +08:00
|
|
|
item.position || '',
|
2026-09-29 19:01:36 +08:00
|
|
|
item.activityTitle,
|
|
|
|
|
item.sourceSharer,
|
2026-10-08 13:06:43 +08:00
|
|
|
item.remindAccepted ? '是' : '否',
|
2026-09-29 19:01:36 +08:00
|
|
|
item.createdAt,
|
|
|
|
|
item.checkedIn ? '是' : '否',
|
|
|
|
|
item.checkedInAt
|
|
|
|
|
]
|
|
|
|
|
.map(csvCell)
|
|
|
|
|
.join(',')
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
|
|
|
|
res.setHeader('Content-Disposition', 'attachment; filename="signups.csv"')
|
|
|
|
|
res.send(`\uFEFF${lines.join('\n')}`)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
router.delete('/signups/:id', auth, (req, res) => {
|
|
|
|
|
const removed = db.update((data) => {
|
|
|
|
|
const before = data.signups.length
|
|
|
|
|
data.signups = data.signups.filter((item) => item.id !== req.params.id)
|
|
|
|
|
return data.signups.length !== before
|
|
|
|
|
})
|
|
|
|
|
if (!removed) return fail(res, 404, '报名不存在')
|
|
|
|
|
ok(res, { id: req.params.id })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function withQr(code, width) {
|
|
|
|
|
return QRCode.toDataURL(code.token, { width, margin: 1 }).then((qrDataUrl) =>
|
|
|
|
|
Object.assign({}, code, { qrDataUrl })
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router.get(
|
|
|
|
|
'/checkin-codes',
|
|
|
|
|
auth,
|
|
|
|
|
wrap(async (req, res) => {
|
|
|
|
|
ok(res, await Promise.all(db.read().checkinCodes.map((code) => withQr(code, 280))))
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router.post(
|
|
|
|
|
'/checkin-codes',
|
|
|
|
|
auth,
|
|
|
|
|
wrap(async (req, res) => {
|
|
|
|
|
const activityId = str(req.body && req.body.activityId)
|
|
|
|
|
const hours = Math.min(Math.max(Number(req.body && req.body.ttlHours) || 12, 1), 24 * 30)
|
|
|
|
|
if (!db.read().activities.some((item) => item.id === activityId)) return fail(res, 404, '活动不存在')
|
|
|
|
|
const code = {
|
|
|
|
|
id: uid('c'),
|
|
|
|
|
activityId,
|
|
|
|
|
token: crypto.randomBytes(16).toString('hex'),
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
expiresAt: new Date(Date.now() + hours * 3600 * 1000).toISOString()
|
|
|
|
|
}
|
|
|
|
|
db.update((data) => data.checkinCodes.push(code))
|
|
|
|
|
ok(res, await withQr(code, 360))
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
module.exports = router
|