- 基于 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)与截图联调脚本
162 lines
5.7 KiB
JavaScript
162 lines
5.7 KiB
JavaScript
/**
|
||
* 本地开发用 mock 账号服务(畅联 PC 端联调用)
|
||
*
|
||
* ⚠️ 仅供本地开发联调!
|
||
* - 内置测试环境默认密钥(OpenIM secret / LiveKit key),都是内网测试环境的公开默认值
|
||
* - 禁止打包进客户端、禁止用于正式环境
|
||
* - 真实员工账号由管理员在服务器上的账号服务(:10010)开通
|
||
*
|
||
* 用法:
|
||
* 1. node scripts/mock-account-server.js(或 npm run mock:account)
|
||
* 2. 把 .env 里的 VITE_ACCOUNT_URL 临时指向 http://127.0.0.1:11010
|
||
* 3. 用 test001 / test123(或 test002 / test123)登录
|
||
*
|
||
* 接口形状与真实账号服务完全一致:POST /api/login、POST /api/rtc_token。
|
||
* 零依赖,Node >= 18(用到全局 fetch)。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const http = require('http');
|
||
const crypto = require('crypto');
|
||
|
||
const PORT = 11010;
|
||
const OPENIM_API_URL = 'http://192.168.200.11:10002';
|
||
const OPENIM_SECRET = 'openIM123';
|
||
const LIVEKIT_API_KEY = 'openimLKkey';
|
||
const LIVEKIT_API_SECRET = 'lk-secret-9f2c7a41d5e8b063f1a4c795e2d8b3a6';
|
||
|
||
// 本地测试账号(密码均为 test123)
|
||
const USERS = {
|
||
test001: { password: 'test123', nickname: '测试一号' },
|
||
test002: { password: 'test123', nickname: '测试二号' },
|
||
};
|
||
|
||
// ---------------- OpenIM 管理接口 ----------------
|
||
|
||
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() {
|
||
const r = await imApi('/auth/get_admin_token', {
|
||
secret: OPENIM_SECRET,
|
||
userID: 'imAdmin',
|
||
});
|
||
if (r.errCode !== 0) throw new Error('OpenIM 管理员令牌获取失败: ' + (r.errMsg || r.errCode));
|
||
return r.data.token;
|
||
}
|
||
|
||
/** 确保 OpenIM 里存在该用户(不存在则注册,尽力而为) */
|
||
async function ensureImUser(userID, nickname, admin) {
|
||
try {
|
||
await imApi('/user/user_register', {
|
||
users: [{ userID, nickname, faceURL: '' }],
|
||
}, admin);
|
||
} catch { /* 忽略,后面取令牌时再报错 */ }
|
||
}
|
||
|
||
async function getImUserToken(userID, platformID) {
|
||
const admin = await getAdminToken();
|
||
await ensureImUser(userID, USERS[userID].nickname, admin);
|
||
const r = await imApi('/auth/get_user_token', { secret: OPENIM_SECRET, platformID, userID }, admin);
|
||
if (r.errCode !== 0) throw new Error('OpenIM 用户令牌获取失败: ' + (r.errMsg || r.errCode));
|
||
return r.data;
|
||
}
|
||
|
||
// ---------------- LiveKit RTC 令牌签发 ----------------
|
||
|
||
const b64url = (s) => Buffer.from(s).toString('base64url');
|
||
|
||
function signLivekitToken(userID, room) {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||
const payload = b64url(JSON.stringify({
|
||
iss: LIVEKIT_API_KEY,
|
||
sub: userID,
|
||
exp: now + 3600,
|
||
nbf: now - 10,
|
||
video: { roomJoin: true, room },
|
||
metadata: '',
|
||
}));
|
||
const sig = crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(`${header}.${payload}`).digest('base64url');
|
||
return `${header}.${payload}.${sig}`;
|
||
}
|
||
|
||
// ---------------- HTTP 服务 ----------------
|
||
|
||
function send(res, status, obj) {
|
||
res.writeHead(status, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'Content-Type, authorization',
|
||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||
});
|
||
res.end(JSON.stringify(obj));
|
||
}
|
||
|
||
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; });
|
||
req.on('end', () => {
|
||
if (!buf) return resolve({});
|
||
try { resolve(JSON.parse(buf)); } catch { reject(new Error('请求体不是合法 JSON')); }
|
||
});
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
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 === 'POST' && url.pathname === '/api/login') {
|
||
const body = await readBody(req);
|
||
const { staffNo, password, platformID } = body;
|
||
if (!staffNo || !password) return fail(res, '工号和密码不能为空');
|
||
const user = USERS[staffNo];
|
||
if (!user || user.password !== String(password)) return fail(res, '工号或密码错误');
|
||
const t = await getImUserToken(staffNo, Number.isInteger(platformID) ? platformID : 3);
|
||
return ok(res, {
|
||
userID: staffNo,
|
||
nickname: user.nickname,
|
||
imToken: t.token,
|
||
expireTimeSeconds: t.expireTimeSeconds,
|
||
});
|
||
}
|
||
|
||
if (req.method === 'POST' && url.pathname === '/api/rtc_token') {
|
||
// 与真实账号服务一致:Authorization: Bearer <imToken> + {room, identity},返回 {token}
|
||
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||
if (!m) return fail(res, '未登录或登录已过期', 401);
|
||
const body = await readBody(req);
|
||
const { room, identity } = body;
|
||
if (!room || !identity) return fail(res, '房间号或用户标识不能为空');
|
||
return ok(res, { token: signLivekitToken(String(identity), String(room)) });
|
||
}
|
||
|
||
return fail(res, '接口不存在', 404);
|
||
} catch (e) {
|
||
return fail(res, '服务内部错误: ' + e.message, 500);
|
||
}
|
||
});
|
||
|
||
server.listen(PORT, '127.0.0.1', () => {
|
||
console.log(`mock-account-server listening on http://127.0.0.1:${PORT}`);
|
||
console.log('测试账号:test001 / test123,test002 / test123');
|
||
});
|