This commit is contained in:
Your Name
2026-08-14 14:37:30 +08:00
parent 21790e35f4
commit 18c15d1262
117 changed files with 28157 additions and 8080 deletions
@@ -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;
}
}