geng
This commit is contained in:
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user