/** * 公司账号服务(内部通讯 app) * * 职责: * - 员工账号:管理员导入/新增/停用/启用/重置密码(本地 JSON 文件存储,密码 scrypt 哈希) * - 登录:校验工号+密码后,调用 OpenIM REST API 换取 IM token 返回给客户端 * - 管理页:GET / 直接返回内置的单文件管理页 admin.html * * 零 npm 依赖,Node >= 18(用到全局 fetch)。 * * 环境变量: * PORT 监听端口,默认 10010 * DATA_FILE 员工数据文件,默认 ./data/employees.json * OPENIM_API_URL OpenIM REST 地址,默认 http://127.0.0.1:10002 * OPENIM_SECRET OpenIM 管理密钥(与服务端 IMENV_SHARE_SECRET 一致),默认 openIM123 * ADMIN_TOKEN 管理页/管理接口口令,默认 admin123(部署时务必修改) */ 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const PORT = parseInt(process.env.PORT || '10010', 10); const DATA_FILE = process.env.DATA_FILE || path.join(__dirname, 'data', 'employees.json'); const OPENIM_API_URL = (process.env.OPENIM_API_URL || 'http://127.0.0.1:10002').replace(/\/+$/, ''); const OPENIM_SECRET = process.env.OPENIM_SECRET || 'openIM123'; const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'admin123'; // LiveKit 语音通话(与 livekit 容器同一对 key/secret,见 .env 的 LIVEKIT_API_KEY / LIVEKIT_API_SECRET) const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || ''; const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || ''; // OpenIM 平台号:1 iOS, 2 Android, 3 Windows, 4 OSX, 5 Web... 停用时逐个踢下线 const ALL_PLATFORM_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // ---------------- 员工数据存储(JSON 文件) ---------------- let employees = {}; // staffNo -> { name, salt, hash, status, createdAt, updatedAt } function loadStore() { try { employees = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch { employees = {}; } } function saveStore() { fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); const tmp = DATA_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(employees, null, 2)); fs.renameSync(tmp, DATA_FILE); } function hashPassword(password, salt) { return crypto.scryptSync(password, salt, 32).toString('hex'); } function verifyPassword(emp, password) { const a = Buffer.from(emp.hash, 'hex'); const b = Buffer.from(hashPassword(password, emp.salt), 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } const STAFF_NO_RE = /^[A-Za-z0-9_-]{2,32}$/; function upsertEmployee(staffNo, name, password) { const salt = crypto.randomBytes(16).toString('hex'); const now = new Date().toISOString(); employees[staffNo] = { name, salt, hash: hashPassword(password, salt), status: 'active', createdAt: employees[staffNo] ? employees[staffNo].createdAt : now, updatedAt: now, }; saveStore(); } // ---------------- OpenIM REST 客户端 ---------------- let adminTokenCache = { token: null, expireAt: 0 }; 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() { if (adminTokenCache.token && Date.now() < adminTokenCache.expireAt - 60000) { return adminTokenCache.token; } const r = await imApi('/auth/get_admin_token', { secret: OPENIM_SECRET, platformID: 10, userID: 'imAdmin', }); if (r.errCode !== 0) throw new Error('OpenIM 管理员令牌获取失败: ' + (r.errMsg || r.errCode)); adminTokenCache = { token: r.data.token, expireAt: Date.now() + r.data.expireTimeSeconds * 1000, }; return adminTokenCache.token; } /** 确保 OpenIM 里存在该用户;返回 true 表示已存在 */ async function ensureImUser(staffNo, name) { const admin = await getAdminToken(); await imApi('/user/user_register', { users: [{ userID: staffNo, nickname: name, faceURL: '' }], }, admin); // 注册接口对已存在用户的行为各版本不一,统一以查询结果为准 const q = await imApi('/user/get_users_info', { userIDs: [staffNo] }, admin); return q.errCode === 0 && Array.isArray(q.data?.usersInfo) && q.data.usersInfo.length > 0; } async function getImUserToken(staffNo, platformID) { const admin = await getAdminToken(); const r = await imApi('/auth/get_user_token', { userID: staffNo, platformID }, admin); if (r.errCode !== 0) throw new Error('OpenIM 用户令牌获取失败: ' + (r.errMsg || r.errCode)); return r.data; } /** 停用后踢下线(逐平台,尽力而为,失败不阻断) */ async function forceLogoutAll(staffNo) { try { const admin = await getAdminToken(); await Promise.all(ALL_PLATFORM_IDS.map((pid) => imApi('/auth/force_logout', { platformID: pid, userID: staffNo }, admin).catch(() => {}) )); } catch { /* 忽略 */ } } // ---------------- LiveKit 通话 token ---------------- /** 用 OpenIM parse_token 校验客户端带来的 IM token,返回对应的 userID(无效/过期返回 null) */ async function parseImToken(token) { try { const admin = await getAdminToken(); const r = await imApi('/auth/parse_token', { token }, admin); if (r.errCode !== 0 || !r.data || !r.data.userID) return null; return String(r.data.userID); } catch { return null; } } /** 签 LiveKit 访问 token(HS256 JWT,零依赖手写;claims 格式与 livekit-server 约定一致) */ function signLivekitToken(identity, room) { const now = Math.floor(Date.now() / 1000); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); const data = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({ iss: LIVEKIT_API_KEY, sub: identity, iat: now, nbf: now - 10, exp: now + 2 * 3600, // 2 小时,一场通话足够 video: { roomJoin: true, room }, }); const sig = crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(data).digest('base64url'); return data + '.' + sig; } // ---------------- HTTP 服务 ---------------- function send(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, admin-token, authorization', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', }); res.end(body); } 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; if (buf.length > 2 * 1024 * 1024) reject(new Error('请求体过大')); }); req.on('end', () => { if (!buf) return resolve({}); try { resolve(JSON.parse(buf)); } catch { reject(new Error('请求体不是合法 JSON')); } }); req.on('error', reject); }); } /** 从已解析的 URL 里取查询参数(原样返回,未解码处理由调用方负责) */ function urlParam(req, key) { return req.urlObj ? (req.urlObj.searchParams.get(key) || '') : ''; } function checkAdmin(req, res) { const t = req.headers['admin-token'] || ''; const a = Buffer.from(String(t)); const b = Buffer.from(ADMIN_TOKEN); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { fail(res, '管理口令错误或未提供', 401); return false; } return true; } function publicView(emp, staffNo) { return { staffNo, name: emp.name, status: emp.status, createdAt: emp.createdAt, updatedAt: emp.updatedAt }; } const routes = { 'GET /api/health': async (req, res) => ok(res, { status: 'up', employees: Object.keys(employees).length }), // 员工目录:登录用户可读的启用员工列表(供客户端「添加同事」搜索)。 // 请求头 Authorization: Bearer (与 /api/rtc_token 一致,用 parse_token 校验身份)。 // 只返回启用员工、排除自己,不返回密码/手机号/管理字段。 'GET /api/directory': async (req, res) => { const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || '')); if (!m) return fail(res, '未登录或登录已过期', 401); const userID = await parseImToken(m[1]); if (!userID) return fail(res, '登录已过期,请重新登录', 401); const keyword = (urlParam(req, 'keyword') || '').trim(); const limit = Math.min(Math.max(parseInt(urlParam(req, 'limit') || '20', 10) || 20, 1), 100); let list = Object.keys(employees) .filter((k) => employees[k].status === 'active' && k !== userID) .map((k) => ({ userID: k, nickname: employees[k].name, department: '', title: '', faceURL: '' })); if (keyword) { const kw = keyword.toLowerCase(); list = list.filter((u) => u.userID.toLowerCase().includes(kw) || u.nickname.toLowerCase().includes(kw)); } const items = list.slice(0, limit); ok(res, { items, nextCursor: null }); }, 'POST /api/login': async (req, res, body) => { const { staffNo, password, platformID } = body; if (!staffNo || !password) return fail(res, '工号和密码不能为空'); const emp = employees[staffNo]; if (!emp || !verifyPassword(emp, String(password))) return fail(res, '工号或密码错误'); if (emp.status !== 'active') return fail(res, '账号已停用,请联系管理员'); const pid = Number.isInteger(platformID) && platformID >= 1 && platformID <= 10 ? platformID : 10; // 兜底:OpenIM 侧账号不存在(例如服务端重建过)时先补开户 if (!(await ensureImUser(staffNo, emp.name))) return fail(res, 'IM 开户失败,请联系管理员', 500); const t = await getImUserToken(staffNo, pid); ok(res, { userID: staffNo, nickname: emp.name, imToken: t.token, expireTimeSeconds: t.expireTimeSeconds }); }, // 语音通话:用登录拿到的 IM token 换 LiveKit 进房 token // 请求头 Authorization: Bearer ,body {room, identity} 'POST /api/rtc_token': async (req, res, body) => { if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) return fail(res, '语音通话服务未配置,请联系管理员', 500); const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || '')); if (!m) return fail(res, '未登录或登录已过期', 401); const { room, identity } = body; if (!room || typeof room !== 'string' || room.length > 128) return fail(res, '房间号不合法'); if (!identity || typeof identity !== 'string') return fail(res, '缺少用户标识'); const userID = await parseImToken(m[1]); if (!userID) return fail(res, '登录已过期,请重新登录', 401); if (userID !== identity) return fail(res, '用户标识与登录凭证不一致', 403); ok(res, { token: signLivekitToken(identity, room) }); }, 'GET /api/admin/employees': async (req, res) => { const list = Object.keys(employees).sort().map((k) => publicView(employees[k], k)); ok(res, { total: list.length, employees: list }); }, 'POST /api/admin/employees': async (req, res, body) => { const { staffNo, name, password } = body; if (!STAFF_NO_RE.test(staffNo || '')) return fail(res, '工号需为 2-32 位字母/数字/中划线/下划线'); if (!name || !String(name).trim()) return fail(res, '姓名不能为空'); if (!password || String(password).length < 6) return fail(res, '初始密码至少 6 位'); if (!(await ensureImUser(staffNo, String(name).trim()))) return fail(res, 'IM 开户失败', 500); upsertEmployee(staffNo, String(name).trim(), String(password)); ok(res, publicView(employees[staffNo], staffNo)); }, // 批量导入:csv 文本,每行 "工号,姓名,初始密码"(也支持 Tab 分隔),首行为表头时自动跳过 'POST /api/admin/import': async (req, res, body) => { const lines = String(body.csv || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean); if (lines.length === 0) return fail(res, '名单为空'); const results = []; for (const line of lines) { const parts = line.split(/[,\t]/).map((s) => s.trim()); const [staffNo, name, password] = parts; if (!STAFF_NO_RE.test(staffNo || '') || /工号|staff/i.test(staffNo || '')) { results.push({ line, ok: false, msg: parts.length < 3 ? '格式错误或表头行' : '工号不合法' }); continue; } if (parts.length < 3 || !name) { results.push({ line, ok: false, msg: '格式:工号,姓名,初始密码' }); continue; } if (String(password).length < 6) { results.push({ line, ok: false, msg: '密码至少 6 位' }); continue; } try { if (!(await ensureImUser(staffNo, name))) { results.push({ line, ok: false, msg: 'IM 开户失败' }); continue; } upsertEmployee(staffNo, name, String(password)); results.push({ line, ok: true, staffNo, name }); } catch (e) { results.push({ line, ok: false, msg: e.message }); } } ok(res, { total: results.length, imported: results.filter((r) => r.ok).length, results }); }, 'POST /api/admin/disable': async (req, res, body) => { const emp = employees[body.staffNo]; if (!emp) return fail(res, '员工不存在'); emp.status = 'disabled'; emp.updatedAt = new Date().toISOString(); saveStore(); forceLogoutAll(body.staffNo); // 异步踢下线,不等结果 ok(res, publicView(emp, body.staffNo)); }, 'POST /api/admin/enable': async (req, res, body) => { const emp = employees[body.staffNo]; if (!emp) return fail(res, '员工不存在'); emp.status = 'active'; emp.updatedAt = new Date().toISOString(); saveStore(); ok(res, publicView(emp, body.staffNo)); }, // 删除员工:本地账号直接移除(立即无法再登录并被踢下线)。 // 注意:OpenIM 侧的同名用户不删(其公开 API 不提供删除),但删除后本服务不再为其签发令牌,等于不可用。 'POST /api/admin/delete': async (req, res, body) => { const emp = employees[body.staffNo]; if (!emp) return fail(res, '员工不存在'); delete employees[body.staffNo]; saveStore(); forceLogoutAll(body.staffNo); // 异步踢下线,不等结果 ok(res, { staffNo: body.staffNo, deleted: true }); }, 'POST /api/admin/reset_password': async (req, res, body) => { const emp = employees[body.staffNo]; if (!emp) return fail(res, '员工不存在'); if (!body.password || String(body.password).length < 6) return fail(res, '新密码至少 6 位'); upsertEmployee(body.staffNo, emp.name, String(body.password)); if (employees[body.staffNo].status !== 'active') employees[body.staffNo].status = emp.status; // 保留停用状态 saveStore(); forceLogoutAll(body.staffNo); // 密码已改,立即踢掉该员工所有端,防止旧 token 继续使用 ok(res, publicView(employees[body.staffNo], body.staffNo)); }, }; const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://localhost'); req.urlObj = url; if (req.method === 'OPTIONS') return send(res, 204, {}); // 管理页(免登录打开,页面内再输入管理口令) if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/admin')) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return fs.createReadStream(path.join(__dirname, 'admin.html')).pipe(res); } const key = `${req.method} ${url.pathname}`; const handler = routes[key]; if (!handler) return fail(res, '接口不存在', 404); if (url.pathname.startsWith('/api/admin/') && !checkAdmin(req, res)) return; const body = req.method === 'POST' ? await readBody(req) : {}; await handler(req, res, body); } catch (e) { fail(res, '服务内部错误: ' + e.message, 500); } }); loadStore(); server.listen(PORT, () => { console.log(`account-server listening on :${PORT}, OpenIM API: ${OPENIM_API_URL}, employees: ${Object.keys(employees).length}`); });