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
+31
View File
@@ -0,0 +1,31 @@
const path = require('path')
const express = require('express')
const { uploadDir } = require('./lib/upload')
const { fail } = require('./lib/http')
const app = express()
const publicDir = path.join(__dirname, 'public')
app.disable('x-powered-by')
app.use(express.json({ limit: '1mb' }))
app.use('/static/uploads', express.static(uploadDir, { maxAge: '7d' }))
app.use('/static', express.static(publicDir))
app.use('/api/admin', require('./routes/admin'))
app.use('/api', require('./routes/public'))
app.get('/admin', (req, res) => res.sendFile(path.join(publicDir, 'admin.html')))
app.get('/', (req, res) => res.redirect('/admin'))
app.use('/api', (req, res) => fail(res, 404, '接口不存在'))
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
if (error.type === 'entity.parse.failed') return fail(res, 400, '请求格式错误')
if (error.type === 'entity.too.large') return fail(res, 413, '请求内容过大')
const status = error.status && error.status < 500 ? error.status : 500
if (status === 500) console.error(error)
fail(res, status, status === 500 ? '服务器开小差了' : error.message)
})
module.exports = app
+37
View File
@@ -0,0 +1,37 @@
const db = require('./db')
const { fail } = require('./http')
function headerToken(req) {
return String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim()
}
function isAdmin(openid, data) {
return (data || db.read()).adminAllowlist.includes(openid)
}
function userAuth(req, res, next) {
const token = headerToken(req)
const user = token && db.read().users.find((item) => item.token === token)
if (!user) return fail(res, 401, '请先登录')
req.user = user
next()
}
function requireUserAdmin(req, res, next) {
if (!req.user || !isAdmin(req.user.openid)) return fail(res, 403, '仅管理员可操作')
next()
}
// Query tokens are only accepted where a plain link must work (CSV download).
function adminAuth(options) {
const allowQuery = !!(options && options.allowQueryToken)
return (req, res, next) => {
const token = headerToken(req) || (allowQuery ? String(req.query.token || '').trim() : '')
const admin = token && db.read().admins.find((item) => item.token === token)
if (!admin) return fail(res, 401, '请先登录管理后台')
req.admin = admin
next()
}
}
module.exports = { userAuth, requireUserAdmin, adminAuth, isAdmin }
+109
View File
@@ -0,0 +1,109 @@
const fs = require('fs')
const path = require('path')
const passwords = require('./passwords')
const file = process.env.DB_FILE
? path.resolve(process.env.DB_FILE)
: path.join(__dirname, '../data/db.json')
const SEED = {
users: [],
admins: [{ username: 'admin', password: '123456', token: '' }],
adminAllowlist: [],
activities: [
{
id: '93',
title: '2026年卓米第93期卓米会',
brandName: '卓米会 ZHUOMI',
brandDesc: '专注于品牌增长与企业服务的共创平台,连接资源、方法与同行者。',
cover: '',
video: '',
startAt: '2026-06-18 14:00',
address: '上海市浦东新区世纪大道 100 号 卓米中心 3F',
latitude: 31.235929,
longitude: 121.506321,
status: 'current',
isCurrent: true,
pinned: false,
pinnedAt: '',
theme: 0,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T00:00:00.000Z'
}
],
posters: [],
signups: [],
checkinCodes: []
}
const COLLECTIONS = ['users', 'admins', 'adminAllowlist', 'activities', 'posters', 'signups', 'checkinCodes']
let cache = null
// Returns true when the stored shape changed and must be persisted.
function normalize(data) {
let dirty = false
COLLECTIONS.forEach((key) => {
if (!Array.isArray(data[key])) {
data[key] = []
dirty = true
}
})
// The allowlist is the only source of truth for mini-program admins.
data.users.forEach((user) => {
// Client temp paths (http://tmp/..., wxfile://...) were saved by older builds and never resolve.
if (user.avatarUrl && !user.avatarUrl.startsWith('/static/uploads/')) {
user.avatarUrl = ''
dirty = true
}
if (!('role' in user)) return
if (user.role === 'admin' && !data.adminAllowlist.includes(user.openid)) {
data.adminAllowlist.push(user.openid)
}
delete user.role
dirty = true
})
data.admins.forEach((admin) => {
if (!passwords.isHashed(admin.password)) {
admin.password = passwords.hash(admin.password || '')
dirty = true
}
})
return dirty
}
function persist(data) {
fs.mkdirSync(path.dirname(file), { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(data, null, 2))
fs.renameSync(tmp, file)
}
function load() {
if (cache) return cache
const data = fs.existsSync(file)
? JSON.parse(fs.readFileSync(file, 'utf8'))
: JSON.parse(JSON.stringify(SEED))
if (normalize(data) || !fs.existsSync(file)) persist(data)
cache = data
return cache
}
function read() {
return load()
}
function update(mutator) {
const data = load()
const result = mutator(data)
persist(data)
return result
}
function reset() {
cache = null
}
module.exports = { read, update, reset, file, SEED }
+42
View File
@@ -0,0 +1,42 @@
const crypto = require('crypto')
class HttpError extends Error {
constructor(status, message) {
super(message)
this.status = status
}
}
function uid(prefix) {
return `${prefix}${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`
}
function token() {
return crypto.randomBytes(24).toString('hex')
}
function ok(res, data) {
res.json({ ok: true, data })
}
function fail(res, status, message) {
res.status(status).json({ ok: false, message })
}
// Express 4 does not forward rejected promises to the error handler.
function wrap(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next)
}
function str(value, max) {
const text = value == null ? '' : String(value).trim()
return max ? text.slice(0, max) : text
}
function has(body, key) {
return Object.prototype.hasOwnProperty.call(body || {}, key)
}
const PHONE_RE = /^1\d{10}$/
module.exports = { HttpError, uid, token, ok, fail, wrap, str, has, PHONE_RE }
+23
View File
@@ -0,0 +1,23 @@
const crypto = require('crypto')
const PREFIX = 'scrypt$'
function hash(plain) {
const salt = crypto.randomBytes(16).toString('hex')
const digest = crypto.scryptSync(String(plain), salt, 32).toString('hex')
return `${PREFIX}${salt}$${digest}`
}
function isHashed(stored) {
return typeof stored === 'string' && stored.startsWith(PREFIX)
}
function verify(plain, stored) {
if (!isHashed(stored)) return false
const parts = stored.split('$')
const expected = Buffer.from(parts[2], 'hex')
const actual = crypto.scryptSync(String(plain), parts[1], expected.length)
return crypto.timingSafeEqual(expected, actual)
}
module.exports = { hash, verify, isHashed }
+132
View File
@@ -0,0 +1,132 @@
const { uid, str, has } = require('./http')
const { isAdmin } = require('./auth')
const STATUSES = ['current', 'past', 'draft']
const DEFAULT_BRAND = '卓米会 ZHUOMI'
function publicActivity(activity, posters) {
const poster = (posters || [])
.filter((item) => item.activityId === activity.id && item.enabled && item.image)
.sort((a, b) => a.sort - b.sort)[0]
return {
id: activity.id,
title: activity.title,
brandName: activity.brandName || DEFAULT_BRAND,
brandDesc: activity.brandDesc || '',
startAt: activity.startAt || '',
address: activity.address || '',
latitude: activity.latitude,
longitude: activity.longitude,
status: activity.status,
isCurrent: !!activity.isCurrent,
pinned: !!activity.pinned,
pinnedAt: activity.pinnedAt || '',
theme: activity.theme || 0,
cover: activity.cover || '',
video: activity.video || '',
poster: (poster && poster.image) || activity.cover || '',
createdAt: activity.createdAt
}
}
function currentActivity(data) {
return (
data.activities.find((item) => item.isCurrent && item.status !== 'draft') ||
data.activities.find((item) => item.status === 'current') ||
null
)
}
function byPinnedThenNewest(a, b) {
const ap = a.pinned ? 1 : 0
const bp = b.pinned ? 1 : 0
if (ap !== bp) return bp - ap
if (ap) return String(b.pinnedAt || '').localeCompare(String(a.pinnedAt || ''))
return String(b.createdAt).localeCompare(String(a.createdAt))
}
function pastActivities(data) {
return data.activities.filter((item) => item.status === 'past').sort(byPinnedThenNewest)
}
function nextPastTheme(data) {
return data.activities.filter((item) => item.status === 'past').length % 2
}
function setPinned(activity, pinned) {
const now = new Date().toISOString()
activity.pinned = !!pinned
activity.pinnedAt = pinned ? now : ''
activity.updatedAt = now
return activity
}
const PROFILE_FIELDS = ['nickName', 'avatarUrl', 'name', 'phone', 'company', 'position']
function publicProfile(user, data) {
const profile = { openid: user.openid }
PROFILE_FIELDS.forEach((key) => {
profile[key] = user[key] || ''
})
profile.isAdmin = isAdmin(user.openid, data)
return profile
}
function coordinate(value) {
if (value === '' || value == null) return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function activityPayload(body, prev, data) {
const now = new Date().toISOString()
const status = STATUSES.includes(body.status) ? body.status : 'draft'
const keep = (key, fallback) => (has(body, key) ? str(body[key]) : prev ? prev[key] || fallback : fallback)
let pinned = prev ? !!prev.pinned : false
let pinnedAt = prev ? prev.pinnedAt || '' : ''
if (status !== 'past') {
pinned = false
pinnedAt = ''
} else if (has(body, 'pinned')) {
const requested = body.pinned === true || body.pinned === 'on' || body.pinned === 'true'
if (requested !== pinned) pinnedAt = requested ? now : ''
pinned = requested
}
let theme = Number(body.theme)
if (!has(body, 'theme') || !Number.isFinite(theme)) {
theme = prev ? prev.theme || 0 : status === 'past' ? nextPastTheme(data) : 0
}
return {
id: prev ? prev.id : uid('a'),
title: str(body.title, 60),
brandName: str(body.brandName) || DEFAULT_BRAND,
brandDesc: str(body.brandDesc, 300),
cover: keep('cover', ''),
video: keep('video', ''),
startAt: str(body.startAt, 32),
address: str(body.address, 120),
latitude: coordinate(body.latitude),
longitude: coordinate(body.longitude),
status,
isCurrent: !!body.isCurrent,
pinned,
pinnedAt,
theme,
createdAt: prev ? prev.createdAt : now,
updatedAt: now
}
}
module.exports = {
publicActivity,
currentActivity,
pastActivities,
nextPastTheme,
setPinned,
publicProfile,
activityPayload,
DEFAULT_BRAND
}
+52
View File
@@ -0,0 +1,52 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const { ok, fail } = require('./http')
const uploadDir = process.env.UPLOAD_DIR
? path.resolve(process.env.UPLOAD_DIR)
: path.join(__dirname, '..', 'public', 'uploads')
const IMAGE_RE = /^image\/(png|jpe?g|webp)$/i
const VIDEO_RE = /^video\/(mp4|quicktime|webm)$/i
const storage = multer.diskStorage({
destination(req, file, cb) {
fs.mkdirSync(uploadDir, { recursive: true })
cb(null, uploadDir)
},
filename(req, file, cb) {
const ext = path.extname(file.originalname || '').toLowerCase() || '.bin'
cb(null, `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}${ext}`)
}
})
function uploader(options) {
const imagesOnly = !!(options && options.imagesOnly)
const middleware = multer({
storage,
limits: { fileSize: imagesOnly ? 5 * 1024 * 1024 : 80 * 1024 * 1024 },
fileFilter(req, file, cb) {
const mime = file.mimetype || ''
const allowed = IMAGE_RE.test(mime) || (!imagesOnly && VIDEO_RE.test(mime))
cb(null, allowed)
}
}).single('file')
return (req, res) => {
middleware(req, res, (error) => {
if (error) {
const tooLarge = error.code === 'LIMIT_FILE_SIZE'
return fail(res, 400, tooLarge ? (imagesOnly ? '图片需小于 5MB' : '文件需小于 80MB') : '上传失败')
}
if (!req.file) {
return fail(res, 400, imagesOnly ? '仅支持 png/jpg/webp 图片' : '仅支持 png/jpg/webp 图片或 mp4/mov/webm 视频')
}
const type = VIDEO_RE.test(req.file.mimetype) ? 'video' : 'image'
ok(res, { url: `/static/uploads/${req.file.filename}`, type })
})
}
}
module.exports = { uploader, uploadDir }
+34
View File
@@ -0,0 +1,34 @@
const crypto = require('crypto')
const { HttpError } = require('./http')
async function code2session(code, deviceId) {
const appid = process.env.WX_APPID
const secret = process.env.WX_SECRET
if (!appid || !secret) {
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({
appid,
secret,
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 }
}
module.exports = { code2session }
+1182
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "zhuomi-server",
"version": "1.1.0",
"private": true,
"main": "server.js",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"multer": "^2.4.0",
"qrcode": "^1.5.4"
}
}
+477
View File
@@ -0,0 +1,477 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>卓米会管理后台</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #f3f5f7; color: #1f2937; }
button, input, select, textarea { font: inherit; }
.login { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.panel { width: min(420px, 100%); background: #fff; border-radius: 16px; padding: 28px; }
h1 { margin: 0 0 8px; font-size: 22px; }
.muted { color: #6b7280; font-size: 13px; }
label { display: block; margin: 14px 0 6px; font-size: 13px; color: #6b7280; }
input, select, textarea { width: 100%; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 12px; background: #fff; }
textarea { min-height: 72px; resize: vertical; }
.primary, .ghost, .danger { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; }
.primary { background: #128a80; color: #fff; }
.ghost { background: #fff; color: #128a80; border: 1px solid #b7e0dc; }
.danger { background: #fff; color: #dc2626; border: 1px solid #fecaca; }
.app { display: none; min-height: 100vh; }
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #fff; }
nav { display: flex; gap: 8px; padding: 12px 24px; }
nav button { background: transparent; border: 0; padding: 8px 12px; border-radius: 999px; cursor: pointer; color: #4b5563; }
nav button.on { background: #128a80; color: #fff; }
main { padding: 8px 24px 40px; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 12px; }
.stat { font-size: 28px; font-weight: 700; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 14px; overflow: hidden; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #f3f4f6; font-size: 14px; vertical-align: top; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.split { display: grid; grid-template-columns: 1.1fr .9fr; gap: 16px; }
.error { color: #dc2626; min-height: 1.2em; font-size: 13px; }
img.qr { width: 140px; height: 140px; }
@media (max-width: 800px) { .grid, .split { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="login" id="loginView">
<form class="panel" id="loginForm">
<h1>卓米会后台</h1>
<p class="muted">默认账号 admin / 123456,上线前请修改密码。</p>
<label>账号</label>
<input name="username" value="admin" autocomplete="username" />
<label>密码</label>
<input name="password" type="password" value="123456" autocomplete="current-password" />
<p class="error" id="loginError"></p>
<button class="primary" type="submit">登录</button>
</form>
</div>
<div class="app" id="appView">
<header>
<strong>卓米会</strong>
<div class="row">
<button class="ghost" id="passwordBtn" type="button">修改密码</button>
<button class="ghost" id="logoutBtn" type="button">退出</button>
</div>
</header>
<nav>
<button type="button" data-tab="stats" class="on">数据统计</button>
<button type="button" data-tab="activities">活动</button>
<button type="button" data-tab="posters">海报</button>
<button type="button" data-tab="signups">报名</button>
<button type="button" data-tab="checkin">签到码</button>
<button type="button" data-tab="staff">管理员</button>
</nav>
<main>
<section id="stats"></section>
<section id="activities" hidden></section>
<section id="posters" hidden></section>
<section id="signups" hidden></section>
<section id="checkin" hidden></section>
<section id="staff" hidden></section>
</main>
</div>
<script>
const state = { token: localStorage.getItem('zhuomi_admin') || '', activities: [] }
const loginView = document.getElementById('loginView')
const appView = document.getElementById('appView')
function showApp(on) {
loginView.style.display = on ? 'none' : 'flex'
appView.style.display = on ? 'block' : 'none'
}
async function api(url, options = {}) {
const headers = { ...(options.headers || {}) }
if (state.token) headers.Authorization = state.token
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
const res = await fetch(url, { ...options, headers })
if (options.raw) return res
const data = await res.json().catch(() => ({}))
if (res.status === 401 && url !== '/api/admin/login') {
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
}
if (!res.ok || data.ok === false) throw new Error(data.message || '请求失败')
return data.data
}
async function uploadMedia(file) {
const form = new FormData()
form.append('file', file)
return api('/api/admin/upload-file', { method: 'POST', body: form })
}
function mediaFields(value) {
return `
<label>封面图片地址</label>
<input name="cover" id="coverUrl" value="${escapeHtml(value.cover || '')}" placeholder="/static/uploads/..." />
<label>上传封面图片</label>
<input type="file" id="coverFile" accept="image/png,image/jpeg,image/webp" />
<label>视频地址</label>
<input name="video" id="videoUrl" value="${escapeHtml(value.video || '')}" placeholder="/static/uploads/..." />
<label>上传视频</label>
<input type="file" id="videoFile" accept="video/mp4,video/webm,video/quicktime" />
<div class="muted" id="mediaHint" style="margin-top:8px"></div>
`
}
function bindMediaPickers(scope) {
const root = scope || document
const coverFile = root.querySelector('#coverFile')
const videoFile = root.querySelector('#videoFile')
const hint = root.querySelector('#mediaHint')
if (coverFile) {
coverFile.onchange = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
if (hint) hint.textContent = '图片上传中...'
const uploaded = await uploadMedia(file)
root.querySelector('#coverUrl').value = uploaded.url
if (hint) hint.textContent = '图片已上传'
} catch (error) {
if (hint) hint.textContent = error.message
alert(error.message)
}
}
}
if (videoFile) {
videoFile.onchange = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
if (hint) hint.textContent = '视频上传中...'
const uploaded = await uploadMedia(file)
root.querySelector('#videoUrl').value = uploaded.url
if (hint) hint.textContent = '视频已上传'
} catch (error) {
if (hint) hint.textContent = error.message
alert(error.message)
}
}
}
}
document.getElementById('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
const form = new FormData(event.target)
try {
const data = await api('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ username: form.get('username'), password: form.get('password') })
})
state.token = data.token
localStorage.setItem('zhuomi_admin', data.token)
document.getElementById('loginError').textContent = ''
boot()
} catch (error) {
document.getElementById('loginError').textContent = error.message
}
})
document.getElementById('logoutBtn').onclick = () => {
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
}
document.getElementById('passwordBtn').onclick = async () => {
const oldPassword = prompt('原密码')
const newPassword = prompt('新密码(至少 6 位)')
if (!oldPassword || !newPassword) return
try {
await api('/api/admin/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) })
state.token = ''
localStorage.removeItem('zhuomi_admin')
showApp(false)
alert('密码已更新,请重新登录')
} catch (error) {
alert(error.message)
}
}
document.querySelectorAll('nav button').forEach((button) => {
button.onclick = () => {
document.querySelectorAll('nav button').forEach((item) => item.classList.remove('on'))
button.classList.add('on')
document.querySelectorAll('main section').forEach((section) => {
section.hidden = section.id !== button.dataset.tab
})
}
})
async function boot() {
showApp(true)
await loadActivities()
await Promise.all([loadStats(), loadPosters(), loadSignups(), loadCodes(), loadStaff()])
}
async function loadStats() {
const stats = await api('/api/admin/stats')
const rows = stats.bySharer.map((item) => `<tr><td>${escapeHtml(item.sharer)}</td><td>${item.count}</td></tr>`).join('')
document.getElementById('stats').innerHTML = `
<div class="grid">
<div class="card"><div class="muted">活动</div><div class="stat">${stats.activityCount}</div></div>
<div class="card"><div class="muted">报名</div><div class="stat">${stats.signupCount}</div></div>
<div class="card"><div class="muted">签到</div><div class="stat">${stats.checkinCount}</div></div>
</div>
<div class="card"><strong>推荐来源</strong>
<table><thead><tr><th>分享人</th><th>报名数</th></tr></thead><tbody>${rows || '<tr><td colspan="2">暂无报名</td></tr>'}</tbody></table>
</div>`
}
function activityForm(activity) {
const value = activity || { status: 'draft', isCurrent: false, brandName: '卓米会 ZHUOMI' }
return `
<form id="activityForm" class="card">
<strong>${value.id ? '编辑活动' : '新建活动'}</strong>
<p class="muted">状态选 past 即为往期;往期可置顶,并支持封面图 / 视频。</p>
<input type="hidden" name="id" value="${value.id || ''}" />
<label>名称</label><input name="title" value="${escapeHtml(value.title || '')}" />
<label>品牌名</label><input name="brandName" value="${escapeHtml(value.brandName || '')}" />
<label>简介</label><textarea name="brandDesc">${escapeHtml(value.brandDesc || '')}</textarea>
<label>时间</label><input name="startAt" value="${escapeHtml(value.startAt || '')}" />
<label>地址</label><input name="address" value="${escapeHtml(value.address || '')}" />
<label>纬度</label><input name="latitude" value="${value.latitude ?? ''}" />
<label>经度</label><input name="longitude" value="${value.longitude ?? ''}" />
${mediaFields(value)}
<label>状态</label>
<select name="status" id="activityStatus">
<option value="current" ${value.status === 'current' ? 'selected' : ''}>current(当前)</option>
<option value="past" ${value.status === 'past' ? 'selected' : ''}>past(往期)</option>
<option value="draft" ${value.status === 'draft' ? 'selected' : ''}>draft(草稿)</option>
</select>
<label><input type="checkbox" name="isCurrent" ${value.isCurrent ? 'checked' : ''} style="width:auto" /> 设为当前活动</label>
<label><input type="checkbox" name="pinned" ${value.pinned ? 'checked' : ''} style="width:auto" /> 往期置顶(仅 past 生效)</label>
<div class="row"><button class="primary" type="submit">保存</button></div>
</form>`
}
async function loadActivities() {
state.activities = await api('/api/admin/activities')
const list = state.activities.slice().sort((a, b) => {
const rank = (item) => (item.isCurrent ? 0 : item.status === 'current' ? 1 : item.status === 'past' ? 2 : 3)
const ra = rank(a)
const rb = rank(b)
if (ra !== rb) return ra - rb
if (a.status === 'past' && b.status === 'past') {
const ap = a.pinned ? 1 : 0
const bp = b.pinned ? 1 : 0
if (ap !== bp) return bp - ap
}
return String(b.createdAt || '').localeCompare(String(a.createdAt || ''))
})
const rows = list.map((item) => `
<tr>
<td>
${escapeHtml(item.title)}
<div class="muted">
${item.status}${item.isCurrent ? ' · 当前' : ''}${item.pinned ? ' · 置顶' : ''}${item.cover ? ' · 图' : ''}${item.video ? ' · 视频' : ''}
</div>
</td>
<td>${escapeHtml(item.startAt || '')}</td>
<td class="row">
${item.status === 'past'
? `<button class="ghost" data-pin="${item.id}" data-pinned="${item.pinned ? 1 : 0}" type="button">${item.pinned ? '取消置顶' : '置顶'}</button>`
: ''}
<button class="ghost" data-edit="${item.id}" type="button">编辑</button>
<button class="danger" data-del="${item.id}" type="button">删除</button>
</td>
</tr>`).join('')
document.getElementById('activities').innerHTML = `
<div class="split">
<table><thead><tr><th>活动</th><th>时间</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无活动</td></tr>'}</tbody></table>
<div id="activityEditor">${activityForm()}</div>
</div>`
bindActivityEditor()
}
function bindActivityEditor() {
const form = document.getElementById('activityForm')
bindMediaPickers(form)
form.onsubmit = async (event) => {
event.preventDefault()
const formData = new FormData(form)
const body = Object.fromEntries(formData.entries())
body.isCurrent = formData.get('isCurrent') === 'on'
body.pinned = formData.get('pinned') === 'on'
const id = body.id
delete body.id
if (id) await api(`/api/admin/activities/${id}`, { method: 'PUT', body: JSON.stringify(body) })
else await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(body) })
loadActivities()
loadStats()
}
document.querySelectorAll('#activities [data-edit]').forEach((button) => {
button.onclick = () => {
const activity = state.activities.find((item) => item.id === button.dataset.edit)
document.getElementById('activityEditor').innerHTML = activityForm(activity)
bindActivityEditor()
}
})
document.querySelectorAll('#activities [data-del]').forEach((button) => {
button.onclick = async () => {
if (!confirm('删除这场活动?')) return
await api(`/api/admin/activities/${button.dataset.del}`, { method: 'DELETE' })
loadActivities()
loadStats()
}
})
document.querySelectorAll('#activities [data-pin]').forEach((button) => {
button.onclick = async () => {
const next = String(button.dataset.pinned) !== '1'
await api(`/api/admin/activities/${button.dataset.pin}/pin`, {
method: 'POST',
body: JSON.stringify({ pinned: next })
})
loadActivities()
}
})
}
async function loadPosters() {
const posters = await api('/api/admin/posters')
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
const rows = posters.map((item) => `
<tr>
<td>${escapeHtml(item.title || item.image)}</td>
<td>${item.enabled ? '启用' : '停用'}</td>
<td><button class="danger" data-poster="${item.id}" type="button">删除</button></td>
</tr>`).join('')
document.getElementById('posters').innerHTML = `
<form id="posterForm" class="card">
<strong>新增海报</strong>
<label>活动</label><select name="activityId">${options}</select>
<label>标题</label><input name="title" />
<label>图片地址</label><input name="image" id="posterImage" placeholder="/static/uploads/..." />
<label>或上传图片</label><input type="file" id="posterFile" accept="image/png,image/jpeg,image/webp" />
<div class="row" style="margin-top:12px"><button class="primary" type="submit">保存海报</button></div>
</form>
<table><thead><tr><th>海报</th><th>状态</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="3">暂无海报</td></tr>'}</tbody></table>`
document.getElementById('posterFile').onchange = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
const uploaded = await uploadMedia(file)
document.getElementById('posterImage').value = uploaded.url
} catch (error) {
alert(error.message)
}
}
document.getElementById('posterForm').onsubmit = async (event) => {
event.preventDefault()
const body = Object.fromEntries(new FormData(event.target).entries())
await api('/api/admin/posters', { method: 'POST', body: JSON.stringify(body) })
loadPosters()
}
document.querySelectorAll('[data-poster]').forEach((button) => {
button.onclick = async () => {
await api(`/api/admin/posters/${button.dataset.poster}`, { method: 'DELETE' })
loadPosters()
}
})
}
async function loadSignups() {
const signups = await api('/api/admin/signups')
const rows = signups.map((item) => `
<tr>
<td>${escapeHtml(item.name)}<div class="muted">${escapeHtml(item.phone)}</div></td>
<td>${escapeHtml(item.activityTitle || '')}</td>
<td>${escapeHtml(item.sourceSharer || '直接访问')}</td>
<td>${item.checkedIn ? '已签到' : '未签到'}</td>
<td><button class="danger" data-signup="${item.id}" type="button">删除</button></td>
</tr>`).join('')
document.getElementById('signups').innerHTML = `
<div class="row" style="margin-bottom:12px">
<a class="primary" style="text-decoration:none" href="/api/admin/signups/export?token=${encodeURIComponent(state.token)}">导出 CSV</a>
</div>
<table><thead><tr><th>报名人</th><th>活动</th><th>分享人</th><th>签到</th><th></th></tr></thead><tbody>${rows || '<tr><td colspan="5">暂无报名</td></tr>'}</tbody></table>`
document.querySelectorAll('[data-signup]').forEach((button) => {
button.onclick = async () => {
await api(`/api/admin/signups/${button.dataset.signup}`, { method: 'DELETE' })
loadSignups()
loadStats()
}
})
}
async function loadCodes() {
const codes = await api('/api/admin/checkin-codes')
const options = state.activities.map((item) => `<option value="${item.id}">${escapeHtml(item.title)}</option>`).join('')
const cards = codes.map((item) => `
<div class="card">
<img class="qr" src="${item.qrDataUrl}" alt="签到码" />
<div class="muted">${escapeHtml(item.token)}</div>
<div class="muted">有效至 ${escapeHtml(item.expiresAt)}</div>
</div>`).join('')
document.getElementById('checkin').innerHTML = `
<form id="codeForm" class="card row">
<select name="activityId">${options}</select>
<input name="ttlHours" type="number" value="12" style="width:120px" />
<button class="primary" type="submit">生成签到码</button>
</form>
<div class="grid">${cards || '<div class="card">还没有签到码</div>'}</div>`
document.getElementById('codeForm').onsubmit = async (event) => {
event.preventDefault()
const body = Object.fromEntries(new FormData(event.target).entries())
await api('/api/admin/checkin-codes', { method: 'POST', body: JSON.stringify(body) })
loadCodes()
}
}
async function loadStaff() {
const users = await api('/api/admin/users')
const rows = users.map((item) => `
<tr>
<td>${escapeHtml(item.nickName || item.name || '未命名')}<div class="muted">${escapeHtml(item.phone || '')}</div></td>
<td style="font-size:12px;word-break:break-all">${escapeHtml(item.openid)}</td>
<td>${item.allowed ? '管理员' : '普通用户'}</td>
<td>
${item.allowed
? `<button class="danger" data-remove="${encodeURIComponent(item.openid)}" type="button">取消管理员</button>`
: `<button class="ghost" data-add="${escapeHtml(item.openid)}" type="button">设为管理员</button>`}
</td>
</tr>`).join('')
document.getElementById('staff').innerHTML = `
<div class="card"><strong>小程序管理员</strong>
<p class="muted">在后台设为管理员后,该用户打开小程序「往期」会直接看到增加、删除、置顶,界面上没有「管理员登录」入口。</p>
</div>
<table>
<thead><tr><th>用户</th><th>openid</th><th>状态</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="4">暂无用户,请先在小程序登录一次</td></tr>'}</tbody>
</table>`
document.querySelectorAll('[data-add]').forEach((button) => {
button.onclick = async () => {
await api('/api/admin/allowlist', { method: 'POST', body: JSON.stringify({ openid: button.dataset.add }) })
loadStaff()
}
})
document.querySelectorAll('[data-remove]').forEach((button) => {
button.onclick = async () => {
await api(`/api/admin/allowlist/${button.dataset.remove}`, { method: 'DELETE' })
loadStaff()
}
})
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[char]))
}
if (state.token) {
api('/api/admin/stats').then(boot).catch(() => showApp(false))
}
</script>
</body>
</html>
+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
+14
View File
@@ -0,0 +1,14 @@
const app = require('./app')
const db = require('./lib/db')
const PORT = Number(process.env.PORT) || 3000
db.read()
app.listen(PORT, () => {
console.log(`卓米会服务已启动 http://localhost:${PORT}`)
console.log(`管理后台 http://localhost:${PORT}/admin`)
if (!process.env.WX_APPID || !process.env.WX_SECRET) {
console.log('未配置 WX_APPID / WX_SECRET,小程序登录走演示模式')
}
})
+156
View File
@@ -0,0 +1,156 @@
const test = require('node:test')
const assert = require('node:assert')
const fs = require('fs')
const os = require('os')
const path = require('path')
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zhuomi-'))
process.env.DB_FILE = path.join(tmp, 'db.json')
process.env.UPLOAD_DIR = path.join(tmp, 'uploads')
delete process.env.WX_APPID
delete process.env.WX_SECRET
const app = require('../app')
let base = ''
let server
test.before(async () => {
server = app.listen(0)
await new Promise((resolve) => server.once('listening', resolve))
base = `http://127.0.0.1:${server.address().port}`
})
test.after(() => {
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
})
async function call(method, url, options) {
const opts = options || {}
const headers = {}
if (opts.token) headers.Authorization = opts.token
let body
if (opts.form) body = opts.form
else if (opts.body) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(opts.body)
}
const res = await fetch(base + url, { method, headers, body })
const type = res.headers.get('content-type') || ''
return { status: res.status, body: type.includes('json') ? await res.json() : await res.text() }
}
async function login(deviceId) {
const res = await call('POST', '/api/login', { body: { code: 'x', deviceId } })
assert.strictEqual(res.status, 200)
return res.body.data
}
test('seeded admin password is stored hashed', async () => {
await call('GET', '/api/activity/current')
const data = JSON.parse(fs.readFileSync(process.env.DB_FILE, 'utf8'))
assert.match(data.admins[0].password, /^scrypt\$/)
})
test('full flow: admin, past activity, signup, checkin, export', async () => {
const adminLogin = await call('POST', '/api/admin/login', { body: { username: 'admin', password: '123456' } })
const adminToken = adminLogin.body.data.token
assert.ok(adminToken)
assert.strictEqual((await call('POST', '/api/admin/login', { body: { username: 'admin', password: 'bad' } })).status, 401)
const user = await login('device-a')
assert.strictEqual(user.isAdmin, false)
const denied = await call('POST', '/api/activity/past', { token: user.token, body: { title: 'x', startAt: 'y' } })
assert.strictEqual(denied.status, 403)
await call('POST', '/api/admin/allowlist', { token: adminToken, body: { openid: user.openid } })
const profile = await call('GET', '/api/user/profile', { token: user.token })
assert.strictEqual(profile.body.data.isAdmin, true)
const first = await call('POST', '/api/activity/past', { token: user.token, body: { title: '往期一', startAt: '2026-01-01' } })
const second = await call('POST', '/api/activity/past', { token: user.token, body: { title: '往期二', startAt: '2026-02-01' } })
assert.strictEqual(first.body.data.theme, 0)
assert.strictEqual(second.body.data.theme, 1)
await call('POST', `/api/activity/past/${first.body.data.id}/pin`, { token: user.token, body: { pinned: true } })
const past = await call('GET', '/api/activity/past')
assert.deepStrictEqual(past.body.data.map((item) => item.title), ['往期一', '往期二'])
const del = await call('DELETE', `/api/activity/past/${second.body.data.id}`, { token: user.token })
assert.strictEqual(del.status, 200)
const bad = await call('POST', '/api/signup', { token: user.token, body: { activityId: '93', name: '张三', phone: '123' } })
assert.strictEqual(bad.status, 400)
const guest = await login('device-b')
const signup = await call('POST', '/api/signup', {
token: guest.token,
body: { activityId: '93', name: '张三', phone: '13800000000', company: '=cmd', sourceSharer: user.openid }
})
assert.strictEqual(signup.status, 200)
const dup = await call('POST', '/api/signup', { token: guest.token, body: { activityId: '93', name: '张三', phone: '13800000000' } })
assert.strictEqual(dup.status, 400)
const checkin = await call('POST', '/api/checkin/scan', { token: guest.token, body: { simulate: true } })
assert.strictEqual(checkin.body.data.checkedIn, true)
const mine = await call('GET', '/api/signup/mine', { token: guest.token })
assert.strictEqual(mine.body.data.checkins.length, 1)
const stats = await call('GET', '/api/admin/stats', { token: adminToken })
assert.strictEqual(stats.body.data.checkinCount, 1)
const csv = await call('GET', `/api/admin/signups/export?token=${adminToken}`)
assert.strictEqual(csv.status, 200)
assert.match(csv.body, /张三,13800000000,'=cmd/)
const queryOnly = await call('GET', `/api/admin/stats?token=${adminToken}`)
assert.strictEqual(queryOnly.status, 401)
})
test('profile rejects temp avatar paths and accepts uploaded avatars', async () => {
const user = await login('device-c')
const temp = await call('POST', '/api/user/profile', {
token: user.token,
body: { nickName: '小幂', avatarUrl: 'http://tmp/abc.png' }
})
assert.strictEqual(temp.body.data.nickName, '小幂')
assert.strictEqual(temp.body.data.avatarUrl, '')
const form = new FormData()
form.append('file', new Blob([Buffer.from('89504e47', 'hex')], { type: 'image/png' }), 'a.png')
const upload = await call('POST', '/api/user/avatar', { token: user.token, form })
assert.strictEqual(upload.status, 200)
assert.match(upload.body.data.url, /^\/static\/uploads\/.+\.png$/)
const saved = await call('POST', '/api/user/profile', { token: user.token, body: { avatarUrl: upload.body.data.url } })
assert.strictEqual(saved.body.data.avatarUrl, upload.body.data.url)
const served = await fetch(base + upload.body.data.url)
assert.strictEqual(served.status, 200)
const video = new FormData()
video.append('file', new Blob([Buffer.from('00')], { type: 'video/mp4' }), 'a.mp4')
const rejected = await call('POST', '/api/user/avatar', { token: user.token, form: video })
assert.strictEqual(rejected.status, 400)
})
test('admin activity CRUD keeps a single current activity', async () => {
const token = (await call('POST', '/api/admin/login', { body: { username: 'admin', password: '123456' } })).body.data.token
const created = await call('POST', '/api/admin/activities', {
token,
body: { title: '第94期', status: 'current', isCurrent: true, startAt: '2026-07-01' }
})
assert.strictEqual(created.status, 200)
const list = (await call('GET', '/api/admin/activities', { token })).body.data
assert.deepStrictEqual(list.filter((item) => item.isCurrent).map((item) => item.id), [created.body.data.id])
const current = await call('GET', '/api/activity/current')
assert.strictEqual(current.body.data.title, '第94期')
const draft = await call('POST', '/api/admin/activities', { token, body: { title: '草稿', status: 'draft' } })
assert.strictEqual((await call('GET', `/api/activity/${draft.body.data.id}`)).status, 404)
assert.strictEqual((await call('GET', '/api/nope')).status, 404)
})