Files
zyt/server/app/common/service/prescriptionai/PrescriptionAiVisibility.php
T
2026-10-17 16:51:48 +08:00

225 lines
10 KiB
PHP

<?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;
}
}