Files
training/website/server/mock-api.mjs
T

590 lines
29 KiB
JavaScript
Raw Normal View History

2026-08-23 22:32:42 +08:00
/**
* 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: '接口不存在' });
}