/** * 本地开发用 mock 账号服务(畅联 PC 端联调用) * * ⚠️ 仅供本地开发联调! * - 内置测试环境默认密钥(OpenIM secret / LiveKit key),都是内网测试环境的公开默认值 * - 禁止打包进客户端、禁止用于正式环境 * - 真实员工账号由管理员在服务器上的账号服务(:10010)开通 * * 用法: * 1. node scripts/mock-account-server.js(或 npm run mock:account) * 2. 把 .env 里的 VITE_ACCOUNT_URL 临时指向 http://127.0.0.1:11010 * 3. 用 test001 / test123(或 test002 / test123)登录 * * 接口形状与真实账号服务完全一致:POST /api/login、POST /api/rtc_token。 * 零依赖,Node >= 18(用到全局 fetch)。 */ 'use strict'; const http = require('http'); const crypto = require('crypto'); const PORT = 11010; const OPENIM_API_URL = 'http://192.168.200.11:10002'; const OPENIM_SECRET = 'openIM123'; const LIVEKIT_API_KEY = 'openimLKkey'; const LIVEKIT_API_SECRET = 'lk-secret-9f2c7a41d5e8b063f1a4c795e2d8b3a6'; // 本地测试账号(密码均为 test123) const USERS = { test001: { password: 'test123', nickname: '测试一号' }, test002: { password: 'test123', nickname: '测试二号' }, }; // ---------------- OpenIM 管理接口 ---------------- async function imApi(route, body, token) { const res = await fetch(OPENIM_API_URL + route, { method: 'POST', headers: { 'Content-Type': 'application/json', operationID: crypto.randomUUID(), ...(token ? { token } : {}), }, body: JSON.stringify(body), }); return res.json(); } async function getAdminToken() { const r = await imApi('/auth/get_admin_token', { secret: OPENIM_SECRET, userID: 'imAdmin', }); if (r.errCode !== 0) throw new Error('OpenIM 管理员令牌获取失败: ' + (r.errMsg || r.errCode)); return r.data.token; } /** 确保 OpenIM 里存在该用户(不存在则注册,尽力而为) */ async function ensureImUser(userID, nickname, admin) { try { await imApi('/user/user_register', { users: [{ userID, nickname, faceURL: '' }], }, admin); } catch { /* 忽略,后面取令牌时再报错 */ } } async function getImUserToken(userID, platformID) { const admin = await getAdminToken(); await ensureImUser(userID, USERS[userID].nickname, admin); const r = await imApi('/auth/get_user_token', { secret: OPENIM_SECRET, platformID, userID }, admin); if (r.errCode !== 0) throw new Error('OpenIM 用户令牌获取失败: ' + (r.errMsg || r.errCode)); return r.data; } // ---------------- LiveKit RTC 令牌签发 ---------------- const b64url = (s) => Buffer.from(s).toString('base64url'); function signLivekitToken(userID, room) { const now = Math.floor(Date.now() / 1000); const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const payload = b64url(JSON.stringify({ iss: LIVEKIT_API_KEY, sub: userID, exp: now + 3600, nbf: now - 10, video: { roomJoin: true, room }, metadata: '', })); const sig = crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(`${header}.${payload}`).digest('base64url'); return `${header}.${payload}.${sig}`; } // ---------------- HTTP 服务 ---------------- function send(res, status, obj) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, authorization', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', }); res.end(JSON.stringify(obj)); } const ok = (res, data) => send(res, 200, { code: 0, msg: '', data }); const fail = (res, msg, httpStatus = 200) => send(res, httpStatus, { code: 1, msg }); function readBody(req) { return new Promise((resolve, reject) => { let buf = ''; req.on('data', (c) => { buf += c; }); req.on('end', () => { if (!buf) return resolve({}); try { resolve(JSON.parse(buf)); } catch { reject(new Error('请求体不是合法 JSON')); } }); req.on('error', reject); }); } const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost'); if (req.method === 'OPTIONS') return send(res, 204, {}); if (req.method === 'POST' && url.pathname === '/api/login') { const body = await readBody(req); const { staffNo, password, platformID } = body; if (!staffNo || !password) return fail(res, '工号和密码不能为空'); const user = USERS[staffNo]; if (!user || user.password !== String(password)) return fail(res, '工号或密码错误'); const t = await getImUserToken(staffNo, Number.isInteger(platformID) ? platformID : 3); return ok(res, { userID: staffNo, nickname: user.nickname, imToken: t.token, expireTimeSeconds: t.expireTimeSeconds, }); } if (req.method === 'POST' && url.pathname === '/api/rtc_token') { // 与真实账号服务一致:Authorization: Bearer + {room, identity},返回 {token} const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || '')); if (!m) return fail(res, '未登录或登录已过期', 401); const body = await readBody(req); const { room, identity } = body; if (!room || !identity) return fail(res, '房间号或用户标识不能为空'); return ok(res, { token: signLivekitToken(String(identity), String(room)) }); } return fail(res, '接口不存在', 404); } catch (e) { return fail(res, '服务内部错误: ' + e.message, 500); } }); server.listen(PORT, '127.0.0.1', () => { console.log(`mock-account-server listening on http://127.0.0.1:${PORT}`); console.log('测试账号:test001 / test123,test002 / test123'); });