334 lines
12 KiB
JavaScript
Raw Normal View History

2026-07-27 11:28:33 +08:00
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 } };
}