更新
This commit is contained in:
@@ -17,8 +17,10 @@ namespace app\adminapi\controller\tcm;
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\tcm\DiagnosisLists;
|
||||
use app\adminapi\logic\order\OrderActionLogLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisAiLogic;
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
||||
use app\adminapi\logic\tcm\TrackingNoteLogic;
|
||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||
use app\common\model\Order;
|
||||
use app\common\model\WechatChatRecord;
|
||||
@@ -222,7 +224,7 @@ class DiagnosisController extends BaseAdminController
|
||||
|
||||
return $this->data(DiagnosisLogic::guahaoLogList((int) $params['id']));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 诊单详情(患者端)
|
||||
* @return \think\response\Json
|
||||
@@ -230,22 +232,22 @@ class DiagnosisController extends BaseAdminController
|
||||
public function diagnosisDetail()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
|
||||
|
||||
if (empty($params['id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
if (empty($params['user_id'])) {
|
||||
return $this->fail('用户ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
$result = DiagnosisLogic::diagnosisDetail($params);
|
||||
if ($result) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 检查手机号是否重复
|
||||
* @return \think\response\Json
|
||||
@@ -256,7 +258,7 @@ class DiagnosisController extends BaseAdminController
|
||||
$result = DiagnosisLogic::checkPhone($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 检查身份证号是否重复
|
||||
* @return \think\response\Json
|
||||
@@ -281,7 +283,7 @@ class DiagnosisController extends BaseAdminController
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 指派医助
|
||||
* @return \think\response\Json
|
||||
@@ -289,12 +291,12 @@ class DiagnosisController extends BaseAdminController
|
||||
public function assign()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
|
||||
|
||||
// 验证参数
|
||||
if (empty($params['id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
if (!array_key_exists('assistant_id', $params)) {
|
||||
return $this->fail('请选择医助或取消指派');
|
||||
}
|
||||
@@ -327,25 +329,25 @@ class DiagnosisController extends BaseAdminController
|
||||
public function getCallSignature()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
|
||||
|
||||
if (empty($params['diagnosis_id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
if (empty($params['patient_id'])) {
|
||||
return $this->fail('患者ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
// 传递当前管理员ID
|
||||
$params['admin_id'] = $this->adminId;
|
||||
|
||||
|
||||
$result = DiagnosisLogic::getCallSignature($params);
|
||||
if ($result) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 发起通话
|
||||
* @return \think\response\Json
|
||||
@@ -353,57 +355,57 @@ class DiagnosisController extends BaseAdminController
|
||||
public function startCall()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
|
||||
|
||||
if (empty($params['diagnosis_id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
// 传递当前管理员ID
|
||||
$params['admin_id'] = $this->adminId;
|
||||
|
||||
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
|
||||
if ($result !== false) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
|
||||
public function startCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::startCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 幂等写入当前通话的已完成转写分段 */
|
||||
public function upsertCallTranscriptSegments()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 完成当前通话转写并固化对话文字 */
|
||||
public function finishCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::finishCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
$result = DiagnosisLogic::startCall($params, $this->adminInfo);
|
||||
if ($result !== false) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
/** @notes 为当前医生的指定通话记录启动实时录音转写 */
|
||||
public function startCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::startCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 幂等写入当前通话的已完成转写分段 */
|
||||
public function upsertCallTranscriptSegments()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::upsertCallTranscriptSegments($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 完成当前通话转写并固化对话文字 */
|
||||
public function finishCallTranscription()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::finishCallTranscription($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 结束通话
|
||||
* @return \think\response\Json
|
||||
@@ -411,21 +413,21 @@ class DiagnosisController extends BaseAdminController
|
||||
public function endCall()
|
||||
{
|
||||
$params = $this->request->post();
|
||||
|
||||
|
||||
if (empty($params['diagnosis_id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
// 传递当前管理员ID
|
||||
$params['admin_id'] = $this->adminId;
|
||||
|
||||
|
||||
$result = DiagnosisLogic::endCall($params);
|
||||
if ($result) {
|
||||
return $this->success('', [], 1, 1);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取通话记录
|
||||
* @return \think\response\Json
|
||||
@@ -433,11 +435,11 @@ class DiagnosisController extends BaseAdminController
|
||||
public function getCallRecords()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
|
||||
|
||||
if (empty($params['diagnosis_id'])) {
|
||||
return $this->fail('诊单ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
$result = DiagnosisLogic::getCallRecords($params);
|
||||
return $this->data($result);
|
||||
}
|
||||
@@ -603,11 +605,11 @@ class DiagnosisController extends BaseAdminController
|
||||
public function getDoctorSignature()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
|
||||
|
||||
if (empty($params['patient_id'])) {
|
||||
return $this->fail('医助理ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
$result = DiagnosisLogic::getDoctorSignature((int)$params['patient_id']);
|
||||
if ($result) {
|
||||
return $this->data($result);
|
||||
@@ -621,18 +623,18 @@ class DiagnosisController extends BaseAdminController
|
||||
public function getPatientSignature()
|
||||
{
|
||||
$params = $this->request->get();
|
||||
|
||||
|
||||
if (empty($params['patient_id'])) {
|
||||
return $this->fail('患者ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
$result = DiagnosisLogic::getPatientSignature((int)$params['patient_id']);
|
||||
if ($result) {
|
||||
return $this->data($result);
|
||||
}
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取医助列表
|
||||
* @return \think\response\Json
|
||||
@@ -642,7 +644,7 @@ class DiagnosisController extends BaseAdminController
|
||||
$result = DiagnosisLogic::getAssistants((int) $this->adminId, $this->adminInfo);
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 获取医生列表
|
||||
* @return \think\response\Json
|
||||
@@ -652,7 +654,7 @@ class DiagnosisController extends BaseAdminController
|
||||
$result = DiagnosisLogic::getDoctors();
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 生成小程序码
|
||||
* @return \think\response\Json
|
||||
@@ -824,7 +826,7 @@ class DiagnosisController extends BaseAdminController
|
||||
}
|
||||
|
||||
$offset = ($page_no - 1) * $page_size;
|
||||
|
||||
|
||||
$lists = \app\common\model\tcm\Diagnosis::where('patient_name|phone|id_card', 'like', '%' . $keyword . '%')
|
||||
->field(['id', 'patient_name', 'phone', 'id_card', 'gender', 'age'])
|
||||
->limit($offset, $page_size)
|
||||
@@ -842,4 +844,129 @@ class DiagnosisController extends BaseAdminController
|
||||
'page_size' => $page_size
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 读取已保存的双模型诊单 AI 报告,不触发上游调用
|
||||
*/
|
||||
public function aiReports()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->get()->goCheck('aiReports');
|
||||
$reports = DiagnosisAiLogic::getSavedReports(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($reports === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($reports);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
|
||||
*/
|
||||
public function aiAssistant()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
|
||||
$result = DiagnosisAiLogic::assistant(
|
||||
(int) $params['id'],
|
||||
(string) $params['task'],
|
||||
(string) ($params['prompt'] ?? ''),
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
|
||||
*/
|
||||
public function aiAnalysis()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('aiAnalysis');
|
||||
$result = DiagnosisAiLogic::analysis(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo,
|
||||
(string) ($params['model'] ?? 'qwen')
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 查询患者级 AI 诊断报告全部历史及各模型最新版本,不触发模型调用
|
||||
*/
|
||||
public function patientAiReports()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->get()->goCheck('patientAiReports');
|
||||
$result = PatientAiReportLogic::reports(
|
||||
(int) $params['patient_id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(PatientAiReportLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 聚合当前数据域内患者纵向资料,调用指定固定模型并新增一份不可变报告快照
|
||||
*/
|
||||
public function generatePatientAiReport()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('generatePatientAiReport');
|
||||
$result = PatientAiReportLogic::generate(
|
||||
(int) $params['patient_id'],
|
||||
(string) $params['model'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($result === null) {
|
||||
return $this->fail(PatientAiReportLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 整份重新生成两个固定模型的诊单 AI 报告并保存成功项
|
||||
*/
|
||||
public function generateAiReports()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('generateAiReports');
|
||||
$reports = DiagnosisAiLogic::generateAll(
|
||||
(int) $params['id'],
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($reports === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($reports);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 编辑一份已保存的诊单 AI 报告
|
||||
*/
|
||||
public function editAiReport()
|
||||
{
|
||||
$params = (new DiagnosisValidate())->post()->goCheck('editAiReport');
|
||||
$report = DiagnosisAiLogic::editReport(
|
||||
(int) $params['id'],
|
||||
(int) $params['report_id'],
|
||||
$params['content'] ?? null,
|
||||
$this->adminId,
|
||||
$this->adminInfo
|
||||
);
|
||||
if ($report === null) {
|
||||
return $this->fail(DiagnosisAiLogic::getError());
|
||||
}
|
||||
return $this->data($report);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -198,7 +198,7 @@ class PrescriptionLibraryAiLogic extends BaseLogic
|
||||
'prescription_id' => $id,
|
||||
'model_key' => $modelKey,
|
||||
'admin_id' => $adminId,
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
$result = [
|
||||
'ok' => false,
|
||||
@@ -246,7 +246,7 @@ class PrescriptionLibraryAiLogic extends BaseLogic
|
||||
'prescription_id' => $id,
|
||||
'model_key' => $modelKey,
|
||||
'admin_id' => $adminId,
|
||||
'error' => $e->getMessage(),
|
||||
'exception_class' => get_class($e),
|
||||
]);
|
||||
$failureCount++;
|
||||
$results[] = array_merge($resultBase, [
|
||||
|
||||
@@ -45,6 +45,12 @@ class DiagnosisValidate extends BaseValidate
|
||||
'diagnosis_id' => 'require|integer|checkDiagnosisId',
|
||||
'tracking_content' => 'require|length:1,1000',
|
||||
'revisit_slot_start_offset' => 'integer|between:0,20',
|
||||
'report_id' => 'number|gt:0',
|
||||
'content' => 'max:12000',
|
||||
'task' => 'require|in:summary,tcm_pattern,prescription_review,medication_review,exam_review,complication_risk,guideline_review,custom',
|
||||
'prompt' => 'max:500',
|
||||
'model' => 'in:qwen,openai',
|
||||
'patient_id' => 'integer|gt:0',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
@@ -72,6 +78,18 @@ class DiagnosisValidate extends BaseValidate
|
||||
'diagnosis_id.require' => '诊单ID不能为空',
|
||||
'tracking_content.require' => '跟踪备注内容不能为空',
|
||||
'tracking_content.length' => '跟踪备注最多1000个字符',
|
||||
'report_id.require' => '报告ID不能为空',
|
||||
'report_id.number' => '报告ID必须为数字',
|
||||
'report_id.gt' => '报告ID必须大于0',
|
||||
'content.require' => '报告内容不能为空',
|
||||
'content.max' => '报告内容最多12000个字符',
|
||||
'task.require' => '请选择 AI 助手任务',
|
||||
'task.in' => 'AI 助手任务不受支持',
|
||||
'prompt.max' => '问题最多500个字符',
|
||||
'model.in' => 'AI模型仅支持qwen或openai',
|
||||
'patient_id.require' => '患者ID不能为空',
|
||||
'patient_id.integer' => '患者ID必须为整数',
|
||||
'patient_id.gt' => '患者ID必须大于0',
|
||||
];
|
||||
|
||||
public function sceneAdd()
|
||||
@@ -114,17 +132,17 @@ class DiagnosisValidate extends BaseValidate
|
||||
return $this->only(['diagnosis_id']);
|
||||
}
|
||||
|
||||
public function sceneGenerateQrcode()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
|
||||
// The global diagnosis_id rule is required for diagnosis APIs, but
|
||||
// video QR codes identify the doctor instead. Keep diagnosis_id
|
||||
// optional here while still validating it when it is supplied.
|
||||
->remove('diagnosis_id', 'require')
|
||||
->append('patient_id', 'require|integer|checkQrcodeIds')
|
||||
->append('share_user_id', 'require|integer')
|
||||
->append('doctor_id', 'integer');
|
||||
}
|
||||
public function sceneGenerateQrcode()
|
||||
{
|
||||
return $this->only(['diagnosis_id', 'doctor_id', 'patient_id', 'share_user_id', 'mini_program_path'])
|
||||
// The global diagnosis_id rule is required for diagnosis APIs, but
|
||||
// video QR codes identify the doctor instead. Keep diagnosis_id
|
||||
// optional here while still validating it when it is supplied.
|
||||
->remove('diagnosis_id', 'require')
|
||||
->append('patient_id', 'require|integer|checkQrcodeIds')
|
||||
->append('share_user_id', 'require|integer')
|
||||
->append('doctor_id', 'integer');
|
||||
}
|
||||
|
||||
public function sceneGenerateOrderQrcode()
|
||||
{
|
||||
@@ -150,6 +168,48 @@ class DiagnosisValidate extends BaseValidate
|
||||
->append('revisit_slot_start_offset', 'require|integer|between:0,20');
|
||||
}
|
||||
|
||||
public function sceneAiReports()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneAiAssistant()
|
||||
{
|
||||
return $this->only(['id', 'task', 'prompt']);
|
||||
}
|
||||
|
||||
public function sceneAiAnalysis()
|
||||
{
|
||||
return $this->only(['id', 'model'])
|
||||
->remove('id', 'checkDiagnosis')
|
||||
->append('id', 'integer|gt:0|checkAiAnalysisPayload');
|
||||
}
|
||||
|
||||
public function scenePatientAiReports()
|
||||
{
|
||||
return $this->only(['patient_id'])
|
||||
->append('patient_id', 'require|integer|gt:0|checkPatientAiReportsPayload');
|
||||
}
|
||||
|
||||
public function sceneGeneratePatientAiReport()
|
||||
{
|
||||
return $this->only(['patient_id', 'model'])
|
||||
->append('patient_id', 'require|integer|gt:0|checkGeneratePatientAiReportPayload')
|
||||
->append('model', 'require|in:qwen,openai');
|
||||
}
|
||||
|
||||
public function sceneGenerateAiReports()
|
||||
{
|
||||
return $this->only(['id']);
|
||||
}
|
||||
|
||||
public function sceneEditAiReport()
|
||||
{
|
||||
return $this->only(['id', 'report_id', 'content'])
|
||||
->append('report_id', 'require|number|gt:0')
|
||||
->append('content', 'require|max:12000');
|
||||
}
|
||||
|
||||
protected function checkDiagnosis($value)
|
||||
{
|
||||
$diagnosis = Diagnosis::findOrEmpty($value);
|
||||
@@ -183,11 +243,71 @@ class DiagnosisValidate extends BaseValidate
|
||||
protected function checkQrcodeIds($value, $rule, $data = [])
|
||||
{
|
||||
$page = $data['mini_program_path'] ?? '';
|
||||
$hasDoctor = !empty($data['doctor_id']);
|
||||
$hasDoctor = !empty($data['doctor_id']);
|
||||
$hasDiagnosis = !empty($data['diagnosis_id']);
|
||||
if ($page === 'pages/login/login') {
|
||||
return $hasDoctor ? true : '视频二维码需传挂号医生ID';
|
||||
}
|
||||
return $hasDiagnosis ? true : '诊单ID不能为空';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 智能分析仅接受诊单 ID 和可选的固定模型键。客户端不得提供
|
||||
* provider、凭据、上游地址、提示词等服务端配置或控制字段。
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array<string,mixed> $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkAiAnalysisPayload($value, $rule, array $data = [])
|
||||
{
|
||||
if (!array_key_exists('id', $data)) {
|
||||
return 'AI智能分析请求缺少id参数';
|
||||
}
|
||||
|
||||
$extraFields = array_diff(array_keys($data), ['id', 'model']);
|
||||
if ($extraFields !== []) {
|
||||
return 'AI智能分析请求仅允许id及可选model参数';
|
||||
}
|
||||
|
||||
if (array_key_exists('model', $data)) {
|
||||
$model = $data['model'];
|
||||
if (!is_string($model) || !in_array($model, ['qwen', 'openai'], true)) {
|
||||
return 'AI模型仅支持qwen或openai';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
protected function checkPatientAiReportsPayload($value, $rule, array $data = [])
|
||||
{
|
||||
if (!array_key_exists('patient_id', $data)) {
|
||||
return '请求缺少patient_id参数';
|
||||
}
|
||||
$extraFields = array_diff(array_keys($data), ['patient_id']);
|
||||
return $extraFields === [] ? true : '请求仅允许patient_id参数';
|
||||
}
|
||||
|
||||
/**
|
||||
* 患者报告生成端点只接受稳定患者ID与服务端模型白名单键。
|
||||
* provider、凭据、BASE_URL、提示词和任意来源正文均不得由客户端注入。
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
protected function checkGeneratePatientAiReportPayload($value, $rule, array $data = [])
|
||||
{
|
||||
if (!array_key_exists('patient_id', $data) || !array_key_exists('model', $data)) {
|
||||
return '请求缺少patient_id或model参数';
|
||||
}
|
||||
$extraFields = array_diff(array_keys($data), ['patient_id', 'model']);
|
||||
if ($extraFields !== []) {
|
||||
return '请求仅允许patient_id和model参数';
|
||||
}
|
||||
if (!is_string($data['model']) || !in_array($data['model'], ['qwen', 'openai'], true)) {
|
||||
return 'AI模型仅支持qwen或openai';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 诊单 AI 报告。
|
||||
*/
|
||||
class DiagnosisAiReport extends BaseModel
|
||||
{
|
||||
protected $name = 'diagnosis_ai_report';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 患者级 AI 诊断报告快照。
|
||||
*
|
||||
* 每次生成均新增一行;本模型没有更新/覆盖历史报告的业务方法。
|
||||
*/
|
||||
class PatientAiReport extends BaseModel
|
||||
{
|
||||
protected $name = 'patient_ai_report';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
protected $dateFormat = false;
|
||||
}
|
||||
@@ -5,12 +5,20 @@ declare(strict_types=1);
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* Dify Chat App blocking 客户端。
|
||||
* 处方/诊单 AI 上游客户端。
|
||||
*
|
||||
* 只接受服务端配置中的模型 profile,避免把上游地址和密钥暴露给前端。
|
||||
* 兼容 Dify blocking chat-messages 与 OpenAI-compatible chat completions。
|
||||
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
|
||||
*/
|
||||
class DifyChatService
|
||||
{
|
||||
/** @var array<int,string> */
|
||||
private const ALLOWED_PROFILES = ['qwen', 'openai'];
|
||||
|
||||
private const MIN_TIMEOUT = 1;
|
||||
|
||||
private const MAX_TIMEOUT = 300;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
|
||||
@@ -19,45 +27,197 @@ class DifyChatService
|
||||
{
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
return self::error('CONFIG_DISABLED', '处方 AI 解释未启用');
|
||||
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
|
||||
}
|
||||
|
||||
$modelConfig = $config['models'][$profile] ?? null;
|
||||
if (!is_array($modelConfig)) {
|
||||
$modelConfig = self::resolveProfileConfig($config, $profile);
|
||||
if ($modelConfig === null) {
|
||||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||||
$apiKey = trim((string) ($modelConfig['api_key'] ?? ''));
|
||||
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
|
||||
$apiKey = trim($rawApiKey);
|
||||
if ($baseUrl === '' || $apiKey === '') {
|
||||
return self::error('CONFIG_MISSING', '该模型尚未配置 Dify 地址或 App Key');
|
||||
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
|
||||
}
|
||||
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
|
||||
}
|
||||
|
||||
$timeout = (int) ($config['timeout'] ?? 0);
|
||||
if (!self::isValidTimeout($timeout)) {
|
||||
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking',
|
||||
'user' => $user,
|
||||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||||
if ($model === '') {
|
||||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||||
}
|
||||
|
||||
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
|
||||
$startedAt = microtime(true);
|
||||
$lastResponse = null;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendRequest(
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout
|
||||
);
|
||||
$lastResponse = $response;
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return self::formatResponse($response, $startedAt);
|
||||
}
|
||||
|
||||
return self::formatResponse($lastResponse ?? [
|
||||
'body' => '',
|
||||
'errno' => 0,
|
||||
'http_code' => 0,
|
||||
], $startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private static function resolveProfileConfig(array $config, string $profile): ?array
|
||||
{
|
||||
if (!in_array($profile, self::ALLOWED_PROFILES, true)) {
|
||||
return null;
|
||||
}
|
||||
$modelConfig = $config['models'][$profile] ?? null;
|
||||
return is_array($modelConfig) ? $modelConfig : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
|
||||
*/
|
||||
private static function buildRequestSpecs(
|
||||
string $baseUrl,
|
||||
string $model,
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user
|
||||
): array {
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
|
||||
$difySpec = [
|
||||
'protocol' => 'dify',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
|
||||
'payload' => [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking',
|
||||
'user' => $user,
|
||||
],
|
||||
];
|
||||
$openAiSpec = [
|
||||
'protocol' => 'openai',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
|
||||
'payload' => [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $query],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (str_ends_with($path, '/chat-messages')) {
|
||||
return [$difySpec];
|
||||
}
|
||||
if (str_ends_with($path, '/chat/completions')) {
|
||||
return [$openAiSpec];
|
||||
}
|
||||
|
||||
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
|
||||
return [$difySpec, $openAiSpec];
|
||||
}
|
||||
|
||||
private static function buildEndpoint(string $baseUrl, string $endpoint): string
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) {
|
||||
return $baseUrl;
|
||||
}
|
||||
if (str_ends_with($path, '/v1')) {
|
||||
return $baseUrl . '/' . $endpoint;
|
||||
}
|
||||
return $baseUrl . '/v1/' . $endpoint;
|
||||
}
|
||||
|
||||
private static function isValidBaseUrl(string $baseUrl): bool
|
||||
{
|
||||
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
|
||||
return false;
|
||||
}
|
||||
$parts = parse_url($baseUrl);
|
||||
if (!is_array($parts)) {
|
||||
return false;
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
return in_array($scheme, ['http', 'https'], true)
|
||||
&& trim((string) ($parts['host'] ?? '')) !== ''
|
||||
&& !isset($parts['user'])
|
||||
&& !isset($parts['pass'])
|
||||
&& !isset($parts['query'])
|
||||
&& !isset($parts['fragment']);
|
||||
}
|
||||
|
||||
private static function isValidTimeout(int $timeout): bool
|
||||
{
|
||||
return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
* @return array{body:string,errno:int,http_code:int}
|
||||
*/
|
||||
private static function sendRequest(
|
||||
string $url,
|
||||
array $payload,
|
||||
string $apiKey,
|
||||
int $timeout
|
||||
): array {
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($body === false) {
|
||||
return self::error('REQUEST_BUILD_FAILED', '处方数据编码失败');
|
||||
return ['body' => '', 'errno' => -1, 'http_code' => 0];
|
||||
}
|
||||
|
||||
$timeout = max(10, min(120, (int) ($config['timeout'] ?? 90)));
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求');
|
||||
return ['body' => '', 'errno' => -2, 'http_code' => 0];
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => self::buildEndpoint($baseUrl),
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => min(8, max(3, (int) ceil($timeout / 4))),
|
||||
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
@@ -68,35 +228,56 @@ class DifyChatService
|
||||
],
|
||||
]);
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$responseBody = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$latencyMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
return [
|
||||
'body' => is_string($responseBody) ? $responseBody : '',
|
||||
'errno' => $errno,
|
||||
'http_code' => $httpCode,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{body:string,errno:int,http_code:int} $response
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||||
*/
|
||||
private static function formatResponse(array $response, float $startedAt): array
|
||||
{
|
||||
$latencyMs = self::elapsedMilliseconds($startedAt);
|
||||
$errno = $response['errno'];
|
||||
$httpCode = $response['http_code'];
|
||||
|
||||
if ($errno !== 0) {
|
||||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($errno === -1) {
|
||||
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
|
||||
}
|
||||
if ($errno === -2) {
|
||||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
|
||||
}
|
||||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $responseBody, true);
|
||||
$decoded = json_decode($response['body'], true);
|
||||
if ($httpCode === 401 || $httpCode === 403) {
|
||||
return self::error('CONFIG_INVALID', '模型 App Key 无效或无权限', $latencyMs);
|
||||
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
|
||||
}
|
||||
if ($httpCode === 429 || $httpCode >= 500) {
|
||||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($httpCode >= 400) {
|
||||
if ($httpCode >= 400 || $httpCode < 200) {
|
||||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||||
}
|
||||
if (!is_array($decoded)) {
|
||||
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
$answer = trim((string) ($decoded['answer'] ?? ''));
|
||||
$answer = self::extractContent($decoded);
|
||||
if ($answer === '') {
|
||||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||||
}
|
||||
@@ -104,21 +285,34 @@ class DifyChatService
|
||||
return [
|
||||
'ok' => true,
|
||||
'content' => $answer,
|
||||
'message_id' => (string) ($decoded['message_id'] ?? ''),
|
||||
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildEndpoint(string $baseUrl): string
|
||||
/** @param array<string,mixed> $decoded */
|
||||
private static function extractContent(array $decoded): string
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
if (str_ends_with($baseUrl, '/chat-messages')) {
|
||||
return $baseUrl;
|
||||
$content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? '';
|
||||
if (is_string($content)) {
|
||||
return trim($content);
|
||||
}
|
||||
if (str_ends_with($baseUrl, '/v1')) {
|
||||
return $baseUrl . '/chat-messages';
|
||||
if (!is_array($content)) {
|
||||
return '';
|
||||
}
|
||||
return $baseUrl . '/v1/chat-messages';
|
||||
|
||||
$parts = [];
|
||||
foreach ($content as $part) {
|
||||
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
|
||||
$parts[] = $part['text'];
|
||||
}
|
||||
}
|
||||
return trim(implode('', $parts));
|
||||
}
|
||||
|
||||
private static function elapsedMilliseconds(float $startedAt): int
|
||||
{
|
||||
return (int) round((microtime(true) - $startedAt) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user