This commit is contained in:
Your Name
2026-04-17 09:47:17 +08:00
parent fd44feaf09
commit 398f91bc55
280 changed files with 2536 additions and 808 deletions
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace app\api\controller;
use app\BaseController;
use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use think\facade\Config;
@@ -13,337 +12,347 @@ use think\Response;
/**
* 甘草订单状态回调控制器
*
*
* 回调地址在【中药处方下单】时通过 callback_url 字段传入。
* 当订单状态发生变化后,甘草会 POST 回调此地址。
* 必须在 5 秒内返回纯文本 "ok",否则甘草视为失败并最多重试 10 次(间隔=失败次数×5分钟)。
*
* @see https://apidoc.igancao.com/service-doc/scm-outer-recipel.html#订单状态回调
*/
class GancaoCallbackController extends BaseController
class GancaoCallbackController extends BaseApiController
{
public array $notNeedLogin = ['orderStatus'];
/**
* 甘草订单状态回调接口
*
* 回调地址在【中药处方下单】时用 callback_url 字段传入
* 当订单状态发生变化后会触发业务回调
*
* @return Response
* 甘草 state → 中文名称映射
*/
private const STATE_MAP = [
10 => '系统审核中',
11 => '系统审核通过',
110 => '订单药房流转制作中',
20 => '物流中',
30 => '完成',
90 => '拦截',
91 => '主动撤单',
92 => '驳回',
];
/**
* 物流商名称 → express_company 编码映射
*/
private const EXPRESS_MAP = [
'顺丰' => 'sf',
'京东' => 'jd',
'极兔' => 'jt',
'圆通' => 'yt',
'中通' => 'zt',
'韵达' => 'yd',
'申通' => 'st',
'邮政' => 'yz',
'EMS' => 'ems',
];
/**
* 甘草订单状态回调入口
*/
public function orderStatus(): Response
{
try {
// 获取原始请求体
$rawBody = file_get_contents('php://input');
// 获取请求头
$headers = $this->request->header();
$accessAppkey = $headers['access-appkey'] ?? '';
$accessNonce = $headers['access-nonce'] ?? '';
$accessTimestamp = $headers['access-timestamp'] ?? '';
$accessSign = $headers['access-sign'] ?? '';
// 记录回调请求
$headers = $this->request->header();
$accessAppkey = (string) ($headers['access-appkey'] ?? '');
$accessNonce = (string) ($headers['access-nonce'] ?? '');
$accessTimestamp = (string) ($headers['access-timestamp'] ?? '');
$accessSign = (string) ($headers['access-sign'] ?? '');
Log::info('Gancao callback received', [
'headers' => [
'access-appkey' => $accessAppkey,
'access-nonce' => $accessNonce,
'access-timestamp' => $accessTimestamp,
'access-sign' => $accessSign,
],
'body' => $rawBody,
'appkey' => $accessAppkey,
'nonce' => $accessNonce,
'ts' => $accessTimestamp,
'sign' => $accessSign,
'body' => $rawBody,
]);
// 验证签名
if (!$this->verifySign($accessAppkey, $accessNonce, $accessTimestamp, $accessSign, $rawBody)) {
Log::warning('Gancao callback sign verification failed', [
'access-appkey' => $accessAppkey,
'access-sign' => $accessSign,
]);
return $this->response('sign verification failed', 403);
Log::warning('Gancao callback sign verification failed');
return $this->ok();
}
// 解析回调数据
$data = json_decode($rawBody, true);
if (!is_array($data)) {
Log::error('Gancao callback invalid json', ['body' => $rawBody]);
return $this->response('invalid json', 400);
return $this->ok();
}
// 处理回调数据
$this->handleCallback($data);
// 必须在5秒内返回 "ok",否则会认为回调失败
return $this->response('ok');
} catch (\Throwable $e) {
Log::error('Gancao callback exception', [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
// 即使出错也要返回 ok,避免甘草重试
return $this->response('ok');
}
return $this->ok();
}
/* ------------------------------------------------------------------ */
/* 签名验证 */
/* ------------------------------------------------------------------ */
/**
* 验证回调签名
*
* 签名算法:md5(access-appkey + secret-key + access-nonce + access-timestamp + $sBody)
*
* @param string $appkey
* @param string $nonce
* @param string $timestamp
* @param string $sign
* @param string $body
* @return bool
* md5(access-appkey + secret-key + access-nonce + access-timestamp + $sBody)
*/
private function verifySign(string $appkey, string $nonce, string $timestamp, string $sign, string $body): bool
{
$config = Config::get('gancao_scm', []);
// 获取配置的 appkey 和 secret-key
$configAppkey = (string) ($config['callback_appkey'] ?? $config['biz_ak'] ?? '');
$secretKey = (string) ($config['callback_secret_key'] ?? $config['biz_sk'] ?? '');
// 验证 appkey
if ($appkey !== $configAppkey) {
Log::warning('Gancao callback appkey mismatch', [
'received' => $appkey,
'expected' => $configAppkey,
]);
if ($sign === '' || $appkey === '') {
return false;
}
// 计算签名
$expectedSign = md5($appkey . $secretKey . $nonce . $timestamp . $body);
// 验证签名
if ($sign !== $expectedSign) {
Log::warning('Gancao callback sign mismatch', [
'received' => $sign,
'expected' => $expectedSign,
]);
$config = Config::get('gancao_scm', []);
$cfgAppkey = (string) ($config['biz_ak'] ?? '');
$secretKey = (string) ($config['biz_sk'] ?? '');
if ($appkey !== $cfgAppkey) {
Log::warning('Gancao callback appkey mismatch', compact('appkey', 'cfgAppkey'));
return false;
}
$expected = md5($appkey . $secretKey . $nonce . $timestamp . $body);
if (!hash_equals($expected, $sign)) {
Log::warning('Gancao callback sign mismatch', compact('sign', 'expected'));
return false;
}
return true;
}
/**
* 处理回调数据
*
* @param array<string,mixed> $data
* @return void
*/
/* ------------------------------------------------------------------ */
/* 回调数据处理 */
/* ------------------------------------------------------------------ */
private function handleCallback(array $data): void
{
$recipelOrderNo = (string) ($data['recipel_order_no'] ?? '');
$state = (int) ($data['state'] ?? 0);
$ext = $data['ext'] ?? [];
if ($recipelOrderNo === '') {
Log::warning('Gancao callback missing recipel_order_no', ['data' => $data]);
$appOrderNo = (string) ($data['app_order_no'] ?? '');
$state = (int) ($data['state'] ?? 0);
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : [];
if ($recipelOrderNo === '' && $appOrderNo === '') {
Log::warning('Gancao callback missing order no', ['data' => $data]);
return;
}
// 查找订单
$order = PrescriptionOrder::where('gancao_reciperl_order_no', $recipelOrderNo)
->whereNull('delete_time')
->find();
$order = $this->findOrder($recipelOrderNo, $appOrderNo);
if (!$order) {
Log::warning('Gancao callback order not found', [
'recipel_order_no' => $recipelOrderNo,
]);
Log::warning('Gancao callback order not found', compact('recipelOrderNo', 'appOrderNo'));
return;
}
// 更新订单状态
$this->updateOrderStatus($order, $state, $ext);
// 记录日志
$this->writeCallbackLog($order, $state, $ext);
Log::info('Gancao callback processed', [
'order_id' => $order->id,
'order_id' => $order->id,
'recipel_order_no' => $recipelOrderNo,
'state' => $state,
'ext' => $ext,
'state' => $state,
'ext' => $ext,
]);
}
/**
* 更新订单状态
*
* 状态说明:
* - 10: 系统审核中
* - 11: 系统审核通过
* - 110: 订单药房流转制作中
* - 20: 物流中
* - 30: 完成(终态)
* - 90: 拦截(终止流转:可恢复)
* - 91: 主动撤单(退费:终态)
* - 92: 驳回(无法制作并退费:终态)
*
* @param PrescriptionOrder $order
* @param int $state
* @param array<string,mixed> $ext
* @return void
* 通过甘草处方单号或应用商订单号查找本地订单
*/
private function findOrder(string $recipelOrderNo, string $appOrderNo): ?PrescriptionOrder
{
if ($recipelOrderNo !== '') {
$order = PrescriptionOrder::where('gancao_reciperl_order_no', $recipelOrderNo)
->whereNull('delete_time')
->find();
if ($order) {
return $order;
}
}
if ($appOrderNo !== '') {
return PrescriptionOrder::where('order_no', $appOrderNo)
->whereNull('delete_time')
->find() ?: null;
}
return null;
}
/* ------------------------------------------------------------------ */
/* 订单状态更新 */
/* ------------------------------------------------------------------ */
/**
* state 说明:
* 10 系统审核中
* 11 系统审核通过
* 110 订单药房流转制作中(ext: flow_name, supplier
* 20 物流中(ext: shipping_name, nu, supplier
* 30 完成 - 终态(ext: shipping_name, nu, supplier
* 90 拦截 - 可恢复
* 91 主动撤单 - 终态(退费)
* 92 驳回 - 终态(无法制作并退费)
*/
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
{
// 保存甘草订单状态
$order->gancao_order_state = $state;
// 根据状态更新订单履约状态
switch ($state) {
case 10: // 系统审核中
case 11: // 系统审核通过
// 保持当前状态
case 10:
case 11:
break;
case 110: // 订单药房流转制作中
$flowName = (string) ($ext['flow_name'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
// 记录流程信息
$order->gancao_flow_name = mb_substr($flowName, 0, 100);
$order->gancao_supplier = mb_substr($supplier, 0, 100);
// 如果是发货流程,更新履约状态
if (str_contains($flowName, '发货') || str_contains($flowName, '寄出')) {
if ((int) $order->fulfillment_status === 2) {
$order->fulfillment_status = 5; // 已发货
}
}
case 110:
$this->handleProduction($order, $ext);
break;
case 20: // 物流中
$shippingName = (string) ($ext['shipping_name'] ?? '');
$nu = (string) ($ext['nu'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
// 更新物流信息
if ($nu !== '') {
$order->tracking_number = mb_substr($nu, 0, 100);
}
if ($shippingName !== '') {
// 转换快递公司名称
$expressMap = [
'顺丰' => 'sf',
'京东' => 'jd',
'极兔' => 'jt',
];
foreach ($expressMap as $name => $code) {
if (str_contains($shippingName, $name)) {
$order->express_company = $code;
break;
}
}
}
// 更新履约状态为已发货
if ((int) $order->fulfillment_status === 2) {
$order->fulfillment_status = 5;
}
case 20:
$this->handleShipping($order, $ext);
break;
case 30: // 完成
// 更新履约状态为已完成
case 30:
$this->handleShipping($order, $ext);
if ((int) $order->fulfillment_status !== 4) {
$order->fulfillment_status = 3;
$order->fulfillment_status = 3; // 已完成
}
break;
case 90: // 拦截
// 记录拦截原因
$order->gancao_remark = '订单被拦截';
case 90:
$order->gancao_remark = '甘草订单被拦截(可恢复)';
break;
case 91: // 主动撤单
// 更新履约状态为已取消
case 91:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4;
$order->fulfillment_status = 4; // 已取消
}
$order->gancao_remark = '主动撤单(已退费)';
$order->gancao_remark = '甘草主动撤单(已退费)';
break;
case 92: // 驳回
// 更新履约状态为已取消
case 92:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4;
$order->fulfillment_status = 4; // 已取消
}
$order->gancao_remark = '订单被驳回(无法制作并退费)';
$order->gancao_remark = '甘草驳回(无法制作并退费)';
break;
}
try {
$order->save();
} catch (\Throwable $e) {
Log::error('Gancao callback update order failed', [
Log::error('Gancao callback save failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
'error' => $e->getMessage(),
]);
}
}
/**
* 记录回调日志
*
* @param PrescriptionOrder $order
* @param int $state
* @param array<string,mixed> $ext
* @return void
* state=110:药房流转制作中
*/
private function handleProduction(PrescriptionOrder $order, array $ext): void
{
$flowName = (string) ($ext['flow_name'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
if ($flowName !== '') {
$order->gancao_flow_name = mb_substr($flowName, 0, 100);
}
if ($supplier !== '') {
$order->gancao_supplier = mb_substr($supplier, 0, 100);
}
$fs = (int) $order->fulfillment_status;
if ($fs === 2 && (str_contains($flowName, '发货') || str_contains($flowName, '寄出'))) {
$order->fulfillment_status = 5; // 已发货
}
}
/**
* state=20/30:物流中 / 已完成 — 回写快递单号与快递公司
*/
private function handleShipping(PrescriptionOrder $order, array $ext): void
{
$shippingName = (string) ($ext['shipping_name'] ?? '');
$nu = (string) ($ext['nu'] ?? '');
$supplier = (string) ($ext['supplier'] ?? '');
if ($nu !== '') {
$order->tracking_number = mb_substr($nu, 0, 80);
}
if ($shippingName !== '') {
$order->gancao_shipping_name = mb_substr($shippingName, 0, 50);
$order->express_company = $this->resolveExpressCode($shippingName);
}
if ($supplier !== '') {
$order->gancao_supplier = mb_substr($supplier, 0, 100);
}
$fs = (int) $order->fulfillment_status;
if (in_array($fs, [1, 2], true)) {
$order->fulfillment_status = 5; // 已发货
}
}
/**
* 将甘草返回的物流商名称解析为系统内 express_company 短码
*/
private function resolveExpressCode(string $shippingName): string
{
foreach (self::EXPRESS_MAP as $keyword => $code) {
if (str_contains($shippingName, $keyword)) {
return $code;
}
}
return 'auto';
}
/* ------------------------------------------------------------------ */
/* 操作日志 */
/* ------------------------------------------------------------------ */
private function writeCallbackLog(PrescriptionOrder $order, int $state, array $ext): void
{
$stateMap = [
10 => '系统审核中',
11 => '系统审核通过',
110 => '订单药房流转制作中',
20 => '物流中',
30 => '完成',
90 => '拦截',
91 => '主动撤单',
92 => '驳回',
];
$stateName = $stateMap[$state] ?? "状态{$state}";
$summary = "甘草订单状态更新:{$stateName}";
// 添加扩展信息
$stateName = self::STATE_MAP[$state] ?? "未知状态({$state})";
$summary = "甘草回调:{$stateName}";
if (isset($ext['flow_name'])) {
$summary .= "流程:{$ext['flow_name']}";
$summary .= " | 流程:{$ext['flow_name']}";
}
if (isset($ext['shipping_name']) && isset($ext['nu'])) {
$summary .= ",物流{$ext['shipping_name']} {$ext['nu']}";
if (isset($ext['supplier'])) {
$summary .= " | 药房{$ext['supplier']}";
}
$log = new PrescriptionOrderLog();
$log->prescription_order_id = (int) $order->id;
$log->admin_id = 0; // 系统回调
$log->admin_name = '甘草系统';
$log->action = 'gancao_callback';
$log->summary = mb_substr($summary, 0, 500);
$log->create_time = time();
if (isset($ext['shipping_name'])) {
$summary .= " | 物流:{$ext['shipping_name']}";
}
if (isset($ext['nu'])) {
$summary .= " | 单号:{$ext['nu']}";
}
try {
$log = new PrescriptionOrderLog();
$log->prescription_order_id = (int) $order->id;
$log->admin_id = 0;
$log->admin_name = '甘草系统';
$log->action = 'gancao_callback';
$log->summary = mb_substr($summary, 0, 500);
$log->create_time = time();
$log->save();
} catch (\Throwable $e) {
// 忽略日志写入错误
Log::warning('Gancao callback log write failed', ['error' => $e->getMessage()]);
}
}
/**
* 返回响应
*
* @param string $message
* @param int $code
* @return Response
*/
private function response(string $message, int $code = 200): Response
/* ------------------------------------------------------------------ */
/* 响应 */
/* ------------------------------------------------------------------ */
private function ok(): Response
{
return response($message, $code, [], 'html');
return response('ok', 200, [], 'html');
}
}
+255 -34
View File
@@ -14,12 +14,33 @@ use think\facade\Log;
class TrtcController extends BaseApiController
{
/** 免登录:腾讯云服务器回调 */
public array $notNeedLogin = ['recordingNotify'];
public array $notNeedLogin = ['recordingNotify'];
/**
* POST /api/trtc/recording-notify 或 /api/trtc/recordingNotify(与控制台回调 URL 一致即可
* 可选安全:环境变量 TRTC_RECORDING_CALLBACK_TOKEN 非空时,Query 需带 ?token=xxx
* 事件类型常量(EventGroupId=3 云端录制
* @see https://cloud.tencent.com/document/product/647/81113
*/
private const ET_RECORDER_START = 301;
private const ET_RECORDER_STOP = 302;
private const ET_UPLOAD_START = 303;
private const ET_FILE_INFO = 304;
private const ET_UPLOAD_STOP = 305;
private const ET_FILE_SLICE = 307;
private const ET_MP4_STOP = 310; // COS MP4 上传完成
private const ET_VOD_COMMIT = 311; // VOD 上传完成
private const ET_VOD_STOP = 312;
private const PROGRESS_EVENTS = [
self::ET_RECORDER_START,
self::ET_RECORDER_STOP,
self::ET_UPLOAD_START,
self::ET_FILE_INFO,
self::ET_UPLOAD_STOP,
self::ET_FILE_SLICE,
self::ET_VOD_STOP,
306, 308, 309,
];
public function recordingNotify()
{
$token = (string)config('trtc.recording_callback_token', '');
@@ -38,39 +59,80 @@ class TrtcController extends BaseApiController
}
$eventType = (int)($json['EventType'] ?? 0);
$roomId = $this->extractRoomId($json);
$taskId = trim((string)data_get($json, 'EventInfo.TaskId', ''));
$roomId = $this->extractRoomId($json);
$taskId = trim((string)data_get($json, 'EventInfo.TaskId', ''));
$payload = data_get($json, 'EventInfo.Payload');
if (!is_array($payload)) {
$payload = [];
}
Log::info('TRTC callback recv', [
'EventType' => $eventType,
'roomId' => $roomId,
'TaskId' => $taskId,
'PayloadKeys' => implode(',', array_keys($payload)),
]);
// 0. 提前查找 call_recordCOS 310 拼 URL 需要它的 diagnosis_id + id 来还原前缀)
$record = $this->resolveCallRecordForNotify($roomId, $taskId);
// 1. 尝试提取 HTTP URLVOD 311 等)
$urls = $this->extractRecordingUrls($json);
// 2. COS 存储:EventType=310 MP4 上传完成,从文件名拼接 COS URL
if ($urls === [] && $eventType === self::ET_MP4_STOP) {
$cosPrefix = $this->resolveRecordingCosPrefix($record);
$urls = $this->buildCosUrlsFromPayload($payload, $taskId, $cosPrefix);
if ($urls === []) {
Log::warning('TRTC 310 (COS MP4) 未解析到文件', [
'Status' => $payload['Status'] ?? null,
'FileList' => json_encode($payload['FileList'] ?? null, JSON_UNESCAPED_UNICODE),
'FileMessage' => json_encode($payload['FileMessage'] ?? null, JSON_UNESCAPED_UNICODE),
'TaskId' => $taskId,
'roomId' => $roomId,
'cosPrefix' => $cosPrefix,
]);
}
}
// 2b. COS HLS 兜底:305(上传结束) 时用同样的前缀
if ($urls === [] && $eventType === self::ET_UPLOAD_STOP) {
$cosPrefix = $this->resolveRecordingCosPrefix($record);
$hlsUrls = $this->buildCosHlsUrlFromPayload($payload, $taskId, $roomId, $cosPrefix);
if ($hlsUrls !== []) {
$urls = $hlsUrls;
}
}
// 3. 311 但无 URLStatus!=0 或字段变更)
if ($urls === [] && $eventType === self::ET_VOD_COMMIT) {
Log::warning('TRTC 311 (VOD) 未解析到 VideoUrl', [
'Status' => $payload['Status'] ?? null,
'Errmsg' => $payload['Errmsg'] ?? $payload['ErrMsg'] ?? null,
'TaskId' => $taskId,
'roomId' => $roomId,
]);
}
// 4. 无 URL:进度/状态事件,正常跳过
if ($urls === []) {
// 301–308 等为进度事件,无播放地址;311=VOD 上传完成带 VideoUrl(见文档 81113
$mayHaveUrl = in_array($eventType, [309, 310, 311], true);
$payload = data_get($json, 'EventInfo.Payload');
if ($urls === [] && $eventType === 311) {
Log::warning('TRTC recording callback: 311 但未解析到 VideoUrl(可能 Status!=0 或字段名变更)', [
'PayloadStatus' => is_array($payload) ? ($payload['Status'] ?? null) : null,
'Errmsg' => is_array($payload) ? ($payload['Errmsg'] ?? $payload['ErrMsg'] ?? null) : null,
'TaskId' => $taskId !== '' ? $taskId : null,
'roomId' => $roomId !== '' ? $roomId : null,
if (!in_array($eventType, self::PROGRESS_EVENTS, true)) {
Log::info('TRTC callback: no url', [
'EventType' => $eventType,
'TaskId' => $taskId,
'roomId' => $roomId,
]);
}
Log::info(
'TRTC recording callback: skip (no playback url in payload) '
. 'EventType=' . $eventType
. ' EventGroupId=' . (string)($json['EventGroupId'] ?? '')
. ' TaskId=' . ($taskId !== '' ? $taskId : '-')
. ' roomId=' . ($roomId !== '' ? $roomId : '-')
. ' ' . ($mayHaveUrl ? 'expect_url' : 'progress_ok')
);
return json(['code' => 0, 'msg' => 'ok']);
}
$record = $this->resolveCallRecordForNotify($roomId, $taskId);
// 5. 写入 call_record
if (!$record) {
Log::warning('TRTC recording: no call_record (try room_id + cloud_recording_task_id)', [
Log::warning('TRTC recording: no call_record', [
'roomId' => $roomId,
'TaskId' => $taskId,
'EventType' => $eventType,
'urls' => json_encode($urls, JSON_UNESCAPED_UNICODE),
]);
return json(['code' => 0, 'msg' => 'ok']);
}
@@ -91,11 +153,174 @@ class TrtcController extends BaseApiController
'update_time' => time(),
]);
Log::info('TRTC recording saved', ['id' => $record->id, 'urls' => count($merged)]);
Log::info('TRTC recording saved', [
'id' => $record->id,
'EventType' => $eventType,
'roomId' => $roomId,
'newUrls' => count($urls),
'totalUrls' => count($merged),
]);
return json(['code' => 0, 'msg' => 'ok']);
}
/* ------------------------------------------------------------------ */
/* COS 前缀还原 */
/* ------------------------------------------------------------------ */
/**
* 还原 CreateCloudRecording 时使用的 FileNamePrefix。
* 录制启动时前缀为 mix_{diagnosisId}_{callRecordId},回调时需还原。
*/
private function resolveRecordingCosPrefix(?CallRecord $record): string
{
if ($record && !empty($record->diagnosis_id) && !empty($record->id)) {
return 'mix_' . (int)$record->diagnosis_id . '_' . (int)$record->id;
}
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
}
/* ------------------------------------------------------------------ */
/* COS 配置读取(config() 优先,env() 兜底,兼容 config 文件未同步部署) */
/* ------------------------------------------------------------------ */
private function cosConfig(string $key, string $default = ''): string
{
$val = trim((string)config("trtc.{$key}", ''));
if ($val !== '') {
return $val;
}
return trim((string)env("trtc.{$key}", $default));
}
private function cosBucket(): string
{
return $this->cosConfig('recording_cos_bucket');
}
private function cosRegion(): string
{
$r = $this->cosConfig('recording_cos_region');
return $r !== '' ? $r : $this->cosConfig('recording_api_region', 'ap-guangzhou');
}
private function cosPrefix(): string
{
return $this->cosConfig('recording_cos_prefix', 'trtc-recording');
}
private function cosPrefixParts(): array
{
$raw = $this->cosPrefix();
return $raw !== '' ? explode('/', rtrim($raw, '/')) : [];
}
/* ------------------------------------------------------------------ */
/* COS 文件 URL 拼接 */
/* ------------------------------------------------------------------ */
/**
* EventType=310 的 Payload 中提取文件名,拼接完整 COS 下载 URL。
*
* COS 路径格式:{FileNamePrefix}/{TaskId}/{FileName}
* URLhttps://{Bucket}.cos.{Region}.myqcloud.com/{path}
*
* @return list<string>
*/
private function buildCosUrlsFromPayload(array $payload, string $taskId, string $prefix): array
{
$status = (int)($payload['Status'] ?? -1);
if ($status === 2 || $status === -1) {
return [];
}
$fileNames = [];
if (!empty($payload['FileMessage']) && is_array($payload['FileMessage'])) {
foreach ($payload['FileMessage'] as $fm) {
if (is_array($fm) && !empty($fm['FileName'])) {
$fileNames[] = trim((string)$fm['FileName']);
}
}
}
if ($fileNames === [] && !empty($payload['FileList'])) {
$fl = $payload['FileList'];
if (is_array($fl)) {
foreach ($fl as $f) {
if (is_string($f) && trim($f) !== '') {
$fileNames[] = trim($f);
}
}
} elseif (is_string($fl) && trim($fl) !== '') {
$fileNames[] = trim($fl);
}
}
if ($fileNames === []) {
return [];
}
$bucket = $this->cosBucket();
$region = $this->cosRegion();
if ($bucket === '') {
Log::warning('TRTC 310: COS bucket 未配置,无法拼接下载 URL', ['files' => implode(', ', $fileNames)]);
return [];
}
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
$baseUrl = "https://{$bucket}.cos.{$region}.myqcloud.com";
$urls = [];
foreach ($fileNames as $fn) {
$parts = array_merge($prefixParts, [$taskId, $fn]);
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
$urls[] = $baseUrl . '/' . $path;
}
return $urls;
}
/**
* COS HLS 兜底:OutputFormat=0(hls) 时不会有 310 事件。
* 305(UPLOAD_STOP) 后用录制的 m3u8 文件名拼 COS URL。
*/
private function buildCosHlsUrlFromPayload(array $payload, string $taskId, string $roomId, string $prefix): array
{
$status = (int)($payload['Status'] ?? -1);
if ($status !== 0) {
return [];
}
$bucket = $this->cosBucket();
if ($bucket === '') {
return [];
}
$region = $this->cosRegion();
$prefixParts = $prefix !== '' ? explode('/', rtrim($prefix, '/')) : [];
$sdkAppId = (int)(config('trtc.sdkAppId', 0) ?: env('trtc.sdk_app_id', 0));
$m3u8Name = "{$sdkAppId}_{$roomId}.m3u8";
$parts = array_merge($prefixParts, [$taskId, $m3u8Name]);
$path = implode('/', array_filter($parts, fn($p) => $p !== ''));
$url = "https://{$bucket}.cos.{$region}.myqcloud.com/{$path}";
Log::info('TRTC 305 HLS fallback', [
'roomId' => $roomId,
'TaskId' => $taskId,
'url' => $url,
]);
return [$url];
}
/* ------------------------------------------------------------------ */
/* 字段提取 */
/* ------------------------------------------------------------------ */
private function extractRoomId(array $data): string
{
$candidates = [
@@ -119,9 +344,6 @@ class TrtcController extends BaseApiController
return '';
}
/**
* 先按 room_id,再按 CreateCloudRecording 返回的 TaskId(须库中仍保留 cloud_recording_task_id
*/
private function resolveCallRecordForNotify(string $roomId, string $taskId): ?CallRecord
{
if ($roomId !== '') {
@@ -148,7 +370,7 @@ class TrtcController extends BaseApiController
}
/**
* 从回调 JSON 中提取录制文件地址(混流/单路字段名在不同版本可能不同
* 从回调 JSON 中递归提取 HTTP URL(适用于 VOD 311 等含 VideoUrl/MediaUrl 的事件
*/
private function extractRecordingUrls(array $data): array
{
@@ -177,11 +399,10 @@ class TrtcController extends BaseApiController
}
}
/**
* 将腾讯云录制地址拉取到本服务器 public/uploads(需在 .env 开启 trtc.recording_mirror_to_storage=1
* @param array<int, string> $urls
* @return array<int, string>
*/
/* ------------------------------------------------------------------ */
/* 可选:镜像录制文件到本服务器 */
/* ------------------------------------------------------------------ */
private function mirrorRecordingUrlsIfEnabled(array $urls): array
{
if (!(int)config('trtc.recording_mirror_to_storage', 0)) {
@@ -103,10 +103,12 @@ class TrtcCloudRecordingService
$req->UserSig = $botUserSig;
$recordParams = new \TencentCloud\Trtc\V20190722\Models\RecordParams();
// 合流录制:RecordMode=2(见 https://cloud.tencent.com/document/product/647/76497 方案二 API 手动录制)
$recordParams->RecordMode = self::RECORD_MODE_MIX;
$recordParams->StreamType = 0;
$recordParams->MaxIdleTime = (int)config('trtc.recording_max_idle_time', 300);
// COS 存储时 OutputFormat 决定输出格式:0=hls(默认)、1=hls+mp4、3=mp4。
// 310 回调仅在产出 MP4 时触发;默认 hls 不会生成 MP4 → 永远收不到 310。
$recordParams->OutputFormat = (int)config('trtc.recording_output_format', 3);
// 不订阅录制机器人自身流,避免占混流画面;医患流仍默认全订阅
$subscribe = new \TencentCloud\Trtc\V20190722\Models\SubscribeStreamUserIds();
$subscribe->UnSubscribeAudioUserIds = [$botUserId];
@@ -114,21 +116,25 @@ class TrtcCloudRecordingService
$recordParams->SubscribeStreamUserIds = $subscribe;
$req->RecordParams = $recordParams;
$tencentVod = new \TencentCloud\Trtc\V20190722\Models\TencentVod();
$tencentVod->ExpireTime = 0;
$tencentVod->MediaType = 0;
// 使用 COS 对象存储
$cloudStorage = new \TencentCloud\Trtc\V20190722\Models\CloudStorage();
$cloudStorage->Vendor = (int)config('trtc.recording_cos_vendor', 0);
$cosRegion = trim((string)config('trtc.recording_cos_region', ''));
$cloudStorage->Region = $cosRegion !== '' ? $cosRegion : $region;
$cloudStorage->Bucket = trim((string)config('trtc.recording_cos_bucket', ''));
$cosAk = trim((string)config('trtc.recording_cos_access_key', ''));
$cosSk = trim((string)config('trtc.recording_cos_secret_key', ''));
$cloudStorage->AccessKey = $cosAk !== '' ? $cosAk : $secretId;
$cloudStorage->SecretKey = $cosSk !== '' ? $cosSk : $secretKey;
$prefix = self::sanitizeVodUserDefineRecordId($vodUserDefineRecordId);
if ($prefix !== '') {
$tencentVod->UserDefineRecordId = $prefix;
if ($prefix === '') {
$prefix = rtrim((string)config('trtc.recording_cos_prefix', 'trtc-recording'), '/');
}
$vodSubApp = (int)config('trtc.recording_vod_sub_app_id', 0);
if ($vodSubApp > 0) {
$tencentVod->SubAppId = $vodSubApp;
}
$cloudVod = new \TencentCloud\Trtc\V20190722\Models\CloudVod();
$cloudVod->TencentVod = $tencentVod;
$cloudStorage->FileNamePrefix = $prefix !== '' ? explode('/', $prefix) : [];
$storage = new \TencentCloud\Trtc\V20190722\Models\StorageParams();
$storage->CloudVod = $cloudVod;
$storage->CloudStorage = $cloudStorage;
$req->StorageParams = $storage;
// MixTranscodeParamsSDK 说明「若设置该参数则内部字段须填全」。仅填 VideoParams 未填 AudioParams 可能导致合流异常或退化为非预期行为
@@ -157,13 +163,19 @@ class TrtcCloudRecordingService
$mixLayout->MixLayoutMode = $layoutMode;
$req->MixLayoutParams = $mixLayout;
Log::info('CreateCloudRecording mix', [
$usedAk = (string)$cloudStorage->AccessKey;
Log::info('CreateCloudRecording mix (COS)', [
'sdkAppId' => $sdkAppId,
'roomId' => $roomId,
'roomIdType' => $roomIdType,
'recordMode' => self::RECORD_MODE_MIX,
'outputFormat' => $recordParams->OutputFormat,
'mixLayoutMode' => $layoutMode,
'vodUserDefineRecordId' => $prefix !== '' ? $prefix : null,
'bucket' => $cloudStorage->Bucket,
'region' => $cloudStorage->Region,
'fileNamePrefix' => implode('/', $cloudStorage->FileNamePrefix ?: []),
'cosAkSource' => $cosAk !== '' ? 'cos_config' : 'api_secret_id',
'cosAkPrefix' => substr($usedAk, 0, 8) . '***',
]);
$resp = $client->CreateCloudRecording($req);
@@ -265,20 +277,20 @@ class TrtcCloudRecordingService
}
/**
* TencentVod.UserDefineRecordId:仅 a-zA-Z0-9_-
* COS 存储路径前缀:仅 a-zA-Z0-9_-/
*/
private static function sanitizeVodUserDefineRecordId(?string $raw): string
{
if ($raw === null || $raw === '') {
return '';
}
$s = preg_replace('/[^a-zA-Z0-9_-]/', '', $raw) ?? '';
$s = preg_replace('/[^a-zA-Z0-9_\/-]/', '', $raw) ?? '';
return strlen($s) > 64 ? substr($s, 0, 64) : $s;
}
/**
* 混流时 StorageFile.UserId 应为空串;非空则多为单流。用于区分控制台全局单流与 API 合流
* COS 存储时 StorageFile 结构不同于 VOD
* CreateCloudRecording 后立刻查询常为 Idle,约 2s 后再查一次再下结论。
*/
private static function logDescribeCloudRecordingHint(
@@ -315,8 +327,8 @@ class TrtcCloudRecordingService
sleep(2);
$second = $snap();
if (strcasecmp($second['status'], 'Idle') !== 0) {
Log::info('DescribeCloudRecording(合流校验): 首次 Idle,约2s 后已非 Idle', $second + [
'hint' => '启动瞬间 Idle 属常见;VOD 成片仍以回调311与点播为准',
Log::info('DescribeCloudRecording(合流校验-COS): 首次 Idle,约2s 后已非 Idle', $second + [
'hint' => '启动瞬间 Idle 属常见;COS 文件以实际上传为准',
]);
return;
@@ -329,8 +341,8 @@ class TrtcCloudRecordingService
return;
}
Log::info('DescribeCloudRecording(合流校验)', $first + [
'hint' => $first['firstFileUserId'] === '' ? 'VOD 场景 StorageFileList 常为空,以点播媒资+311 回调为准' : 'firstFileUserId 非空更像单流',
Log::info('DescribeCloudRecording(合流校验-COS)', $first + [
'hint' => 'COS 存储场景,文件将直接上传到对象存储桶',
]);
} catch (\Throwable $e) {
Log::info('DescribeCloudRecording 跳过: ' . $e->getMessage());
@@ -429,7 +429,8 @@ final class GancaoScmRecipelService
'taboo'=>$rx['dietary_taboo'],
'usage_time'=>$rx['usage_time'],
'usage_brief'=>$rx['usage_instruction'],
'others'=>$rx['usage_notes']
'others'=>$rx['usage_notes'],
'notes_doctor'=>$order['remark_extra']
];
$base['express_type']="general";
$base['cradle_store']="线上接诊";
@@ -455,6 +456,7 @@ final class GancaoScmRecipelService
'sex'=>$rx['gender_desc']=='男'?1:0
];
}
if ($dfId === 102) {
$base['df102ext'] = [
'times_per_day' =>$rx['times_per_day'],
@@ -466,8 +468,10 @@ final class GancaoScmRecipelService
'taboo'=>$rx['dietary_taboo'],
'usage_time'=>$rx['usage_time'],
'usage_brief'=>$rx['usage_instruction'],
'others'=>$rx['usage_notes']
'others'=>$rx['usage_notes'],
'notes_doctor'=>$order['remark_extra']
];
$base['express_type']="general";
$base['cradle_store']="线上接诊";
$base['app_order_no']=$order['order_no'];
@@ -626,7 +630,6 @@ final class GancaoScmRecipelService
'sex' => $sex,
'phone' => $patientPhone,
];
return $preview;
}
}