Files
tongxunruanjian/account-service/server.js
T
KIMI 7ce40e356f 新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)
- 基于 openim-electron-demo 改造:工号+密码登录(自研账号服务)、会话列表/聊天窗/通讯录界面重做
- 文字/语音/文件/图片消息、文件拖拽发送、表情面板、一对一语音通话(LiveKit)
- RTC 令牌对接账号服务带鉴权版 /api/rtc_token(Bearer imToken + {room,identity})
- account-service: CORS Allow-Headers 补 authorization(浏览器/渲染进程跨域调 rtc_token 需要)
- 附本地 mock 账号服务(scripts/mock-account-server.js)与截图联调脚本
2026-08-09 09:11:06 +08:00

357 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 公司账号服务(内部通讯 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 访问 tokenHS256 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);
});
}
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 }),
'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 <imToken>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();
ok(res, publicView(employees[body.staffNo], body.staffNo));
},
};
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 === '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}`);
});