更新
This commit is contained in:
@@ -6,10 +6,32 @@ namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\PrescriptionAiLogic;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility;
|
||||
use DomainException;
|
||||
|
||||
final class PrescriptionAiController extends BaseAdminController
|
||||
{
|
||||
public function capabilities()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::capabilities($this->adminId));
|
||||
}
|
||||
|
||||
public function getVisibilityConfig()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::getConfig($this->adminId));
|
||||
}
|
||||
|
||||
public function setVisibilityConfig()
|
||||
{
|
||||
return $this->handle(true, ['role_ids', 'admin_ids'],
|
||||
fn (array $p): array => PrescriptionAiVisibility::setConfig($p, $this->adminId));
|
||||
}
|
||||
|
||||
public function visibilityOptions()
|
||||
{
|
||||
return $this->handle(false, [], fn (array $p): array => PrescriptionAiVisibility::options($this->adminId));
|
||||
}
|
||||
|
||||
public function statuses()
|
||||
{
|
||||
return $this->handle(false, ['ids'], function (array $p): array {
|
||||
|
||||
@@ -235,7 +235,7 @@ class PrescriptionOrderController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改关联处方服用参数(主方/辅方次数与开立天数)及订单服用天数
|
||||
* 任意履约状态下修改主辅方服用参数(浓缩水丸袋数、次数、开立天数)及订单服用天数,并记录日志
|
||||
*/
|
||||
public function patchPrescriptionUsage()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
|
||||
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStatistics;
|
||||
use app\common\service\prescriptionai\PrescriptionAiStore as Store;
|
||||
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
@@ -22,15 +23,16 @@ final class PrescriptionAiLogic
|
||||
public static function statuses(array $ids, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('statuses', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
if (!Store::enabled()) {
|
||||
return ['enabled' => false, 'items' => []];
|
||||
return ['enabled' => false, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
if (count($ids) > 100 || array_filter($ids, static fn ($id): bool => $id <= 0)) {
|
||||
throw new DomainException('最多查询100个有效处方');
|
||||
}
|
||||
if ($ids === []) {
|
||||
return ['enabled' => true, 'items' => []];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => []];
|
||||
}
|
||||
$subjects = Db::name('prescription_ai_subject')->whereIn('prescription_id', $ids)->column('latest_batch_id', 'prescription_id');
|
||||
$batches = $subjects ? Db::name('prescription_ai_batch')->whereIn('id', array_values($subjects))->select()->toArray() : [];
|
||||
@@ -50,18 +52,20 @@ final class PrescriptionAiLogic
|
||||
// Do not reveal that a report with a broader source scope exists.
|
||||
continue;
|
||||
}
|
||||
$items[] = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
$item = $batch ? self::formatBatch($batch, $modelMap[(int) $batch['id']] ?? [], $rx) : [
|
||||
'prescription_id' => $id, 'batch_id' => null,
|
||||
'status' => (int) ($rx['is_system_auto'] ?? 0) === 1 ? 'blank' : 'not_generated',
|
||||
'coverage_status' => 'pending', 'validity' => 'current', 'comparison_type' => 'unavailable', 'models' => [],
|
||||
];
|
||||
$items[] = Visibility::projectBatch($item, $agreementVisible);
|
||||
}
|
||||
return ['enabled' => true, 'items' => $items];
|
||||
return ['enabled' => true, 'agreement_visible' => $agreementVisible, 'items' => $items];
|
||||
}
|
||||
|
||||
public static function reports(array $params, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('reports', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$rxId = (int) ($params['prescription_id'] ?? 0);
|
||||
$diagnosisId = (int) ($params['diagnosis_id'] ?? 0);
|
||||
if (($rxId > 0) === ($diagnosisId > 0)) {
|
||||
@@ -100,22 +104,24 @@ final class PrescriptionAiLogic
|
||||
} while (count($chunk) === 200);
|
||||
$rows = array_slice($visible, ($page - 1) * $size, $size);
|
||||
$modelMap = self::models(array_column($rows, 'id'), false);
|
||||
return ['lists' => array_map(static fn ($b): array => self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $rows),
|
||||
return ['agreement_visible' => $agreementVisible,
|
||||
'lists' => array_map(static fn ($b): array => Visibility::projectBatch(self::formatBatch($b, $modelMap[(int) $b['id']] ?? []), $agreementVisible), $rows),
|
||||
'count' => count($visible), 'page_no' => $page, 'page_size' => $size];
|
||||
}
|
||||
|
||||
public static function detail(int $batchId, int $actor, array $info): array
|
||||
{
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$agreementVisible = Visibility::agreementVisible($actor);
|
||||
$batch = self::loadBatch($batchId, $actor, $info);
|
||||
$models = self::models([$batchId], true);
|
||||
$models = self::models([$batchId], true, $agreementVisible);
|
||||
$detail = self::formatBatch($batch, $models[$batchId] ?? [], Access::prescription((int) $batch['prescription_id'], $actor, $info));
|
||||
// loadBatch has checked both current prescription and historical source permissions.
|
||||
// This cipher is saved at enqueue time, so it is available before models start and
|
||||
// remains the correct original when the user selects an older report revision.
|
||||
$detail['doctor_snapshot'] = empty($batch['prescription_cipher']) ? null
|
||||
: DoctorSnapshot::project((new Cipher())->decrypt($batch['prescription_cipher'], 'prescription'));
|
||||
return $detail;
|
||||
return Visibility::projectBatch($detail, $agreementVisible);
|
||||
}
|
||||
|
||||
public static function regenerate(int $rxId, string $reason, int $actor, array $info): array
|
||||
@@ -205,6 +211,9 @@ final class PrescriptionAiLogic
|
||||
|
||||
public static function statistics(array $params, int $actor, array $info): array
|
||||
{
|
||||
if (!Visibility::agreementVisible($actor)) {
|
||||
throw new DomainException('无权查看处方AI一致度统计');
|
||||
}
|
||||
self::requirePermission('statistics', $actor, $info);
|
||||
self::requirePermission('detail', $actor, $info);
|
||||
$from = self::date((string) ($params['date_from'] ?? date('Y-m-d', strtotime('-30 days'))));
|
||||
@@ -271,7 +280,7 @@ final class PrescriptionAiLogic
|
||||
'metric_label' => '药味与剂量一致度(不代表临床准确率)'];
|
||||
}
|
||||
|
||||
private static function models(array $batchIds, bool $full): array
|
||||
private static function models(array $batchIds, bool $full, bool $agreementVisible = true): array
|
||||
{
|
||||
if ($batchIds === []) {
|
||||
return [];
|
||||
@@ -301,7 +310,7 @@ final class PrescriptionAiLogic
|
||||
foreach ($results as $result) {
|
||||
$body = $full ? (new Cipher())->decrypt($result['body_cipher'], 'result:' . $result['batch_id'] . ':' . $result['model_key']) : [];
|
||||
if ($full) {
|
||||
$body = self::localizeSourceMetadata($body);
|
||||
$body = Visibility::reportBody(self::localizeSourceMetadata($body), $agreementVisible);
|
||||
}
|
||||
unset($result['body_cipher']);
|
||||
$result['score'] = $result['score'] === null ? null : (float) $result['score'];
|
||||
|
||||
@@ -5763,7 +5763,7 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务订单详情场景:更新主方/辅方服用次数与开立天数,以及订单服用天数
|
||||
* 业务订单详情场景:任意履约状态下更新主辅方服用参数,变更必须写入操作日志
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
@@ -5774,7 +5774,8 @@ class PrescriptionOrderLogic
|
||||
try {
|
||||
return (bool) LockedPharmacySnapshotMutation::execute(
|
||||
$prescriptionOrderId,
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo)
|
||||
static fn (): bool => self::patchPrescriptionUsageLocked($params, $adminId, $adminInfo),
|
||||
false // 服用参数允许在提交药房后修正,继续保留订单/提交记录行锁与事务。
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
if (self::getError() === '') {
|
||||
@@ -5799,21 +5800,13 @@ class PrescriptionOrderLogic
|
||||
|
||||
return false;
|
||||
}
|
||||
if (!self::assertRemoteSnapshotMutable($order)) {
|
||||
return false;
|
||||
}
|
||||
if ((int) $order->fulfillment_status === 4) {
|
||||
self::setError('已取消的订单不可修改');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rxId = (int) ($order->prescription_id ?? 0);
|
||||
if ($rxId <= 0) {
|
||||
self::setError('该订单未关联处方');
|
||||
|
||||
return false;
|
||||
}
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->find();
|
||||
$rx = Prescription::where('id', $rxId)->whereNull('delete_time')->lock(true)->find();
|
||||
if (!$rx) {
|
||||
self::setError('处方不存在');
|
||||
|
||||
@@ -5828,25 +5821,37 @@ class PrescriptionOrderLogic
|
||||
$timesPerDay = (int) ($params['times_per_day'] ?? 0);
|
||||
$usageDays = (int) ($params['usage_days'] ?? 0);
|
||||
$medDays = (int) ($params['medication_days'] ?? 0);
|
||||
if ($timesPerDay < 1 || $timesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['times_per_day'] ?? null, 6)) {
|
||||
self::setError('主方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($usageDays < 1 || $usageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['usage_days'] ?? null, 999)) {
|
||||
self::setError('主方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($medDays < 1 || $medDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['medication_days'] ?? null, 999)) {
|
||||
self::setError('订单服用天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$isWaterPill = (string) $rx->prescription_type === '浓缩水丸';
|
||||
$dosageBagCount = null;
|
||||
if ($isWaterPill && array_key_exists('dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['dosage_bag_count'], 5)) {
|
||||
self::setError('主方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$dosageBagCount = (int) $params['dosage_bag_count'];
|
||||
}
|
||||
|
||||
$hasAux = self::prescriptionHasAuxFormula($rx);
|
||||
$auxTimesPerDay = null;
|
||||
$auxUsageDays = null;
|
||||
$auxDosageBagCount = null;
|
||||
if ($hasAux) {
|
||||
if (!array_key_exists('aux_times_per_day', $params) || !array_key_exists('aux_usage_days', $params)) {
|
||||
self::setError('含辅方处方须填写辅方服用参数');
|
||||
@@ -5855,20 +5860,29 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$auxTimesPerDay = (int) $params['aux_times_per_day'];
|
||||
$auxUsageDays = (int) $params['aux_usage_days'];
|
||||
if ($auxTimesPerDay < 1 || $auxTimesPerDay > 6) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_times_per_day'], 6)) {
|
||||
self::setError('辅方每天次数须在 1~6 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($auxUsageDays < 1 || $auxUsageDays > 999) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_usage_days'], 999)) {
|
||||
self::setError('辅方开立天数须在 1~999 之间');
|
||||
|
||||
return false;
|
||||
}
|
||||
if ($isWaterPill && array_key_exists('aux_dosage_bag_count', $params)) {
|
||||
if (!self::isPrescriptionUsageInteger($params['aux_dosage_bag_count'], 5)) {
|
||||
self::setError('辅方袋数须为 1~5 的整数');
|
||||
|
||||
return false;
|
||||
}
|
||||
$auxDosageBagCount = (int) $params['aux_dosage_bag_count'];
|
||||
}
|
||||
}
|
||||
|
||||
$oldTimes = (int) ($rx->times_per_day ?? 0);
|
||||
$oldUsageDays = (int) ($rx->usage_days ?? 0);
|
||||
$oldDosageBagCount = max(1, (int) ($rx->dosage_bag_count ?? 1));
|
||||
$oldMedDays = (int) ($order->medication_days ?? 0);
|
||||
$oldAuxUsage = $rx->aux_usage;
|
||||
if (is_string($oldAuxUsage)) {
|
||||
@@ -5880,16 +5894,48 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
$oldAuxTimes = (int) ($oldAuxUsage['times_per_day'] ?? 0);
|
||||
$oldAuxUsageDays = (int) ($oldAuxUsage['usage_days'] ?? 0);
|
||||
$oldAuxDosageBagCount = max(1, (int) ($oldAuxUsage['dosage_bag_count'] ?? 1));
|
||||
|
||||
$changes = [
|
||||
'主方每天次数' => [$oldTimes, $timesPerDay, '次'],
|
||||
'主方开立天数' => [$oldUsageDays, $usageDays, '天'],
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$changes['主方袋数'] = [$oldDosageBagCount, $dosageBagCount, '袋'];
|
||||
}
|
||||
if ($hasAux) {
|
||||
$changes['辅方每天次数'] = [$oldAuxTimes, $auxTimesPerDay, '次'];
|
||||
$changes['辅方开立天数'] = [$oldAuxUsageDays, $auxUsageDays, '天'];
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$changes['辅方袋数'] = [$oldAuxDosageBagCount, $auxDosageBagCount, '袋'];
|
||||
}
|
||||
}
|
||||
$changes['订单服用天数'] = [$oldMedDays, $medDays, '天'];
|
||||
$parts = [];
|
||||
foreach ($changes as $label => [$oldValue, $newValue, $unit]) {
|
||||
if ($oldValue !== $newValue) {
|
||||
$parts[] = sprintf('%s %d%s → %d%s', $label, $oldValue, $unit, $newValue, $unit);
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$rxUpdates = [
|
||||
'times_per_day' => $timesPerDay,
|
||||
'usage_days' => $usageDays,
|
||||
];
|
||||
if ($dosageBagCount !== null) {
|
||||
$rxUpdates['dosage_bag_count'] = $dosageBagCount;
|
||||
}
|
||||
if ($hasAux) {
|
||||
$auxUsage = $oldAuxUsage;
|
||||
$auxUsage['times_per_day'] = $auxTimesPerDay;
|
||||
$auxUsage['usage_days'] = $auxUsageDays;
|
||||
if ($auxDosageBagCount !== null) {
|
||||
$auxUsage['dosage_bag_count'] = $auxDosageBagCount;
|
||||
}
|
||||
$rxUpdates['aux_usage'] = $auxUsage;
|
||||
}
|
||||
$rx->save($rxUpdates);
|
||||
@@ -5897,31 +5943,8 @@ class PrescriptionOrderLogic
|
||||
$order->medication_days = $medDays;
|
||||
$order->save();
|
||||
|
||||
$parts = [
|
||||
sprintf(
|
||||
'主方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldTimes > 0 ? $oldTimes : 0,
|
||||
$oldUsageDays > 0 ? $oldUsageDays : 0,
|
||||
$timesPerDay,
|
||||
$usageDays
|
||||
),
|
||||
];
|
||||
if ($hasAux) {
|
||||
$parts[] = sprintf(
|
||||
'辅方 每天%d次/开立%d天 → 每天%d次/开立%d天',
|
||||
$oldAuxTimes > 0 ? $oldAuxTimes : 0,
|
||||
$oldAuxUsageDays > 0 ? $oldAuxUsageDays : 0,
|
||||
$auxTimesPerDay,
|
||||
$auxUsageDays
|
||||
);
|
||||
}
|
||||
$parts[] = sprintf(
|
||||
'订单设置 %d天 → %d天',
|
||||
$oldMedDays > 0 ? $oldMedDays : 0,
|
||||
$medDays
|
||||
);
|
||||
$summary = '服用参数:' . implode(';', $parts);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary);
|
||||
self::writeLog($prescriptionOrderId, $adminId, $adminInfo, 'patch_rx_usage', $summary, true);
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
@@ -5931,6 +5954,13 @@ class PrescriptionOrderLogic
|
||||
}
|
||||
}
|
||||
|
||||
private static function isPrescriptionUsageInteger($value, int $max): bool
|
||||
{
|
||||
return (is_int($value) || is_string($value))
|
||||
&& preg_match('/^[1-9][0-9]*$/D', (string) $value) === 1
|
||||
&& (int) $value <= $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处方是否含辅方药材(与列表/详情 has_aux_formula 口径一致)
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,7 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
'uploadToPharmacy' => ['id'],
|
||||
'previewGancaoRecipel' => ['id'],
|
||||
'patchPrescriptionPatient' => ['id', 'patient_name', 'phone'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'],
|
||||
'patchPrescriptionUsage' => ['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'],
|
||||
'updateAmount' => ['id', 'amount'],
|
||||
'setShipMode' => ['id', 'ship_mode'],
|
||||
'confirmGancaoSubmission' => ['id', 'resolution', 'remote_order_no', 'note'],
|
||||
@@ -133,14 +133,28 @@ class PrescriptionOrderValidate extends BaseValidate
|
||||
->append('express_company', 'max:20');
|
||||
}
|
||||
|
||||
public function patchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
public function scenePatchPrescriptionUsage(): PrescriptionOrderValidate
|
||||
{
|
||||
return $this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'aux_times_per_day', 'aux_usage_days'])
|
||||
$this->only(['id', 'times_per_day', 'usage_days', 'medication_days', 'dosage_bag_count', 'aux_times_per_day', 'aux_usage_days', 'aux_dosage_bag_count'])
|
||||
->append('id', 'require|integer|gt:0')
|
||||
->append('times_per_day', 'require|integer|between:1,6')
|
||||
->append('usage_days', 'require|integer|between:1,999')
|
||||
->append('medication_days', 'require|integer|between:1,999')
|
||||
->append('aux_times_per_day', 'integer|between:1,6')
|
||||
->append('aux_usage_days', 'integer|between:1,999');
|
||||
|
||||
foreach (['dosage_bag_count' => '主方', 'aux_dosage_bag_count' => '辅方'] as $field => $label) {
|
||||
$this->append($field, [static function ($value, array $data) use ($field, $label) {
|
||||
if (!array_key_exists($field, $data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (is_int($value) || is_string($value)) && preg_match('/^[1-5]$/D', (string) $value) === 1
|
||||
? true
|
||||
: $label . '袋数须为 1~5 的整数';
|
||||
}]);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\prescriptionai;
|
||||
|
||||
use app\common\service\ConfigService;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Independent access to agreement metrics; never grants access to a patient or report. */
|
||||
final class PrescriptionAiVisibility
|
||||
{
|
||||
public const CONFIG_TYPE = 'prescription_ai_visibility';
|
||||
public const CONFIG_NAME = 'agreement_policy';
|
||||
public const MAX_ROLES = 500;
|
||||
public const MAX_ADMINS = 5000;
|
||||
|
||||
private const COMPARISON_FIELDS = ['comparison', 'score', 'herb_score', 'comparison_status',
|
||||
'comparison_reason_code', 'comparison_type', 'baseline_eligible', 'baseline_exclusion_reasons',
|
||||
'baseline_exclusions_json', 'algorithm_version', 'dictionary_version',
|
||||
'raw', 'raw_body', 'raw_response', 'result', 'response', 'body', 'body_cipher', 'context_cipher', 'progress_cipher'];
|
||||
|
||||
// Scalar leaves and explicitly shaped lists prevent historical/debug body fields from escaping at any depth.
|
||||
private const REPORT_BODY_SCHEMA = [
|
||||
'report' => ['summary' => true, 'diagnosis' => true, 'treatment_advice' => true,
|
||||
'risk_assessment' => ['*' => ['label' => true, 'level' => true, 'evidence_references' => ['*' => true]]],
|
||||
'evidence_references' => ['*' => true], 'missing_information' => ['*' => true]],
|
||||
'candidate' => ['status' => true, 'reason' => true, 'prescription_name' => true, 'prescription_type' => true,
|
||||
'dose_basis' => true, 'usage_instruction' => true, 'times_per_day' => true, 'usage_days' => true,
|
||||
'rationale' => true, 'risk_warnings' => ['*' => true], 'evidence_references' => ['*' => true],
|
||||
'herbs' => ['*' => ['name' => true, 'dosage' => true, 'unit' => true, 'dose_basis' => true,
|
||||
'processing' => true, 'formula_type' => true, 'instructions' => true, 'evidence_references' => ['*' => true]]]],
|
||||
'coverage' => ['status' => true, 'complete' => true, 'source_complete' => true, 'source_ids' => ['*' => true],
|
||||
'token_budget_method' => true, 'clinical_interpretation_verified' => true,
|
||||
'missing' => ['*' => ['source_id' => true, 'code' => true, 'critical' => true]],
|
||||
'files' => ['*' => ['file_id' => true, 'type' => true, 'status' => true, 'transmitted' => true,
|
||||
'version_verified' => true, 'reason' => true]]],
|
||||
'usage' => ['total_calls' => true, 'calls' => ['*' => ['stage' => true, 'input_hash' => true, 'latency_ms' => true,
|
||||
'ok' => true, 'file_count' => true, 'input_token_upper_bound' => true, 'error_code' => true,
|
||||
'usage' => ['prompt_tokens' => true, 'completion_tokens' => true, 'total_tokens' => true]]]],
|
||||
'knowledge_source' => ['title' => true, 'url' => true, 'path' => true, 'commit' => true, 'sections' => ['*' => true]],
|
||||
'ancient_book_sources' => ['*' => ['title' => true, 'chapter' => true, 'excerpt' => true,
|
||||
'path' => true, 'line' => true, 'commit' => true, 'url' => true]],
|
||||
];
|
||||
|
||||
public static function capabilities(int $adminId): array
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null) {
|
||||
throw new DomainException('账号已停用或无权访问');
|
||||
}
|
||||
return ['agreement_visible' => self::visibleForActor($adminId),
|
||||
'can_manage_visibility' => PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')];
|
||||
}
|
||||
|
||||
public static function agreementVisible(int $adminId): bool
|
||||
{
|
||||
return PrescriptionAiAccess::actor($adminId) !== null && self::visibleForActor($adminId);
|
||||
}
|
||||
|
||||
public static function getConfig(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
return self::loadPolicy();
|
||||
}
|
||||
|
||||
public static function setConfig(array $params, int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
$policy = self::normalizePolicy($params);
|
||||
foreach (['role_ids' => 'system_role', 'admin_ids' => 'admin'] as $field => $table) {
|
||||
if ($policy[$field] === []) {
|
||||
continue;
|
||||
}
|
||||
$query = Db::name($table)->whereIn('id', $policy[$field])->whereNull('delete_time');
|
||||
if ($table === 'admin') {
|
||||
$query->where('disable', 0);
|
||||
}
|
||||
if ((int) $query->count() !== count($policy[$field])) {
|
||||
throw new DomainException($table === 'admin' ? '授权用户不存在或已停用,请移除失效用户' : '授权角色不存在,请移除失效角色');
|
||||
}
|
||||
}
|
||||
// Store one object so readers never observe half of a role/user policy update.
|
||||
ConfigService::set(self::CONFIG_TYPE, self::CONFIG_NAME, $policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
public static function options(int $adminId): array
|
||||
{
|
||||
self::requireManage($adminId);
|
||||
// The endpoint is intentionally bounded. Never silently truncate an authorization selector.
|
||||
$roles = Db::name('system_role')->whereNull('delete_time')->field('id,name')->order('id')
|
||||
->limit(self::MAX_ROLES + 1)->select()->toArray();
|
||||
$admins = Db::name('admin')->whereNull('delete_time')->where('disable', 0)->field('id,name,account')->order('id')
|
||||
->limit(self::MAX_ADMINS + 1)->select()->toArray();
|
||||
if (count($roles) > self::MAX_ROLES || count($admins) > self::MAX_ADMINS) {
|
||||
throw new DomainException('授权选项超过上限(500个角色、5000个用户),请联系管理员');
|
||||
}
|
||||
return ['roles' => array_map(static fn (array $r): array => ['id' => (int) $r['id'], 'name' => (string) $r['name']], $roles),
|
||||
'admins' => array_map(static fn (array $a): array => ['id' => (int) $a['id'], 'name' => (string) $a['name'],
|
||||
'account' => (string) $a['account']], $admins)];
|
||||
}
|
||||
|
||||
public static function normalizePolicy(array $params): array
|
||||
{
|
||||
if (array_diff(array_keys($params), ['role_ids', 'admin_ids']) !== []
|
||||
|| !array_key_exists('role_ids', $params) || !array_key_exists('admin_ids', $params)) {
|
||||
throw new DomainException('授权配置须包含且仅包含角色和用户列表');
|
||||
}
|
||||
return ['role_ids' => self::ids($params['role_ids'], self::MAX_ROLES),
|
||||
'admin_ids' => self::ids($params['admin_ids'], self::MAX_ADMINS)];
|
||||
}
|
||||
|
||||
/** Build the response from known saved-body fields, never merge an arbitrary decrypted object. */
|
||||
public static function reportBody(array $body, bool $agreementVisible): array
|
||||
{
|
||||
$projected = self::projectValue($body, self::REPORT_BODY_SCHEMA);
|
||||
if ($agreementVisible && array_key_exists('comparison', $body)) {
|
||||
$projected['comparison'] = $body['comparison'];
|
||||
}
|
||||
return $projected;
|
||||
}
|
||||
|
||||
public static function projectBatch(array $batch, bool $agreementVisible): array
|
||||
{
|
||||
if (!$agreementVisible) {
|
||||
$batch = self::withoutComparison($batch);
|
||||
foreach ($batch['models'] ?? [] as $key => $model) {
|
||||
// A failed task's reason is a generation error; a successful result's reason is a comparison.
|
||||
if (($model['error_code'] ?? '') === '') {
|
||||
unset($batch['models'][$key]['reason']);
|
||||
}
|
||||
}
|
||||
}
|
||||
$batch['agreement_visible'] = $agreementVisible;
|
||||
return $batch;
|
||||
}
|
||||
|
||||
private static function withoutComparison(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (in_array($key, self::COMPARISON_FIELDS, true)) {
|
||||
unset($value[$key]);
|
||||
} elseif (is_array($item)) {
|
||||
$value[$key] = self::withoutComparison($item);
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function projectValue($value, $schema)
|
||||
{
|
||||
if ($schema === true) {
|
||||
return is_scalar($value) || $value === null ? $value : null;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
if (array_key_exists('*', $schema)) {
|
||||
return array_map(static fn ($item) => self::projectValue($item, $schema['*']), array_values($value));
|
||||
}
|
||||
$result = [];
|
||||
foreach ($schema as $key => $shape) {
|
||||
if (array_key_exists($key, $value)) {
|
||||
$result[$key] = self::projectValue($value[$key], $shape);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function requireManage(int $adminId): void
|
||||
{
|
||||
$actor = PrescriptionAiAccess::actor($adminId);
|
||||
if ($actor === null || !PrescriptionAiAccess::allowed($adminId, $actor, 'manageVisibility')) {
|
||||
throw new DomainException('无权管理处方AI一致度授权');
|
||||
}
|
||||
}
|
||||
|
||||
private static function visibleForActor(int $adminId): bool
|
||||
{
|
||||
$policy = self::loadPolicy();
|
||||
if (in_array($adminId, $policy['admin_ids'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($policy['role_ids'] === []) {
|
||||
return false;
|
||||
}
|
||||
// Read current membership and active roles, never the login token's role snapshot.
|
||||
return Db::name('admin_role')->alias('ar')->join('system_role r', 'r.id=ar.role_id')
|
||||
->where('ar.admin_id', $adminId)->whereIn('r.id', $policy['role_ids'])->whereNull('r.delete_time')->count() > 0;
|
||||
}
|
||||
|
||||
private static function loadPolicy(): array
|
||||
{
|
||||
$empty = ['role_ids' => [], 'admin_ids' => []];
|
||||
$stored = ConfigService::get(self::CONFIG_TYPE, self::CONFIG_NAME, $empty);
|
||||
if (!is_array($stored)) {
|
||||
return $empty;
|
||||
}
|
||||
try {
|
||||
return self::normalizePolicy($stored);
|
||||
} catch (DomainException $e) {
|
||||
return $empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static function ids($raw, int $max): array
|
||||
{
|
||||
if (!is_array($raw) || !array_is_list($raw) || count($raw) > $max) {
|
||||
throw new DomainException('授权列表格式或数量无效');
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($raw as $id) {
|
||||
if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $id)) {
|
||||
throw new DomainException('授权标识须为有效正整数');
|
||||
}
|
||||
$ids[] = (int) $id;
|
||||
}
|
||||
$ids = array_values(array_unique($ids));
|
||||
sort($ids, SORT_NUMERIC);
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user