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:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user