This commit is contained in:
Your Name
2026-11-12 09:07:07 +08:00
parent 06afe2c453
commit c737960d4f
1399 changed files with 19163 additions and 3089 deletions
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\tcm\AssistantServiceLogic as Logic;
use app\adminapi\validate\tcm\AssistantServiceValidate as Validate;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use DomainException;
final class AssistantServiceController extends BaseAdminController
{
public function capabilities() { return $this->handle(__FUNCTION__); }
public function patients() { return $this->handle(__FUNCTION__); }
public function staff() { return $this->handle(__FUNCTION__); }
public function teamOverview() { return $this->handle(__FUNCTION__); }
public function staffOverview() { return $this->handle(__FUNCTION__); }
public function staffCases() { return $this->handle(__FUNCTION__); }
public function staffIssues() { return $this->handle(__FUNCTION__); }
public function staffDailyContacts() { return $this->handle(__FUNCTION__); }
public function alerts() { return $this->handle(__FUNCTION__); }
public function wecomDirectory() { return $this->handle(__FUNCTION__); }
public function wecomChats() { return $this->handle(__FUNCTION__); }
public function wecomMessages() { return $this->handle(__FUNCTION__); }
public function analyzeStaff() { return $this->handle(__FUNCTION__, true); }
public function context() { return $this->handle(__FUNCTION__); }
public function job() { return $this->handle(__FUNCTION__); }
public function jobs() { return $this->handle(__FUNCTION__); }
public function actions() { return $this->handle(__FUNCTION__); }
public function knowledge() { return $this->handle(__FUNCTION__); }
public function record() { return $this->handle(__FUNCTION__, true); }
public function analyze() { return $this->handle(__FUNCTION__, true); }
public function draftReply() { return $this->handle(__FUNCTION__, true); }
public function reviewQuality() { return $this->handle(__FUNCTION__, true); }
public function reviewAlert() { return $this->handle(__FUNCTION__, true); }
public function adoptAction() { return $this->handle(__FUNCTION__, true); }
public function completeAction() { return $this->handle(__FUNCTION__, true); }
public function saveKnowledge() { return $this->handle(__FUNCTION__, true); }
public function publishKnowledge() { return $this->handle(__FUNCTION__, true); }
private function handle(string $action, bool $post = false)
{
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
return $this->fail('请求方式错误');
}
try {
Access::actor($this->adminId, Access::permissionForAction($action));
// 禁止全局 trim 将 JSON boolean 转为字符串;验证器独立处理每个字段。
$input = $post ? $this->request->post('', null, null) : $this->request->get();
return $this->data(Logic::$action(Validate::parameters($action, $input), $this->adminId));
} catch (DomainException $e) {
return $this->fail($e->getMessage());
} catch (\Throwable $e) {
// 不把 SQL、模型凭据、患者原文或第三方响应带给浏览器。
return $this->fail('医助服务暂不可用,请联系管理员检查部署或稍后重试');
}
}
}
@@ -26,8 +26,9 @@ class MyPatientLogic
/**
* @param Query $query 以 d 作为 zyt_tcm_diagnosis 别名的查询
* @param int[]|null $currentRoleIds 可选:调用方已从 DB 当前读核验的有效角色;禁止使用请求参数
*/
public static function applyScope(Query $query, int $adminId, array $adminInfo): void
public static function applyScope(Query $query, int $adminId, array $adminInfo, ?array $currentRoleIds = null): void
{
if ($adminId <= 0) {
$query->whereRaw('0 = 1');
@@ -39,9 +40,12 @@ class MyPatientLogic
return;
}
$roleIds = self::roleIds($adminId);
$roleIds = $currentRoleIds === null ? self::roleIds($adminId) : self::normalizeIds($currentRoleIds);
$appointmentTable = (new Appointment())->getTable();
$statusList = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
// 新授权入口已显式要求当前读时,预约子查询也必须使用当前读。
// 仅外层 FOR UPDATE 不会把 EXISTS 内部从 MySQL RR 旧快照切换出来。
$appointmentLock = $currentRoleIds !== null && $query->getConfig('type') === 'mysql' ? ' FOR UPDATE' : '';
// 管理角色按系统的数据范围查看“范围内医助归属或医生接诊”的患者。
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
@@ -61,7 +65,7 @@ class MyPatientLogic
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id IN ({$ids})))"
. " AND scope_apt.doctor_id IN ({$ids}){$appointmentLock}))"
);
return;
@@ -76,7 +80,7 @@ class MyPatientLogic
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id = {$adminId})";
. " AND scope_apt.doctor_id = {$adminId}{$appointmentLock})";
}
// 未知/异常角色按本人医助或本人医生关系收窄,拒绝意外放大全库。
@@ -85,7 +89,7 @@ class MyPatientLogic
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
. ' WHERE scope_apt.patient_id = d.id'
. " AND scope_apt.status IN ({$statusList})"
. " AND scope_apt.doctor_id = {$adminId})";
. " AND scope_apt.doctor_id = {$adminId}{$appointmentLock})";
}
$query->whereRaw('(' . implode(' OR ', $conditions) . ')');
@@ -0,0 +1,651 @@
<?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');
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\tcm;
use DomainException;
/** 保留 JSON 的类型,拒绝数组文本、浮点 ID、未知参数和未确认转写。 */
final class AssistantServiceValidate
{
public static function parameters(string $action, array $input): array
{
$fields = [
'capabilities' => [], 'patients' => ['keyword', 'page_no', 'page_size'],
'staff' => ['keyword', 'page_no', 'page_size'],
'teamOverview' => ['keyword', 'filter', 'page_no', 'page_size'],
'staffOverview' => ['assistant_id', 'days'], 'staffCases' => ['assistant_id', 'days', 'keyword', 'page_no', 'page_size'],
'staffIssues' => ['assistant_id', 'days', 'page_no', 'page_size'],
'staffDailyContacts' => ['assistant_id', 'status', 'page_no', 'page_size'],
'alerts' => ['keyword', 'status', 'severity', 'role', 'page_no', 'page_size'],
'wecomDirectory' => [],
'wecomChats' => ['assistant_id', 'account'],
'wecomMessages' => ['assistant_id', 'account', 'conversation_id', 'cursor'],
'reviewAlert' => ['id', 'status', 'note'],
'analyzeStaff' => ['assistant_id', 'days', 'scenario', 'diagnosis_ids'],
'context' => ['diagnosis_id', 'days', 'assistant_id'], 'record' => ['diagnosis_id', 'content', 'occurred_at', 'record_type', 'speaker_confirmed'],
'analyze' => ['diagnosis_id', 'days', 'scenario', 'assistant_id'], 'draftReply' => ['diagnosis_id', 'days', 'scenario', 'question', 'assistant_id'],
'job' => ['id'], 'jobs' => ['diagnosis_id', 'status', 'assistant_id', 'page_no', 'page_size'],
'reviewQuality' => ['job_id', 'item_key', 'decision', 'note', 'version'],
'adoptAction' => ['job_id', 'action_key', 'version', 'due_at', 'content', 'contact_required'],
'actions' => ['diagnosis_id', 'assistant_id', 'state', 'page_no', 'page_size'], 'completeAction' => ['id', 'state', 'note'],
'knowledge' => ['keyword', 'status', 'page_no', 'page_size'],
'saveKnowledge' => ['id', 'version', 'question', 'answer', 'category', 'source_note'],
'publishKnowledge' => ['id', 'version', 'status'],
];
if (!isset($fields[$action]) || array_diff(array_keys($input), $fields[$action])) {
throw new DomainException('请求包含不支持的字段');
}
$p = $input;
if (in_array('assistant_id', $fields[$action], true)) {
$required = in_array($action, ['staffOverview', 'staffCases', 'staffIssues', 'staffDailyContacts', 'analyzeStaff'], true);
$p['assistant_id'] = self::integer($p['assistant_id'] ?? ($required ? null : 0), '医助', $required ? 1 : 0, PHP_INT_MAX);
}
if ($action === 'analyzeStaff') {
if (!is_array($p['diagnosis_ids'] ?? null) || array_keys($p['diagnosis_ids']) !== range(0, count($p['diagnosis_ids']) - 1)
|| count($p['diagnosis_ids']) < 1 || count($p['diagnosis_ids']) > 10) {
throw new DomainException('请勾选 1 至 10 条沟通记录');
}
$p['diagnosis_ids'] = array_map(static fn ($id): int => self::integer($id, '诊单', 1, PHP_INT_MAX), $p['diagnosis_ids']);
if (count(array_unique($p['diagnosis_ids'])) !== count($p['diagnosis_ids'])) { throw new DomainException('不能重复勾选同一诊单'); }
}
foreach (['id', 'job_id', 'version', 'diagnosis_id', 'occurred_at', 'due_at'] as $key) {
if (in_array($key, $fields[$action], true)) {
$optional = ($key === 'diagnosis_id' && in_array($action, ['jobs', 'actions'], true))
|| ($action === 'saveKnowledge' && in_array($key, ['id', 'version'], true));
$p[$key] = self::integer($p[$key] ?? ($optional ? 0 : null), $key, $optional ? 0 : 1, PHP_INT_MAX);
}
}
if ($action === 'saveKnowledge' && $p['id'] > 0 && $p['version'] <= 0) {
throw new DomainException('更新知识必须提供当前版本');
}
if (in_array('page_no', $fields[$action], true)) {
$p['page_no'] = self::integer(array_key_exists('page_no', $p) ? $p['page_no'] : 1, '页码', 1, 100000);
$p['page_size'] = self::integer(array_key_exists('page_size', $p) ? $p['page_size'] : 20, '每页数量', 1, 50);
}
if (in_array('days', $fields[$action], true)) {
$p['days'] = self::integer($p['days'] ?? 30, '时间范围', 7, 90);
self::oneOf($p['days'], [7, 30, 90], '时间范围');
}
$limits = ['keyword' => 100, 'question' => $action === 'saveKnowledge' ? 300 : (int) config('assistant_service.max_question_chars', 1000),
'answer' => 10000, 'category' => 64, 'source_note' => 1000, 'note' => $action === 'reviewAlert' ? 200 : 1000, 'content' =>
$action === 'record' ? (int) config('assistant_service.max_manual_chars', 10000) : 500,
'item_key' => 64, 'action_key' => 64, 'account' => 64, 'conversation_id' => 80, 'cursor' => 200];
foreach ($limits as $key => $limit) {
if (in_array($key, $fields[$action], true)) {
$required = ($key === 'content' && $action === 'record') || ($key === 'conversation_id' && $action === 'wecomMessages')
|| in_array($key, ['question', 'answer', 'item_key', 'action_key'], true);
$p[$key] = self::text($p[$key] ?? '', $key, $limit, $required);
}
}
$enums = [
'filter' => ['all', 'issues', 'overdue', 'attention', 'today_pending', 'today_done'],
'scenario' => ['initial', 'followup', 'revisit'], 'record_type' => ['service_note', 'verified_transcript'],
'decision' => ['confirmed', 'dismissed', 'not_applicable'],
'severity' => ['all', 'high', 'medium', 'low'],
'role' => ['all', 'assistant', 'doctor'],
'state' => $action === 'actions' ? ['', 'pending', 'in_progress', 'completed', 'cancelled'] : ['in_progress', 'completed', 'cancelled'],
'status' => $action === 'jobs' ? ['', 'queued', 'running', 'ready', 'failed', 'needs_reconciliation', 'stale']
: ($action === 'staffDailyContacts' ? ['all', 'pending', 'reported', 'contacted', 'exempt']
: ($action === 'alerts' ? ['all', 'open', 'confirmed', 'false_positive', 'followed']
: ($action === 'reviewAlert' ? ['confirmed', 'false_positive', 'followed']
: ($action === 'knowledge' ? ['', 'draft', 'published', 'disabled'] : ['published', 'disabled'])))),
];
foreach ($enums as $key => $values) {
if (in_array($key, $fields[$action], true)) {
$p[$key] = array_key_exists($key, $p) ? $p[$key]
: ($key === 'filter' ? 'all' : ($action === 'staffDailyContacts' && $key === 'status' ? 'pending'
: ($action === 'alerts' && $key === 'status' ? 'open'
: ($action === 'alerts' && in_array($key, ['severity', 'role'], true) ? 'all' : ''))));
self::oneOf($p[$key], $values, $key);
}
}
if ($action === 'reviewAlert' && $p['status'] === 'false_positive' && $p['note'] === '') {
throw new DomainException('误报需要填写原因');
}
if ($action === 'adoptAction') {
if (array_key_exists('contact_required', $p) && !is_bool($p['contact_required'])) {
throw new DomainException('需要联系患者标记必须是布尔值');
}
$p['contact_required'] = $p['contact_required'] ?? false;
}
if ($action === 'record') {
if (!is_bool($p['speaker_confirmed'] ?? false)) {
throw new DomainException('说话人确认必须是布尔值');
}
$p['speaker_confirmed'] = $p['speaker_confirmed'] ?? false;
if ($p['record_type'] === 'verified_transcript' && !$p['speaker_confirmed']) {
throw new DomainException('请先明确确认转写内容及说话人');
}
if ($p['occurred_at'] > time() + 300) {
throw new DomainException('服务记录时间不能在未来');
}
}
if ($action === 'completeAction' && in_array($p['state'], ['completed', 'cancelled'], true) && $p['note'] === '') {
throw new DomainException('完成或取消时必须填写执行结果或原因');
}
return $p;
}
private static function integer($value, string $label, int $min, int $max): int
{
if (!(is_int($value) || is_string($value)) || !preg_match('/^(0|[1-9][0-9]{0,17})$/D', (string) $value)
|| (int) $value < $min || (int) $value > $max) {
throw new DomainException($label . ' 无效');
}
return (int) $value;
}
private static function text($value, string $label, int $max, bool $required): string
{
if (!is_string($value) || mb_strlen($value) > max(1, $max) || ($required && trim($value) === '')) {
throw new DomainException($label . ' 为空或超过长度限制');
}
return trim($value);
}
private static function oneOf($value, array $allowed, string $label): void
{
if (!in_array($value, $allowed, true)) {
throw new DomainException($label . ' 无效');
}
}
}