From 0f72fce82b30466f7da090c5eaeb9c367a84aecb Mon Sep 17 00:00:00 2001 From: KIMI Date: Sun, 9 Aug 2026 00:39:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20account-server=EF=BC=9A?= =?UTF-8?q?=E8=87=AA=E7=A0=94=E5=B7=A5=E5=8F=B7+=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=BD=93=E7=B3=BB=EF=BC=88B-60=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account-service/:零依赖 Node 单文件服务 + 极简管理页 + Dockerfile - 员工账号管理员导入/新增/停用/启用/重置密码,JSON 文件存储,密码 scrypt 加盐哈希 - 登录校验后调 OpenIM REST 自动开户并换取 IM token;停用即拒登并逐平台 force_logout - 并入 docker-compose(端口 10010,管理口令 ACCOUNT_ADMIN_TOKEN) - 已对 192.168.200.11 真实环境端到端自测:导入 50 人、双端登录、停用拒登、重置密码全部通过 --- .env.example | 6 + .gitignore | 1 + README.md | 4 +- account-service/Dockerfile | 6 + account-service/README.md | 58 ++++++++ account-service/admin.html | 154 +++++++++++++++++++ account-service/server.js | 297 +++++++++++++++++++++++++++++++++++++ docker-compose.yaml | 21 +++ 8 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 account-service/Dockerfile create mode 100644 account-service/README.md create mode 100644 account-service/admin.html create mode 100644 account-service/server.js diff --git a/.env.example b/.env.example index a9bae20..1f74bbe 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,12 @@ LIVEKIT_PORT=7880 LIVEKIT_RTC_TCP_PORT=7881 LIVEKIT_RTC_UDP_PORT=7882 +# ===== 公司账号服务(工号+密码登录,自研)===== +# 宿主端口;管理页 http://:10010/ +ACCOUNT_PORT=10010 +# 管理页/管理接口口令,部署时务必改掉 +ACCOUNT_ADMIN_TOKEN=admin123 + # ===== 监控端口(可选)===== PROMETHEUS_PORT=19090 ALERTMANAGER_PORT=19093 diff --git a/.gitignore b/.gitignore index 9043519..35dba22 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .env components/ .selftest/ +data/ diff --git a/README.md b/README.md index b236c3a..eb29280 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ | --- | --- | --- | | OpenIM Server | `openim/openim-server:v3.8.3-patch.12` | IM 服务端(REST API + WebSocket 消息网关) | | LiveKit | `livekit/livekit-server:v1.13.5` | 语音通话 SFU | +| account-server | 本地构建(`account-service/`) | 自研公司账号登录(工号+密码、管理员导入名单、管理页),见 `account-service/README.md` | | MongoDB / Redis / Etcd / Kafka / MinIO | 见 `.env.example` | OpenIM 依赖组件 | 按任务约束做的裁剪(相对官方 openim-docker v3.8): @@ -24,13 +25,14 @@ - 已安装 Docker(含 compose 插件,`docker compose version` 能跑通即可) - 不需要公网入口 -## 端口表(需要对客户端开放的只有前 5 个) +## 端口表(需要对客户端开放的只有前 6 个) | 端口 | 协议 | 用途 | | --- | --- | --- | | 10001 | TCP/WS | OpenIM 消息网关(客户端长连接) | | 10002 | TCP/HTTP | OpenIM REST API | | 10005 | TCP/HTTP | MinIO(图片/语音/文件下载) | +| 10010 | TCP/HTTP | 公司账号服务(员工登录接口 + 管理页) | | 7880 | TCP/WS | LiveKit 信令(`.env` 的 `LIVEKIT_PORT` 可改) | | 7882 | UDP | LiveKit 语音媒体(`LIVEKIT_RTC_UDP_PORT` 可改) | | 7881 | TCP | LiveKit 媒体备用通道(`LIVEKIT_RTC_TCP_PORT` 可改) | diff --git a/account-service/Dockerfile b/account-service/Dockerfile new file mode 100644 index 0000000..acbf676 --- /dev/null +++ b/account-service/Dockerfile @@ -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"] diff --git a/account-service/README.md b/account-service/README.md new file mode 100644 index 0000000..fb5c90f --- /dev/null +++ b/account-service/README.md @@ -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": "", "expireTimeSeconds": 7776000 } } +``` + +客户端拿到后用 `userID + imToken` 走 OpenIM SDK 登录;OpenIM 的 API 地址为 `http://<服务器IP>:10002`,WebSocket 为 `ws://<服务器IP>:10001`。 + +失败:`code != 0`,`msg` 为大白话原因(工号或密码错误 / 账号已停用,请联系管理员)。 + +### 管理接口(请求头带 `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`,重建容器不丢。 diff --git a/account-service/admin.html b/account-service/admin.html new file mode 100644 index 0000000..118db0a --- /dev/null +++ b/account-service/admin.html @@ -0,0 +1,154 @@ + + + + + +员工账号管理 + + + +
+

员工账号管理

+ +
+
+ + +
+
+
+ +
+

批量导入员工名单

+ +
支持逗号或 Tab 分隔(Excel 复制出来直接粘贴即可);重复工号会覆盖原账号(姓名与密码重置、状态恢复为启用)。
+
+ +
+
+ +
+

添加员工

+
+ + + + +
+
+ +
+

员工列表

+
+ + + +
工号姓名状态更新时间操作
尚未加载
+
+
+ + + + diff --git a/account-service/server.js b/account-service/server.js new file mode 100644 index 0000000..7435fa6 --- /dev/null +++ b/account-service/server.js @@ -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}`); +}); diff --git a/docker-compose.yaml b/docker-compose.yaml index dec6903..71830d6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -386,3 +386,24 @@ services: - ./config/livekit.yaml:/etc/livekit.yaml networks: - openim + + # 公司账号服务:自研工号+密码登录(替代 OpenIM 样板注册登录),含极简管理页。 + # 员工客户端调 http://:10010/api/login 拿 OpenIM token; + # 管理员开 http://: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