Files
tongxunruanjian/account-service/server.js
T
编码工程师andmultica-agent 12b0a1c943 feat(mobile,account-service): 统一发送服务 + 员工目录接口 + 好友申请五态/验证消息(B-52 施工 2/3)
- message_send_service:所有消息走统一门禁(登录/连接/空接收方/好友校验),
  失败打印 SDK 原始 code 并映射中文提示,不再只吞异常
- chat_screen 接入统一发送服务,失败提示真实原因
- account-service 新增 GET /api/directory(登录用户可读启用员工,用于添加同事)
- add_friend_screen 增加可编辑验证消息(视觉规范 4.2)
- new_friends_screen 五态 + 窄屏(<360)按钮组换行不挤压姓名(视觉规范 5.3)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-16 02:16:00 +08:00

385 lines
16 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);
});
}
/** 从已解析的 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 <OpenIM imToken>(与 /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 <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();
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}`);
});