97054a9699
- user 端:课程/活动/报名/测评/政策/调研 - 后端已并入 server-core/app/training,/api/* 走 opc.pinesound.cn
590 lines
29 KiB
JavaScript
590 lines
29 KiB
JavaScript
/**
|
||
* 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: '接口不存在' });
|
||
}
|