/** * OPC 培训站 · 模拟后端 API(mock-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.mjs(bookings.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: '交流沙龙 · 12–30 人小场', 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: '接口不存在' }); }