self::formatJob($row, $adminId, null, false, $readContext)); $result['automation'] = Automation::status(); return $result; } public static function staffCases(array $p, int $adminId): array { self::requireSchema(); return Staff::cases($p, $adminId); } public static function staffOverview(array $p, int $adminId): array { self::requireSchema(); return Staff::overview($p, $adminId, static fn (array $row): array => self::formatJob($row, $adminId)); } public static function staffIssues(array $p, int $adminId): array { self::requireSchema(); return Staff::issues($p, $adminId, static fn (array $row): array => self::formatJob($row, $adminId)); } public static function staffDailyContacts(array $p, int $adminId): array { Access::actor($adminId); return DailyContact::patients($p, $adminId); } public static function alerts(array $p, int $adminId): array { Access::actor($adminId); return Alert::listing($p, $adminId); } public static function wecomDirectory(array $p, int $adminId): array { return Alert::wecomDirectory($adminId); } public static function wecomChats(array $p, int $adminId): array { return Alert::wecomChats($p, $adminId); } public static function wecomMessages(array $p, int $adminId): array { return Alert::wecomMessages($p, $adminId); } public static function reviewAlert(array $p, int $adminId): array { Access::actor($adminId, 'reviewQuality'); return Db::transaction(static function () use ($p, $adminId): array { Access::actor($adminId, 'reviewQuality'); return Alert::review($p, $adminId); }); } public static function analyzeStaff(array $p, int $adminId): array { Access::actor($adminId, 'analyze'); self::requireEnabled(); return Db::transaction(static function () use ($p, $adminId): array { Access::actor($adminId, 'analyze'); $available = array_column(Staff::caseRows($p['assistant_id'], $adminId, $p['days']), 'diagnosis_id'); if (array_diff($p['diagnosis_ids'], $available)) { throw new DomainException('勾选记录不属于该医助当前可见的服务范围,请刷新后重试'); } $jobs = []; foreach ($p['diagnosis_ids'] as $diagnosisId) { $context = Context::build($diagnosisId, $adminId, $p['days'], $p['assistant_id']); $activity = array_filter($context['sources'], static fn (array $source): bool => in_array($source['channel'], ['im', 'manual'], true)); if ((int) $context['patient']['assistant_id'] !== $p['assistant_id'] && !$activity) { throw new DomainException('勾选记录已不属于该医助的当前负责或期间沟通范围,请刷新后重试'); } $jobs[] = self::enqueue('analysis', ['diagnosis_id' => $diagnosisId, 'assistant_id' => $p['assistant_id'], 'days' => $p['days'], 'scenario' => $p['scenario']], $adminId); } return ['jobs' => $jobs, 'count' => count($jobs)]; }); } public static function capabilities(array $p, int $adminId): array { $actor = Access::actor($adminId); $schema = self::schemaReady(); $enabled = (bool) config('assistant_service.enabled', false); $model = self::modelReady(); $reason = !$schema ? '医助服务数据表尚未就绪,请先执行模块迁移' : (!$enabled ? 'AI 分析尚未启用,可继续登记服务记录和处理已有待办' : (!$model ? '模型服务尚未完整配置,暂不能创建分析或回复任务' : '')); return ['enabled' => $enabled, 'ready' => $enabled && $schema && $model, 'reason' => $reason, 'permissions' => $actor['permissions'], 'schema_ready' => $schema, 'scenarios' => [['value' => 'initial', 'label' => '初次服务'], ['value' => 'followup', 'label' => '日常跟进'], ['value' => 'revisit', 'label' => '复诊服务']], 'limits' => ['max_manual_chars' => (int) config('assistant_service.max_manual_chars', 10000), 'max_source_chars' => (int) config('assistant_service.max_source_chars', 30000)]]; } public static function patients(array $p, int $adminId): array { $actor = Access::actor($adminId); $query = Access::patientQuery($adminId, $actor); if ($p['keyword'] !== '') { $keyword = '%' . addcslashes($p['keyword'], '%_\\') . '%'; $query->where(function ($q) use ($keyword): void { $q->whereLike('d.patient_name', $keyword)->whereOr('d.phone', 'like', $keyword); }); } $count = (clone $query)->count(); $rows = $query->field('d.id,d.patient_id,d.patient_name,d.phone,d.assistant_id')->order('d.id', 'desc') ->page($p['page_no'], $p['page_size'])->select()->toArray(); $lists = array_map(static function (array $row): array { $phone = (string) ($row['phone'] ?? ''); return ['diagnosis_id' => (int) $row['id'], 'patient_id' => (int) $row['patient_id'], 'patient_name' => (string) $row['patient_name'], 'assistant_id' => (int) $row['assistant_id'], 'phone_masked' => mb_strlen($phone) >= 7 ? mb_substr($phone, 0, 3) . '****' . mb_substr($phone, -4) : ($phone === '' ? '' : '****')]; }, $rows); return self::page($lists, (int) $count, $p); } public static function context(array $p, int $adminId): array { Access::actor($adminId); self::requireSchema(); $context = Context::build($p['diagnosis_id'], $adminId, $p['days'], $p['assistant_id'] ?? 0); $latest = null; $latestAnalysis = null; $candidates = Db::name('assistant_service_job')->where('diagnosis_id', $p['diagnosis_id']) ->where('patient_id', $context['patient']['patient_id'])->order('id', 'desc')->cursor(); foreach ($candidates as $candidate) { if (self::subject($candidate) !== ($p['assistant_id'] ?? 0) || (($p['assistant_id'] ?? 0) > 0 && (int) $candidate['days'] !== $p['days'])) { continue; } $latest ??= $candidate; // 回复任务可能更晚创建;最新分析不能依赖通用最新任务或历史分页。 if ($candidate['type'] === 'analysis') { $latestAnalysis = $candidate; break; } } $context['latest_job'] = $latest ? self::formatJob($latest, $adminId, $context) : null; $context['latest_analysis_job'] = $latestAnalysis ? ($latestAnalysis['id'] === $latest['id'] ? $context['latest_job'] : self::formatJob($latestAnalysis, $adminId, $context)) : null; return $context; } public static function record(array $p, int $adminId): array { Access::actor($adminId, 'record'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { $diagnosis = Access::diagnosis($p['diagnosis_id'], $adminId, 'record', true); $id = Db::name('assistant_service_record')->insertGetId([ 'diagnosis_id' => $p['diagnosis_id'], 'patient_id' => (int) $diagnosis['patient_id'], 'actor_id' => $adminId, 'content' => $p['content'], 'record_type' => $p['record_type'], 'speaker_confirmed' => (int) $p['speaker_confirmed'], 'occurred_at' => $p['occurred_at'], 'created_at' => time(), ]); return ['id' => (int) $id]; }); } public static function analyze(array $p, int $adminId): array { return self::enqueue('analysis', $p, $adminId); } public static function analyzeAutomatic(array $p, int $adminId): array { $p = \app\adminapi\validate\tcm\AssistantServiceValidate::parameters('analyze', $p); if ($p['assistant_id'] <= 0 || $p['days'] !== 30) { throw new DomainException('自动分析必须明确医助及最近 30 天范围'); } return self::enqueue('analysis', $p, $adminId, true); } public static function automationReady(): bool { return self::schemaReady() && (bool) config('assistant_service.enabled', false) && self::modelReady(); } public static function draftReply(array $p, int $adminId): array { return self::enqueue('reply', $p, $adminId); } private static function enqueue(string $type, array $p, int $adminId, bool $automatic = false): array { $permission = $type === 'reply' ? 'draftReply' : 'analyze'; Access::actor($adminId, $permission); self::requireEnabled(); if (!self::modelReady()) { throw new DomainException('模型服务尚未完整配置,暂不能创建任务'); } $row = Db::transaction(static function () use ($type, $p, $adminId, $permission, $automatic): array { // actor 行锁串行同人入队,额度检查不能被并发绕过。 Access::actor($adminId, $permission); Access::diagnosis($p['diagnosis_id'], $adminId, $permission, true); $context = Context::build($p['diagnosis_id'], $adminId, $p['days'], $p['assistant_id'] ?? 0); if ($automatic) { // 在实际操作者及诊单锁内复查,封闭自动扫描初检与入队间的竞争窗口。 $active = Db::name('assistant_service_job')->where('diagnosis_id', $p['diagnosis_id']) ->where('patient_id', $context['patient']['patient_id'])->where('type', 'analysis') ->whereIn('status', ['queued', 'running', 'needs_reconciliation'])->order('id', 'desc')->lock(true)->select()->toArray(); foreach ($active as $existing) { if (self::subject($existing) === $p['assistant_id']) { return $existing; } } } if (!$context['sources']) { throw new DomainException('所选时间范围没有可用服务来源,请先登记服务记录或调整时间范围'); } $knowledge = self::publishedKnowledge(); if ($type === 'reply' && !$knowledge) { throw new DomainException('暂无已发布服务知识,请管理员补充并审核发布后再生成回复'); } $question = (string) ($p['question'] ?? ''); $key = hash('sha256', self::json([$adminId, $p['diagnosis_id'], $type, $p['scenario'], $p['days'], $context['source_hash'], $context['scope_hash'], $question, $knowledge])); $existing = Db::name('assistant_service_job')->where('dedupe_key', $key)->lock(true)->find(); if ($existing) { // 用户再次点击后,只恢复明确未生成的失败;未知上游状态和已执行但结果无效不重发。 if (!$automatic && $existing['status'] === 'failed' && in_array($existing['error_code'], ['CONFIG_INVALID', 'DISABLED', 'NO_SOURCES', 'KNOWLEDGE_REQUIRED', 'INPUT_INVALID', 'INTERNAL_ERROR'], true)) { $reset = ['status' => 'queued', 'version' => (int) $existing['version'] + 1, 'result_json' => null, 'claim_token' => '', 'lease_until' => 0, 'error_code' => '', 'error_message' => '', 'upstream_id' => '', 'updated_at' => time(), 'finished_at' => 0]; Db::name('assistant_service_job')->where('id', $existing['id'])->update($reset); return array_replace($existing, $reset); } return $existing; } $daily = Db::name('assistant_service_job')->where('actor_id', $adminId)->where('created_at', '>=', strtotime('today'))->count(); if ($daily >= max(1, (int) config('assistant_service.daily_job_limit', 100))) { throw new DomainException('今日任务数量已达到上限,请明日再试'); } $now = time(); $row = ['diagnosis_id' => $p['diagnosis_id'], 'patient_id' => $context['patient']['patient_id'], 'actor_id' => $adminId, 'type' => $type, 'scenario' => $p['scenario'], 'days' => $p['days'], 'status' => 'queued', 'version' => 1, 'dedupe_key' => $key, 'source_hash' => $context['source_hash'], 'scope_hash' => $context['scope_hash'], 'payload_json' => self::json(['context' => $context, 'knowledge' => $knowledge, 'question' => $question] + ($automatic ? ['trigger' => 'automatic'] : [])), 'created_at' => $now, 'updated_at' => $now]; $row['id'] = (int) Db::name('assistant_service_job')->insertGetId($row); return $row; }); return self::formatJob($row, $adminId); } public static function job(array $p, int $adminId): array { Access::actor($adminId); self::requireSchema(); $row = self::jobRow($p['id']); return self::formatJob($row, $adminId); } public static function jobs(array $p, int $adminId): array { $actor = Access::actor($adminId); self::requireSchema(); $query = self::scopedQuery('assistant_service_job', 'j', $adminId, $actor) ->whereColumn('j.patient_id', 'd.patient_id'); if (($p['assistant_id'] ?? 0) > 0) { Access::assistant($p['assistant_id'], $adminId); } if ($p['diagnosis_id'] > 0) { Access::diagnosis($p['diagnosis_id'], $adminId); $query->where('j.diagnosis_id', $p['diagnosis_id']); } // stale 是基于当前来源/授权计算的状态。先重检再分页,不能按旧状态先截页。 // 分批扫描保持内存有界;指定诊单可进一步缩小主管筛选的检查成本。 if ($p['status'] !== '' && $p['status'] !== 'stale') { $query->where('j.status', $p['status']); } $lists = []; $count = 0; $cursor = null; $offset = ($p['page_no'] - 1) * $p['page_size']; do { $batch = clone $query; if ($cursor !== null) { $batch->where('j.id', '<', $cursor); } $rows = $batch->field('j.*')->order('j.id', 'desc')->limit(100)->select()->toArray(); foreach ($rows as $row) { if (($p['assistant_id'] ?? 0) > 0 && self::subject($row) !== $p['assistant_id']) { continue; } try { $view = self::formatJob($row, $adminId, null, false); } catch (DomainException $e) { continue; } if ($p['status'] !== '' && $view['status'] !== $p['status']) { continue; } if ($count >= $offset && count($lists) < $p['page_size']) { if ($view['result'] !== null) { $view['reviews'] = self::reviews((int) $row['id']); } $lists[] = $view; } $count++; } if ($rows) { $cursor = (int) end($rows)['id']; } } while (count($rows) === 100); return self::page($lists, $count, $p); } public static function reviewQuality(array $p, int $adminId): array { Access::actor($adminId, 'reviewQuality'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { $row = self::jobRow($p['job_id'], true); Access::diagnosis((int) $row['diagnosis_id'], $adminId, 'reviewQuality', true); $result = self::actionableResult($row, $p['version'], $adminId); self::item($result, 'quality_checks', $p['item_key']); $id = Db::name('assistant_service_review')->insertGetId(['job_id' => $p['job_id'], 'item_key' => $p['item_key'], 'reviewer_id' => $adminId, 'decision' => $p['decision'], 'note' => $p['note'], 'created_at' => time()]); return ['id' => (int) $id]; }); } public static function adoptAction(array $p, int $adminId): array { Access::actor($adminId, 'adoptAction'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { $row = self::jobRow($p['job_id'], true); $diagnosis = Access::diagnosis((int) $row['diagnosis_id'], $adminId, 'adoptAction', true); self::assertPatient($row, $diagnosis); $result = self::actionableResult($row, $p['version'], $adminId); $item = self::item($result, 'suggested_actions', $p['action_key']); $actor = Access::actor($adminId, 'adoptAction'); $assignee = self::subject($row) ?: $adminId; if (self::subject($row) > 0) { Access::assistant($assignee, $adminId); if ($assignee !== $adminId && !Access::hasPermission($actor, 'reviewQuality')) { throw new DomainException('仅有质检复核权限的主管可向目标医助分派跟进'); } if ((int) $diagnosis['assistant_id'] !== $assignee) { throw new DomainException('该医助已不负责此患者,可复核历史问题但不能向其分派跟进'); } } $contactRequired = (bool) ($p['contact_required'] ?? false); $actionFields = self::actionFields(); if ($contactRequired && !isset($actionFields['contact_required'])) { throw new DomainException('今日联系任务数据表尚未升级,请先执行今日联系模块迁移'); } $existing = Db::name('assistant_service_action')->where('job_id', $p['job_id'])->where('action_key', $p['action_key'])->lock(true)->find(); if ($existing) { return ['id' => (int) $existing['id'], 'todo_id' => (int) $existing['todo_id']]; } if ($p['due_at'] <= time() + 30) { throw new DomainException('跟进提醒时间必须晚于当前时间至少 30 秒'); } $content = trim($p['content'] !== '' ? $p['content'] : (string) $item['text']); if ($content === '' || mb_strlen($content) > 500) { throw new DomainException('采纳后的跟进内容须为 1 至 500 字'); } $now = time(); $todoId = (int) Db::name('tcm_diagnosis_todo')->insertGetId([ 'diagnosis_id' => (int) $row['diagnosis_id'], 'patient_id' => (int) $row['patient_id'], 'content' => $content, 'remind_time' => $p['due_at'], 'status' => 0, 'creator_id' => $adminId, 'creator_name' => $actor['name'], 'create_time' => $now, 'update_time' => $now, ]); $action = ['diagnosis_id' => (int) $row['diagnosis_id'], 'job_id' => $p['job_id'], 'action_key' => $p['action_key'], 'todo_id' => $todoId, 'assignee_id' => $assignee, 'content' => $content, 'due_at' => $p['due_at'], 'business_state' => 'pending', 'created_at' => $now, 'updated_at' => $now]; if (isset($actionFields['contact_required'])) { $action['contact_required'] = (int) $contactRequired; } $id = Db::name('assistant_service_action')->insertGetId($action); return ['id' => (int) $id, 'todo_id' => $todoId]; }); } public static function actions(array $p, int $adminId): array { $actor = Access::actor($adminId); self::requireSchema(); $query = self::scopedQuery('assistant_service_action', 'a', $adminId, $actor) ->join('assistant_service_job j', 'j.id = a.job_id')->whereColumn('j.patient_id', 'd.patient_id') ->leftJoin('tcm_diagnosis_todo t', 't.id = a.todo_id'); if (($p['assistant_id'] ?? 0) > 0) { Access::assistant($p['assistant_id'], $adminId); $query->where('a.assignee_id', $p['assistant_id']); } if ($p['diagnosis_id'] > 0) { Access::diagnosis($p['diagnosis_id'], $adminId); $query->where('a.diagnosis_id', $p['diagnosis_id']); } if ($p['state'] !== '') { $query->where('a.business_state', $p['state']); } $count = (clone $query)->count(); $actionFields = self::actionFields(); $optionalFields = (isset($actionFields['contact_required']) ? ',a.contact_required' : '') . (isset($actionFields['completed_by']) ? ',a.completed_by' : ''); $rows = $query->field('a.id,a.diagnosis_id,d.patient_name,a.content,a.due_at,a.business_state,a.completed_at,a.completion_note,a.assignee_id,t.status as notification_status' . $optionalFields) ->order('a.due_at', 'asc')->order('a.id', 'desc')->page($p['page_no'], $p['page_size'])->select()->toArray(); foreach ($rows as &$row) { foreach (['id', 'diagnosis_id', 'due_at', 'completed_at', 'assignee_id'] as $key) { $row[$key] = (int) $row[$key]; } $row['notification_status'] = $row['notification_status'] === null ? null : (int) $row['notification_status']; $row['contact_required'] = (int) ($row['contact_required'] ?? 0) === 1; $row['completed_by'] = (int) ($row['completed_by'] ?? 0); } unset($row); return self::page($rows, (int) $count, $p); } public static function completeAction(array $p, int $adminId): array { Access::actor($adminId, 'completeAction'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { $row = Db::name('assistant_service_action')->where('id', $p['id'])->lock(true)->find(); if (!$row) { throw new DomainException('服务跟进不存在或无权操作'); } $diagnosis = Access::diagnosis((int) $row['diagnosis_id'], $adminId, 'completeAction', true); $job = self::jobRow((int) $row['job_id']); self::assertPatient($job, $diagnosis); if (self::subject($job) > 0) { Access::assistant((int) $row['assignee_id'], $adminId); } $actor = Access::actor($adminId, 'completeAction'); if ((int) $row['assignee_id'] !== $adminId && !Access::hasPermission($actor, 'reviewQuality')) { throw new DomainException('仅负责人或有质检复核权限的主管可更新此跟进'); } $terminal = in_array($row['business_state'], ['completed', 'cancelled'], true); if ($terminal) { if ($row['business_state'] !== $p['state']) { throw new DomainException('已完成或取消的跟进不能重新打开或改成另一终态'); } return ['id' => (int) $row['id']]; } $now = time(); $finish = in_array($p['state'], ['completed', 'cancelled'], true); if ($finish && trim($p['note']) === '') { throw new DomainException('完成或取消时必须填写执行结果或原因'); } if ($finish) { // 与旧通知抢占竞争同一行:只取消仍待发送的提醒,不把已发送误记成业务完成。 Db::name('tcm_diagnosis_todo')->where('id', (int) $row['todo_id'])->where('status', 0)->whereNull('delete_time') ->update(['status' => 2, 'cancelled_at' => $now, 'cancelled_by' => $adminId, 'update_time' => $now]); } $update = ['business_state' => $p['state'], 'completion_note' => $p['note'], 'completed_at' => $finish ? $now : 0, 'updated_at' => $now]; if (isset(self::actionFields()['completed_by'])) { $update['completed_by'] = $finish ? $adminId : 0; } Db::name('assistant_service_action')->where('id', $p['id'])->update($update); return ['id' => $p['id']]; }); } public static function knowledge(array $p, int $adminId): array { $actor = Access::actor($adminId); self::requireSchema(); $manage = Access::hasPermission($actor, 'manageKnowledge'); $query = Db::name('assistant_service_knowledge'); if (!$manage) { $query->where('status', 'published'); } if ($p['status'] !== '') { $query->where('status', $p['status']); } if ($p['keyword'] !== '') { $keyword = '%' . addcslashes($p['keyword'], '%_\\') . '%'; $query->where(function ($q) use ($keyword): void { $q->whereLike('question', $keyword)->whereOr('category', 'like', $keyword); }); } $count = (clone $query)->count(); $rows = $query->field(self::KNOWLEDGE_FIELDS . ',status,updated_at')->order('id', 'desc') ->page($p['page_no'], $p['page_size'])->select()->toArray(); foreach ($rows as &$row) { foreach (['id', 'version', 'updated_at'] as $key) { $row[$key] = (int) $row[$key]; } } unset($row); return self::page($rows, (int) $count, $p); } public static function saveKnowledge(array $p, int $adminId): array { Access::actor($adminId, 'manageKnowledge'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { Access::actor($adminId, 'manageKnowledge'); $now = time(); $data = array_intersect_key($p, array_flip(['question', 'answer', 'category', 'source_note'])); $data += ['status' => 'draft', 'updated_at' => $now]; if ($p['id'] <= 0) { $id = Db::name('assistant_service_knowledge')->insertGetId($data + ['version' => 1, 'created_by' => $adminId, 'published_by' => 0, 'created_at' => $now]); return ['id' => (int) $id, 'version' => 1]; } $row = self::knowledgeRow($p['id'], $p['version']); $version = (int) $row['version'] + 1; Db::name('assistant_service_knowledge')->where('id', $p['id'])->where('version', $p['version']) ->update($data + ['version' => $version, 'published_by' => 0]); return ['id' => $p['id'], 'version' => $version]; }); } public static function publishKnowledge(array $p, int $adminId): array { Access::actor($adminId, 'manageKnowledge'); self::requireSchema(); return Db::transaction(static function () use ($p, $adminId): array { Access::actor($adminId, 'manageKnowledge'); $row = self::knowledgeRow($p['id'], $p['version']); if ($p['status'] === 'published' && trim((string) $row['source_note']) === '') { throw new DomainException('发布前请补充可核实的服务知识来源说明'); } if ($row['status'] === $p['status']) { return ['id' => $p['id'], 'version' => (int) $row['version']]; } $version = (int) $row['version'] + 1; Db::name('assistant_service_knowledge')->where('id', $p['id'])->where('version', $p['version'])->update([ 'status' => $p['status'], 'version' => $version, 'published_by' => $adminId, 'updated_at' => time()]); return ['id' => $p['id'], 'version' => $version]; }); } private static function knowledgeRow(int $id, int $version): array { $row = Db::name('assistant_service_knowledge')->where('id', $id)->lock(true)->find(); if (!$row) { throw new DomainException('服务知识不存在'); } if ((int) $row['version'] !== $version) { throw new DomainException('服务知识版本已被更新,请刷新后重试'); } return $row; } private static function publishedKnowledge(): array { $limit = max(1, (int) config('assistant_service.max_knowledge_rows', 40)); $rows = Db::name('assistant_service_knowledge')->where('status', 'published')->field(self::KNOWLEDGE_FIELDS) ->order('id', 'asc')->limit($limit + 1)->lock(true)->select()->toArray(); if (count($rows) > $limit) { throw new DomainException('已发布知识数量超过当前模型上下文配置,请管理员调整知识范围或容量'); } foreach ($rows as &$row) { $row['id'] = (int) $row['id']; $row['version'] = (int) $row['version']; } unset($row); return $rows; } private static function jobRow(int $id, bool $lock = false): array { $row = Db::name('assistant_service_job')->where('id', $id)->lock($lock)->find(); if (!$row) { throw new DomainException('服务任务不存在或无权访问'); } return $row; } private static function assertPatient(array $job, array $diagnosis): void { if ((int) $job['patient_id'] !== (int) ($diagnosis['patient_id'] ?? 0)) { throw new DomainException('诊单患者关联已变化,不能访问原服务任务'); } } private static function formatJob(array $row, int $adminId, ?array $viewerContext = null, bool $includeReviews = true, ?callable $readContext = null): array { $diagnosis = Access::diagnosis((int) $row['diagnosis_id'], $adminId); self::assertPatient($row, $diagnosis); $payload = self::decode($row['payload_json'] ?? ''); $snapshot = $payload['context'] ?? []; $subject = (int) ($snapshot['subject_admin_id'] ?? 0); if ($subject > 0) { Access::assistant($subject, $adminId); } try { $viewer = $viewerContext !== null && (int) ($viewerContext['days'] ?? 0) === (int) $row['days'] && (int) ($viewerContext['subject_admin_id'] ?? 0) === $subject ? $viewerContext : ($readContext ?? [Context::class, 'build'])((int) $row['diagnosis_id'], $adminId, (int) $row['days'], $subject); } catch (DomainException $e) { if ($e->getCode() !== 413) { throw $e; } // 来源增多到超限时仍可读取任务状态,但不能绕过容量/员工域检查披露旧结果。 $viewer = ['sources' => [], 'coverage' => [['channel' => 'sources', 'status' => 'over_limit', 'count' => 0, 'note' => '当前来源已超出处理容量,请缩小时间范围后重新分析']]]; } $visible = array_fill_keys(array_column($viewer['sources'], 'id'), true); $sources = array_values(array_filter($snapshot['sources'] ?? [], static fn (array $s): bool => isset($visible[$s['id']]))); $hidden = count($sources) !== count($snapshot['sources'] ?? []); $stale = $hidden || self::isStale($row, $payload, $readContext); $result = !$hidden && !empty($row['result_json']) ? self::decode($row['result_json']) : null; $reviews = !$hidden && $includeReviews ? self::reviews((int) $row['id']) : []; return ['id' => (int) $row['id'], 'type' => $row['type'], 'diagnosis_id' => (int) $row['diagnosis_id'], 'subject_admin_id' => $subject, 'scenario' => $row['scenario'], 'status' => $stale ? 'stale' : $row['status'], 'version' => (int) $row['version'], 'created_at' => (int) $row['created_at'], 'updated_at' => (int) $row['updated_at'], 'error_code' => $stale ? 'STALE_SOURCE' : (string) ($row['error_code'] ?? ''), 'error_message' => $stale ? '来源、权限或知识版本已变化,请重新分析;历史结果仅供查看' : (string) ($row['error_message'] ?? ''), 'result' => $result, 'coverage' => $viewer['coverage'], 'sources' => $sources, 'reviews' => $reviews, 'stale' => $stale]; } private static function reviews(int $jobId): array { return Db::name('assistant_service_review')->where('job_id', $jobId) ->field('id,item_key,reviewer_id,decision,note,created_at')->order('id', 'asc')->select()->toArray(); } private static function isStale(array $row, array $payload, ?callable $readContext = null): bool { if ($row['status'] === 'stale') { return true; } try { Access::actor((int) $row['actor_id'], $row['type'] === 'reply' ? 'draftReply' : 'analyze'); $current = ($readContext ?? [Context::class, 'build'])((int) $row['diagnosis_id'], (int) $row['actor_id'], (int) $row['days'], (int) ($payload['context']['subject_admin_id'] ?? 0)); if (!hash_equals((string) $row['source_hash'], $current['source_hash']) || !hash_equals((string) $row['scope_hash'], $current['scope_hash'])) { return true; } foreach ($payload['knowledge'] ?? [] as $snapshot) { $item = Db::name('assistant_service_knowledge')->where('id', (int) $snapshot['id'])->where('status', 'published')->lock(true)->find(); if (!$item) { return true; } foreach (explode(',', self::KNOWLEDGE_FIELDS) as $field) { if ((string) ($item[$field] ?? '') !== (string) ($snapshot[$field] ?? '')) { return true; } } } return false; } catch (DomainException $e) { return true; } } private static function actionableResult(array $row, int $version, int $adminId): array { if ((int) $row['version'] !== $version || $row['status'] !== 'ready' || $row['type'] !== 'analysis') { throw new DomainException('任务尚未完成或版本已变化,请刷新后重试'); } $view = self::formatJob($row, $adminId); if ($view['stale'] || !is_array($view['result'])) { throw new DomainException('来源、权限或知识已变化,请重新分析后操作'); } return $view['result']; } private static function item(array $result, string $field, string $key): array { foreach ($result[$field] ?? [] as $item) { if (($item['key'] ?? '') === $key) { return $item; } } throw new DomainException('所选项目不在当前分析结果中'); } private static function scopedQuery(string $table, string $alias, int $adminId, array $actor) { $query = Db::name($table)->alias($alias)->join('tcm_diagnosis d', 'd.id = ' . $alias . '.diagnosis_id') ->whereNull('d.delete_time')->where('d.status', 1); Access::applyScope($query, $adminId, $actor); return $query; } private static function page(array $lists, int $count, array $p): array { return ['lists' => $lists, 'count' => $count, 'page_no' => $p['page_no'], 'page_size' => $p['page_size']]; } private static function json(array $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } private static function subject(array $row): int { return (int) (self::decode($row['payload_json'] ?? '')['context']['subject_admin_id'] ?? 0); } private static function decode(string $value): array { $data = json_decode($value, true); return is_array($data) ? $data : []; } private static function requireEnabled(): void { self::requireSchema(); if (!(bool) config('assistant_service.enabled', false)) { throw new DomainException('AI 分析尚未启用'); } } /** 旧部署缺少增量列时仍支持普通跟进;数据库异常不伪装成缺列。 */ private static function actionFields(): array { return Db::connect()->getFields(Db::name('assistant_service_action')->getTable()); } private static function requireSchema(): void { if (!self::schemaReady()) { throw new DomainException('医助服务数据表尚未就绪,请先执行模块迁移'); } } private static function schemaReady(): bool { try { foreach (['record' => 'id,diagnosis_id,patient_id,actor_id,content,record_type,speaker_confirmed,occurred_at,created_at,delete_time', 'job' => 'id,diagnosis_id,patient_id,actor_id,type,scenario,days,status,version,dedupe_key,source_hash,scope_hash,payload_json,result_json,claim_token,lease_until,error_code,error_message,upstream_id,created_at,updated_at,finished_at', 'review' => 'id,job_id,item_key,reviewer_id,decision,note,created_at', 'action' => 'id,diagnosis_id,job_id,action_key,todo_id,assignee_id,content,due_at,business_state,completion_note,completed_at,created_at,updated_at', 'knowledge' => self::KNOWLEDGE_FIELDS . ',status,created_by,published_by,created_at,updated_at'] as $table => $fields) { Db::name('assistant_service_' . $table)->field($fields)->limit(1)->select(); } return true; } catch (\Throwable $e) { return false; } } private static function modelReady(): bool { $config = config('prescription_ai') ?: []; $profile = (string) config('assistant_service.profile', 'qwen'); $model = $config['models'][$profile] ?? []; $url = trim((string) ($config['base_url'] ?? '')); $key = (string) ($model['api_key'] ?? ''); $timeout = (int) config('assistant_service.timeout', 120); return !empty($config['enable']) && in_array($profile, ['qwen', 'openai'], true) && filter_var($url, FILTER_VALIDATE_URL) !== false && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) && trim($key) !== '' && strpbrk($key, "\r\n") === false && trim((string) ($model['name'] ?? '')) !== '' && $timeout >= 1 && $timeout <= 300 && function_exists('curl_init'); } }