This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY server/package.json server/package.json
|
||||
RUN npm ci --omit=dev --workspace server
|
||||
|
||||
FROM node:22-alpine
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache curl
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
COPY server/package.json server/package.json
|
||||
COPY server/src server/src
|
||||
COPY server/public server/public
|
||||
RUN mkdir -p /app/uploads && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -fsS http://localhost:3000/health || exit 1
|
||||
CMD ["node", "server/src/index.js"]
|
||||
@@ -0,0 +1,48 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), openid text UNIQUE NOT NULL, unionid text,
|
||||
nickname text NOT NULL DEFAULT '微信用户', avatar_url text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS exhibitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, venue text NOT NULL,
|
||||
address text NOT NULL DEFAULT '', starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL,
|
||||
description text NOT NULL DEFAULT '', cover_url text, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (ends_at > starts_at)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text UNIQUE NOT NULL, color text NOT NULL DEFAULT '#7C5CFC'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), exhibition_id uuid NOT NULL REFERENCES exhibitions(id) ON DELETE CASCADE,
|
||||
title text NOT NULL, description text NOT NULL DEFAULT '', location text NOT NULL DEFAULT '',
|
||||
starts_at timestamptz NOT NULL, ends_at timestamptz, capacity integer CHECK (capacity IS NULL OR capacity > 0),
|
||||
published boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (ends_at IS NULL OR ends_at > starts_at)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activity_images (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
url text NOT NULL, sort_order integer NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activity_tags (
|
||||
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
tag_id uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (activity_id, tag_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS participations (
|
||||
activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose text NOT NULL DEFAULT '',
|
||||
joined_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (activity_id, user_id)
|
||||
);
|
||||
DO $$ BEGIN CREATE TYPE group_review_status AS ENUM ('pending', 'approved', 'rejected'); EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
CREATE TABLE IF NOT EXISTS activity_groups (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), activity_id uuid NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
submitted_by uuid REFERENCES users(id) ON DELETE SET NULL, name text NOT NULL, qr_image_url text NOT NULL,
|
||||
note text NOT NULL DEFAULT '', status group_review_status NOT NULL DEFAULT 'pending', review_note text NOT NULL DEFAULT '',
|
||||
reviewed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_exhibition_start ON activities(exhibition_id, starts_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_published_start ON activities(published, starts_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_participations_activity ON participations(activity_id, joined_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_review ON activity_groups(status, created_at);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
INSERT INTO tags (name, color) VALUES
|
||||
('创业', '#FF7A59'), ('AI', '#7C5CFC'), ('投资', '#2FA36B'), ('出海', '#1687A7'), ('设计', '#E65C9C')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"name":"coffeechat-server","version":"1.0.0","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"dev":"node --watch src/index.js","start":"node src/index.js","test":"node --test","check":"node --check src/index.js && node --check src/app.js"},"dependencies":{"cors":"^2.8.5","express":"^5.1.0","helmet":"^8.1.0","jsonwebtoken":"^9.0.2","multer":"^2.0.2","pg":"^8.16.3","zod":"^4.1.5"}}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const state={token:localStorage.adminToken||'',data:null};
|
||||
const $=(s)=>document.querySelector(s); const esc=(v='')=>String(v).replace(/[&<>"']/g,(c)=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function toast(message){const el=$('#toast');el.textContent=message;el.style.display='block';setTimeout(()=>el.style.display='none',2200)}
|
||||
async function api(path,options={}){const response=await fetch(`/api/admin${path}`,{...options,headers:{'Content-Type':'application/json','x-admin-token':state.token,...options.headers}});const data=response.status===204?null:await response.json();if(!response.ok)throw new Error(data.error||'请求失败');return data}
|
||||
async function uploadImages(files){if(!files.length)return[];if(files.length>9)throw new Error('最多上传 9 张图片');const body=new FormData();Array.from(files).forEach(file=>body.append('files',file));const response=await fetch('/api/uploads/admin',{method:'POST',headers:{'x-admin-token':state.token},body});const data=await response.json();if(!response.ok)throw new Error(data.error||'图片上传失败');return data.files.map(x=>x.url)}
|
||||
function date(v){return new Date(v).toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
async function load(){try{state.data=await api('/dashboard');localStorage.adminToken=state.token;$('#login').hidden=true;$('#app').hidden=false;render()}catch(e){$('#login').hidden=false;$('#app').hidden=true;toast(e.message)}}
|
||||
function render(){const d=state.data;$('#exhibitionCount').textContent=d.exhibitions.length;$('#activityCount').textContent=d.activities.length;$('#pendingCount').textContent=d.groups.filter(g=>g.status==='pending').length;
|
||||
$('#exhibitionSelect').innerHTML=d.exhibitions.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('');$('#tagChecks').innerHTML=d.tags.map(t=>`<label><input type="checkbox" name="tagIds" value="${t.id}">${esc(t.name)}</label>`).join('');
|
||||
$('#activityList').innerHTML=d.activities.map(a=>`<article class="row"><div><h3>${esc(a.title)}</h3><p>${esc(a.exhibitionName)} · ${date(a.startsAt)} · ${a.published?'已发布':'草稿'}</p></div><button data-publish="${a.id}" data-value="${!a.published}">${a.published?'下架':'发布'}</button></article>`).join('')||'<p>还没有活动</p>';
|
||||
$('#exhibitionList').innerHTML=d.exhibitions.map(e=>`<article class="row"><div><h3>${esc(e.name)}</h3><p>${esc(e.venue)} · ${date(e.startsAt)} 至 ${date(e.endsAt)}</p></div></article>`).join('')||'<p>还没有会展</p>';
|
||||
$('#groupList').innerHTML=d.groups.map(g=>`<article class="row"><div class="group-main"><img class="qr" src="${esc(g.qrImageUrl)}"><div><h3>${esc(g.name)} · ${esc(g.activityTitle)}</h3><p>${esc(g.submitterName||'未知用户')} · ${esc(g.note)} · ${g.status}</p></div></div>${g.status==='pending'?`<div class="row-actions"><button data-review="${g.id}" data-status="approved">通过</button><button class="reject" data-review="${g.id}" data-status="rejected">拒绝</button></div>`:''}</article>`).join('')||'<p>还没有群资料</p>';}
|
||||
$('#loginForm').onsubmit=(e)=>{e.preventDefault();state.token=$('#token').value;load()};$('#logout').onclick=()=>{localStorage.removeItem('adminToken');location.reload()};
|
||||
document.addEventListener('click',async(e)=>{const tab=e.target.dataset.tab;if(tab){document.querySelectorAll('nav button').forEach(x=>x.classList.toggle('active',x===e.target));document.querySelectorAll('.panel').forEach(x=>x.hidden=x.id!==tab)}if(e.target.dataset.open)document.getElementById(e.target.dataset.open).showModal();if(e.target.classList.contains('close'))e.target.closest('dialog').close();
|
||||
try{if(e.target.dataset.publish){await api(`/activities/${e.target.dataset.publish}/publish`,{method:'PATCH',body:JSON.stringify({published:e.target.dataset.value==='true'})});await load()}if(e.target.dataset.review){await api(`/groups/${e.target.dataset.review}/review`,{method:'PATCH',body:JSON.stringify({status:e.target.dataset.status})});await load()}}catch(err){toast(err.message)}});
|
||||
$('#exhibitionForm').onsubmit=async(e)=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{await api('/exhibitions',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('会展已创建')}catch(err){toast(err.message)}};
|
||||
$('#activityForm').onsubmit=async(e)=>{e.preventDefault();const form=new FormData(e.target);const data=Object.fromEntries(form);data.published=form.has('published');data.tagIds=form.getAll('tagIds');const typed=(data.imageUrls||'').split('\n').map(x=>x.trim()).filter(Boolean);data.endsAt=data.endsAt||null;try{const uploaded=await uploadImages(e.target.elements.images.files);data.imageUrls=uploaded.concat(typed).slice(0,9);delete data.images;await api('/activities',{method:'POST',body:JSON.stringify(data)});e.target.closest('dialog').close();e.target.reset();await load();toast('活动已创建')}catch(err){toast(err.message)}};
|
||||
if(state.token)load();
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CoffeeChat 管理后台</title><link rel="stylesheet" href="/admin/style.css"></head>
|
||||
<body><main>
|
||||
<header><div><span class="eyebrow">COFFEECHAT CONSOLE</span><h1>让每一次会面都有意义</h1></div><button id="logout" class="ghost">退出</button></header>
|
||||
<section id="login" class="card login"><h2>管理员登录</h2><p>输入部署时配置的管理令牌。</p><form id="loginForm"><input id="token" type="password" placeholder="ADMIN_TOKEN" required><button>进入后台</button></form></section>
|
||||
<div id="app" hidden>
|
||||
<section class="stats"><article><b id="exhibitionCount">0</b><span>会展</span></article><article><b id="activityCount">0</b><span>活动</span></article><article><b id="pendingCount">0</b><span>待审核群</span></article></section>
|
||||
<nav><button data-tab="activities" class="active">活动</button><button data-tab="exhibitions">会展</button><button data-tab="groups">群审核</button></nav>
|
||||
<section id="activities" class="panel"><div class="section-title"><div><h2>活动管理</h2><p>创建活动并决定何时在小程序发布</p></div><button data-open="activityDialog">新建活动</button></div><div id="activityList" class="list"></div></section>
|
||||
<section id="exhibitions" class="panel" hidden><div class="section-title"><div><h2>会展管理</h2><p>活动必须归属于一个会展</p></div><button data-open="exhibitionDialog">新建会展</button></div><div id="exhibitionList" class="list"></div></section>
|
||||
<section id="groups" class="panel" hidden><div class="section-title"><div><h2>微信群审核</h2><p>确认群名称和二维码后再向参与者展示</p></div></div><div id="groupList" class="list"></div></section>
|
||||
</div>
|
||||
</main>
|
||||
<dialog id="exhibitionDialog"><form id="exhibitionForm"><button type="button" class="close">×</button><h2>新建会展</h2><label>名称<input name="name" required></label><label>场馆<input name="venue" required></label><label>地址<input name="address"></label><div class="grid"><label>开始时间<input name="startsAt" type="datetime-local" required></label><label>结束时间<input name="endsAt" type="datetime-local" required></label></div><label>描述<textarea name="description" rows="5"></textarea></label><label>封面图 URL<input name="coverUrl" type="url"></label><button>保存会展</button></form></dialog>
|
||||
<dialog id="activityDialog"><form id="activityForm"><button type="button" class="close">×</button><h2>新建活动</h2><label>所属会展<select name="exhibitionId" id="exhibitionSelect" required></select></label><label>标题<input name="title" required></label><label>地点<input name="location"></label><div class="grid"><label>开始时间<input name="startsAt" type="datetime-local" required></label><label>结束时间<input name="endsAt" type="datetime-local"></label></div><label>活动描述<textarea name="description" rows="6"></textarea></label><label>活动图片(最多 9 张)<input name="images" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple></label><label>也可填写图片 URL(每行一张)<textarea name="imageUrls" rows="3"></textarea></label><label>标签<div id="tagChecks" class="checks"></div></label><label class="inline"><input name="published" type="checkbox"> 立即发布</label><button>保存活动</button></form></dialog>
|
||||
<div id="toast"></div><script src="/admin/app.js"></script></body></html>
|
||||
@@ -0,0 +1,2 @@
|
||||
:root{font-family:Inter,"PingFang SC",sans-serif;color:#241f1b;background:#f4efe8;font-size:16px}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#eadbca,transparent 38%),#f4efe8}main{max-width:1100px;margin:auto;padding:40px 24px 80px}header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:34px}h1{font-family:Georgia,"Songti SC",serif;font-size:42px;line-height:1.1;margin:8px 0}.eyebrow{font-size:12px;letter-spacing:3px;color:#8a6750}button{border:0;border-radius:999px;padding:12px 22px;background:#2e5b47;color:white;font-weight:600;cursor:pointer}.ghost{background:transparent;color:#684c3a;border:1px solid #bca895}.card,.panel{background:#fffdf9;border:1px solid #e3d8ca;border-radius:24px;box-shadow:0 16px 50px #604a3420}.login{max-width:480px;padding:34px}.login form{display:flex;gap:10px}.login input{flex:1}input,textarea,select{width:100%;padding:12px 14px;border:1px solid #d9ccbd;border-radius:12px;background:#fff;font:inherit}textarea{resize:vertical}label{display:block;margin:14px 0;color:#705c4c;font-size:14px}label input,label textarea,label select{margin-top:7px;color:#241f1b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:26px}.stats article{padding:24px;border-radius:20px;background:#2e5b47;color:#fff}.stats b{font:36px Georgia}.stats span{display:block;opacity:.75;margin-top:5px}nav{display:flex;gap:8px;margin-bottom:14px}nav button{background:transparent;color:#705c4c}nav button.active{background:#2e5b47;color:#fff}.panel{padding:28px}.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.section-title h2{margin:0}.section-title p{margin:6px 0;color:#8f7d6e}.list{display:grid;gap:10px}.row{display:flex;align-items:center;justify-content:space-between;padding:16px;border-radius:14px;background:#f8f4ee}.row h3{font-size:16px;margin:0 0 5px}.row p{margin:0;color:#847365;font-size:13px}.row-actions{display:flex;gap:8px}.row-actions button{padding:8px 13px;font-size:12px}.reject{background:#a74b3f}.qr{width:64px;height:64px;object-fit:cover;border-radius:10px;margin-right:14px}.group-main{display:flex;align-items:center}dialog{border:0;border-radius:24px;padding:0;box-shadow:0 24px 90px #241f1b55;width:min(620px,calc(100% - 32px))}dialog::backdrop{background:#241f1b88}dialog form{padding:32px;position:relative}.close{position:absolute;right:20px;top:18px;background:transparent;color:#705c4c;font-size:24px;padding:5px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.checks{display:flex;flex-wrap:wrap;gap:8px}.checks label,.inline{display:flex;align-items:center;gap:6px}.checks input,.inline input{width:auto;margin:0}#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#241f1b;color:#fff;padding:12px 20px;border-radius:99px;display:none}@media(max-width:620px){h1{font-size:32px}.stats{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.section-title,.row{align-items:flex-start;gap:12px}.row{flex-direction:column}.login form{flex-direction:column}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createAuth } from './lib/auth.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { catalogRoutes } from './routes/catalog.js';
|
||||
import { participationRoutes } from './routes/participations.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { uploadRoutes } from './routes/uploads.js';
|
||||
|
||||
export function createApp({ db, config }) {
|
||||
fs.mkdirSync(config.uploadDir, { recursive: true });
|
||||
const app = express(); const auth = createAuth(config);
|
||||
app.use(helmet({ contentSecurityPolicy: false })); app.use(cors()); app.use(express.json({ limit: '1mb' }));
|
||||
app.get('/health', async (_req, res) => { await db.query('SELECT 1'); res.json({ ok: true }); });
|
||||
app.use('/uploads', express.static(config.uploadDir, { maxAge: '7d' }));
|
||||
app.use('/admin', express.static(new URL('../public/admin', import.meta.url).pathname));
|
||||
app.use('/api/auth', authRoutes({ db, config, auth }));
|
||||
app.use('/api/activities', catalogRoutes({ db }));
|
||||
app.use('/api/activities/:activityId/participations', participationRoutes({ db, auth }));
|
||||
app.use('/api/uploads', uploadRoutes({ config, auth }));
|
||||
app.use('/api/admin', adminRoutes({ db, auth }));
|
||||
app.use((error, _req, res, _next) => { console.error(error); res.status(error.code === 'LIMIT_FILE_SIZE' ? 413 : 500).json({ error: '服务器暂时无法处理请求' }); });
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const requiredInProduction = ['DATABASE_URL', 'JWT_SECRET', 'WECHAT_APP_ID', 'WECHAT_APP_SECRET', 'ADMIN_TOKEN'];
|
||||
|
||||
export function loadConfig(env = process.env) {
|
||||
const production = env.NODE_ENV === 'production';
|
||||
if (production) {
|
||||
const missing = requiredInProduction.filter((key) => !env[key]);
|
||||
if (missing.length) throw new Error(`Missing environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
production,
|
||||
port: Number(env.PORT || 3000),
|
||||
databaseUrl: env.DATABASE_URL || 'postgres://coffeechat:coffeechat@localhost:5432/coffeechat',
|
||||
jwtSecret: env.JWT_SECRET || 'development-only-secret-change-me',
|
||||
wechatAppId: env.WECHAT_APP_ID || '',
|
||||
wechatAppSecret: env.WECHAT_APP_SECRET || '',
|
||||
adminToken: env.ADMIN_TOKEN || 'development-admin-token',
|
||||
publicBaseUrl: (env.PUBLIC_BASE_URL || `http://localhost:${env.PORT || 3000}`).replace(/\/$/, ''),
|
||||
uploadDir: env.UPLOAD_DIR || new URL('../uploads', import.meta.url).pathname,
|
||||
allowDevLogin: !production && env.ALLOW_DEV_LOGIN !== 'false'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import pg from 'pg';
|
||||
|
||||
export function createDb(connectionString) {
|
||||
const pool = new pg.Pool({ connectionString });
|
||||
return {
|
||||
query: (query, params) => pool.query(query, params),
|
||||
async transaction(work) {
|
||||
const client = await pool.connect();
|
||||
try { await client.query('BEGIN'); const result = await work(client); await client.query('COMMIT'); return result; }
|
||||
catch (error) { await client.query('ROLLBACK'); throw error; }
|
||||
finally { client.release(); }
|
||||
},
|
||||
close: () => pool.end()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { loadConfig } from './config.js';
|
||||
import { createDb } from './db.js';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const config = loadConfig(); const db = createDb(config.databaseUrl); const app = createApp({ db, config });
|
||||
const server = app.listen(config.port, '0.0.0.0', () => console.log(`CoffeeChat API listening on ${config.port}`));
|
||||
async function shutdown() { server.close(); await db.close(); }
|
||||
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function activityStatus(startsAt, endsAt, now = new Date()) {
|
||||
const start = new Date(startsAt);
|
||||
const end = endsAt ? new Date(endsAt) : start;
|
||||
if (start > now) return 'upcoming';
|
||||
if (end >= now) return 'active';
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
export function normalizeActivity(row, now = new Date()) {
|
||||
return {
|
||||
...row,
|
||||
status: activityStatus(row.startsAt, row.endsAt, now),
|
||||
images: row.images || [], tags: row.tags || [], participantCount: Number(row.participantCount || 0)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export function createAuth(config) {
|
||||
const sign = (user) => jwt.sign({ sub: user.id, openid: user.openid }, config.jwtSecret, { expiresIn: '30d' });
|
||||
function userRequired(req, res, next) {
|
||||
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
||||
if (!token) return res.status(401).json({ error: '请先登录' });
|
||||
try { req.user = jwt.verify(token, config.jwtSecret); next(); }
|
||||
catch { res.status(401).json({ error: '登录已过期,请重新登录' }); }
|
||||
}
|
||||
function adminRequired(req, res, next) {
|
||||
const token = req.headers['x-admin-token'];
|
||||
if (!token || token !== config.adminToken) return res.status(401).json({ error: '管理员凭据无效' });
|
||||
next();
|
||||
}
|
||||
return { sign, userRequired, adminRequired };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({ code: z.string().min(1), nickname: z.string().trim().max(40).optional(), avatarUrl: z.string().url().optional().or(z.literal('')) });
|
||||
export const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), avatarUrl: z.string().url().optional().or(z.literal('')) });
|
||||
export const purposeSchema = z.object({ purpose: z.string().trim().max(300).default('') });
|
||||
export const groupSchema = z.object({ name: z.string().trim().min(1).max(80), qrImageUrl: z.string().url(), note: z.string().trim().max(300).default('') });
|
||||
export const exhibitionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120), venue: z.string().trim().min(1).max(120), address: z.string().trim().max(300).default(''),
|
||||
startsAt: z.coerce.date(), endsAt: z.coerce.date(), description: z.string().trim().max(5000).default(''), coverUrl: z.string().url().optional().or(z.literal(''))
|
||||
}).refine((v) => v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' });
|
||||
export const activitySchema = z.object({
|
||||
exhibitionId: z.string().uuid(), title: z.string().trim().min(1).max(120), description: z.string().trim().max(10000).default(''),
|
||||
location: z.string().trim().max(200).default(''), startsAt: z.coerce.date(), endsAt: z.coerce.date().nullable().optional(),
|
||||
capacity: z.coerce.number().int().positive().nullable().optional(), published: z.boolean().default(false),
|
||||
imageUrls: z.array(z.string().url()).max(9).default([]), tagIds: z.array(z.string().uuid()).max(10).default([])
|
||||
}).refine((v) => !v.endsAt || v.endsAt > v.startsAt, { message: '结束时间必须晚于开始时间' });
|
||||
|
||||
export function parse(schema, req, res) {
|
||||
const result = schema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.error.issues[0]?.message || '请求参数错误' });
|
||||
return null;
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export async function exchangeCode({ code, appId, appSecret, fetchImpl = fetch }) {
|
||||
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
|
||||
url.searchParams.set('appid', appId);
|
||||
url.searchParams.set('secret', appSecret);
|
||||
url.searchParams.set('js_code', code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
const response = await fetchImpl(url);
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.errcode || !data.openid) throw new Error(data.errmsg || '微信登录失败');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Router } from 'express';
|
||||
import { activitySchema, exhibitionSchema, parse } from '../lib/validation.js';
|
||||
|
||||
export function adminRoutes({ db, auth }) {
|
||||
const router = Router();
|
||||
router.use(auth.adminRequired);
|
||||
router.get('/dashboard', async (_req, res) => {
|
||||
const [exhibitions, activities, groups, tags] = await Promise.all([
|
||||
db.query('SELECT id,name,venue,starts_at AS "startsAt",ends_at AS "endsAt" FROM exhibitions ORDER BY starts_at DESC'),
|
||||
db.query(`SELECT a.id,a.title,a.starts_at AS "startsAt",a.published,e.name AS "exhibitionName" FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id ORDER BY a.starts_at DESC`),
|
||||
db.query(`SELECT g.id,g.name,g.qr_image_url AS "qrImageUrl",g.note,g.status,g.created_at AS "createdAt",a.title AS "activityTitle",u.nickname AS "submitterName" FROM activity_groups g JOIN activities a ON a.id=g.activity_id LEFT JOIN users u ON u.id=g.submitted_by ORDER BY g.created_at DESC`),
|
||||
db.query('SELECT id,name,color FROM tags ORDER BY name')
|
||||
]);
|
||||
res.json({ exhibitions: exhibitions.rows, activities: activities.rows, groups: groups.rows, tags: tags.rows });
|
||||
});
|
||||
router.post('/exhibitions', async (req, res) => {
|
||||
const body = parse(exhibitionSchema, req, res); if (!body) return;
|
||||
const { rows: [item] } = await db.query(
|
||||
`INSERT INTO exhibitions(name,venue,address,starts_at,ends_at,description,cover_url) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
[body.name, body.venue, body.address, body.startsAt, body.endsAt, body.description, body.coverUrl || null]
|
||||
);
|
||||
res.status(201).json(item);
|
||||
});
|
||||
router.post('/activities', async (req, res) => {
|
||||
const body = parse(activitySchema, req, res); if (!body) return;
|
||||
const item = await db.transaction(async (client) => {
|
||||
const { rows: [created] } = await client.query(
|
||||
`INSERT INTO activities(exhibition_id,title,description,location,starts_at,ends_at,capacity,published) VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
[body.exhibitionId, body.title, body.description, body.location, body.startsAt, body.endsAt || null, body.capacity || null, body.published]
|
||||
);
|
||||
for (const [index, url] of body.imageUrls.entries()) await client.query('INSERT INTO activity_images(activity_id,url,sort_order) VALUES($1,$2,$3)', [created.id, url, index]);
|
||||
for (const tagId of body.tagIds) await client.query('INSERT INTO activity_tags(activity_id,tag_id) VALUES($1,$2)', [created.id, tagId]);
|
||||
return created;
|
||||
});
|
||||
res.status(201).json(item);
|
||||
});
|
||||
router.patch('/activities/:id/publish', async (req, res) => {
|
||||
const published = req.body.published === true;
|
||||
const { rows: [item] } = await db.query('UPDATE activities SET published=$2,updated_at=now() WHERE id=$1 RETURNING id,published', [req.params.id, published]);
|
||||
if (!item) return res.status(404).json({ error: '活动不存在' });
|
||||
res.json(item);
|
||||
});
|
||||
router.patch('/groups/:id/review', async (req, res) => {
|
||||
if (!['approved', 'rejected'].includes(req.body.status)) return res.status(400).json({ error: '审核状态无效' });
|
||||
const { rows: [item] } = await db.query(
|
||||
`UPDATE activity_groups SET status=$2,review_note=$3,reviewed_at=now() WHERE id=$1 RETURNING id,status`,
|
||||
[req.params.id, req.body.status, String(req.body.reviewNote || '').slice(0, 300)]
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: '群资料不存在' });
|
||||
res.json(item);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Router } from 'express';
|
||||
import { exchangeCode } from '../lib/wechat.js';
|
||||
import { loginSchema, parse, profileSchema } from '../lib/validation.js';
|
||||
|
||||
export function authRoutes({ db, config, auth }) {
|
||||
const router = Router();
|
||||
router.post('/wechat', async (req, res) => {
|
||||
const body = parse(loginSchema, req, res); if (!body) return;
|
||||
try {
|
||||
let session;
|
||||
if (config.allowDevLogin && body.code.startsWith('dev:')) session = { openid: body.code, unionid: null };
|
||||
else session = await exchangeCode({ code: body.code, appId: config.wechatAppId, appSecret: config.wechatAppSecret });
|
||||
const { rows: [user] } = await db.query(
|
||||
`INSERT INTO users (openid, unionid, nickname, avatar_url) VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (openid) DO UPDATE SET unionid=COALESCE(EXCLUDED.unionid,users.unionid),
|
||||
nickname=CASE WHEN EXCLUDED.nickname <> '微信用户' THEN EXCLUDED.nickname ELSE users.nickname END,
|
||||
avatar_url=COALESCE(NULLIF(EXCLUDED.avatar_url,''),users.avatar_url), updated_at=now()
|
||||
RETURNING id, nickname, avatar_url AS "avatarUrl", created_at AS "createdAt"`,
|
||||
[session.openid, session.unionid || null, body.nickname || '微信用户', body.avatarUrl || null]
|
||||
);
|
||||
res.json({ token: auth.sign({ id: user.id, openid: session.openid }), user });
|
||||
} catch (error) { res.status(401).json({ error: error.message }); }
|
||||
});
|
||||
router.get('/me', auth.userRequired, async (req, res) => {
|
||||
const { rows: [user] } = await db.query('SELECT id,nickname,avatar_url AS "avatarUrl",created_at AS "createdAt" FROM users WHERE id=$1', [req.user.sub]);
|
||||
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||
res.json(user);
|
||||
});
|
||||
router.patch('/me', auth.userRequired, async (req, res) => {
|
||||
const body = parse(profileSchema, req, res); if (!body) return;
|
||||
const { rows: [user] } = await db.query(
|
||||
'UPDATE users SET nickname=$2,avatar_url=$3,updated_at=now() WHERE id=$1 RETURNING id,nickname,avatar_url AS "avatarUrl"',
|
||||
[req.user.sub, body.nickname, body.avatarUrl || null]
|
||||
);
|
||||
res.json(user);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { normalizeActivity } from '../lib/activities.js';
|
||||
|
||||
const activitySelect = `
|
||||
SELECT a.id,a.title,a.description,a.location,a.starts_at AS "startsAt",a.ends_at AS "endsAt",a.capacity,
|
||||
json_build_object('id',e.id,'name',e.name,'venue',e.venue,'address',e.address,'startsAt',e.starts_at,'endsAt',e.ends_at,'description',e.description,'coverUrl',e.cover_url) exhibition,
|
||||
COALESCE((SELECT json_agg(json_build_object('id',i.id,'url',i.url) ORDER BY i.sort_order) FROM activity_images i WHERE i.activity_id=a.id),'[]') images,
|
||||
COALESCE((SELECT json_agg(json_build_object('id',t.id,'name',t.name,'color',t.color)) FROM activity_tags at JOIN tags t ON t.id=at.tag_id WHERE at.activity_id=a.id),'[]') tags,
|
||||
(SELECT count(*) FROM participations p WHERE p.activity_id=a.id) AS "participantCount"
|
||||
FROM activities a JOIN exhibitions e ON e.id=a.exhibition_id`;
|
||||
|
||||
export function catalogRoutes({ db }) {
|
||||
const router = Router();
|
||||
router.get('/meta', async (_req, res) => {
|
||||
const [exhibitions, tags] = await Promise.all([
|
||||
db.query('SELECT id,name,venue,address,starts_at AS "startsAt",ends_at AS "endsAt",description,cover_url AS "coverUrl" FROM exhibitions ORDER BY starts_at DESC'),
|
||||
db.query('SELECT id,name,color FROM tags ORDER BY name')
|
||||
]);
|
||||
res.json({ exhibitions: exhibitions.rows, tags: tags.rows });
|
||||
});
|
||||
router.get('/', async (req, res) => {
|
||||
const values = []; const where = ['a.published=true'];
|
||||
if (req.query.exhibitionId) { values.push(req.query.exhibitionId); where.push(`a.exhibition_id=$${values.length}`); }
|
||||
if (req.query.tagId) { values.push(req.query.tagId); where.push(`EXISTS (SELECT 1 FROM activity_tags x WHERE x.activity_id=a.id AND x.tag_id=$${values.length})`); }
|
||||
if (req.query.time === 'upcoming') where.push('a.starts_at > now()');
|
||||
if (req.query.time === 'available') where.push('COALESCE(a.ends_at,a.starts_at) >= now()');
|
||||
const { rows } = await db.query(`${activitySelect} WHERE ${where.join(' AND ')} ORDER BY a.starts_at ASC`, values);
|
||||
res.json(rows.map((row) => normalizeActivity(row)));
|
||||
});
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { rows: [row] } = await db.query(`${activitySelect} WHERE a.id=$1 AND a.published=true`, [req.params.id]);
|
||||
if (!row) return res.status(404).json({ error: '活动不存在' });
|
||||
const participants = await db.query(
|
||||
`SELECT u.id,u.nickname,u.avatar_url AS "avatarUrl",p.purpose,p.joined_at AS "joinedAt"
|
||||
FROM participations p JOIN users u ON u.id=p.user_id WHERE p.activity_id=$1 ORDER BY p.joined_at`, [req.params.id]
|
||||
);
|
||||
const groups = await db.query(
|
||||
`SELECT id,name,qr_image_url AS "qrImageUrl",note FROM activity_groups WHERE activity_id=$1 AND status='approved' ORDER BY created_at`, [req.params.id]
|
||||
);
|
||||
res.json({ ...normalizeActivity(row), participants: participants.rows, groups: groups.rows });
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Router } from 'express';
|
||||
import { groupSchema, parse, purposeSchema } from '../lib/validation.js';
|
||||
|
||||
export function participationRoutes({ db, auth }) {
|
||||
const router = Router({ mergeParams: true });
|
||||
router.put('/me', auth.userRequired, async (req, res) => {
|
||||
const body = parse(purposeSchema, req, res); if (!body) return;
|
||||
const capacity = await db.query(`SELECT capacity,(SELECT count(*) FROM participations WHERE activity_id=$1) count FROM activities WHERE id=$1 AND published=true`, [req.params.activityId]);
|
||||
if (!capacity.rows[0]) return res.status(404).json({ error: '活动不存在' });
|
||||
const existing = await db.query('SELECT 1 FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]);
|
||||
if (!existing.rowCount && capacity.rows[0].capacity && Number(capacity.rows[0].count) >= capacity.rows[0].capacity) return res.status(409).json({ error: '活动人数已满' });
|
||||
const { rows: [item] } = await db.query(
|
||||
`INSERT INTO participations(activity_id,user_id,purpose) VALUES($1,$2,$3)
|
||||
ON CONFLICT(activity_id,user_id) DO UPDATE SET purpose=EXCLUDED.purpose,updated_at=now()
|
||||
RETURNING activity_id AS "activityId",purpose,joined_at AS "joinedAt"`, [req.params.activityId, req.user.sub, body.purpose]
|
||||
);
|
||||
res.json(item);
|
||||
});
|
||||
router.delete('/me', auth.userRequired, async (req, res) => {
|
||||
await db.query('DELETE FROM participations WHERE activity_id=$1 AND user_id=$2', [req.params.activityId, req.user.sub]);
|
||||
res.status(204).end();
|
||||
});
|
||||
router.post('/groups', auth.userRequired, async (req, res) => {
|
||||
const body = parse(groupSchema, req, res); if (!body) return;
|
||||
const { rows: [group] } = await db.query(
|
||||
`INSERT INTO activity_groups(activity_id,submitted_by,name,qr_image_url,note) VALUES($1,$2,$3,$4,$5)
|
||||
RETURNING id,status,created_at AS "createdAt"`, [req.params.activityId, req.user.sub, body.name, body.qrImageUrl, body.note]
|
||||
);
|
||||
res.status(201).json(group);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function uploader(uploadDir) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, done) => done(null, `${Date.now()}-${crypto.randomUUID()}${path.extname(file.originalname).toLowerCase()}`)
|
||||
});
|
||||
return multer({ storage, limits: { fileSize: 8 * 1024 * 1024, files: 9 }, fileFilter: (_req, file, done) => done(null, /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype)) });
|
||||
}
|
||||
|
||||
export function uploadRoutes({ config, auth }) {
|
||||
const router = Router(); const upload = uploader(config.uploadDir);
|
||||
const response = (req, res) => res.status(201).json({ files: req.files.map((file) => ({ url: `${config.publicBaseUrl}/uploads/${file.filename}` })) });
|
||||
router.post('/', auth.userRequired, upload.array('files', 9), response);
|
||||
router.post('/admin', auth.adminRequired, upload.array('files', 9), response);
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { activityStatus, normalizeActivity } from '../src/lib/activities.js';
|
||||
|
||||
test('activityStatus distinguishes upcoming, active and ended', () => {
|
||||
const now = new Date('2026-09-21T12:00:00Z');
|
||||
assert.equal(activityStatus('2026-09-22T12:00:00Z', null, now), 'upcoming');
|
||||
assert.equal(activityStatus('2026-09-21T11:00:00Z', '2026-09-21T13:00:00Z', now), 'active');
|
||||
assert.equal(activityStatus('2026-09-20T11:00:00Z', '2026-09-20T13:00:00Z', now), 'ended');
|
||||
});
|
||||
test('normalizeActivity supplies stable arrays and numeric count', () => {
|
||||
const value = normalizeActivity({ startsAt: '2026-09-22T12:00:00Z', participantCount: '3' }, new Date('2026-09-21T12:00:00Z'));
|
||||
assert.deepEqual(value.images, []); assert.deepEqual(value.tags, []); assert.equal(value.participantCount, 3);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
|
||||
test('production rejects missing secrets', () => assert.throws(() => loadConfig({ NODE_ENV: 'production' }), /Missing environment variables/));
|
||||
test('development permits local defaults', () => assert.equal(loadConfig({ NODE_ENV: 'development' }).allowDevLogin, true));
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { exchangeCode } from '../src/lib/wechat.js';
|
||||
|
||||
test('exchangeCode returns WeChat session', async () => {
|
||||
const session = await exchangeCode({ code: 'abc', appId: 'id', appSecret: 'secret', fetchImpl: async (url) => {
|
||||
assert.equal(url.searchParams.get('js_code'), 'abc'); return { ok: true, json: async () => ({ openid: 'openid-1' }) };
|
||||
}});
|
||||
assert.equal(session.openid, 'openid-1');
|
||||
});
|
||||
test('exchangeCode rejects WeChat API errors', async () => {
|
||||
await assert.rejects(exchangeCode({ code: 'bad', appId: 'id', appSecret: 'secret', fetchImpl: async () => ({ ok: true, json: async () => ({ errcode: 40029, errmsg: 'invalid code' }) }) }), /invalid code/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user