新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)

- 基于 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)与截图联调脚本
This commit is contained in:
KIMI
2026-08-09 09:11:06 +08:00
parent b95159f7eb
commit 7ce40e356f
284 changed files with 31599 additions and 7 deletions
+161
View File
@@ -0,0 +1,161 @@
/**
* 本地开发用 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 / test123test002 / test123');
});