新增 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:
@@ -70,6 +70,12 @@ LIVEKIT_PORT=7880
|
|||||||
LIVEKIT_RTC_TCP_PORT=7881
|
LIVEKIT_RTC_TCP_PORT=7881
|
||||||
LIVEKIT_RTC_UDP_PORT=7882
|
LIVEKIT_RTC_UDP_PORT=7882
|
||||||
|
|
||||||
|
# ===== 公司账号服务(工号+密码登录,自研)=====
|
||||||
|
# 宿主端口;管理页 http://<SERVER_IP>:10010/
|
||||||
|
ACCOUNT_PORT=10010
|
||||||
|
# 管理页/管理接口口令,部署时务必改掉
|
||||||
|
ACCOUNT_ADMIN_TOKEN=admin123
|
||||||
|
|
||||||
# ===== 监控端口(可选)=====
|
# ===== 监控端口(可选)=====
|
||||||
PROMETHEUS_PORT=19090
|
PROMETHEUS_PORT=19090
|
||||||
ALERTMANAGER_PORT=19093
|
ALERTMANAGER_PORT=19093
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
.env
|
.env
|
||||||
components/
|
components/
|
||||||
.selftest/
|
.selftest/
|
||||||
|
data/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| OpenIM Server | `openim/openim-server:v3.8.3-patch.12` | IM 服务端(REST API + WebSocket 消息网关) |
|
| OpenIM Server | `openim/openim-server:v3.8.3-patch.12` | IM 服务端(REST API + WebSocket 消息网关) |
|
||||||
| LiveKit | `livekit/livekit-server:v1.13.5` | 语音通话 SFU |
|
| LiveKit | `livekit/livekit-server:v1.13.5` | 语音通话 SFU |
|
||||||
|
| account-server | 本地构建(`account-service/`) | 自研公司账号登录(工号+密码、管理员导入名单、管理页),见 `account-service/README.md` |
|
||||||
| MongoDB / Redis / Etcd / Kafka / MinIO | 见 `.env.example` | OpenIM 依赖组件 |
|
| MongoDB / Redis / Etcd / Kafka / MinIO | 见 `.env.example` | OpenIM 依赖组件 |
|
||||||
|
|
||||||
按任务约束做的裁剪(相对官方 openim-docker v3.8):
|
按任务约束做的裁剪(相对官方 openim-docker v3.8):
|
||||||
@@ -24,13 +25,14 @@
|
|||||||
- 已安装 Docker(含 compose 插件,`docker compose version` 能跑通即可)
|
- 已安装 Docker(含 compose 插件,`docker compose version` 能跑通即可)
|
||||||
- 不需要公网入口
|
- 不需要公网入口
|
||||||
|
|
||||||
## 端口表(需要对客户端开放的只有前 5 个)
|
## 端口表(需要对客户端开放的只有前 6 个)
|
||||||
|
|
||||||
| 端口 | 协议 | 用途 |
|
| 端口 | 协议 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 10001 | TCP/WS | OpenIM 消息网关(客户端长连接) |
|
| 10001 | TCP/WS | OpenIM 消息网关(客户端长连接) |
|
||||||
| 10002 | TCP/HTTP | OpenIM REST API |
|
| 10002 | TCP/HTTP | OpenIM REST API |
|
||||||
| 10005 | TCP/HTTP | MinIO(图片/语音/文件下载) |
|
| 10005 | TCP/HTTP | MinIO(图片/语音/文件下载) |
|
||||||
|
| 10010 | TCP/HTTP | 公司账号服务(员工登录接口 + 管理页) |
|
||||||
| 7880 | TCP/WS | LiveKit 信令(`.env` 的 `LIVEKIT_PORT` 可改) |
|
| 7880 | TCP/WS | LiveKit 信令(`.env` 的 `LIVEKIT_PORT` 可改) |
|
||||||
| 7882 | UDP | LiveKit 语音媒体(`LIVEKIT_RTC_UDP_PORT` 可改) |
|
| 7882 | UDP | LiveKit 语音媒体(`LIVEKIT_RTC_UDP_PORT` 可改) |
|
||||||
| 7881 | TCP | LiveKit 媒体备用通道(`LIVEKIT_RTC_TCP_PORT` 可改) |
|
| 7881 | TCP | LiveKit 媒体备用通道(`LIVEKIT_RTC_TCP_PORT` 可改) |
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.js admin.html ./
|
||||||
|
ENV PORT=10010 DATA_FILE=/app/data/employees.json
|
||||||
|
EXPOSE 10010
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 公司账号服务(account-service)
|
||||||
|
|
||||||
|
内部通讯 app 的自研登录体系:替代 OpenIM 样板的注册登录(open-im-chat,GPLv3,不用)。
|
||||||
|
|
||||||
|
- 员工账号只能由管理员开通:批量导入 / 单个添加 / 停用 / 启用 / 重置密码
|
||||||
|
- 员工用 **工号 + 密码** 登录;校验通过后,本服务向 OpenIM 换取 IM token 返回客户端
|
||||||
|
- 员工数据存在本地 JSON 文件(容器内 `/app/data/employees.json`,已挂卷),密码 scrypt 加盐哈希,不落明文
|
||||||
|
- 停用 = 本服务拒绝登录 + 调 OpenIM `force_logout` 踢掉各端在线连接
|
||||||
|
- 零 npm 依赖,Node 20 单文件服务
|
||||||
|
|
||||||
|
## 接口约定(供手机端 / PC 端对接)
|
||||||
|
|
||||||
|
### 员工登录(客户端调用)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/login
|
||||||
|
{ "staffNo": "10001", "password": "xxx", "platformID": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`platformID` 按端传:1 iOS、2 Android、3 Windows、4 macOS、5/10 Web(不传默认 10)。
|
||||||
|
|
||||||
|
成功:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 0, "msg": "", "data": { "userID": "10001", "nickname": "张三", "imToken": "<openim token>", "expireTimeSeconds": 7776000 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端拿到后用 `userID + imToken` 走 OpenIM SDK 登录;OpenIM 的 API 地址为 `http://<服务器IP>:10002`,WebSocket 为 `ws://<服务器IP>:10001`。
|
||||||
|
|
||||||
|
失败:`code != 0`,`msg` 为大白话原因(工号或密码错误 / 账号已停用,请联系管理员)。
|
||||||
|
|
||||||
|
### 管理接口(请求头带 `admin-token: <ACCOUNT_ADMIN_TOKEN>`)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/admin/employees` | 员工列表 |
|
||||||
|
| POST | `/api/admin/employees` | 添加 `{staffNo, name, password}` |
|
||||||
|
| POST | `/api/admin/import` | 批量导入 `{csv}`,每行 `工号,姓名,初始密码`(Tab 分隔也行,Excel 直接粘) |
|
||||||
|
| POST | `/api/admin/disable` | 停用 `{staffNo}`,立即无法登录并被踢下线 |
|
||||||
|
| POST | `/api/admin/enable` | 启用 `{staffNo}` |
|
||||||
|
| POST | `/api/admin/reset_password` | 重置密码 `{staffNo, password}` |
|
||||||
|
|
||||||
|
管理页:浏览器打开 `http://<服务器IP>:10010/`,输入管理口令即可操作,无需写接口。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
已并入根目录 `docker-compose.yaml`(服务名 `account-server`),随整套环境一起起:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build account-server
|
||||||
|
```
|
||||||
|
|
||||||
|
相关环境变量(见 `.env.example`):
|
||||||
|
|
||||||
|
- `ACCOUNT_PORT`:宿主端口,默认 10010
|
||||||
|
- `ACCOUNT_ADMIN_TOKEN`:管理口令,**部署时务必改掉默认值**
|
||||||
|
|
||||||
|
重启/升级:改代码后 `docker compose up -d --build account-server`;数据在 `./data/account/employees.json`,重建容器不丢。
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>员工账号管理</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, "Microsoft YaHei", "PingFang SC", sans-serif; background: #f3f5f7; color: #1f2329; padding: 24px; }
|
||||||
|
.wrap { max-width: 920px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 16px; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||||
|
.card h2 { font-size: 15px; margin-bottom: 12px; }
|
||||||
|
input, textarea, button { font: inherit; }
|
||||||
|
input, textarea { border: 1px solid #d0d5dd; border-radius: 6px; padding: 8px 10px; outline: none; }
|
||||||
|
input:focus, textarea:focus { border-color: #1677ff; }
|
||||||
|
button { border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; background: #1677ff; color: #fff; }
|
||||||
|
button.ghost { background: #eef1f5; color: #1f2329; }
|
||||||
|
button.danger { background: #f04438; }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.row input { flex: 1; min-width: 140px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #eef1f5; }
|
||||||
|
th { color: #667085; font-weight: 500; }
|
||||||
|
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||||
|
.tag.on { background: #e7f6ec; color: #12934a; }
|
||||||
|
.tag.off { background: #fdecea; color: #d92d20; }
|
||||||
|
td button { padding: 4px 10px; font-size: 13px; margin-right: 6px; }
|
||||||
|
textarea { width: 100%; min-height: 110px; font-family: inherit; resize: vertical; }
|
||||||
|
.msg { margin-top: 10px; font-size: 13px; white-space: pre-wrap; max-height: 200px; overflow: auto; }
|
||||||
|
.msg.err { color: #d92d20; }
|
||||||
|
.msg.okm { color: #12934a; }
|
||||||
|
.hint { font-size: 12px; color: #98a2b3; margin-top: 6px; }
|
||||||
|
#loginBar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
#loginBar input { flex: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>员工账号管理</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div id="loginBar">
|
||||||
|
<input id="token" type="password" placeholder="请输入管理口令">
|
||||||
|
<button onclick="saveToken()">确定</button>
|
||||||
|
</div>
|
||||||
|
<div id="msg" class="msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>批量导入员工名单</h2>
|
||||||
|
<textarea id="csv" placeholder="每行一条:工号,姓名,初始密码 例如: 10001,张三,abc123456 10002,李四,abc123456"></textarea>
|
||||||
|
<div class="hint">支持逗号或 Tab 分隔(Excel 复制出来直接粘贴即可);重复工号会覆盖原账号(姓名与密码重置、状态恢复为启用)。</div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<button onclick="doImport()">导入</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>添加员工</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="addNo" placeholder="工号">
|
||||||
|
<input id="addName" placeholder="姓名">
|
||||||
|
<input id="addPwd" placeholder="初始密码(至少6位)">
|
||||||
|
<button onclick="addEmp()">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>员工列表 <span id="total" style="color:#98a2b3;font-weight:400"></span></h2>
|
||||||
|
<div class="row" style="margin-bottom:10px"><button class="ghost" onclick="loadList()">刷新</button></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>工号</th><th>姓名</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="tbody"><tr><td colspan="5" style="color:#98a2b3">尚未加载</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
$('token').value = localStorage.getItem('adminToken') || '';
|
||||||
|
|
||||||
|
function saveToken() { localStorage.setItem('adminToken', $('token').value.trim()); show('口令已保存', true); loadList(); }
|
||||||
|
function show(text, ok) { const m = $('msg'); m.textContent = text; m.className = 'msg ' + (ok ? 'okm' : 'err'); }
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: body ? 'POST' : 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'admin-token': $('token').value.trim() },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.code !== 0) throw new Error(j.msg || '请求失败');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/admin/employees');
|
||||||
|
$('total').textContent = '(共 ' + d.total + ' 人)';
|
||||||
|
$('tbody').innerHTML = d.employees.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${e.staffNo}</td><td>${e.name}</td>
|
||||||
|
<td><span class="tag ${e.status === 'active' ? 'on' : 'off'}">${e.status === 'active' ? '启用' : '已停用'}</span></td>
|
||||||
|
<td>${(e.updatedAt || '').replace('T', ' ').slice(0, 19)}</td>
|
||||||
|
<td>
|
||||||
|
${e.status === 'active'
|
||||||
|
? `<button class="danger" onclick="toggle('${e.staffNo}', false)">停用</button>`
|
||||||
|
: `<button class="ghost" onclick="toggle('${e.staffNo}', true)">启用</button>`}
|
||||||
|
<button class="ghost" onclick="resetPwd('${e.staffNo}')">重置密码</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('') || '<tr><td colspan="5" style="color:#98a2b3">暂无员工,请先导入</td></tr>';
|
||||||
|
} catch (e) { show(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(staffNo, enable) {
|
||||||
|
try {
|
||||||
|
await api('/api/admin/' + (enable ? 'enable' : 'disable'), { staffNo });
|
||||||
|
show((enable ? '已启用 ' : '已停用 ') + staffNo, true);
|
||||||
|
loadList();
|
||||||
|
} catch (e) { show(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPwd(staffNo) {
|
||||||
|
const pwd = prompt('为 ' + staffNo + ' 设置新密码(至少 6 位):');
|
||||||
|
if (!pwd) return;
|
||||||
|
try {
|
||||||
|
await api('/api/admin/reset_password', { staffNo, password: pwd });
|
||||||
|
show('已重置 ' + staffNo + ' 的密码', true);
|
||||||
|
loadList();
|
||||||
|
} catch (e) { show(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEmp() {
|
||||||
|
try {
|
||||||
|
await api('/api/admin/employees', { staffNo: $('addNo').value.trim(), name: $('addName').value.trim(), password: $('addPwd').value });
|
||||||
|
show('已添加 ' + $('addNo').value.trim(), true);
|
||||||
|
$('addNo').value = $('addName').value = $('addPwd').value = '';
|
||||||
|
loadList();
|
||||||
|
} catch (e) { show(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/admin/import', { csv: $('csv').value });
|
||||||
|
const bad = d.results.filter(r => !r.ok);
|
||||||
|
show('导入完成:成功 ' + d.imported + ' / ' + d.total + (bad.length ? '\n失败明细:\n' + bad.map(b => b.line + ' → ' + b.msg).join('\n') : ''), bad.length === 0);
|
||||||
|
loadList();
|
||||||
|
} catch (e) { show(e.message, false); }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -386,3 +386,24 @@ services:
|
|||||||
- ./config/livekit.yaml:/etc/livekit.yaml
|
- ./config/livekit.yaml:/etc/livekit.yaml
|
||||||
networks:
|
networks:
|
||||||
- openim
|
- openim
|
||||||
|
|
||||||
|
# 公司账号服务:自研工号+密码登录(替代 OpenIM 样板注册登录),含极简管理页。
|
||||||
|
# 员工客户端调 http://<SERVER_IP>:10010/api/login 拿 OpenIM token;
|
||||||
|
# 管理员开 http://<SERVER_IP>:10010/ 加人/停人/重置密码(口令见 .env ACCOUNT_ADMIN_TOKEN)。
|
||||||
|
account-server:
|
||||||
|
build: ./account-service
|
||||||
|
image: tongxun-account-server:latest
|
||||||
|
container_name: account-server
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- OPENIM_API_URL=http://openim-server:10002
|
||||||
|
- OPENIM_SECRET=${OPENIM_SECRET}
|
||||||
|
- ADMIN_TOKEN=${ACCOUNT_ADMIN_TOKEN:-admin123}
|
||||||
|
ports:
|
||||||
|
- "${ACCOUNT_PORT:-10010}:10010"
|
||||||
|
volumes:
|
||||||
|
- ./data/account:/app/data
|
||||||
|
depends_on:
|
||||||
|
- openim-server
|
||||||
|
networks:
|
||||||
|
- openim
|
||||||
|
|||||||
Reference in New Issue
Block a user