Files
opc-backup/code/opc-training/website/server/mock-api.mjs
T

590 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OPC 培训站 · 模拟后端 APImock-api
* -------------------------------------------------------------
* 结构:
* - 认证:POST /api/auth/login · POST /api/auth/logout · GET /api/auth/verify
* - 公开(无需登录):
* POST /api/bookings 提交预约 / 报名
* GET /api/events?current=1 当期(最近的公益课 + 沙龙)排期
* POST /api/tests 上报一次 OPC 测评结果
* - 管理(需 Bearer token,供 /pine 后台):
* GET /api/ops/stats 概览统计
* GET /api/bookings 预约 / 报名列表(可 ?status= 过滤)
* PATCH /api/bookings/:id 更新预约状态(pending/confirmed/arrived/converted
* DELETE /api/bookings/:id 删除预约
* GET /api/events 全部排期
* POST /api/events 新增排期(公益课 / 沙龙)
* PUT /api/events/:id 更新排期
* DELETE /api/events/:id 删除排期
* GET /api/tests 测评记录
*
* 数据落盘:server/store.mjsbookings.json / events.json / tests.json)。
* 同时作为 connect 风格中间件(Vite dev/preview 挂载),也可被 start.mjs 复用。
*/
import { loadAll, add, update, remove, seedIfEmpty } from './store.mjs';
import { createHash } from 'node:crypto';
import { currentQuestions, calculate, expandResult, SECTIONS, AXIS_NAMES, ADAPT_LABELS, PROFILES } from './opcTest.js';
// 模拟账号(写死;迁移后由真实用户库替代)
const CREDENTIALS = { username: 'pine', password: '123456' };
const SECRET = 'pine-mock-secret-2026';
const TOKEN_TTL_MS = 1000 * 60 * 60 * 12; // 12 小时
const hash = (s) => createHash('sha256').update(String(s)).digest('hex');
// 排期种子(对齐 宣传与运营/07 主题表;首次运行写入 events.json
seedIfEmpty('events', [
{ id: 'E-F001', type: 'free', mode: 'online', title: 'AI 让民宿主每月省 10 小时', subtitle: '民宿 / 文旅创业 · 公益课', desc: '拆解民宿经营里的重复劳动,AI 怎么接住(点到即止,不卖课)', location: '视频号直播', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-08-27T19:30:00+08:00', durationMin: 90, capacity: 0, status: 'open' },
{ id: 'E-S001', type: 'salon', mode: 'offline', title: '民宿 AI 化改造实战', subtitle: '交流沙龙 · 1230 人小场', desc: '主题深谈 + AI 现场演示 + 1 对 1 快诊', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-08-29T14:00:00+08:00', durationMin: 180, capacity: 30, status: 'open' },
{ id: 'E-F002', type: 'free', mode: 'offline', title: '用 AI 做云南咖啡品牌', subtitle: '咖啡 / 农特产创业 · 公益课', desc: '云南咖啡现状 + 品牌从 0 到 1 + AI 内容打法', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-03T19:30:00+08:00', durationMin: 90, capacity: 60, status: 'open' },
{ id: 'E-F003', type: 'free', mode: 'online', title: '一人公司财税避坑入门', subtitle: '自由职业 / 小老板 · 公益课', desc: '常见财税坑、发票、成本票、申报节点', location: '视频号直播', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-10T19:30:00+08:00', durationMin: 90, capacity: 0, status: 'open' },
{ id: 'E-S002', type: 'salon', mode: 'offline', title: '技能打包:把手艺变成产品', subtitle: '交流沙龙 · 副业转型专场', desc: '工作坊 + 快诊,把你的技能变成可定价产品', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-12T14:00:00+08:00', durationMin: 180, capacity: 24, status: 'open' },
{ id: 'E-F004', type: 'free', mode: 'offline', title: '你适不适合创业?先做一次 OPC 测评', subtitle: '大众 · 公益课', desc: '适配 6 维速览 + 现场 30 题快速测评', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-17T19:30:00+08:00', durationMin: 90, capacity: 60, status: 'open' },
{ id: 'E-S003', type: 'salon', mode: 'offline', title: 'OPC 测评报告 1 对 1 解读专场', subtitle: '交流沙龙 · 测评者组场', desc: '每人 15 分钟快诊,读懂你的类型码与赛道', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-26T14:00:00+08:00', durationMin: 180, capacity: 20, status: 'open' },
{ id: 'E-S004', type: 'salon', mode: 'offline', title: '资源对接夜:能提供 × 需要', subtitle: '交流沙龙 · 综合场', desc: '供需现场配对,本地人脉面对面', location: '昆明市大学生创业园', host: '云超服 · OPC 培训', image: '', link: '', startAt: '2026-09-27T14:00:00+08:00', durationMin: 180, capacity: 30, status: 'open' }
]);
const b64url = (buf) => Buffer.from(buf).toString('base64url');
function makeToken(username) {
const payload = b64url(JSON.stringify({ username, exp: Date.now() + TOKEN_TTL_MS }));
const sig = b64url(SECRET + '.' + payload);
return `${payload}.${sig}`;
}
export function verifyToken(token) {
if (!token || typeof token !== 'string') return false;
const [payloadPart, sigPart] = token.split('.');
if (!payloadPart || !sigPart) return false;
const expectSig = b64url(SECRET + '.' + payloadPart);
if (sigPart !== expectSig) return false;
try {
const payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8'));
if (!payload.exp || payload.exp < Date.now()) return false;
return { username: payload.username };
} catch {
return false;
}
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (chunk) => { data += chunk; });
req.on('end', () => {
try { resolve(JSON.parse(data || '{}')); }
catch { resolve({}); }
});
req.on('error', () => resolve({}));
});
}
function json(res, status, obj) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(obj));
}
function requireAuth(req, res) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
const payload = verifyToken(token);
if (!payload) {
json(res, 401, { ok: false, error: '未授权或登录已过期' });
return null;
}
return payload;
}
const genId = (prefix) => prefix + Date.now().toString(36).toUpperCase() + Math.floor(Math.random() * 36).toString(36).toUpperCase();
/* ---------------- 短信验证码(演示环境) ----------------
内存存储 手机号 → {code, exp};响应返回验证码供演示(真实环境:接短信服务商,删除 debugCode)。
报名 / 登录 / 注册统一走「手机号 + 验证码」,手机号即账号,未注册自动注册。 */
const smsCodes = new Map();
const PHONE_RE = /^1\d{10}$/;
const CODE_TTL = 5 * 60 * 1000; // 5 分钟
function sendSmsCode(phone) {
const code = String(Math.floor(100000 + Math.random() * 900000));
smsCodes.set(phone, { code, exp: Date.now() + CODE_TTL });
return code;
}
/** 手机号 + 验证码 → 校验并确保账号存在(自动注册或登录) */
function authByPhone(body) {
const phone = String(body.phone || '').trim();
const code = String(body.code || '').trim();
if (!PHONE_RE.test(phone)) return { error: '请输入正确的 11 位手机号' };
const rec = smsCodes.get(phone);
if (!rec || rec.code !== code || Date.now() > rec.exp) {
return { error: '验证码错误或已过期,请重新获取' };
}
smsCodes.delete(phone);
const accounts = loadAll('accounts');
let acct = accounts.find((a) => a.username === phone);
let isNew = false;
if (!acct) {
acct = {
id: genId('U'),
username: phone,
name: String(body.name || '').trim() || phone,
contact: phone,
identities: ['opc_member|certified'],
createdAt: new Date().toISOString()
};
add('accounts', acct);
isNew = true;
}
return { acct, isNew };
}
/**
* connect 风格中间件:(req, res, next)
* 命中 /api/* 则处理并结束响应;否则 next()。
*/
export async function mockApi(req, res, next) {
if (typeof next !== 'function') next = () => {};
const url = (req.url || '').split('?')[0];
const segs = url.split('/').filter(Boolean); // ['api', ...rest]
if (segs[0] !== 'api') return next();
const method = (req.method || 'GET').toUpperCase();
const rest = segs.slice(1); // 去掉 'api'
const resName = rest[0]; // 'auth' | 'bookings' | 'events' | 'ops' | 'tests'
const sub = rest[1]; // 'login' | ':id' | 'stats'
const id = rest.length >= 2 ? rest[1] : undefined;
/* ---------------- 认证 ---------------- */
// 注册(创建账号 → 返回 token;报名页"注册即报名"走这里)
if (resName === 'auth' && sub === 'register' && method === 'POST') {
const body = (await readBody(req)) || {};
const username = String(body.username || '').trim();
const password = String(body.password || '');
const name = String(body.name || '').trim();
const contact = String(body.contact || '').trim();
if (!username || !password) return json(res, 400, { ok: false, error: '请填写账号与密码' });
if (username.length < 2) return json(res, 400, { ok: false, error: '账号至少 2 个字符' });
if (password.length < 4) return json(res, 400, { ok: false, error: '密码至少 4 个字符' });
if (loadAll('accounts').some((a) => a.username === username)) {
return json(res, 400, { ok: false, error: '账号已存在,请直接登录' });
}
const entry = {
id: genId('U'),
username,
password: hash(password),
name,
contact,
identities: ['opc_member|certified'],
createdAt: new Date().toISOString()
};
add('accounts', entry);
return json(res, 200, { ok: true, token: makeToken(username), username, name: name || username });
}
// 登录(支持注册账号 + 内置超级账号;区分"账号不存在 / 密码错误")
if (resName === 'auth' && sub === 'login' && method === 'POST') {
const body = await readBody(req);
const { username, password } = body || {};
// 内置超级账号
if (username === CREDENTIALS.username) {
if (password === CREDENTIALS.password) {
return json(res, 200, { ok: true, token: makeToken(username), username, name: 'Pine' });
}
return json(res, 401, { ok: false, error: '密码错误,请重新输入' });
}
// 注册账号
const acct = loadAll('accounts').find((a) => a.username === username);
if (!acct) return json(res, 404, { ok: false, error: '账号不存在,请先注册' });
if (acct.password !== hash(password)) return json(res, 401, { ok: false, error: '密码错误,请重新输入' });
return json(res, 200, { ok: true, token: makeToken(username), username, name: acct.name || username });
}
// 发送短信验证码(演示环境:响应返回验证码,供前端直接展示;真实环境接短信服务商)
if (resName === 'auth' && sub === 'send-code' && method === 'POST') {
const body = await readBody(req);
const phone = String(body.phone || '').trim();
if (!PHONE_RE.test(phone)) return json(res, 400, { ok: false, error: '请输入正确的 11 位手机号' });
const code = sendSmsCode(phone);
return json(res, 200, { ok: true, sent: true, debugCode: code });
}
// 手机号 + 验证码登录 / 注册(未注册自动注册,返回 token)
if (resName === 'auth' && sub === 'phone-login' && method === 'POST') {
const body = await readBody(req);
const r = authByPhone(body);
if (r.error) return json(res, 401, { ok: false, error: r.error });
return json(res, 200, { ok: true, token: makeToken(r.acct.username), username: r.acct.username, name: r.acct.name, isNew: r.isNew });
}
if (resName === 'auth' && sub === 'logout' && method === 'POST') return json(res, 200, { ok: true });
if (resName === 'auth' && sub === 'verify' && method === 'GET') {
const payload = requireAuth(req, res);
if (!payload) return;
return json(res, 200, { ok: true, username: payload.username });
}
/* ---------------- OPC 测评:题目下发 + 计分(测评逻辑与评语全在后端) ---------------- */
if (resName === 'tests' && sub === 'opc') {
// GET /api/tests/opc/questions?version=quick|full —— 下发题目与渲染元数据
if (rest[2] === 'questions' && method === 'GET') {
const q = new URLSearchParams((req.url || '').split('?')[1] || '');
const version = q.get('version') === 'quick' ? 'quick' : 'full';
return json(res, 200, {
ok: true,
version,
questions: currentQuestions(version).map((x) => ({ id: x.id, part: x.part, question: x.question, A: x.A, B: x.B })),
sections: SECTIONS,
axisNames: AXIS_NAMES,
dimLabels: ADAPT_LABELS,
disclaimer: PROFILES.disclaimer,
quickNote: PROFILES.quickNote
});
}
// POST /api/tests/opc/calculate { answers, version } —— 后端计分 + 返回完整报告/评语
if (rest[2] === 'calculate' && method === 'POST') {
const body = (await readBody(req)) || {};
const version = body.version === 'quick' ? 'quick' : 'full';
const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
try {
const result = expandResult(calculate(answers, version));
return json(res, 200, { ok: true, result });
} catch (e) {
return json(res, 400, { ok: false, error: '测评计算失败,请稍后重试' });
}
}
return json(res, 404, { ok: false, error: 'Not Found' });
}
/* ---------------- 公开:提交预约 / 报名(报名即注册/登录) ---------------- */
if (resName === 'bookings' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
// 手机号 + 验证码认证:已注册→登录,未注册→自动注册
const auth = authByPhone(body);
if (auth.error) return json(res, 401, { ok: false, error: auth.error });
const acct = auth.acct;
const name = String(body.name || '').trim() || acct.name || acct.username;
const entry = {
id: genId('B'),
createdAt: new Date().toISOString(),
status: 'pending', // pending / confirmed / arrived / converted
username: acct.username,
name,
contact: acct.username, // 手机号即联系方式
statusLabel: String(body.status || '').trim(),
want: body.want || '',
eventId: String(body.eventId || '').trim(),
eventTitle: String(body.eventTitle || '').trim(),
eventStart: String(body.eventStart || '').trim(),
topics: Array.isArray(body.topics) ? body.topics : [],
question: body.question || '',
source: body.source || ''
};
add('bookings', entry);
// 新注册用户:用报名时填写的姓名补全账号资料
if (auth.isNew && body.name) update('accounts', acct.id, { name });
return json(res, 200, {
ok: true,
id: entry.id,
createdAt: entry.createdAt,
token: makeToken(acct.username),
username: acct.username,
name,
isNew: auth.isNew
});
}
/* ---------------- 公开:当期排期 ---------------- */
if (resName === 'events' && method === 'GET' && !id) {
const q = new URLSearchParams((req.url || '').split('?')[1] || '');
const all = loadAll('events');
const now = Date.now();
const upcoming = all
.filter((e) => new Date(e.startAt).getTime() >= now - 1000 * 60 * 30)
.sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
if (q.get('current')) {
const free = upcoming.find((e) => e.type === 'free') || null;
const salon = upcoming.find((e) => e.type === 'salon') || null;
const next = upcoming[0] || null;
return json(res, 200, { ok: true, next, free, salon, upcoming: upcoming.slice(0, 6) });
}
// 可报名场次:未结束/未关闭的最近场次(报名页选择用)
if (q.get('bookable')) {
const list = upcoming.filter((e) => e.status !== 'closed' && e.status !== 'done').slice(0, 30);
return json(res, 200, { ok: true, list });
}
return json(res, 200, { ok: true, list: all });
}
/* ---------------- 公开:活动详情 ---------------- */
if (resName === 'events' && method === 'GET' && id) {
const e = loadAll('events').find((x) => x.id === id);
if (!e) return json(res, 404, { ok: false, error: '活动不存在' });
return json(res, 200, { ok: true, event: e });
}
/* ---------------- 公开:上报测评结果 ---------------- */
if (resName === 'tests' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
if (!body.typeCode) return json(res, 400, { ok: false, error: '缺少测评结果' });
const entry = {
id: genId('T'),
createdAt: new Date().toISOString(),
username: String(body.username || '').trim(),
typeCode: body.typeCode,
persona: body.persona || '',
adaptIndex: Number(body.adaptIndex) || 0,
adaptLevel: body.adaptLevel || '',
tracks: Array.isArray(body.tracks) ? body.tracks : [],
version: body.version || ''
};
add('tests', entry);
return json(res, 200, { ok: true, id: entry.id });
}
/* ---------------- 公开:上报政策测评结果 ---------------- */
if (resName === 'policy-logs' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
if (!body.answers) return json(res, 400, { ok: false, error: '缺少测评结果' });
const entry = {
id: genId('P'),
createdAt: new Date().toISOString(),
username: String(body.username || '').trim(),
answers: body.answers,
policiesCount: Number(body.policiesCount) || 0,
subsidiesCount: Number(body.subsidiesCount) || 0,
loansCount: Number(body.loansCount) || 0,
summary: String(body.summary || '').slice(0, 200)
};
add('policyLogs', entry);
return json(res, 200, { ok: true, id: entry.id });
}
/* ---------------- 公开:上报启动流程生成 ---------------- */
if (resName === 'plan-logs' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
if (!body.region) return json(res, 400, { ok: false, error: '缺少流程信息' });
const entry = {
id: genId('L'),
createdAt: new Date().toISOString(),
username: String(body.username || '').trim(),
region: String(body.region || '').trim(),
status: String(body.status || '').trim(),
needPark: !!body.needPark,
hasStaff: !!body.hasStaff,
stepsCount: Number(body.stepsCount) || 0
};
add('planLogs', entry);
return json(res, 200, { ok: true, id: entry.id });
}
/* ---------------- 公开:上报 OPC 创业伙伴调研 ---------------- */
if (resName === 'survey-logs' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
if (!body.answers || typeof body.answers !== 'object') {
return json(res, 400, { ok: false, error: '缺少调研答案' });
}
const entry = {
id: genId('S'),
createdAt: new Date().toISOString(),
username: String(body.username || '').trim(),
source: String(body.source || '').trim() || 'web',
answers: body.answers
};
add('surveyLogs', entry);
return json(res, 200, { ok: true, id: entry.id, createdAt: entry.createdAt });
}
/* ---------------- 用户端:微信登录 / 我的 / 绑定手机 ---------------- */
// POST /api/auth/wx-login { code, nickName, avatarUrl } → 微信一键登录
// 演示:未接微信 appid/secret,用 code 哈希作伪 openid(真实环境换成 code→openid 换号)
if (resName === 'auth' && sub === 'wx-login' && method === 'POST') {
const body = (await readBody(req)) || {};
const code = String(body.code || '');
if (!code) return json(res, 400, { ok: false, error: '缺少微信登录凭证' });
const wxId = 'wx_' + hash(code + '::wx').slice(0, 24);
let acct = loadAll('accounts').find((a) => a.wxId === wxId);
if (!acct) {
const name = String(body.nickName || '').trim() || '微信用户';
acct = {
id: genId('U'), username: wxId, wxId, phone: '', name, avatar: String(body.avatarUrl || ''),
phoneBound: false, identities: ['wx'], createdAt: new Date().toISOString()
};
add('accounts', acct);
}
return json(res, 200, {
ok: true, token: makeToken(acct.username), username: acct.username,
name: acct.name || '微信用户', avatar: acct.avatar || '', phoneBound: !!acct.phoneBound
});
}
// GET /api/auth/me { auth } → 当前账号信息
if (resName === 'auth' && sub === 'me' && method === 'GET') {
const payload = requireAuth(req, res); if (!payload) return;
const acct = loadAll('accounts').find((a) => a.username === payload.username);
if (!acct) return json(res, 404, { ok: false, error: '账号不存在' });
return json(res, 200, {
ok: true,
user: { username: acct.username, name: acct.name || acct.username, avatar: acct.avatar || '', phone: acct.phone || '', phoneBound: !!acct.phoneBound }
});
}
// POST /api/auth/bind-phone { phone, code } { auth } → 绑定手机
if (resName === 'auth' && sub === 'bind-phone' && method === 'POST') {
const payload = requireAuth(req, res); if (!payload) return;
const body = (await readBody(req)) || {};
const phone = String(body.phone || '').trim();
const code = String(body.code || '').trim();
if (!PHONE_RE.test(phone)) return json(res, 400, { ok: false, error: '请输入正确的 11 位手机号' });
const rec = smsCodes.get(phone);
if (!rec || rec.code !== code || Date.now() > rec.exp) return json(res, 401, { ok: false, error: '验证码错误或已过期' });
smsCodes.delete(phone);
const acct = loadAll('accounts').find((a) => a.username === payload.username);
if (!acct) return json(res, 404, { ok: false, error: '账号不存在' });
update('accounts', acct.id, { phone, phoneBound: true });
return json(res, 200, { ok: true, phoneBound: true, phone });
}
/* ---------------- 用户端:我的报名 / 签到(需登录) ---------------- */
if (resName === 'bookings' && sub === 'mine' && method === 'GET') {
const payload = requireAuth(req, res); if (!payload) return;
const acct = loadAll('accounts').find((a) => a.username === payload.username);
// 匹配:微信账号本身 + 绑定手机号(两者都可能产生报名)
const ids = new Set([payload.username]);
if (acct && acct.phone) ids.add(acct.phone);
const list = loadAll('bookings')
.filter((b) => ids.has(b.username) || ids.has(b.contact || ''))
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return json(res, 200, { ok: true, list });
}
if (resName === 'checkins' && method === 'POST' && !id) {
const payload = requireAuth(req, res); if (!payload) return;
const body = (await readBody(req)) || {};
const acct = loadAll('accounts').find((a) => a.username === payload.username);
const ids = new Set([payload.username]);
if (acct && acct.phone) ids.add(acct.phone);
const b = loadAll('bookings').find((x) => x.id === body.bookingId);
if (!b) return json(res, 404, { ok: false, error: '报名记录不存在' });
if (!ids.has(b.username) && !ids.has(b.contact || '')) return json(res, 403, { ok: false, error: '无权操作该记录' });
if (b.checkinAt) return json(res, 400, { ok: false, error: '该场次已签到' });
const updated = update('bookings', b.id, { checkinAt: new Date().toISOString() });
return json(res, 200, { ok: true, entry: updated });
}
/* ================ 以下为管理端点(需登录) ================ */
const auth = requireAuth(req, res);
if (!auth) return;
/* ---- 概览统计 ---- */
if (resName === 'ops' && sub === 'stats' && method === 'GET') {
const bookings = loadAll('bookings');
const events = loadAll('events');
const tests = loadAll('tests');
const policyLogs = loadAll('policyLogs');
const planLogs = loadAll('planLogs');
const surveyLogs = loadAll('surveyLogs');
const now = Date.now();
return json(res, 200, {
ok: true,
stats: {
bookings: bookings.length,
pending: bookings.filter((b) => b.status === 'pending').length,
confirmed: bookings.filter((b) => b.status === 'confirmed').length,
arrived: bookings.filter((b) => b.status === 'arrived').length,
converted: bookings.filter((b) => b.status === 'converted').length,
events: events.length,
upcomingEvents: events.filter((e) => new Date(e.startAt).getTime() >= now).length,
free: events.filter((e) => e.type === 'free' && new Date(e.startAt).getTime() >= now).length,
salon: events.filter((e) => e.type === 'salon' && new Date(e.startAt).getTime() >= now).length,
tests: tests.length,
policyLogs: policyLogs.length,
planLogs: planLogs.length,
surveyLogs: surveyLogs.length
}
});
}
/* ---- 预约 / 报名管理 ---- */
if (resName === 'bookings' && method === 'GET' && !id) {
const q = new URLSearchParams((req.url || '').split('?')[1] || '');
let list = loadAll('bookings').slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (q.get('status')) list = list.filter((b) => b.status === q.get('status'));
return json(res, 200, { ok: true, list });
}
if (resName === 'bookings' && method === 'PATCH' && id) {
const body = (await readBody(req)) || {};
const VALID = ['pending', 'confirmed', 'arrived', 'converted'];
const patch = {};
if (body.status && VALID.includes(body.status)) patch.status = body.status;
const entry = update('bookings', id, patch);
if (!entry) return json(res, 404, { ok: false, error: '预约不存在' });
return json(res, 200, { ok: true, entry });
}
if (resName === 'bookings' && method === 'DELETE' && id) {
remove('bookings', id);
return json(res, 200, { ok: true });
}
/* ---- 排期管理 ---- */
if (resName === 'events' && method === 'POST' && !id) {
const body = (await readBody(req)) || {};
if (!body.type || !body.title || !body.startAt) return json(res, 400, { ok: false, error: '请填写类型 / 主题 / 开始时间' });
const entry = {
id: genId(body.type === 'salon' ? 'E-S' : 'E-F'),
type: body.type === 'salon' ? 'salon' : 'free',
mode: body.mode === 'online' ? 'online' : 'offline',
title: String(body.title).trim(),
subtitle: body.subtitle || '',
desc: body.desc || '',
location: body.location || '',
host: String(body.host || '').trim(),
image: String(body.image || '').trim(),
link: body.link || '',
startAt: body.startAt,
durationMin: Number(body.durationMin) || 90,
capacity: Number(body.capacity) || 0,
status: body.status || 'open'
};
add('events', entry);
return json(res, 200, { ok: true, entry });
}
if (resName === 'events' && method === 'PUT' && id) {
const body = (await readBody(req)) || {};
const patch = {};
for (const k of ['title', 'subtitle', 'desc', 'location', 'link', 'startAt', 'status', 'host', 'image']) {
if (body[k] !== undefined) patch[k] = body[k];
}
if (body.type === 'salon' || body.type === 'free') patch.type = body.type;
if (body.mode === 'online' || body.mode === 'offline') patch.mode = body.mode;
if (body.durationMin !== undefined) patch.durationMin = Number(body.durationMin);
if (body.capacity !== undefined) patch.capacity = Number(body.capacity);
const entry = update('events', id, patch);
if (!entry) return json(res, 404, { ok: false, error: '排期不存在' });
return json(res, 200, { ok: true, entry });
}
if (resName === 'events' && method === 'DELETE' && id) {
remove('events', id);
return json(res, 200, { ok: true });
}
/* ---- 测评记录 ---- */
if (resName === 'tests' && method === 'GET' && !id) {
const list = loadAll('tests').slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return json(res, 200, { ok: true, list });
}
/* ---- 政策测评记录 ---- */
if (resName === 'policy-logs' && method === 'GET' && !id) {
const list = loadAll('policyLogs').slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return json(res, 200, { ok: true, list });
}
/* ---- 启动流程记录 ---- */
if (resName === 'plan-logs' && method === 'GET' && !id) {
const list = loadAll('planLogs').slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return json(res, 200, { ok: true, list });
}
/* ---- 调研记录 ---- */
if (resName === 'survey-logs' && method === 'GET' && !id) {
const list = loadAll('surveyLogs').slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return json(res, 200, { ok: true, list });
}
return json(res, 404, { ok: false, error: '接口不存在' });
}