2026-07-27 11:28:33 +08:00

334 lines
12 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.

const crypto = require("crypto");
let db = null;
const MRTC_RECORD_COL = "mrtc-video-record";
// ===== 阿里音视频通话基础配置(直接写在业务服务端) =====
// 下面 4 个值请按 mPaaS 控制台上的实际配置替换
// appIdmPaaS 应用 appId
const APP_ID = "ALIPUB9402693121353";
// workspaceIdmPaaS 工作空间 ID一般为 "default"
const WORKSPACE_ID = "default";
// bizName音视频通话应用的业务 ID
const BIZ_NAME = "D49D5833DA5B64E46E81EA3A09D45F23";
// keymPaaS 控制台「查看密钥」得到的 RSA 私钥Base64PKCS8 格式)
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 } };
}