const crypto = require("crypto"); let db = null; const MRTC_RECORD_COL = "mrtc-video-record"; // ===== 阿里音视频通话基础配置(直接写在业务服务端) ===== // 下面 4 个值请按 mPaaS 控制台上的实际配置替换 // appId:mPaaS 应用 appId const APP_ID = "ALIPUB9402693121353"; // workspaceId:mPaaS 工作空间 ID,一般为 "default" const WORKSPACE_ID = "default"; // bizName:音视频通话应用的业务 ID const BIZ_NAME = "D49D5833DA5B64E46E81EA3A09D45F23"; // key:mPaaS 控制台「查看密钥」得到的 RSA 私钥(Base64,PKCS8 格式) const PRIVATE_KEY_BASE64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4SoyF8RQ89YFU0KbeuyxgILJT3224s75pCZW/FA6z1vHSf2DW77kVJRWyh9jMnbcx1bqzS+j1qhzSAMdRSDetGNN5rZjgwNRa/rBm/x+XkN9YezkSI/2+DcoCCgXhOlizspf/UDYRzg9c7I5g8bCat9bA1HuLh9z9IkBKEkCKMVMDVTFqSAFRaYt1qTSJVmAvt0UpFzERR2Xuj3WV0IEq143jwVFtU+SqSiO1/0xmZFgiou6t4exS0oZ+kLMGXR9mJ/0BTDy90S0Nmiz2UFuWFuIq66ZYvIH3EF/GDvXF40raYfj7Xd4yjdl8ptChH6oRnbOEqL1ELAnliJJ2yWVtAgMBAAECggEAAyOfRyd3HR61cu4RmZj98C6Tuw1YP3LYWyOtqFE9et9ow8QlsHjpjIGoNUqjwmsr/q5RqCeH3VS7eZxHyOD+XyAdd2TMm4TNWTYeEnMQD7MztWchRBIsNVDjewC+tDpxM7ERbmjoXYnxWwzbKVg/QN4Wy2FohVTtdRELSqJgSmT18C+6NwSW7WKyVl6BqcnlGHJdUkzWx0qXJrpBc0cPCu1CMry2ep705YcBaRGUcsMY4YurJaQPEW6tQedYxLXSCCw3v4M0qXZG+LjOo0sAHSHPi5XlBdaozCfMLfILOZmQhke2lA51/2MFthoFk8CybQFQsAgy2iKCwJehM+yeZQKBgQDqbA+lbkPswTMP3LFyeED4jIOPHib6weIf3oiwRGk3do7n9eyUMvYptzq7Hd61+75Fvxm53/HCjCPXbWc5Z4nLVqKixp4nNqmfHkvP+xNSv1SoG64Rz27s4QSNhy4dqHmc20TszNOyteoyhYq/hQQzjn18db6kVPfRTnUMWlCChwKBgQDJQTMb53+LbzNCZ592K3sFaMWwihlRuPO78A6uCL6WEmPBhm1XfDwN2mRsyA7PqzFGmnJ/VvL7VyMsmjwMhsO3lojPeYZfEE2ZUxyrsUATR+iFamjvfE34uxkfIKIMGZAGPqha5GIaPwYCSJSI3iCetj9lm4PYpdBqVFUUn2ExawKBgC3U1LZGOZd3KlGGl1bG9gTiC/KWNY6/C0Op0VWM+nkyf7wQE6Hh2MjNHvloXJt9zXR7aT8y7mRX28YJiK3rdUBA/ukOJeuKv1zxombWMq/x4k4/oyeuQ4Sida5p6HU59BTbtb0KSrVc5Z3dCARrsCeYSyZjKtOcHRtZ2H6CxczPAoGAEo9swzoKTTWoKIud7Sv9YHZVJP7Xbu37rG1rLSmpSfXQNVzMYLEYaL6nXFDm7MgA913vCUdfiUZz+/0NZKpZCmlbnKa8ea/rOCSuFDd4jU6oZwV1QADawAGhQ3mi+DKzUaAdrqQ5V+pQB43iBO1XXMu59Qjuk+BW5Dd60TgRopkCgYEAuAlMEKIcdWDwV06D6j+Or+0F7eEhF56YSwTjS9Q91xJ1ZSpXtsk5EAQoYddxmmJYHouprYPMMF7JIPQ/3cJYkt8LXw49OXktN+gXaJhH8zzFT5fwjbnsIhey1noN+J2FzEqH00zCgs1X8qOa0Bint82FrAR2LXQ3EN6gKFTVwNU="; // roomServerUrl 不参与签名,仅用于 MeetingController.Connect // 如果你有正式环境地址,请在这里替换 const ROOM_SERVER_URL = "wss://mrtc.mpaas.cn-hangzhou.aliyuncs.com/ws"; /** * 阿里音视频通话相关接口 * - getMrtcConfig: 生成 Web 端入会/建连配置(含签名) * - notifyVideoRoomCreated: 前端创建房间成功后的回调(记录房间信息) * - notifyVideoRoomEnded: 视频问诊结束回调(更新房间状态) */ module.exports = async (item, mongodb) => { db = mongodb; // 调试:打印收到的请求类型和部分关键字段 console.log("[MRTC] incoming request:", { type: item.type, corpId: item.corpId, doctorCode: item.doctorCode, orderId: item.orderId, }); switch (item.type) { case "getMrtcConfig": return await getMrtcConfig(item); case "notifyVideoRoomCreated": return await notifyVideoRoomCreated(item); case "notifyVideoRoomEnded": return await notifyVideoRoomEnded(item); case "saveMrtcRecordEvent": return await saveMrtcRecordEvent(item); case "getMrtcRecordList": return await getMrtcRecordList(item); default: return { success: false, message: "未知的音视频通话接口类型", }; } }; /** * 生成阿里 MRTC 签名及连接参数 * @param {{ corpId: string, doctorCode: string, orderId?: string }} item */ async function getMrtcConfig(item) { const { corpId, doctorCode } = item; // corpId 目前未参与签名,仅为兼容现有入参 if (!doctorCode) { return { success: false, message: "doctorCode 不能为空" }; } // 业务用户 ID(医生编号),需满足:字母/数字/下划线,长度 <= 128 const uid = doctorCode; // 签名有效期(ms),例如 5 分钟 const expireMs = 5 * 60 * 1000; const expireTime = Date.now() + expireMs; // 按阿里文档要求拼接待加密字符串 // String encryptStr = bizName + appId + workspaceId + uid + expireTime; const encryptStr = `${BIZ_NAME}${APP_ID}${WORKSPACE_ID}${uid}${expireTime}`; // 将 Base64 编码后的 RSA 私钥转换成 PEM(等价于文档中的 getPrivateKey) const privateKeyPem = base64ToPem(PRIVATE_KEY_BASE64); let sign; try { // 使用 RSA 私钥加密生成签名(等价于文档中的 encryptByPrivate) sign = rsaEncryptWithPrivateKey(encryptStr, privateKeyPem); // 调试:打印关键中间结果(不要打印完整私钥) console.log("[MRTC] getMrtcConfig computed:", { uid, APP_ID, WORKSPACE_ID, BIZ_NAME, expireTime, encryptStr, signPreview: typeof sign === "string" ? sign.slice(0, 32) + "..." : "", }); } catch (e) { return { success: false, message: `生成音视频签名失败: ${e.message || "加密异常"}`, }; } return { success: true, message: "", data: { data: { uid, bizName: BIZ_NAME, subBiz: APP_ID, workspaceId: WORKSPACE_ID, roomServerUrl: ROOM_SERVER_URL, // 仅用于 Web SDK 连接,不参与签名 sign, expireTime, // 一些通用推荐参数,前端可直接使用 networkCheckTimeout: 120 * 1000, logCacheMaxLines: 30, autoUploadLog: true, }, }, }; } /** * 前端创建房间成功后通知(记录房间信息) * @param {{ orderId: string, roomId: string, rtoken?: string, corpId?: string }} item */ async function notifyVideoRoomCreated(item) { const { orderId, roomId, rtoken } = item; if (!orderId || !roomId) { return { success: false, message: "orderId 或 roomId 不能为空" }; } const now = Date.now(); const update = { $set: { mrtcRoomId: roomId, mrtcRtoken: rtoken || "", mrtcRoomStatus: "ACTIVE", mrtcRoomCreatedAt: now, mrtcRoomUpdatedAt: now, }, }; const result = await db.collection("consult-order").updateOne({ orderId }, update); if (result.matchedCount === 0) { return { success: false, message: "未找到对应的问诊订单" }; } return { success: true, message: "视频房间信息已记录" }; } /** * 视频问诊结束通知 * @param {{ orderId: string, roomId?: string }} item */ async function notifyVideoRoomEnded(item) { const { orderId, roomId } = item; if (!orderId) { return { success: false, message: "orderId 不能为空" }; } const now = Date.now(); const filter = roomId ? { orderId, mrtcRoomId: roomId } : { orderId }; const update = { $set: { mrtcRoomStatus: "ENDED", mrtcRoomEndedAt: now, mrtcRoomUpdatedAt: now, }, }; const result = await db.collection("consult-order").updateOne(filter, update); if (result.matchedCount === 0) { return { success: false, message: "未找到对应的问诊订单或房间" }; } return { success: true, message: "视频问诊结束已记录" }; } /** * 将 Base64 编码的 PKCS8 私钥转为 PEM 字符串 * 对应文档中的 getPrivateKey(key) */ function base64ToPem(base64Key) { const body = base64Key.replace(/\s+/g, ""); const wrapped = body.match(/.{1,64}/g) || []; return `-----BEGIN PRIVATE KEY-----\n${wrapped.join("\n")}\n-----END PRIVATE KEY-----`; } /** * 使用 RSA 私钥加密生成签名(Base64 字符串) * 对应文档中的 encryptByPrivate(content, privateKey) */ function rsaEncryptWithPrivateKey(plainText, privateKeyPem) { const buffer = Buffer.from(plainText, "utf8"); const encrypted = crypto.privateEncrypt( { key: privateKeyPem, padding: crypto.constants.RSA_PKCS1_PADDING, }, buffer ); return encrypted.toString("base64"); } function toOptionalNumber(v) { if (v === null || v === undefined || v === "") return undefined; const n = Number(v); return Number.isFinite(n) ? n : undefined; } /** * 记录/更新一次服务端录制(远端录制)的生命周期事件。 * 说明: * - 为了满足“无论录制成功与否都需要记录”,允许仅传 recordThirdId(未拿到 recordId 时先占位)。 * - 展示给业务侧(订单列表视频图标)建议仅使用 status=2 且 filePath 存在的记录。 */ async function saveMrtcRecordEvent(item) { const { corpId, doctorCode, orderId, roomId, recordId, recordThirdId, event, status, fileType, filePath, errCode, errMsg, startAt, endAt, payload, } = item || {}; if (!orderId) { return { success: false, message: "orderId 不能为空" }; } if (!recordId && !recordThirdId) { return { success: false, message: "recordId 或 recordThirdId 不能为空" }; } const now = Date.now(); // 优先使用 recordThirdId 作为幂等键,避免先上报 thirdId 后回调拿到 recordId 时产生两条记录 const filter = recordThirdId ? { recordThirdId } : { recordId }; const $set = { orderId, updatedAt: now }; if (typeof recordThirdId === "string" && recordThirdId) $set.recordThirdId = recordThirdId; if (typeof recordId === "string" && recordId) $set.recordId = recordId; if (typeof roomId === "string" && roomId) $set.roomId = roomId; if (typeof corpId === "string" && corpId) $set.corpId = corpId; if (typeof doctorCode === "string" && doctorCode) $set.doctorCode = doctorCode; const statusNum = toOptionalNumber(status); const fileTypeNum = toOptionalNumber(fileType); const startAtNum = toOptionalNumber(startAt); const endAtNum = toOptionalNumber(endAt); if (statusNum !== undefined) $set.status = statusNum; if (fileTypeNum !== undefined) $set.fileType = fileTypeNum; if (typeof filePath === "string") $set.filePath = filePath; if (errCode !== undefined) $set.errCode = errCode; if (typeof errMsg === "string") $set.errMsg = errMsg; if (startAtNum !== undefined) $set.startAt = startAtNum; if (endAtNum !== undefined) $set.endAt = endAtNum; const eventDoc = { event: event || "UNKNOWN", at: now, }; if (statusNum !== undefined) eventDoc.status = statusNum; if (fileTypeNum !== undefined) eventDoc.fileType = fileTypeNum; if (typeof filePath === "string") eventDoc.filePath = filePath; if (errCode !== undefined) eventDoc.errCode = errCode; if (typeof errMsg === "string") eventDoc.errMsg = errMsg; if (payload !== undefined) eventDoc.payload = payload; await db.collection(MRTC_RECORD_COL).updateOne( filter, { // 避免 $set 与 $setOnInsert 对同一路径重复赋值导致冲突 $setOnInsert: { createdAt: now }, $set, $push: { events: eventDoc }, }, { upsert: true } ); return { success: true, message: "录制事件已记录" }; } /** * 查询某个问诊订单的服务端录制列表 * @param {{ orderId: string, onlyPlayable?: boolean }} item */ async function getMrtcRecordList(item) { const { orderId, onlyPlayable = true } = item || {}; if (!orderId) return { success: false, message: "orderId 不能为空" }; const query = { orderId }; if (onlyPlayable) { query.status = 2; query.filePath = { $exists: true, $ne: "" }; } const list = await db .collection(MRTC_RECORD_COL) .find(query) .sort({ createdAt: -1 }) .project({ _id: 0, orderId: 1, roomId: 1, recordId: 1, recordThirdId: 1, status: 1, fileType: 1, filePath: 1, startAt: 1, endAt: 1, createdAt: 1, updatedAt: 1, }) .toArray(); return { success: true, message: "查询成功", data: { list } }; }