新增 account-server:自研工号+密码登录体系(B-60)
- account-service/:零依赖 Node 单文件服务 + 极简管理页 + Dockerfile - 员工账号管理员导入/新增/停用/启用/重置密码,JSON 文件存储,密码 scrypt 加盐哈希 - 登录校验后调 OpenIM REST 自动开户并换取 IM token;停用即拒登并逐平台 force_logout - 并入 docker-compose(端口 10010,管理口令 ACCOUNT_ADMIN_TOKEN) - 已对 192.168.200.11 真实环境端到端自测:导入 50 人、双端登录、停用拒登、重置密码全部通过
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 公司账号服务(内部通讯 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';
|
||||
|
||||
// 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 { /* 忽略 */ }
|
||||
}
|
||||
|
||||
// ---------------- 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',
|
||||
'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 });
|
||||
},
|
||||
|
||||
'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));
|
||||
},
|
||||
|
||||
'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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user