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
+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 }