account-server 新增 /api/rtc_token:用 IM token 换 LiveKit 进房 token(手机端语音通话用,B-58)

This commit is contained in:
KIMI
2026-08-09 01:28:01 +08:00
parent de67af0f6b
commit bef4ffcf6f
3 changed files with 56 additions and 0 deletions
+5
View File
@@ -3,3 +3,8 @@
components/
.selftest/
data/
# Flutter 手机端构建产物
mobile/.dart_tool/
mobile/build/
mobile/.flutter-plugins*
+48
View File
@@ -28,6 +28,9 @@ const DATA_FILE = process.env.DATA_FILE || path.join(__dirname, 'data', 'employe
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';
// LiveKit 语音通话(与 livekit 容器同一对 key/secret,见 .env 的 LIVEKIT_API_KEY / LIVEKIT_API_SECRET
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || '';
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || '';
// 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];
@@ -139,6 +142,36 @@ async function forceLogoutAll(staffNo) {
} catch { /* 忽略 */ }
}
// ---------------- LiveKit 通话 token ----------------
/** 用 OpenIM parse_token 校验客户端带来的 IM token,返回对应的 userID(无效/过期返回 null */
async function parseImToken(token) {
try {
const admin = await getAdminToken();
const r = await imApi('/auth/parse_token', { token }, admin);
if (r.errCode !== 0 || !r.data || !r.data.userID) return null;
return String(r.data.userID);
} catch {
return null;
}
}
/** 签 LiveKit 访问 tokenHS256 JWT,零依赖手写;claims 格式与 livekit-server 约定一致) */
function signLivekitToken(identity, room) {
const now = Math.floor(Date.now() / 1000);
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const data = b64({ alg: 'HS256', typ: 'JWT' }) + '.' + b64({
iss: LIVEKIT_API_KEY,
sub: identity,
iat: now,
nbf: now - 10,
exp: now + 2 * 3600, // 2 小时,一场通话足够
video: { roomJoin: true, room },
});
const sig = crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(data).digest('base64url');
return data + '.' + sig;
}
// ---------------- HTTP 服务 ----------------
function send(res, status, obj) {
@@ -201,6 +234,21 @@ const routes = {
ok(res, { userID: staffNo, nickname: emp.name, imToken: t.token, expireTimeSeconds: t.expireTimeSeconds });
},
// 语音通话:用登录拿到的 IM token 换 LiveKit 进房 token
// 请求头 Authorization: Bearer <imToken>body {room, identity}
'POST /api/rtc_token': async (req, res, body) => {
if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) return fail(res, '语音通话服务未配置,请联系管理员', 500);
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
if (!m) return fail(res, '未登录或登录已过期', 401);
const { room, identity } = body;
if (!room || typeof room !== 'string' || room.length > 128) return fail(res, '房间号不合法');
if (!identity || typeof identity !== 'string') return fail(res, '缺少用户标识');
const userID = await parseImToken(m[1]);
if (!userID) return fail(res, '登录已过期,请重新登录', 401);
if (userID !== identity) return fail(res, '用户标识与登录凭证不一致', 403);
ok(res, { token: signLivekitToken(identity, room) });
},
'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 });
+3
View File
@@ -399,6 +399,9 @@ services:
- OPENIM_API_URL=http://openim-server:10002
- OPENIM_SECRET=${OPENIM_SECRET}
- ADMIN_TOKEN=${ACCOUNT_ADMIN_TOKEN:-admin123}
# 语音通话进房 token 签发(/api/rtc_token),与 livekit 容器用同一对密钥
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
ports:
- "${ACCOUNT_PORT:-10010}:10010"
volumes: