更新bug

This commit is contained in:
Your Name
2026-08-20 17:47:14 +08:00
parent 35f91ee37a
commit 5794f60c5d
67 changed files with 9257 additions and 1287 deletions
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisLogic;
function callRecordIdentityExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function callRecordIdentityMethodSource(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('call-record lifecycle source is readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$logic = new ReflectionClass(DiagnosisLogic::class);
$endCall = callRecordIdentityMethodSource($logic->getMethod('endCall'));
$bindCallRoom = callRecordIdentityMethodSource($logic->getMethod('bindCallRoom'));
$startCloudRecording = callRecordIdentityMethodSource($logic->getMethod('startCloudRecording'));
foreach (
[
'endCall' => $endCall,
'bindCallRoom' => $bindCallRoom,
'startCloudRecording' => $startCloudRecording,
] as $methodName => $source
) {
callRecordIdentityExpect(
str_contains($source, "\$callRecordId = (int)(\$params['call_record_id'] ?? 0)")
&& str_contains($source, 'if ($callRecordId > 0)')
&& str_contains($source, "CallRecord::where('id', \$callRecordId)")
&& str_contains($source, "->where('diagnosis_id', \$diagnosisId)")
&& str_contains($source, "->where('caller_id', \$adminId)")
&& str_contains($source, "->where('caller_type', 'doctor')"),
$methodName . ' uses the exact doctor-owned call record when its id is supplied'
);
}
callRecordIdentityExpect(
str_contains($bindCallRoom, "'call_record_id' => (int)\$record['id']")
&& str_contains($bindCallRoom, 'startCloudRecording(['),
'room binding forwards the exact saved record to cloud recording'
);
callRecordIdentityExpect(
str_contains($endCall, "(int)(\$record['status'] ?? 0) === 2")
&& str_contains($endCall, 'return true;'),
'ending the same exact call remains idempotent'
);
$workspace = dirname(__DIR__, 2);
$repository = (string) file_get_contents(
$workspace . '/app/src/doctor_workstation/services/repository.py'
);
$lifecycle = (string) file_get_contents(
$workspace . '/app/src/doctor_workstation/video/lifecycle.py'
);
callRecordIdentityExpect(
substr_count($repository, 'body["call_record_id"] = normalized_id') >= 2,
'desktop repository sends call_record_id for bind and end requests'
);
callRecordIdentityExpect(
substr_count($lifecycle, '"call_record_id": record_id') >= 2,
'desktop lifecycle keeps the created call record identity through later phases'
);
echo "Call-record lifecycle identity contract: OK\n";
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
function callSignatureExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function callSignatureMethodSource(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('call signature source is readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$logicMethod = (new ReflectionClass(DiagnosisLogic::class))->getMethod('getCallSignature');
$logicSource = callSignatureMethodSource($logicMethod);
$controllerSource = callSignatureMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('getCallSignature')
);
$validatorSource = callSignatureMethodSource(
(new ReflectionClass(DiagnosisValidate::class))->getMethod('sceneCallIdentity')
);
callSignatureExpect(
$logicMethod->getNumberOfParameters() === 2,
'call signature accepts request params and authenticated admin context'
);
callSignatureExpect(
str_contains($logicSource, 'canManageDiagnosis($diagnosisId, $adminId, $adminInfo)')
&& strpos($logicSource, 'canManageDiagnosis($diagnosisId, $adminId, $adminInfo)')
< strpos($logicSource, "Diagnosis::where('id', \$diagnosisId)")
&& strpos($logicSource, "Diagnosis::where('id', \$diagnosisId)")
< strpos($logicSource, 'self::getTrtcConfig()'),
'row authorization and the exact diagnosis/patient pair are checked before TRTC work'
);
callSignatureExpect(
str_contains($logicSource, "->where('patient_id', \$patientId)")
&& str_contains($logicSource, "'diagnosis_id' => \$diagnosisId")
&& str_contains($logicSource, "'patient_id' => \$patientId")
&& !str_contains($logicSource, "Diagnosis::where('id', \$patientId)"),
'diagnosis id and source patient id stay distinct throughout the signature contract'
);
callSignatureExpect(
str_contains($controllerSource, "goCheck('callIdentity')")
&& str_contains(
$controllerSource,
'DiagnosisLogic::getCallSignature($params, $this->adminInfo)'
),
'controller validates both ids and forwards the authenticated row-scope context'
);
callSignatureExpect(
str_contains($validatorSource, "only(['diagnosis_id', 'patient_id'])")
&& str_contains($validatorSource, "append('patient_id', 'require|integer|gt:0')"),
'call identity validation requires positive diagnosis and patient ids'
);
$root = dirname(__DIR__, 2);
foreach (
[
$root . '/admin/src/views/tcm/appointment/list.vue',
$root . '/admin/src/views/tcm/appointment/list_h5.vue',
$root . '/admin/src/views/patient/reception/index.vue',
] as $callerPath
) {
$caller = (string) file_get_contents($callerPath);
callSignatureExpect(
str_contains($caller, 'patient_id: sourcePatientId')
&& str_contains($caller, 'patientId: sourcePatientId')
&& str_contains($caller, 'diagnosis_id: diagnosisId'),
basename($callerPath) . ' keeps appointment, diagnosis and source patient ids separate'
);
}
echo "Call signature identity contract: OK\n";
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\stats\ConversionLogic;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionFanRuleExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$method = new ReflectionMethod(ConversionLogic::class, 'loadFanRows');
$sourceLines = file($method->getFileName());
if ($sourceLines === false) {
throw new RuntimeException('无法读取加粉统计源码');
}
$methodSource = implode('', array_slice(
$sourceLines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
conversionFanRuleExpect(
str_contains($methodSource, "->where('e.change_type', 'add_external_contact')"),
'加粉必须继续以企微 add_external_contact 事件为事实来源'
);
conversionFanRuleExpect(
!str_contains($methodSource, 'msg_audit_approved'),
'会话存档同意是独立能力,不能再次成为加粉统计的硬性条件'
);
conversionFanRuleExpect(
str_contains($methodSource, "['del_external_contact', \$endTimestamp]"),
'加粉统计必须继续排除区间内已删除客户'
);
conversionFanRuleExpect(
str_contains($methodSource, "['add_external_contact', \$startTimestamp]"),
'加粉统计必须继续排除区间开始前已添加的重加客户'
);
conversionFanRuleExpect(
str_contains($methodSource, "->group('e.user_id, e.external_userid')"),
'加粉统计必须继续按员工和客户去重'
);
$excludeMethod = new ReflectionMethod(ConversionLogic::class, 'excludeUncountedFanPairs');
$excludeSource = implode('', array_slice(
$sourceLines,
$excludeMethod->getStartLine() - 1,
$excludeMethod->getEndLine() - $excludeMethod->getStartLine() + 1
));
conversionFanRuleExpect(
str_contains($excludeSource, '[1, 2, 3, 201, 202]'),
'取消会话存档条件后仍须排除扫一扫、搜手机号、名片分享及继承/分配客户'
);
$pageSource = file_get_contents(
dirname(__DIR__, 2) . '/admin/src/views/first_visit/conversion/index.vue'
);
conversionFanRuleExpect(
is_string($pageSource) && !str_contains($pageSource, '须会话同意'),
'页面口径说明不能继续宣称加粉依赖会话存档同意'
);
echo "Conversion fan event rule: OK\n";
@@ -121,6 +121,9 @@ $myPatientScopeMethod = diagnosisWorkspaceMethodSource(
$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis')
);
$diagnosisManageAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canManageDiagnosis')
);
$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis')
);
@@ -161,9 +164,16 @@ diagnosisWorkspaceAuthExpect(
'DataScope ALL is reserved for team roles before ordinary doctor and assistant self-relations'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'readonly diagnosis authorization reuses the canonical patient row policy'
str_contains($diagnosisReadonlyAuthMethod, "Diagnosis::where('id', \$diagnosisId)")
&& str_contains($diagnosisReadonlyAuthMethod, "in_array(2, \$roleIds, true)")
&& str_contains($diagnosisReadonlyAuthMethod, "\$query->where('assistant_id', \$adminId)")
&& str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds(')
&& !str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'readonly diagnosis authorization matches diagnosis-list visibility instead of my-patient ownership'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisManageAuthMethod, 'MyPatientLogic::canAccessDiagnosis('),
'diagnosis write authorization keeps the canonical my-patient ownership policy'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
@@ -192,10 +202,10 @@ diagnosisWorkspaceAuthExpect(
'doctorNotes authorizes the diagnosis before reading notes'
);
diagnosisWorkspaceAuthExpect(
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canManageDiagnosis(')
< strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('),
'addDoctorNote authorizes the diagnosis before writing any note data'
'addDoctorNote keeps the stricter ownership check before writing any note data'
);
diagnosisWorkspaceAuthExpect(
str_contains($receptionMethod, 'appointmentRowManageableByAdmin(')
@@ -204,10 +214,10 @@ diagnosisWorkspaceAuthExpect(
'reception authorizes the appointment before loading its detail DTO'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
str_contains($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($prescriptionListMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"),
'listByDiagnosis authorizes its parent diagnosis before the first prescription SQL query'
'listByDiagnosis uses read visibility before the first prescription SQL query'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)')
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/vendor/autoload.php';
final class LocalAudioUploadContractExpect
{
public static function contains(string $haystack, string $needle, string $message): void
{
if (!str_contains($haystack, $needle)) {
throw new RuntimeException($message . ': missing ' . $needle);
}
}
public static function isTrue(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
}
$root = dirname(__DIR__);
$controllerPath = $root . '/app/adminapi/controller/tcm/DiagnosisController.php';
$logicPath = $root . '/app/adminapi/logic/tcm/DiagnosisLogic.php';
$migrationPath = $root . '/database/migrations/2026_08_19_add_local_call_audio_fields.sql';
$controller = (string)file_get_contents($controllerPath);
$logic = (string)file_get_contents($logicPath);
$migration = (string)file_get_contents($migrationPath);
LocalAudioUploadContractExpect::contains(
$controller,
'public function uploadCallRecording()',
'The ThinkPHP controller must expose the desktop upload action'
);
LocalAudioUploadContractExpect::contains(
$controller,
'DiagnosisLogic::uploadCallRecording($params)',
'The controller must delegate multipart uploads to the diagnosis logic'
);
foreach (
[
"'webm'",
"'ogg'",
"'media_kind' => \$isLocalAudio ? 'local_audio' : 'video'",
'attachLocalCallAudio($attachmentParams)',
"'local_audio_urls'",
"'local_audio_status' => 2",
"'local_audio_urls_list'",
"'local_audio_status_text'",
"'uploads/audio/'",
'FileEnum::FILE_TYPE',
] as $needle
) {
LocalAudioUploadContractExpect::contains(
$logic,
$needle,
'Local audio upload and DTO contract must remain complete'
);
}
foreach (
[
"'uploads/video/'",
'FileEnum::VIDEO_TYPE',
'attachLocalCallRecording($attachmentParams)',
"'recording_status' => \$isLocalAudio ? 0 : 2",
] as $needle
) {
LocalAudioUploadContractExpect::contains(
$logic,
$needle,
'Manual video uploads must remain separate from local audio'
);
}
LocalAudioUploadContractExpect::isTrue(
str_contains($logic, '!$isAmbiguousWebm && $hasAudioMime'),
'Known non-WebM video extensions must not be misclassified when an old client sends audio/webm'
);
foreach (['zyt_tcm_call_record', 'local_audio_urls', 'local_audio_status'] as $needle) {
LocalAudioUploadContractExpect::contains(
$migration,
$needle,
'The database migration must include every local audio field'
);
}
// Exercise the real internal-file path used after all recording chunks are
// merged. A source-code assertion alone did not catch the missing realPath key
// (or the missing File object required by the local storage engine).
$sourceBase = tempnam(sys_get_temp_dir(), 'zyt-audio-source-');
LocalAudioUploadContractExpect::isTrue(
is_string($sourceBase),
'A temporary local-audio source file must be creatable'
);
$sourcePath = $sourceBase . '.webm';
LocalAudioUploadContractExpect::isTrue(
rename($sourceBase, $sourcePath),
'The temporary local-audio source must keep its WebM extension'
);
$payload = "\x1A\x45\xDF\xA3local-audio-regression";
LocalAudioUploadContractExpect::isTrue(
file_put_contents($sourcePath, $payload) === strlen($payload),
'The local-audio fixture must be written completely'
);
$targetDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'zyt-local-audio-target-' . bin2hex(random_bytes(6));
try {
$localStorage = new \app\common\service\storage\engine\Local();
$localStorage->setUploadFileByReal($sourcePath);
$fileInfo = $localStorage->getFileInfo();
LocalAudioUploadContractExpect::isTrue(
isset($fileInfo['realPath']) && $fileInfo['realPath'] === realpath($sourcePath),
'Internal storage metadata must include the merged recording realPath'
);
LocalAudioUploadContractExpect::isTrue(
($fileInfo['tmp_name'] ?? '') === realpath($sourcePath),
'Internal storage metadata must preserve a compatible temporary path'
);
LocalAudioUploadContractExpect::isTrue(
str_ends_with($localStorage->getFileName(), '.webm'),
'The generated storage name must preserve the recording extension'
);
LocalAudioUploadContractExpect::isTrue(
$localStorage->upload($targetDir),
'The local storage driver must accept a merged internal recording'
);
$storedPath = $targetDir . DIRECTORY_SEPARATOR . $localStorage->getFileName();
LocalAudioUploadContractExpect::isTrue(
is_file($storedPath) && file_get_contents($storedPath) === $payload,
'The internally uploaded local recording must remain byte-identical'
);
} finally {
if (isset($storedPath) && is_file($storedPath)) {
unlink($storedPath);
}
if (is_file($sourcePath)) {
unlink($sourcePath);
}
if (is_dir($targetDir)) {
rmdir($targetDir);
}
}
$missingPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR
. 'zyt-missing-local-audio-' . bin2hex(random_bytes(8)) . '.webm';
$missingRejected = false;
try {
$localStorage = new \app\common\service\storage\engine\Local();
$localStorage->setUploadFileByReal($missingPath);
} catch (Throwable $exception) {
$missingRejected = !str_contains($exception->getMessage(), 'realPath')
&& str_contains($exception->getMessage(), 'does not exist');
}
LocalAudioUploadContractExpect::isTrue(
$missingRejected,
'A missing merged recording must fail clearly before storage-name generation'
);
echo "Local audio upload contract: OK\n";
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\lists\doctor\AppointmentLists;
use app\adminapi\lists\tcm\DiagnosisLists;
use app\adminapi\logic\tcm\PrescriptionLogic;
function prescriptionAppointmentScopeExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function prescriptionAppointmentScopeMethod(ReflectionMethod $method): string
{
$lines = file($method->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('method source must be readable');
}
return implode('', array_slice(
$lines,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
$diagnosisLists = prescriptionAppointmentScopeMethod(
(new ReflectionClass(DiagnosisLists::class))->getMethod('lists')
);
$appointmentLists = prescriptionAppointmentScopeMethod(
(new ReflectionClass(AppointmentLists::class))->getMethod('lists')
);
$getByAppointment = prescriptionAppointmentScopeMethod(
(new ReflectionClass(PrescriptionLogic::class))->getMethod('getByAppointment')
);
foreach ([$diagnosisLists, $appointmentLists] as $listSource) {
prescriptionAppointmentScopeExpect(
str_contains($listSource, "whereIn('appointment_id', \$appointmentIds)"),
'list action state must be selected by the current appointment ids'
);
prescriptionAppointmentScopeExpect(
str_contains($listSource, "['current_has_prescription']"),
'list DTO must expose an appointment-scoped prescription flag'
);
prescriptionAppointmentScopeExpect(
str_contains($listSource, "['current_prescription_id']"),
'list DTO must expose the exact appointment-scoped prescription id'
);
}
prescriptionAppointmentScopeExpect(
!str_contains(
$diagnosisLists,
"['prescription_audit_status'] = (\$item['has_prescription'] && \$fu)"
),
'diagnosis history must not drive the current appointment action state'
);
prescriptionAppointmentScopeExpect(
str_contains($getByAppointment, "(int) (\$candidate->void_status ?? 0) === 0")
&& str_contains($getByAppointment, '$row = $row ?? $fallback;'),
'detail lookup must prefer the same active prescription and only fall back when all are voided'
);
echo "Prescription appointment scope contract: OK\n";