334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
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 } };
|
||
}
|