Add 卓米会 mini program and admin server.

Initial commit of the WeChat mini program, Express backend, and admin console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michelle Zan
2026-09-29 19:01:36 +08:00
co-authored by Cursor
commit cd05113755
67 changed files with 4845 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
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
+223
View File
@@ -0,0 +1,223 @@
const express = require('express')
const db = require('../lib/db')
const wechat = require('../lib/wechat')
const { uploader } = require('../lib/upload')
const { userAuth, requireUserAdmin } = require('../lib/auth')
const { HttpError, uid, token, ok, fail, wrap, str, has, PHONE_RE } = require('../lib/http')
const {
publicActivity,
currentActivity,
pastActivities,
nextPastTheme,
setPinned,
publicProfile,
DEFAULT_BRAND
} = require('../lib/serialize')
const router = express.Router()
router.post(
'/login',
wrap(async (req, res) => {
const body = req.body || {}
const code = str(body.code)
if (!code) throw new HttpError(400, '缺少 code')
const session = await wechat.code2session(code, str(body.deviceId))
const profile = db.update((data) => {
let user = data.users.find((item) => item.openid === session.openid)
if (!user) {
user = {
openid: session.openid,
nickName: '',
avatarUrl: '',
name: '',
phone: '',
company: '',
position: '',
createdAt: new Date().toISOString()
}
data.users.push(user)
}
user.token = token()
return Object.assign({ token: user.token, demo: session.demo }, publicProfile(user, data))
})
ok(res, profile)
})
)
router.get('/user/profile', userAuth, (req, res) => {
ok(res, publicProfile(req.user))
})
const PROFILE_LIMITS = { nickName: 32, name: 20, phone: 11, company: 40, position: 40 }
router.post('/user/profile', userAuth, (req, res) => {
const body = req.body || {}
if (has(body, 'phone') && str(body.phone) && !PHONE_RE.test(str(body.phone))) {
return fail(res, 400, '请填写正确的手机号')
}
const profile = db.update((data) => {
const user = req.user
Object.keys(PROFILE_LIMITS).forEach((key) => {
if (has(body, key)) user[key] = str(body[key], PROFILE_LIMITS[key])
})
// Only server-hosted avatars are accepted; temp paths like http://tmp/... are useless to others.
const avatarUrl = str(body.avatarUrl)
if (avatarUrl && avatarUrl.startsWith('/static/uploads/')) user.avatarUrl = avatarUrl
return publicProfile(user, data)
})
ok(res, profile)
})
router.post('/user/avatar', userAuth, uploader({ imagesOnly: true }))
router.get('/activity/current', (req, res) => {
const data = db.read()
const activity = currentActivity(data)
ok(res, activity ? publicActivity(activity, data.posters) : null)
})
router.get('/activity/past', (req, res) => {
const data = db.read()
ok(res, pastActivities(data).map((item) => publicActivity(item, data.posters)))
})
router.get('/activity/:id', (req, res) => {
const data = db.read()
const activity = data.activities.find((item) => item.id === req.params.id && item.status !== 'draft')
if (!activity) return fail(res, 404, '活动不存在')
ok(res, publicActivity(activity, data.posters))
})
router.post('/activity/past', userAuth, requireUserAdmin, (req, res) => {
const body = req.body || {}
const title = str(body.title, 40)
const startAt = str(body.startAt, 32)
if (!title || !startAt) return fail(res, 400, '请填写名称和时间')
const activity = db.update((data) => {
const now = new Date().toISOString()
const next = {
id: uid('p'),
title,
brandName: DEFAULT_BRAND,
brandDesc: '',
cover: str(body.cover),
video: str(body.video),
startAt,
address: '',
latitude: null,
longitude: null,
status: 'past',
isCurrent: false,
pinned: false,
pinnedAt: '',
theme: nextPastTheme(data),
createdAt: now,
updatedAt: now
}
if (body.pinned) setPinned(next, true)
data.activities.push(next)
return next
})
ok(res, publicActivity(activity))
})
function findPast(data, id) {
return data.activities.find((item) => item.id === id && item.status === 'past')
}
router.post('/activity/past/:id/pin', userAuth, requireUserAdmin, (req, res) => {
const updated = db.update((data) => {
const activity = findPast(data, req.params.id)
return activity ? setPinned(activity, !!(req.body && req.body.pinned)) : null
})
if (!updated) return fail(res, 404, '往期活动不存在')
ok(res, publicActivity(updated))
})
router.delete('/activity/past/:id', userAuth, requireUserAdmin, (req, res) => {
const removed = db.update((data) => {
const activity = findPast(data, req.params.id)
if (!activity) return null
data.activities = data.activities.filter((item) => item.id !== activity.id)
data.posters = data.posters.filter((item) => item.activityId !== activity.id)
return activity.id
})
if (!removed) return fail(res, 404, '往期活动不存在')
ok(res, { id: removed })
})
router.post('/signup', userAuth, (req, res) => {
const body = req.body || {}
const activityId = str(body.activityId)
const name = str(body.name, 20)
const phone = str(body.phone)
let sourceSharer = str(body.sourceSharer, 64)
if (!activityId || !name || !phone) return fail(res, 400, '请填写姓名和手机号')
if (!PHONE_RE.test(phone)) return fail(res, 400, '请填写正确的手机号')
if (sourceSharer === req.user.openid) sourceSharer = ''
const result = db.update((data) => {
const activity = data.activities.find((item) => item.id === activityId)
if (!activity || activity.status === 'draft') return { error: '活动不存在' }
if (activity.status === 'past') return { error: '这场活动已结束' }
const exists = data.signups.some((item) => item.activityId === activityId && item.openid === req.user.openid)
if (exists) return { error: '你已经报名过了' }
const signup = {
id: uid('s'),
activityId,
activityTitle: activity.title,
openid: req.user.openid,
name,
phone,
company: str(body.company, 40),
sourceSharer,
createdAt: new Date().toISOString(),
checkedIn: false,
checkedInAt: ''
}
data.signups.push(signup)
return { signup }
})
if (result.error) return fail(res, 400, result.error)
ok(res, result.signup)
})
router.get('/signup/mine', userAuth, (req, res) => {
const signups = db
.read()
.signups.filter((item) => item.openid === req.user.openid)
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
ok(res, { signups, checkins: signups.filter((item) => item.checkedIn) })
})
router.post('/checkin/scan', userAuth, (req, res) => {
const body = req.body || {}
const result = db.update((data) => {
let activityId = ''
if (body.simulate) {
const activity = currentActivity(data)
if (!activity) return { error: '当前没有进行中的活动' }
activityId = activity.id
} else {
const code = data.checkinCodes.find((item) => item.token === str(body.token))
if (!code) return { error: '签到码无效' }
if (code.expiresAt && Date.parse(code.expiresAt) < Date.now()) return { error: '签到码已过期' }
activityId = code.activityId
}
const signup = data.signups.find((item) => item.activityId === activityId && item.openid === req.user.openid)
if (!signup) return { error: '请先报名再签到' }
if (!signup.checkedIn) {
signup.checkedIn = true
signup.checkedInAt = new Date().toISOString()
}
return { signup }
})
if (result.error) return fail(res, 400, result.error)
ok(res, result.signup)
})
router.post('/upload', userAuth, requireUserAdmin, uploader())
module.exports = router