This commit is contained in:
Your Name
2026-08-18 14:08:38 +08:00
parent 8b9df1154c
commit bc1228a310
77 changed files with 10763 additions and 1181 deletions
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\http\middleware\AuthMiddleware;
function assistantStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return array{event:string,data:array<string,mixed>} */
function parseAssistantSse(string $frame): array
{
$lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: [];
$event = '';
$data = '';
foreach ($lines as $line) {
if (str_starts_with($line, 'event: ')) {
$event = substr($line, 7);
} elseif (str_starts_with($line, 'data: ')) {
$data .= substr($line, 6);
}
}
$decoded = json_decode($data, true);
assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable');
return ['event' => $event, 'data' => $decoded];
}
$protocol = new AssistantSseProtocol();
assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start');
$start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready']));
assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1');
assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once');
$deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你']));
$deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好']));
assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic');
$done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好']));
assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event');
assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected');
assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected');
$errorProtocol = new AssistantSseProtocol();
$errorProtocol->encode('start', []);
$error = parseAssistantSse((string) $errorProtocol->encode('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]));
assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event');
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php');
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable');
$actionStart = strpos($controller, 'public function aiAssistantStream()');
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart);
$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart);
$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart);
assistantStreamExpect(
$actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt,
'request validation and authorized preparation occur before every SSE header'
);
assistantStreamExpect(
str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"),
'stream reuses the strict id/task/prompt assistant scene'
);
assistantStreamExpect(
str_contains($logic, 'self::PERMISSION_ASSISTANT')
&& str_contains($logic, 'MyPatientLogic::canAccessDiagnosis')
&& str_contains($logic, 'streamPreparedAssistant'),
'stream preparation reuses assistant permission and canonical diagnosis row authorization'
);
assistantStreamExpect(
str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'")
&& str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"),
'middleware maps stream access to the old registered assistant permission'
);
$matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias');
$authMiddleware = new AuthMiddleware();
assistantStreamExpect(
$matchPermissionAlias->invoke(
$authMiddleware,
'tcm.diagnosis/aiassistantstream',
['tcm.diagnosis/aiassistant']
) === true,
'stream permission alias accepts the old assistant grant'
);
assistantStreamExpect(
$matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false,
'stream permission alias rejects an administrator without the old assistant grant'
);
assistantStreamExpect(
str_contains($controller, "'text' => \$delta")
&& str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'")
&& str_contains($controller, 'ignore_user_abort(true)')
&& str_contains($controller, 'connection_aborted() === 1')
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
);
assistantStreamExpect(
str_contains($logic, 'DifyChatService::chat(')
&& str_contains($logic, 'DifyChatService::streamChat('),
'legacy blocking and new streaming paths coexist'
);
$sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user'];
foreach ($sensitiveNeedles as $needle) {
assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}");
assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}");
}
echo "Diagnosis AI assistant stream contract: OK\n";
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\controller\doctor\AppointmentController;
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\controller\tcm\PrescriptionController;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PrescriptionLogic;
function diagnosisWorkspaceAuthExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function diagnosisWorkspaceMethodSource(ReflectionMethod $method): string
{
$file = file($method->getFileName());
if (!is_array($file)) {
throw new RuntimeException('authorization method source is readable');
}
return implode('', array_slice(
$file,
$method->getStartLine() - 1,
$method->getEndLine() - $method->getStartLine() + 1
));
}
// Pure policy helpers are invoked directly so this security regression test never needs a real database.
$appointmentScope = (new ReflectionClass(AppointmentLogic::class))
->getMethod('appointmentRowManageableForScope');
$filterPrescriptions = (new ReflectionClass(PrescriptionLogic::class))
->getMethod('filterViewablePrescriptions');
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 31, 41, 31, [1], null, false) === true,
'assigned doctor can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 31, [1], null, false) === false,
'doctor cannot open another doctor appointment row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 32, 41, 41, [2], null, false) === true,
'assigned assistant can open the reception row'
);
diagnosisWorkspaceAuthExpect(
$appointmentScope->invoke(null, 999, 999, 1, [1, 2], [], true) === true,
'root keeps reception compatibility regardless of role and data scope'
);
$ownPrescription = [
'id' => 51,
'creator_id' => 7,
'assistant_id' => 0,
'is_shared' => 0,
'visible_role_ids' => '',
];
$otherPrescription = [
'id' => 52,
'creator_id' => 8,
'assistant_id' => 9,
'is_shared' => 0,
'visible_role_ids' => '',
];
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$ownPrescription], 7, []) === [$ownPrescription],
'visible prescription keeps the existing response row unchanged'
);
diagnosisWorkspaceAuthExpect(
$filterPrescriptions->invoke(null, [$otherPrescription], 1, ['root' => 1]) === [$otherPrescription],
'root keeps prescription compatibility'
);
$diagnosisLogicSource = file_get_contents((new ReflectionClass(DiagnosisLogic::class))->getFileName());
$diagnosisAiLogicSource = file_get_contents((new ReflectionClass(DiagnosisAiLogic::class))->getFileName());
$myPatientLogicSource = file_get_contents((new ReflectionClass(MyPatientLogic::class))->getFileName());
$appointmentLogicSource = file_get_contents((new ReflectionClass(AppointmentLogic::class))->getFileName());
$prescriptionLogicSource = file_get_contents((new ReflectionClass(PrescriptionLogic::class))->getFileName());
$diagnosisControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'
);
$appointmentControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/doctor/AppointmentController.php'
);
$prescriptionControllerSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/controller/tcm/PrescriptionController.php'
);
$appointmentListsSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/lists/doctor/AppointmentLists.php'
);
$doctorNoteLogicSource = file_get_contents(
dirname(__DIR__) . '/app/adminapi/logic/doctor/DoctorNoteLogic.php'
);
foreach ([
$diagnosisLogicSource,
$diagnosisAiLogicSource,
$myPatientLogicSource,
$appointmentLogicSource,
$prescriptionLogicSource,
$diagnosisControllerSource,
$appointmentControllerSource,
$prescriptionControllerSource,
$appointmentListsSource,
$doctorNoteLogicSource,
] as $source) {
diagnosisWorkspaceAuthExpect(is_string($source), 'authorization source is readable');
}
$myPatientScopeMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(MyPatientLogic::class))->getMethod('applyScope')
);
$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis')
);
$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis')
);
$prescriptionListMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionLogic::class))->getMethod('listByDiagnosis')
);
$trackingWindowMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisLogic::class))->getMethod('fetchTrackingWindow')
);
$trackingWindowControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(DiagnosisController::class))->getMethod('trackingWindow')
);
$doctorNotesControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('doctorNotes')
);
$addDoctorNoteControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentController::class))->getMethod('addDoctorNote')
);
$receptionMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(AppointmentLogic::class))->getMethod('reception')
);
$prescriptionControllerMethod = diagnosisWorkspaceMethodSource(
(new ReflectionClass(PrescriptionController::class))->getMethod('listByDiagnosis')
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, "'CAST(d.assistant_id AS UNSIGNED) = ' . \$adminId")
&& str_contains($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)')
&& str_contains($myPatientScopeMethod, 'scope_apt.doctor_id = {$adminId}'),
'diagnosis row policy keeps assistant assignment and doctor appointment ownership contracts'
);
diagnosisWorkspaceAuthExpect(
str_contains($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
&& str_contains($myPatientScopeMethod, 'DataScopeService::getVisibleAdminIds($adminId, $adminInfo)')
&& strpos($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)')
< strpos($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)'),
'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'
);
diagnosisWorkspaceAuthExpect(
str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(')
< strpos($diagnosisAiAuthMethod, 'DiagnosisLogic::detail(')
&& !str_contains($diagnosisAiAuthMethod, 'DataScopeService::getVisibleAdminIds'),
'AI diagnosis authorization reuses the canonical row policy before loading case details'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
&& strpos($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']')
< strpos($trackingWindowControllerMethod, 'DiagnosisLogic::fetchTrackingWindow('),
'trackingWindow authorizes the diagnosis before reading tracking records'
);
diagnosisWorkspaceAuthExpect(
str_contains($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
&& strpos($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId")
< strpos($trackingWindowMethod, "'blood_records'"),
'trackingWindow returns the authorized diagnosis id at the response top level'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($doctorNotesControllerMethod, 'DoctorNoteLogic::getByDiagnosis('),
'doctorNotes authorizes the diagnosis before reading notes'
);
diagnosisWorkspaceAuthExpect(
str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
&& strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(')
< strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('),
'addDoctorNote authorizes the diagnosis before writing any note data'
);
diagnosisWorkspaceAuthExpect(
str_contains($receptionMethod, 'appointmentRowManageableByAdmin(')
&& strpos($receptionMethod, 'appointmentRowManageableByAdmin(')
< strpos($receptionMethod, '$appointment = self::detail($params);'),
'reception authorizes the appointment before loading its detail DTO'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
&& strpos($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(')
< strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"),
'listByDiagnosis authorizes its parent diagnosis before the first prescription SQL query'
);
diagnosisWorkspaceAuthExpect(
str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)')
&& str_contains(
$prescriptionControllerMethod,
'PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)'
)
&& str_contains($prescriptionControllerMethod, "PrescriptionLogic::getError() !== ''"),
'listByDiagnosis keeps child visibility filtering and surfaces parent authorization failure'
);
diagnosisWorkspaceAuthExpect(
str_contains($appointmentListsSource, 'u.patient_id AS source_patient_id'),
'appointment DTO exposes the source patient id separately from the diagnosis id'
);
diagnosisWorkspaceAuthExpect(
str_contains($doctorNoteLogicSource, 'normalizeNewAttachmentPaths(')
&& str_contains($doctorNoteLogicSource, "\$domainHost === \$urlHost")
&& str_contains($doctorNoteLogicSource, "\$domainPort === \$urlPort")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$urlPath, \$domainPath . '/')")
&& str_contains($doctorNoteLogicSource, "str_starts_with(\$path, '//')"),
'new note attachments require an exact configured storage origin and path boundary'
);
diagnosisWorkspaceAuthExpect(
substr_count($diagnosisControllerSource, '诊单不存在或无权访问') >= 2
&& str_contains($appointmentControllerSource, '预约记录不存在或无权访问')
&& str_contains($appointmentControllerSource, '诊单不存在或无权访问'),
'missing and forbidden child-resource lookups share non-enumerating errors'
);
echo "Diagnosis workspace row authorization: OK\n";
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\DifyChatService;
function difyStreamExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
/** @return mixed */
function callDifyStreamPrivate(string $method, array $arguments)
{
return (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $arguments);
}
$generic = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
['case' => 'redacted'],
'safe query',
'admin-safe',
true,
]);
difyStreamExpect(count($generic) === 2, 'generic /v1 keeps Dify then OpenAI fallback order');
difyStreamExpect($generic[0]['protocol'] === 'dify', 'Dify remains the first generic protocol');
difyStreamExpect(
$generic[0]['payload']['response_mode'] === 'streaming',
'Dify stream request uses response_mode=streaming'
);
difyStreamExpect($generic[1]['protocol'] === 'openai', 'OpenAI remains the fallback protocol');
difyStreamExpect($generic[1]['payload']['stream'] === true, 'OpenAI stream request uses stream=true');
difyStreamExpect(
$generic[0]['payload']['inputs'] === ['case' => 'redacted']
&& $generic[0]['payload']['query'] === 'safe query'
&& $generic[0]['payload']['user'] === 'admin-safe',
'Dify streaming preserves structured inputs, query and user'
);
$blocking = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1',
'model-safe',
[],
'safe query',
'admin-safe',
]);
difyStreamExpect(
$blocking[0]['payload']['response_mode'] === 'blocking',
'legacy Dify blocking request remains unchanged'
);
difyStreamExpect(
!array_key_exists('stream', $blocking[1]['payload']),
'legacy OpenAI blocking request does not gain a stream field'
);
$explicitDify = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat-messages', 'model-safe', [], 'query', 'user', true,
]);
$explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [
'https://ai.example.test/v1/chat/completions', 'model-safe', [], 'query', 'user', true,
]);
difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol');
difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol');
$difyWire = ": ping\r\n\r\n"
. "data: {\"event\":\"message\",\"answer\":\"\",\"message_id\":\"msg-safe\"}\r\n\r\n"
. "data: {\"event\":\"agent_message\",\"answer\":\"\"}\r\n\r\n"
. "data: {\"event\":\"ping\"}\r\n\r\n"
. "data: {\"event\":\"message_end\",\"message_id\":\"msg-safe\"}\r\n\r\n";
$difyChunks = str_split($difyWire, 1);
$decodedDify = callDifyStreamPrivate('decodeStreamChunks', ['dify', $difyChunks]);
difyStreamExpect($decodedDify['content'] === '你好', 'Dify decoder handles every possible byte boundary, including UTF-8 bytes');
difyStreamExpect($decodedDify['deltas'] === ['你', '好'], 'Dify decoder emits only message text');
difyStreamExpect($decodedDify['message_id'] === 'msg-safe', 'Dify decoder retains the safe message id internally');
difyStreamExpect($decodedDify['finished'] === true, 'Dify message_end terminates parsing');
$openAiWire = "data: {\"id\":\"chat-safe\",\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n"
. "data: {\"choices\":[{\"delta\":{\"content\":\"\"}}]}\n\n"
. "data: [DONE]";
$decodedOpenAi = callDifyStreamPrivate('decodeStreamChunks', ['openai', str_split($openAiWire, 2)]);
difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles arbitrary byte chunks and final frame without newline');
difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only');
difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing');
$malformed = callDifyStreamPrivate('decodeStreamChunks', [
'dify',
["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"],
]);
difyStreamExpect($malformed['content'] === '', 'malformed and upstream error frames never become text');
difyStreamExpect($malformed['upstream_error'] === true, 'Dify error frame becomes an internal error flag');
difyStreamExpect(!str_contains(json_encode($malformed), 'secret-upstream-body'), 'upstream error body is not retained');
$safeError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => true,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$encodedError = json_encode($safeError, JSON_UNESCAPED_UNICODE);
difyStreamExpect($safeError['error_code'] === 'UPSTREAM_REJECTED', 'upstream SSE errors map to a stable internal code');
difyStreamExpect(!str_contains($encodedError, 'secret'), 'formatted stream errors contain no upstream body, key or prompt');
$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php');
difyStreamExpect(
is_string($serviceSource)
&& str_contains($serviceSource, '$responseCode < 200 || $responseCode >= 300')
&& str_contains($serviceSource, 'CURLOPT_HEADERFUNCTION => $header')
&& str_contains($serviceSource, "'Accept: text/event-stream'")
&& !str_contains($serviceSource, "config('ai')"),
'streaming rejects HTTP error bodies before parsing and never mixes daily-diet AI configuration'
);
$timeoutError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_OPERATION_TIMEDOUT,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
$disconnectError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => CURLE_ABORTED_BY_CALLBACK,
'http_code' => 200,
'content' => 'partial prompt must not appear',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => true,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($timeoutError['error_code'] === 'UPSTREAM_TIMEOUT', 'curl timeout maps to a stable timeout result');
difyStreamExpect($disconnectError['error_code'] === 'CLIENT_DISCONNECTED', 'client abort takes precedence over curl abort errno');
difyStreamExpect(!str_contains(json_encode($disconnectError), 'partial prompt'), 'disconnect result does not echo partial content');
$incompleteError = callDifyStreamPrivate('formatStreamResponse', [[
'errno' => 0,
'http_code' => 200,
'content' => 'partial answer',
'message_id' => '',
'emitted' => true,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], microtime(true)]);
difyStreamExpect($incompleteError['error_code'] === 'INCOMPLETE_RESPONSE', 'missing [DONE]/message_end cannot become a successful done');
difyStreamExpect(!str_contains(json_encode($incompleteError), 'partial answer'), 'incomplete response error does not echo partial content');
echo "Dify chat stream contract: OK\n";
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\common\service\qywx\MediaChannelService;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionFinanceExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
conversionFinanceExpect(
MediaChannelService::buildGroupCode('自媒体4') === 'group:自媒体4',
'Group codes must use the group: prefix'
);
conversionFinanceExpect(
MediaChannelService::parseGroupName('group:自媒体3') === '自媒体3',
'Group codes must round-trip the group name'
);
conversionFinanceExpect(
MediaChannelService::isGroupCode('group:自媒体4')
&& !MediaChannelService::isGroupCode('tag_et4h'),
'Only group: prefixed values are group codes'
);
$leafCodes = MediaChannelService::getChannelCodesForStats([
'channel_code' => 'group:自媒体4',
'channel_codes' => ['tag_et4h', 'tag_et4q', 'group:ignored'],
'is_group' => true,
]);
conversionFinanceExpect(
$leafCodes === ['tag_et4h', 'tag_et4q'],
'Stats channel codes must expand a group into leaf codes only'
);
$reflection = new ReflectionClass(FirstVisitConversionLogic::class);
$canViewFinance = $reflection->getMethod('canViewFinance');
$maskFinanceFields = $reflection->getMethod('maskFinanceFields');
$personalYejiMediaSources = $reflection->getMethod('personalYejiMediaSources');
$canViewFinance->setAccessible(true);
$maskFinanceFields->setAccessible(true);
$personalYejiMediaSources->setAccessible(true);
conversionFinanceExpect(
$canViewFinance->invoke(null, 1, ['root' => 1, 'role_name' => '医助']) === true,
'Root must always see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 8, ['root' => 0, 'role_name' => '经理']) === true,
'Managers must always see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '诊室组长']) === false,
'Group leaders without the finance permission must not see cash cost and ROI'
);
conversionFinanceExpect(
$canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '医助']) === false,
'Assistants without the finance permission must not see cash cost and ROI'
);
$masked = $maskFinanceFields->invoke(null, [
'completed_order_count' => 2,
'account_cost' => 88.5,
'cash_cost' => 12.3,
'roi' => 1.5,
'children' => [[
'name' => '医助甲',
'account_cost' => 40,
'roi' => 2,
'children' => [],
]],
]);
conversionFinanceExpect(
!isset($masked['account_cost'], $masked['cash_cost'], $masked['roi'])
&& $masked['completed_order_count'] === 2
&& !isset($masked['children'][0]['account_cost'], $masked['children'][0]['roi']),
'Finance fields must be stripped from summary rows and nested members'
);
$groupSources = $personalYejiMediaSources->invoke(null, 'group:自媒体4', [
'channel_code' => 'group:自媒体4',
'channel_name' => '自媒体4',
'channel_codes' => ['tag_et4h', 'tag_et4q'],
'channel_names' => ['自媒体4H', '自媒体4Q'],
'is_group' => true,
]);
conversionFinanceExpect(
is_array($groupSources)
&& in_array('自媒体4', $groupSources, true)
&& in_array('自媒体4H', $groupSources, true)
&& in_array('自媒体4Q', $groupSources, true)
&& in_array('tag_et4h', $groupSources, true)
&& !in_array('group:自媒体4', $groupSources, true),
'Group channel opening counts must match every leaf name and code, not the synthetic group code'
);
echo "FirstVisitConversionFinanceAndChannelTest passed\n";
@@ -23,8 +23,17 @@ $tagSql = (string)$tagQuery->fetchSql()->select();
if (!str_contains($tagSql, 'qywx_external_contact_tag')) {
throw new RuntimeException('tag 渠道未使用结构化客户标签关系表');
}
if (!str_contains($tagSql, ' IN (SELECT channel_tag.external_userid')) {
throw new RuntimeException('tag 渠道未通过去重子查询过滤 external_userid');
if (!str_contains($tagSql, 'EXISTS (SELECT 1 FROM')) {
throw new RuntimeException('tag 渠道未使用 EXISTS 半连接,避免物化整渠客户 ID');
}
if (!str_contains($tagSql, 'channel_tag.external_userid = e.external_userid')) {
throw new RuntimeException('tag 渠道未按事实表 external_userid 相关查询');
}
if (!str_contains($tagSql, 'tag_id = ')) {
throw new RuntimeException('单标签渠道应使用 tag_id = 走组合索引');
}
if (str_contains($tagSql, 'tag_id IN (')) {
throw new RuntimeException('单标签渠道不应退化为 tag_id IN');
}
if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) {
throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON');
@@ -47,4 +56,26 @@ if (!str_contains($legacySql, 'channel_contact.delete_time IS NULL')) {
throw new RuntimeException('老渠道回退包含了已删除客户记录');
}
$groupQuery = Db::name('qywx_external_contact_event')->alias('e');
MediaChannelService::applyExternalUserChannelFilter(
$groupQuery,
'e.external_userid',
[
'source_tag_id' => '',
'source_tag_ids' => ['tag-group-a', 'tag-group-b'],
'channel_name' => '自媒体4',
'is_group' => true,
]
);
$groupSql = (string)$groupQuery->fetchSql()->select();
if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
throw new RuntimeException('分组渠道未使用 EXISTS 半连接');
}
if (!str_contains($groupSql, 'tag_id IN (')) {
throw new RuntimeException('分组渠道未按多个 tag_id 过滤');
}
if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE')) {
throw new RuntimeException('分组渠道仍在扫描 follow_users JSON');
}
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";