diff --git a/app/src/doctor_workstation/services/mock_repository.py b/app/src/doctor_workstation/services/mock_repository.py index 827ba4a7e..f5e2077ca 100644 --- a/app/src/doctor_workstation/services/mock_repository.py +++ b/app/src/doctor_workstation/services/mock_repository.py @@ -64,6 +64,7 @@ DEMO_PERMISSIONS: tuple[str, ...] = ( "tcm.diagnosis/editAiReport", "tcm.diagnosis/aiAnalysis", "tcm.diagnosis/aiAssistant", + "tcm.diagnosis/aiGeneratePrescription", "tcm.diagnosis/patientAiReports", "tcm.diagnosis/generatePatientAiReport", "tcm.diagnosis/dailyRecord", diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py index 9bad99c55..d9e17b371 100644 --- a/app/src/doctor_workstation/ui/dialogs/ai_consult.py +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -854,6 +854,11 @@ QUICK_PROMPTS: tuple[tuple[str, str], ...] = ( ("并发症风险评估", "请评估并发症风险并给出随访要点。"), ("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"), ) +AI_PRESCRIPTION_PERMISSION = "tcm.diagnosis/aiGeneratePrescription" +PRESCRIPTION_CREATE_PERMISSIONS = ( + "tcm.diagnosis/chufang", + "tcm.diagnosis/kaifang", +) TOOL_ITEMS: tuple[tuple[str, str, str], ...] = ( ("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"), ("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"), @@ -3662,6 +3667,8 @@ class AiConsultDialog(QDialog): hint.setObjectName("AiConsultComposerLabel") chips.addWidget(hint) for label, prompt in QUICK_PROMPTS[:3]: + if label == "AI 生成处方" and not self._can_generate_ai_prescription(): + continue button = QPushButton(label) button.setObjectName("AiConsultChipButton") button.setCursor(Qt.CursorShape.PointingHandCursor) @@ -5464,6 +5471,13 @@ class AiConsultDialog(QDialog): def _submit(self) -> None: self._ask(self.input.text()) + def _can_generate_ai_prescription(self) -> bool: + return has_permission( + self.permissions, AI_PRESCRIPTION_PERMISSION, default=False + ) and has_permission( + self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False + ) + def _handle_local_action(self, text: str) -> bool: if not _is_open_prescription_intent(text): return False @@ -5474,9 +5488,8 @@ class AiConsultDialog(QDialog): text, time_text=datetime.now().strftime("%H:%M"), ) - can_prescribe = any( - has_permission(self.permissions, code, default=False) - for code in ("tcm.diagnosis/chufang", "tcm.diagnosis/kaifang") + can_prescribe = has_permission( + self.permissions, PRESCRIPTION_CREATE_PERMISSIONS, default=False ) if not can_prescribe: self._append_bubble( @@ -5485,6 +5498,15 @@ class AiConsultDialog(QDialog): time_text="系统", ) return True + if not has_permission( + self.permissions, AI_PRESCRIPTION_PERMISSION, default=False + ): + self._append_bubble( + "ai", + "当前账号没有 AI 生成处方权限,无法生成处方草稿。", + time_text="系统", + ) + return True if not callable(getattr(self.repository, "create_prescription", None)): self._append_bubble( "ai", diff --git a/app/tests/test_ai_consult_ui.py b/app/tests/test_ai_consult_ui.py index 664fb24a8..3f857d5ef 100644 --- a/app/tests/test_ai_consult_ui.py +++ b/app/tests/test_ai_consult_ui.py @@ -197,6 +197,52 @@ def test_prescription_action_without_permission_does_not_call_ai_or_repository( dialog.close() +@pytest.mark.parametrize( + ("grants", "visible"), + [ + (["tcm.diagnosis/aiAssistant"], False), + (["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"], False), + (["tcm.diagnosis/aiAssistant", "tcm.diagnosis/aiGeneratePrescription"], False), + (["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"], True), + ], +) +def test_ai_prescription_quick_prompt_requires_both_permissions( + application: QApplication, + grants: list[str], + visible: bool, +) -> None: + dialog = AiConsultDialog(DemoDoctorRepository(), PermissionSet(grants)) + buttons = dialog.findChildren(QPushButton, "AiConsultChipButton") + assert ("AI 生成处方" in [button.text() for button in buttons]) is visible + dialog.close() + + +def test_prescription_action_with_write_permission_but_without_ai_grant_is_blocked( + application: QApplication, +) -> None: + class Repository: + def create_prescription(self, _payload: Any) -> None: + raise AssertionError("must not create without AI prescription permission") + + def generate_ai_prescription(self, _diagnosis_id: int) -> None: + raise AssertionError("must not generate without AI prescription permission") + + dialog = AiConsultDialog( + Repository(), + PermissionSet(["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]), + ) + dialog.diagnosis_id = 501 + dialog._ask("开个处方") + application.processEvents() + bodies = [ + browser.toPlainText() + for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText") + ] + assert any("没有 AI 生成处方权限" in body for body in bodies) + assert dialog._stream_worker is None + dialog.close() + + def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership( application: QApplication, immediate_async: None, @@ -298,7 +344,7 @@ def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership dialog = AiConsultDialog( Repository(), PermissionSet( - ["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"] + ["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"] ), parent=host, ) @@ -366,7 +412,7 @@ def test_prescription_action_rejects_mismatched_patient_detail( dialog = AiConsultDialog( Repository(), PermissionSet( - ["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"] + ["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang", "tcm.diagnosis/aiGeneratePrescription"] ), ) dialog.diagnosis_id = 501 diff --git a/server/app/adminapi/controller/qywx/CustomerController.php b/server/app/adminapi/controller/qywx/CustomerController.php index 4cdcab45c..7aa8eb9da 100755 --- a/server/app/adminapi/controller/qywx/CustomerController.php +++ b/server/app/adminapi/controller/qywx/CustomerController.php @@ -4,19 +4,19 @@ declare(strict_types=1); namespace app\adminapi\controller\qywx; -use app\adminapi\controller\BaseAdminController; -use app\adminapi\lists\qywx\CustomerLists; -use app\adminapi\logic\auth\AuthLogic; -use app\adminapi\logic\qywx\CustomerLogic; -use app\adminapi\validate\qywx\CustomerValidate; +use app\adminapi\controller\BaseAdminController; +use app\adminapi\lists\qywx\CustomerLists; +use app\adminapi\logic\auth\AuthLogic; +use app\adminapi\logic\qywx\CustomerLogic; +use app\adminapi\validate\qywx\CustomerValidate; /** * 企业微信客户管理控制器 */ -class CustomerController extends BaseAdminController -{ - private const DELETE_PERMISSION = 'qywx.customer/delete'; - +class CustomerController extends BaseAdminController +{ + private const DELETE_PERMISSION = 'qywx.customer/delete'; + /** * @notes 客户列表 */ @@ -28,34 +28,34 @@ class CustomerController extends BaseAdminController /** * @notes 同步企业微信客户 */ - public function sync() - { + public function sync() + { $result = CustomerLogic::triggerBackgroundSync(); if ($result === false) { return $this->fail(CustomerLogic::getError()); } $msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步'; - return $this->success($msg, $result); - } - - /** - * @notes 删除一条本地企业微信客户同步记录 - */ - public function delete() - { - // 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。 - if (!$this->canDeleteCustomer()) { - return $this->fail('权限不足,无法删除企业微信客户'); - } - - $params = (new CustomerValidate())->post()->goCheck('delete'); - if (!CustomerLogic::deleteCustomer((int) $params['id'])) { - return $this->fail(CustomerLogic::getError()); - } - - return $this->success('删除成功'); - } + return $this->success($msg, $result); + } + + /** + * @notes 删除一条本地企业微信客户同步记录 + */ + public function delete() + { + // 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。 + if (!$this->canDeleteCustomer()) { + return $this->fail('权限不足,无法删除企业微信客户'); + } + + $params = (new CustomerValidate())->post()->goCheck('delete'); + if (!CustomerLogic::deleteCustomer((int) $params['id'])) { + return $this->fail(CustomerLogic::getError()); + } + + return $this->success('删除成功'); + } /** * @notes 获取统计信息 @@ -105,22 +105,22 @@ class CustomerController extends BaseAdminController /** * @notes 保存同步设置 */ - public function saveSyncSettings() + public function saveSyncSettings() { $params = (new CustomerValidate())->post()->goCheck('syncSettings'); $result = CustomerLogic::saveSyncSettings($params); if ($result === false) { return $this->fail(CustomerLogic::getError()); } - return $this->success('保存成功'); - } - - private function canDeleteCustomer(): bool - { - if ((int) ($this->adminInfo['root'] ?? 0) === 1) { - return true; - } - - return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true); - } -} + return $this->success('保存成功'); + } + + private function canDeleteCustomer(): bool + { + if ((int) ($this->adminInfo['root'] ?? 0) === 1) { + return true; + } + + return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true); + } +} diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index c4dff599d..2944218b5 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -53,6 +53,8 @@ class DiagnosisAiLogic extends BaseLogic private const PERMISSION_ASSISTANT = 'tcm.diagnosis/aiassistant'; + private const PERMISSION_GENERATE_PRESCRIPTION = 'tcm.diagnosis/aigenerateprescription'; + private const PATIENT_OPTIONS_DEFAULT_PAGE_SIZE = 20; private const PATIENT_OPTIONS_MAX_PAGE_SIZE = 50; @@ -358,6 +360,17 @@ class DiagnosisAiLogic extends BaseLogic array $adminInfo ): ?array { self::$assistantErrorCode = 'AI_ASSISTANT_FAILED'; + $task = strtolower(trim($task)); + if (!isset(self::ASSISTANT_TASKS[$task])) { + self::setError('不支持的 AI 助手任务'); + return null; + } + if ($task === 'prescription_generate' + && !self::hasPermission($adminId, $adminInfo, self::PERMISSION_GENERATE_PRESCRIPTION)) { + self::setError('权限不足,无法使用 AI 生成处方'); + return null; + } + $diagnosis = self::loadAuthorizedDiagnosis( $diagnosisId, $adminId, @@ -369,11 +382,6 @@ class DiagnosisAiLogic extends BaseLogic return null; } - $task = strtolower(trim($task)); - if (!isset(self::ASSISTANT_TASKS[$task])) { - self::setError('不支持的 AI 助手任务'); - return null; - } $prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true); if ($task === 'custom' && $prompt === '') { self::setError('请输入要咨询的问题'); diff --git a/server/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql b/server/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql new file mode 100644 index 000000000..6b3fe1ddd --- /dev/null +++ b/server/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql @@ -0,0 +1,35 @@ +-- AI 生成处方单独授权。默认不给现有角色,须在管理端角色权限中明确勾选。 +-- 默认表前缀为 zyt_;与 AI 助手同级显示。 +START TRANSACTION; + +SET @diagnosis_ai_assistant_parent_id := ( + SELECT `pid` + FROM `zyt_system_menu` + WHERE `perms` = 'tcm.diagnosis/aiAssistant' + ORDER BY `id` + LIMIT 1 +); + +SET @diagnosis_menu_id := ( + SELECT `id` + FROM `zyt_system_menu` + WHERE `perms` = 'tcm.diagnosis/lists' AND `type` = 'C' + ORDER BY `id` + LIMIT 1 +); + +INSERT INTO `zyt_system_menu` ( + `pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, + `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time` +) +SELECT + COALESCE(@diagnosis_ai_assistant_parent_id, @diagnosis_menu_id, 0), + 'A', 'AI生成处方', '', 70, 'tcm.diagnosis/aiGeneratePrescription', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` + WHERE `perms` = 'tcm.diagnosis/aiGeneratePrescription' +); + +COMMIT; diff --git a/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php b/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php new file mode 100644 index 000000000..569c0de37 --- /dev/null +++ b/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php @@ -0,0 +1,102 @@ + */ + public static array $uris = []; + + public function __construct(int $adminId = 0) + { + } + + /** @return array */ + public function getAdminUri(): array + { + return self::$uris; + } +} + +function prescriptionPermissionExpect(bool $condition, string $message): void +{ + if (!$condition) { + fwrite(STDERR, "FAIL: {$message}\n"); + exit(1); + } +} + +prescriptionPermissionExpect( + class_alias(PrescriptionGenerateAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache'), + 'permission cache double is installed before logic autoload' +); + +$reflection = new ReflectionClass(DiagnosisAiLogic::class); +prescriptionPermissionExpect( + $reflection->getConstant('PERMISSION_GENERATE_PRESCRIPTION') === 'tcm.diagnosis/aigenerateprescription', + 'prescription generation has a dedicated permission' +); + +PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant']; +prescriptionPermissionExpect( + DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null, + 'general assistant permission alone cannot generate a prescription' +); +prescriptionPermissionExpect( + DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方', + 'denial names the missing prescription permission' +); +prescriptionPermissionExpect( + DiagnosisAiLogic::assistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null, + 'blocking endpoint rejects the task before any upstream call' +); +prescriptionPermissionExpect( + DiagnosisAiLogic::prepareAssistant(1, 'PRESCRIPTION_GENERATE', '', 7, ['root' => 0]) === null + && DiagnosisAiLogic::getError() === '权限不足,无法使用 AI 生成处方', + 'task name normalization cannot bypass the dedicated permission' +); +prescriptionPermissionExpect( + DiagnosisAiLogic::prepareAssistant(0, 'summary', '', 7, ['root' => 0]) === null + && DiagnosisAiLogic::getError() === '诊单ID必须大于0', + 'ordinary assistant tasks continue past the prescription permission check' +); + +PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiGeneratePrescription']; +prescriptionPermissionExpect( + DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null, + 'prescription permission alone does not bypass the general assistant permission' +); +prescriptionPermissionExpect( + DiagnosisAiLogic::getError() === '权限不足,无法使用诊单 AI 助手', + 'general assistant permission remains required' +); + +$hasPermission = $reflection->getMethod('hasPermission'); +prescriptionPermissionExpect( + $hasPermission->invoke(null, 7, ['root' => 0], 'tcm.diagnosis/aigenerateprescription') === true, + 'new permission is read from role permissions' +); +prescriptionPermissionExpect( + $hasPermission->invoke(null, 1, ['root' => 1], 'tcm.diagnosis/aigenerateprescription') === true, + 'root keeps its existing permission bypass' +); + +$migration = file_get_contents( + dirname(__DIR__) . '/database/migrations/2026_10_08_diagnosis_ai_generate_prescription_permission.sql' +); +prescriptionPermissionExpect( + is_string($migration) + && str_contains($migration, "'tcm.diagnosis/aiGeneratePrescription'") + && str_contains($migration, "'AI生成处方'"), + 'management role tree registers the dedicated checkbox' +); +prescriptionPermissionExpect( + !str_contains($migration, 'zyt_system_role_menu'), + 'migration does not silently grant the new permission to existing roles' +); + +echo "Diagnosis AI prescription generation permission: OK\n";