Files
KIMI 7ce40e356f 新增 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)与截图联调脚本
2026-08-09 09:11:06 +08:00

162 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 本地开发用 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');
});