const crypto = require('crypto') const express = require('express') const QRCode = require('qrcode') const db = require('../lib/db') const passwords = require('../lib/passwords') 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 }) }) 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) => { const header = ['姓名', '手机号', '公司', '活动', '分享人', '报名时间', '是否签到', '签到时间'] const lines = [header.join(',')] signupRows(str(req.query.activityId)).forEach((item) => { lines.push( [ item.name, item.phone, item.company, item.activityTitle, item.sourceSharer, 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