Files
zyt/server/app/adminapi/logic/tcm/AssistantServiceLogic.php
2026-11-12 09:07:07 +08:00

652 lines
39 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use app\common\service\assistantservice\AssistantServiceContext as Context;
use app\common\service\assistantservice\AssistantServiceStaff as Staff;
use app\common\service\assistantservice\AssistantServiceTeam as Team;
use app\common\service\assistantservice\AssistantServiceDailyContact as DailyContact;
use app\common\service\assistantservice\AssistantServiceAlert as Alert;
use app\common\service\assistantservice\AssistantServiceAutomation as Automation;
use DomainException;
use think\facade\Db;
/** 独立服务记录、异步分析、知识发布及跟进执行闭环。 */
final class AssistantServiceLogic
{
private const KNOWLEDGE_FIELDS = 'id,version,question,answer,category,source_note';
public static function staff(array $p, int $adminId): array { return Staff::listing($p, $adminId); }
public static function teamOverview(array $p, int $adminId): array
{
Access::actor($adminId);
self::requireSchema();
// 同一请求内每个操作者/对象/诊单只重建一次来源;不能跨请求复用授权或新鲜度。
$contexts = [];
$readContext = static function (int $diagnosis, int $actor, int $days, int $subject) use (&$contexts): array {
$key = implode(':', [$diagnosis, $actor, $days, $subject]);
if (!isset($contexts[$key])) {
try { $contexts[$key] = Context::build($diagnosis, $actor, $days, $subject); }
catch (DomainException $e) { $contexts[$key] = $e; }
}
if ($contexts[$key] instanceof DomainException) { throw $contexts[$key]; }
return $contexts[$key];
};
$result = Team::overview($p, $adminId, static fn (array $row): array => 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');
}
}