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
@@ -4,9 +4,10 @@ namespace app\adminapi\controller\doctor;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\doctor\AppointmentLists;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\doctor\AppointmentValidate;
/**
* 医生预约控制器
@@ -147,8 +148,11 @@ class AppointmentController extends BaseAdminController
public function reception()
{
$params = (new AppointmentValidate())->goCheck('reception');
$result = AppointmentLogic::reception($params);
return $this->data($result);
$result = AppointmentLogic::reception($params, $this->adminId, $this->adminInfo);
if (empty($result)) {
return $this->fail('预约记录不存在或无权访问');
}
return $this->data($result);
}
/**
@@ -165,10 +169,17 @@ class AppointmentController extends BaseAdminController
return $this->success('通知已发送');
}
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
$params['doctor_id'] = $this->adminId;
public function addDoctorNote()
{
$params = (new AppointmentValidate())->post()->goCheck('addDoctorNote');
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$params['doctor_id'] = $this->adminId;
$result = DoctorNoteLogic::addOrAppend($params);
if ($result === false) {
return $this->fail(DoctorNoteLogic::getError());
@@ -176,10 +187,17 @@ class AppointmentController extends BaseAdminController
return $this->success('保存成功');
}
public function doctorNotes()
{
$params = (new AppointmentValidate())->goCheck('doctorNotes');
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
public function doctorNotes()
{
$params = (new AppointmentValidate())->goCheck('doctorNotes');
if (!DiagnosisLogic::canViewReadonlyDiagnosis(
(int) $params['diagnosis_id'],
$this->adminId,
$this->adminInfo
)) {
return $this->fail('诊单不存在或无权访问');
}
return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id']));
}
public function deleteDoctorNoteImage()
@@ -21,7 +21,8 @@ use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\validate\tcm\DiagnosisValidate;
use app\common\model\Order;
use app\common\model\WechatChatRecord;
@@ -168,10 +169,13 @@ class DiagnosisController extends BaseAdminController
*
* @return \think\response\Json
*/
public function trackingWindow()
{
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
$result = DiagnosisLogic::fetchTrackingWindow(
public function trackingWindow()
{
$params = (new DiagnosisValidate())->goCheck('trackingWindow');
if (!DiagnosisLogic::canViewReadonlyDiagnosis((int) $params['id'], $this->adminId, $this->adminInfo)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$result = DiagnosisLogic::fetchTrackingWindow(
(int) $params['id'],
(string) ($params['start_date'] ?? ''),
(string) ($params['end_date'] ?? '')
@@ -865,9 +869,9 @@ class DiagnosisController extends BaseAdminController
/**
* @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置
*/
public function aiAssistant()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
public function aiAssistant()
{
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$result = DiagnosisAiLogic::assistant(
(int) $params['id'],
(string) $params['task'],
@@ -877,11 +881,113 @@ class DiagnosisController extends BaseAdminController
);
if ($result === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
return $this->data($result);
}
/**
}
return $this->data($result);
}
/**
* @notes 基于当前授权诊单向 AI 助手提问(SSE 真流式)
*
* 路由:POST tcm.diagnosis/aiAssistantStream body: id, task, prompt
*/
public function aiAssistantStream()
{
// 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部
// 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。
$params = (new DiagnosisValidate())->post()->goCheck('aiAssistant');
$prepared = DiagnosisAiLogic::prepareAssistant(
(int) $params['id'],
(string) $params['task'],
(string) ($params['prompt'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($prepared === null) {
return $this->fail(DiagnosisAiLogic::getError());
}
$this->runAssistantSse($prepared);
}
/** @param array<string,mixed> $prepared */
private function runAssistantSse(array $prepared): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
@ini_set('output_buffering', 'off');
@ini_set('zlib.output_compression', '0');
ignore_user_abort(true);
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
header('Content-Encoding: none');
echo ':' . str_repeat(' ', 2048) . "\n\n";
$this->flushSseOutput();
$protocol = new AssistantSseProtocol();
$emit = function (string $event, array $payload) use ($protocol): bool {
if ($protocol->isTerminal() || connection_aborted()) {
return false;
}
$encoded = $protocol->encode($event, $payload);
if ($encoded === null) {
return false;
}
echo $encoded;
$this->flushSseOutput();
return !connection_aborted();
};
$emit('start', [
'task' => (string) ($prepared['task'] ?? ''),
'model_key' => (string) ($prepared['profile'] ?? ''),
'message' => '已连接,正在生成…',
]);
try {
$result = DiagnosisAiLogic::streamPreparedAssistant(
$prepared,
static fn (string $delta): bool => $emit('delta', ['text' => $delta]),
static fn (): bool => connection_aborted() === 1
);
if (connection_aborted()) {
exit;
}
if ($result === null) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]);
} else {
$emit('done', $result);
}
} catch (\Throwable $e) {
$emit('error', [
'code' => 'AI_ASSISTANT_FAILED',
'message' => 'AI 助手暂时不可用,请稍后重试',
]);
}
exit;
}
private function flushSseOutput(): void
{
if (function_exists('ob_flush')) {
@ob_flush();
}
flush();
}
/**
* @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键
*/
public function aiAnalysis()
@@ -137,9 +137,12 @@ class PrescriptionController extends BaseAdminController
$diagnosisId = (int)($this->request->get('diagnosis_id') ?? 0);
if (!$diagnosisId) {
return $this->fail('诊单ID不能为空');
}
$list = PrescriptionLogic::listByDiagnosis($diagnosisId);
return $this->data($list);
}
$list = PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo);
if (PrescriptionLogic::getError() !== '') {
return $this->fail(PrescriptionLogic::getError());
}
return $this->data($list);
}
/**
@@ -74,11 +74,13 @@ class AuthMiddleware
// 全部路由
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) {
return $next($request);
}
// 判断该当前访问的uri是否存在,不存在无需验证
if (!in_array($accessUri, $allUri, true)
&& !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)
&& !($accessUri === 'tcm.diagnosis/aiassistantstream'
&& in_array('tcm.diagnosis/aiassistant', $allUri, true))) {
return $next($request);
}
// 当前管理员拥有的路由权限
$AdminUris = $adminAuthCache->getAdminUri() ?? [];
@@ -109,9 +111,16 @@ class AuthMiddleware
* 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord
* 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。
*/
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
private function matchPermissionAlias(string $accessUri, array $adminUris): bool
{
// AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于
// allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。
if ($accessUri === 'tcm.diagnosis/aiassistantstream'
&& in_array('tcm.diagnosis/aiassistant', $adminUris, true)) {
return true;
}
if (PharmacyUploadPermissionAlias::isControlled($accessUri)) {
return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris);
}
@@ -215,7 +215,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin ad', 'a.doctor_id = ad.id')
->leftJoin('admin asst', 'u.assistant_id = asst.id')
->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
->field('a.*, u.patient_id AS source_patient_id, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id');
if ($this->searchWhere !== []) {
$query->where($this->searchWhere);
}
@@ -627,10 +627,35 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array
*/
public static function reception(array $params): array
{
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
public static function reception(array $params, int $adminId, array $adminInfo): array
{
self::$error = '';
$appointmentId = (int) ($params['id'] ?? 0);
$appointmentRow = $appointmentId > 0
? Appointment::where('id', $appointmentId)->field(['id', 'patient_id', 'doctor_id'])->find()
: null;
if (!$appointmentRow) {
self::setError('预约记录不存在或无权访问');
return [];
}
$diagnosisRow = Diagnosis::where('id', (int) $appointmentRow->patient_id)
->whereNull('delete_time')
->field(['id', 'assistant_id'])
->find();
if (!self::appointmentRowManageableByAdmin(
$appointmentRow,
$diagnosisRow ?: null,
$adminId,
$adminInfo
)) {
self::setError('预约记录不存在或无权访问');
return [];
}
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
if (empty($appointment)) {
return [];
}
@@ -883,40 +908,62 @@ class AppointmentLogic extends BaseLogic
/**
* 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax
*/
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true)) {
$asst = $diag ? (int) $diag->assistant_id : 0;
if ($asst !== $adminId) {
return false;
}
}
if (!DataScopeService::isEnabled()) {
return true;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === []) {
return false;
}
if ($ids === null) {
return true;
}
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
return in_array($docId, $ids, true)
|| ($asstId > 0 && in_array($asstId, $ids, true));
}
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
$roleIds = $isRoot
? []
: array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
$visibleIds = null;
if (!$isRoot && DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
return self::appointmentRowManageableForScope(
$docId,
$asstId,
$adminId,
$roleIds,
$visibleIds,
$isRoot
);
}
/**
* @param array<int, int> $roleIds
* @param array<int, int>|null $visibleIds null 表示未启用数据范围或全量可见
*/
private static function appointmentRowManageableForScope(
int $doctorId,
int $assistantId,
int $adminId,
array $roleIds,
?array $visibleIds,
bool $isRoot
): bool {
if ($isRoot) {
return true;
}
if (in_array(1, $roleIds, true) && $doctorId !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true) && $assistantId !== $adminId) {
return false;
}
if ($visibleIds === []) {
return false;
}
return $visibleIds === null
|| in_array($doctorId, $visibleIds, true)
|| ($assistantId > 0 && in_array($assistantId, $visibleIds, true));
}
/**
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
@@ -25,8 +25,8 @@ class DoctorNoteLogic extends BaseLogic
->find();
$newContent = trim($params['content'] ?? '');
$newImages = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['tongue_images'] ?? []));
$newReports = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['report_files'] ?? []));
$newImages = self::normalizeNewAttachmentPaths($params['tongue_images'] ?? []);
$newReports = self::normalizeNewAttachmentPaths($params['report_files'] ?? []);
if ($existing) {
$data = [];
@@ -169,15 +169,51 @@ class DoctorNoteLogic extends BaseLogic
*/
private static function toRelativePath(string $url): string
{
if (empty($url)) return $url;
if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
$url = trim($url);
if ($url === '') return $url;
$urlParts = parse_url($url);
if (!is_array($urlParts) || empty($urlParts['scheme'])) {
return $url;
}
$scheme = strtolower((string) $urlParts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return $url;
}
// 获取当前存储域名
$domain = self::getStorageDomain();
if ($domain && stripos($url, rtrim($domain, '/')) === 0) {
$relative = substr($url, strlen(rtrim($domain, '/')));
return ltrim($relative, '/');
$domain = rtrim(self::getStorageDomain(), '/');
$domainParts = $domain !== '' ? parse_url($domain) : false;
if (is_array($domainParts)) {
$domainScheme = strtolower((string) ($domainParts['scheme'] ?? ''));
$urlHost = strtolower(rtrim((string) ($urlParts['host'] ?? ''), '.'));
$domainHost = strtolower(rtrim((string) ($domainParts['host'] ?? ''), '.'));
$urlPort = (int) ($urlParts['port'] ?? ($scheme === 'https' ? 443 : 80));
$domainPort = (int) (
$domainParts['port'] ?? ($domainScheme === 'https' ? 443 : 80)
);
$urlPath = (string) ($urlParts['path'] ?? '');
$domainPath = rtrim((string) ($domainParts['path'] ?? ''), '/');
$pathInsideDomain = $domainPath === ''
|| $urlPath === $domainPath
|| str_starts_with($urlPath, $domainPath . '/');
if (
$domainScheme === $scheme
&& $domainHost !== ''
&& $domainHost === $urlHost
&& $domainPort === $urlPort
&& $pathInsideDomain
) {
$relative = $domainPath === ''
? $urlPath
: substr($urlPath, strlen($domainPath));
if (isset($urlParts['query']) && $urlParts['query'] !== '') {
$relative .= '?' . $urlParts['query'];
}
return ltrim($relative, '/');
}
}
// 非当前存储域名,保留完整 URL
return $url;
@@ -193,6 +229,36 @@ class DoctorNoteLogic extends BaseLogic
return $storage ? ($storage['domain'] ?? '') : '';
}
/**
* 备注附件只接受站内相对路径或当前存储域已上传的 URL。
* 存储域 URL 先转为相对路径,避免将任意外部 URL 持久化到病例页。
*
* @param mixed $value
* @return array<int, string>
*/
private static function normalizeNewAttachmentPaths($value): array
{
$paths = [];
foreach (self::parseJsonArray($value) as $rawPath) {
$path = trim((string) $rawPath);
if ($path === '') {
continue;
}
$path = self::toRelativePath($path);
$scheme = parse_url($path, PHP_URL_SCHEME);
if (
(is_string($scheme) && $scheme !== '')
|| str_starts_with($path, '//')
|| str_contains($path, "\0")
) {
throw new \InvalidArgumentException('备注附件必须来自当前文件存储域');
}
$paths[] = $path;
}
return array_values(array_unique($paths));
}
private static function parseJsonArray($value): array
{
if (is_array($value)) return $value;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
@@ -23,6 +24,9 @@ use think\facade\Db;
class FirstVisitConversionLogic
{
private const ASSISTANT_ROLE_ID = 2;
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
@@ -64,11 +68,11 @@ class FirstVisitConversionLogic
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
}
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
@@ -137,23 +141,36 @@ class FirstVisitConversionLogic
(int) $summary['total_open_count']
);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
$selectedMediaChannelName .= '(全部)';
}
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($rows as &$row) {
if (is_array($row)) {
$row = self::maskFinanceFields($row);
}
}
unset($row);
}
return [
'meta' => [
'time_type' => $timeType,
@@ -161,9 +178,9 @@ class FirstVisitConversionLogic
'start_date' => $startDate,
'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode,
@@ -171,6 +188,7 @@ class FirstVisitConversionLogic
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'can_view_finance' => $canViewFinance,
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
@@ -520,45 +538,101 @@ class FirstVisitConversionLogic
return [];
}
$values = [
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
];
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $item) {
$values[] = $item;
}
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
[
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
]
), static fn (string $value): bool => $value !== '')));
$values
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
private static function canViewFinance(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
return true;
}
}
if ($adminId <= 0) {
return false;
}
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
}
/** @return string[] */
private static function roleNamesFromAdminInfo(array $adminInfo): array
{
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (self::FINANCE_FIELD_KEYS as $key) {
unset($entity[$key]);
}
if (isset($entity['children']) && is_array($entity['children'])) {
foreach ($entity['children'] as &$child) {
if (is_array($child)) {
$child = self::maskFinanceFields($child);
}
}
unset($child);
}
return $entity;
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
@@ -583,59 +657,59 @@ class FirstVisitConversionLogic
$chartRows[] = $row;
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
@@ -62,7 +62,9 @@ class ConversionLogic
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
}
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
$mediaChannelCodes = $mediaChannel !== null
? MediaChannelService::getChannelCodesForStats($mediaChannel)
: null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -188,11 +190,11 @@ class ConversionLogic
);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
? self::loadChannelBoundDeptIds($mediaChannelCode)
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== []
? self::loadChannelBoundDeptIds($mediaChannelCodes)
: [];
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
@@ -267,7 +269,7 @@ class ConversionLogic
$startDate,
$endDate,
$mediaChannel,
$mediaChannelCode,
$mediaChannelCodes,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds,
@@ -824,17 +826,18 @@ class ConversionLogic
/**
* 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。
*
* @param string[] $mediaChannelCodes
* @return int[]
*/
private static function loadChannelBoundDeptIds(string $mediaChannelCode): array
private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array
{
$mediaChannelCode = trim($mediaChannelCode);
if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) {
$mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) {
return [];
}
$deptIds = Db::name('account_cost')
->where('media_channel_code', $mediaChannelCode)
->whereIn('media_channel_code', $mediaChannelCodes)
->where('dept_id', '>', 0)
->distinct(true)
->column('dept_id');
@@ -1596,6 +1599,7 @@ class ConversionLogic
* 账户消耗:来源于独立维护表 zyt_account_cost。
*
* @param array<int, array<string, mixed>> $entities
* @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
* @return array{0: float, 1: int[]}
*/
@@ -1603,10 +1607,21 @@ class ConversionLogic
array &$entities,
string $startDate,
string $endDate,
string $mediaChannelCode,
?array $mediaChannelCodes,
?array $visibleDeptIds = null
): array
{
$mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === []) {
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = 0.0;
}
unset($entity);
return [0.0, []];
}
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
@@ -1618,8 +1633,8 @@ class ConversionLogic
$query->field('amount');
}
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
if ($mediaChannelCodes !== null) {
$query->whereIn('media_channel_code', $mediaChannelCodes);
}
if ($supportsDeptBinding) {
@@ -2049,7 +2064,7 @@ class ConversionLogic
string $startDate,
string $endDate,
?array $mediaChannel,
string $mediaChannelCode,
?array $mediaChannelCodes,
bool $restrictAccountCostByDept,
array $eligibleDeptIds,
array $adminToDeptIds,
@@ -2081,9 +2096,9 @@ class ConversionLogic
if ($globalAccountCost < 0) {
// 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。
if ($assistantIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes);
} elseif ($doctorIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes);
} else {
$globalAccountCost = 0.0;
}
@@ -2905,10 +2920,29 @@ class ConversionLogic
return [
'code' => (string)($mediaChannel['channel_code'] ?? ''),
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
'tag_ids' => $mediaChannel['source_tag_ids'] ?? [],
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
];
}
/**
* @param string|string[] $mediaChannelCode
* @return string[]
*/
private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array
{
$values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode];
$codes = [];
foreach ($values as $value) {
$code = trim((string)$value);
if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
}
return array_values($codes);
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DataScope\DataScopeService;
use app\common\service\DifyChatService;
use app\common\cache\AdminAuthCache;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DifyChatService;
use think\facade\Db;
use think\facade\Log;
@@ -201,6 +200,44 @@ class DiagnosisAiLogic extends BaseLogic
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo);
if ($prepared === null) {
return null;
}
try {
$result = DifyChatService::chat(
$prepared['profile'],
$prepared['inputs'],
$prepared['query'],
$prepared['user']
);
} catch (\Throwable $e) {
self::logAssistantFailure($diagnosisId, $prepared['profile'], $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。
* 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。
*
* @param array<string,mixed> $adminInfo
* @return array{
* diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string,
* inputs:array<string,mixed>,query:string,user:string,admin_id:int
* }|null
*/
public static function prepareAssistant(
int $diagnosisId,
string $task,
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
@@ -239,28 +276,63 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
return [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'model_name' => $model,
'model_label' => $modelLabel,
'task' => $task,
'inputs' => self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
'query' => self::buildAssistantPrompt($context, $task, $prompt),
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
];
}
/**
* @param array<string,mixed> $prepared prepareAssistant() 的内部返回值
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
public static function streamPreparedAssistant(
array $prepared,
callable $onDelta,
?callable $shouldAbort = null
): ?array {
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
try {
$result = DifyChatService::chat(
$result = DifyChatService::streamChat(
$profile,
self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
self::buildAssistantPrompt($context, $task, $prompt),
'admin-diagnosis-assistant-' . $adminId
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$shouldAbort
);
} catch (\Throwable $e) {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($e),
]);
self::logAssistantFailure($diagnosisId, $profile, $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* @param array<string,mixed> $prepared
* @param array<string,mixed> $result
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
private static function formatAssistantResult(array $prepared, array $result): ?array
{
if (empty($result['ok'])) {
self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'));
return null;
@@ -273,13 +345,27 @@ class DiagnosisAiLogic extends BaseLogic
return [
'answer' => $content,
'model_key' => $profile,
'model_label' => $modelLabel,
'model_name' => $model,
'task' => $task,
'model_key' => (string) ($prepared['profile'] ?? ''),
'model_label' => (string) ($prepared['model_label'] ?? ''),
'model_name' => (string) ($prepared['model_name'] ?? ''),
'task' => (string) ($prepared['task'] ?? ''),
];
}
private static function logAssistantFailure(
int $diagnosisId,
string $profile,
int $adminId,
\Throwable $exception
): void {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($exception),
]);
}
/**
* 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型,
* 上游失败或响应不符合契约时直接失败,不构造本地伪分析。
@@ -604,28 +690,10 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
$accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time');
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
if (!$isRoot) {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return null;
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return null;
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return null;
}
$diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo);
if ($diagnosis === [] || empty($diagnosis['id'])) {
@@ -28,10 +28,11 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
@@ -4182,32 +4183,10 @@ class DiagnosisLogic extends BaseLogic
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
// 医助仅看自己被指派的
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return [];
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
if (!self::canViewReadonlyDiagnosis($diagnosisId, $adminId, $adminInfo)) {
return [];
}
// 2) 诊单详情(含图片聚合等)+ 字典翻译
$diagnosis = self::detail(['id' => $diagnosisId]);
@@ -4238,24 +4217,43 @@ class DiagnosisLogic extends BaseLogic
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
return [
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
];
}
/**
];
}
/**
* 与 readonlyDetail 共用的诊单行级可见性入口。
*
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举。
*/
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与
* 医生接诊台 reception 通过独立接口 lazy load。
*
* 区间语义:闭区间 [startDate, endDate]Y-m-d),均不传则不限。
*
* @return array{
* blood_records: array<int,array<string,mixed>>,
* diagnosis_id: int,
* blood_records: array<int,array<string,mixed>>,
* diet_records: array<int,array<string,mixed>>,
* exercise_records: array<int,array<string,mixed>>,
* start_date: string,
@@ -4267,10 +4265,11 @@ class DiagnosisLogic extends BaseLogic
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'diagnosis_id' => $diagnosisId,
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'start_date' => $startDate,
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\Prescription;
@@ -934,14 +935,35 @@ class PrescriptionLogic
/**
* 根据诊单ID获取处方列表
*/
public static function listByDiagnosis(int $diagnosisId): array
{
return Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
}
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError('诊单不存在或无权访问');
return [];
}
$rows = Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
return self::filterViewablePrescriptions($rows, $viewerAdminId, $viewerAdminInfo);
}
/**
* @param array<int, array<string,mixed>> $rows
* @return array<int, array<string,mixed>>
*/
private static function filterViewablePrescriptions(array $rows, int $viewerAdminId, array $viewerAdminInfo): array
{
return array_values(array_filter(
$rows,
static fn (array $row): bool => self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)
));
}
/**
* 根据预约ID获取处方(带权限检查)
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/**
* 诊单 AI 助手 SSE 事件状态机:start -> delta* -> done|error。
*/
final class AssistantSseProtocol
{
private int $seq = 0;
private bool $started = false;
private bool $terminal = false;
/** @param array<string,mixed> $payload */
public function encode(string $event, array $payload): ?string
{
if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) {
return null;
}
if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) {
return null;
}
$nextSeq = $this->seq + 1;
$encoded = json_encode(
['seq' => $nextSeq] + $payload,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
);
if (!is_string($encoded)) {
return null;
}
$this->seq = $nextSeq;
$this->started = true;
if (in_array($event, ['done', 'error'], true)) {
$this->terminal = true;
}
return 'event: ' . $event . "\n" . 'data: ' . $encoded . "\n\n";
}
public function isTerminal(): bool
{
return $this->terminal;
}
}
+467 -2
View File
@@ -98,6 +98,114 @@ class DifyChatService
], $startedAt);
}
/**
* 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。
*
* @param array<string,mixed> $inputs
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
public static function streamChat(
string $profile,
array $inputs,
string $query,
string $user,
callable $onDelta,
?callable $shouldAbort = null
): array {
$config = config('prescription_ai') ?: [];
if (empty($config['enable'])) {
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
}
$modelConfig = self::resolveProfileConfig($config, $profile);
if ($modelConfig === null) {
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
}
$baseUrl = trim((string) ($config['base_url'] ?? ''));
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
$apiKey = trim($rawApiKey);
if ($baseUrl === '' || $apiKey === '') {
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
}
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
}
$timeout = (int) ($config['timeout'] ?? 0);
if (!self::isValidTimeout($timeout)) {
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
}
if (!function_exists('curl_init')) {
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
}
$model = trim((string) ($modelConfig['name'] ?? ''));
if ($model === '') {
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
}
$requestSpecs = self::buildRequestSpecs(
$baseUrl,
$model,
$inputs,
$query,
$user,
true
);
$startedAt = microtime(true);
$lastResponse = null;
foreach ($requestSpecs as $index => $requestSpec) {
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
$remainingTimeout = $timeout - $elapsedSeconds;
if ($remainingTimeout < self::MIN_TIMEOUT) {
return self::error(
'UPSTREAM_TIMEOUT',
'模型响应超时,请稍后重试',
self::elapsedMilliseconds($startedAt)
);
}
$response = self::sendStreamRequest(
$requestSpec['protocol'],
$requestSpec['url'],
$requestSpec['payload'],
$apiKey,
$remainingTimeout,
$onDelta,
$shouldAbort
);
$lastResponse = $response;
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
$hasFallback = isset($requestSpecs[$index + 1]);
if (
$hasFallback
&& empty($response['emitted'])
&& in_array($response['http_code'], [404, 405], true)
) {
continue;
}
return self::formatStreamResponse($response, $startedAt);
}
return self::formatStreamResponse($lastResponse ?? [
'errno' => 0,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
], $startedAt);
}
/**
* @param array<string,mixed> $config
* @return array<string,mixed>|null
@@ -120,7 +228,8 @@ class DifyChatService
string $model,
array $inputs,
string $query,
string $user
string $user,
bool $streaming = false
): array {
$baseUrl = rtrim($baseUrl, '/');
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
@@ -131,7 +240,7 @@ class DifyChatService
'payload' => [
'inputs' => $inputs,
'query' => $query,
'response_mode' => 'blocking',
'response_mode' => $streaming ? 'streaming' : 'blocking',
'user' => $user,
],
];
@@ -143,9 +252,14 @@ class DifyChatService
'messages' => [
['role' => 'user', 'content' => $query],
],
'stream' => $streaming,
],
];
if (!$streaming) {
unset($openAiSpec['payload']['stream']);
}
if (str_ends_with($path, '/chat-messages')) {
return [$difySpec];
}
@@ -240,6 +354,357 @@ class DifyChatService
];
}
/**
* @param array<string,mixed> $payload
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function sendStreamRequest(
string $protocol,
string $url,
array $payload,
string $apiKey,
int $timeout,
callable $onDelta,
?callable $shouldAbort
): array {
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if ($body === false) {
return self::emptyStreamResponse(-1);
}
$ch = curl_init();
if ($ch === false) {
return self::emptyStreamResponse(-2);
}
$buffer = '';
$state = self::newStreamState();
$responseCode = 0;
$header = static function ($handle, string $line) use (&$responseCode): int {
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
$responseCode = (int) $matches[1];
}
return strlen($line);
};
$write = static function ($handle, string $chunk) use (
$protocol,
&$buffer,
&$state,
&$responseCode,
$onDelta,
$shouldAbort
): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 0;
}
if ($responseCode < 200 || $responseCode >= 300) {
// Never decode or forward an error response body. Besides preventing
// leakage, this keeps 404/405 protocol fallback side-effect free.
return strlen($chunk);
}
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
return $state['callback_error'] ? 0 : strlen($chunk);
};
$progress = static function () use (&$state, $shouldAbort): int {
if ($shouldAbort !== null && $shouldAbort()) {
$state['client_aborted'] = true;
return 1;
}
return 0;
};
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: text/event-stream',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_HEADERFUNCTION => $header,
CURLOPT_WRITEFUNCTION => $write,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => $progress,
]);
curl_exec($ch);
$errno = curl_errno($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$state['client_aborted'] && !$state['callback_error']) {
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
}
return [
'errno' => $errno,
'http_code' => $httpCode,
'content' => $state['content'],
'message_id' => $state['message_id'],
'emitted' => $state['emitted'],
'upstream_error' => $state['upstream_error'],
'client_aborted' => $state['client_aborted'],
'callback_error' => $state['callback_error'],
'finished' => $state['finished'],
];
}
/**
* @return array{
* content:string,message_id:string,emitted:bool,upstream_error:bool,
* client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function newStreamState(): array
{
return [
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。
*
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamBytes(
string $protocol,
string &$buffer,
string $chunk,
array &$state,
callable $onDelta,
bool $final = false
): void {
$buffer .= $chunk;
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
$delimiter = $match[0][0];
$offset = $match[0][1];
$frame = substr($buffer, 0, $offset);
$buffer = (string) substr($buffer, $offset + strlen($delimiter));
self::consumeStreamFrame($protocol, $frame, $state, $onDelta);
}
if ($final && trim($buffer) !== '') {
self::consumeStreamFrame($protocol, $buffer, $state, $onDelta);
$buffer = '';
}
}
/**
* @param array<string,mixed> $state
* @param callable(string):mixed $onDelta
*/
private static function consumeStreamFrame(
string $protocol,
string $frame,
array &$state,
callable $onDelta
): void {
if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) {
return;
}
$dataLines = [];
foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) {
if ($line === '' || str_starts_with($line, ':')) {
continue;
}
if (str_starts_with($line, 'data:')) {
$dataLines[] = ltrim(substr($line, 5), ' ');
}
}
if ($dataLines === []) {
return;
}
$data = implode("\n", $dataLines);
if ($data === '[DONE]') {
$state['finished'] = true;
return;
}
$decoded = json_decode($data, true);
if (!is_array($decoded)) {
return;
}
$delta = '';
if ($protocol === 'dify') {
$event = strtolower((string) ($decoded['event'] ?? ''));
if ($event === 'message_end') {
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
$state['finished'] = true;
return;
}
if ($event === 'error') {
$state['upstream_error'] = true;
return;
}
if (!in_array($event, ['message', 'agent_message'], true)) {
return;
}
$delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : '';
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
} else {
$delta = self::extractStreamDelta($decoded);
$state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']);
}
if ($delta === '') {
return;
}
try {
$accepted = $onDelta($delta);
if ($accepted === false) {
$state['callback_error'] = true;
return;
}
} catch (\Throwable $e) {
$state['callback_error'] = true;
return;
}
$state['content'] .= $delta;
$state['emitted'] = true;
}
/** @param array<string,mixed> $decoded */
private static function extractStreamDelta(array $decoded): string
{
$content = $decoded['choices'][0]['delta']['content'] ?? '';
if (is_string($content)) {
return $content;
}
if (!is_array($content)) {
return '';
}
$parts = [];
foreach ($content as $part) {
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
$parts[] = $part['text'];
}
}
return implode('', $parts);
}
/**
* 纯解析测试入口:生产流与测试使用同一逐字节解码路径。
*
* @param array<int,string> $chunks
* @return array{content:string,deltas:array<int,string>,message_id:string,finished:bool,upstream_error:bool}
*/
private static function decodeStreamChunks(string $protocol, array $chunks): array
{
$buffer = '';
$state = self::newStreamState();
$deltas = [];
$onDelta = static function (string $delta) use (&$deltas): void {
$deltas[] = $delta;
};
foreach ($chunks as $chunk) {
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
}
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
return [
'content' => $state['content'],
'deltas' => $deltas,
'message_id' => $state['message_id'],
'finished' => $state['finished'],
'upstream_error' => $state['upstream_error'],
];
}
/**
* @return array{
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
* }
*/
private static function emptyStreamResponse(int $errno): array
{
return [
'errno' => $errno,
'http_code' => 0,
'content' => '',
'message_id' => '',
'emitted' => false,
'upstream_error' => false,
'client_aborted' => false,
'callback_error' => false,
'finished' => false,
];
}
/**
* @param array<string,mixed> $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
*/
private static function formatStreamResponse(array $response, float $startedAt): array
{
$latencyMs = self::elapsedMilliseconds($startedAt);
if (!empty($response['client_aborted'])) {
return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs);
}
if (!empty($response['callback_error'])) {
return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs);
}
$errno = (int) ($response['errno'] ?? 0);
if ($errno !== 0) {
if ($errno === CURLE_OPERATION_TIMEDOUT) {
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
}
if ($errno === -1) {
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
}
if ($errno === -2) {
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
}
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
}
$httpCode = (int) ($response['http_code'] ?? 0);
if ($httpCode === 401 || $httpCode === 403) {
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
}
if ($httpCode === 429 || $httpCode >= 500) {
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
}
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
}
if (empty($response['finished'])) {
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
}
$content = (string) ($response['content'] ?? '');
if (trim($content) === '') {
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
}
return [
'ok' => true,
'content' => $content,
'message_id' => (string) ($response['message_id'] ?? ''),
'latency_ms' => $latencyMs,
];
}
/**
* @param array{body:string,errno:int,http_code:int} $response
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
@@ -21,6 +21,8 @@ class MediaChannelService
'update_time',
];
public const GROUP_CODE_PREFIX = 'group:';
/** @var array<int, array<string, mixed>>|null */
private static ?array $activeChannelRowsCache = null;
@@ -199,8 +201,9 @@ SQL;
'code' => (string) ($row['channel_code'] ?? ''),
'name' => (string) ($row['channel_name'] ?? ''),
'tag_id' => (string) ($row['source_tag_id'] ?? ''),
'group_name' => (string) ($row['source_group_name'] ?? ''),
'group_name' => trim((string) ($row['source_group_name'] ?? '')),
'customer_count' => (int) ($row['customer_count'] ?? 0),
'kind' => 'channel',
], self::getCurrentTagChannelRows());
}
@@ -217,6 +220,11 @@ SQL;
return null;
}
$groupName = self::parseGroupName($channelCode);
if ($groupName !== '') {
return self::buildCurrentTagGroupChannel($groupName);
}
foreach (self::getCurrentTagChannelRows() as $row) {
if ((string) ($row['channel_code'] ?? '') === $channelCode) {
return $row;
@@ -226,6 +234,57 @@ SQL;
return null;
}
public static function isGroupCode(string $channelCode): bool
{
return self::parseGroupName($channelCode) !== '';
}
public static function buildGroupCode(string $groupName): string
{
$groupName = trim($groupName);
return $groupName === '' ? '' : self::GROUP_CODE_PREFIX . $groupName;
}
public static function parseGroupName(string $channelCode): string
{
$channelCode = trim($channelCode);
if (!str_starts_with($channelCode, self::GROUP_CODE_PREFIX)) {
return '';
}
return trim(substr($channelCode, strlen(self::GROUP_CODE_PREFIX)));
}
/**
* 账户消耗等事实表使用的真实渠道 code;分组筛选会展开为组内全部叶子渠道。
*
* @param array<string, mixed>|null $channel
* @return string[]
*/
public static function getChannelCodesForStats(?array $channel): array
{
if ($channel === null) {
return [];
}
$codes = [];
if (isset($channel['channel_codes']) && is_array($channel['channel_codes'])) {
foreach ($channel['channel_codes'] as $code) {
$code = trim((string) $code);
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
}
}
$code = trim((string) ($channel['channel_code'] ?? ''));
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
return array_values($codes);
}
public static function getDefaultCode(): string
{
$rows = self::getActiveChannelRows();
@@ -303,12 +362,21 @@ SQL;
return [];
}
$names = array_values(array_unique(array_filter([
$names = [
trim((string) ($channel['channel_name'] ?? '')),
trim((string) ($channel['source_tag_name'] ?? '')),
trim((string) ($channel['legacy_channel_name'] ?? '')),
trim((string) ($channel['legacy_source_tag_name'] ?? '')),
])));
];
foreach (['channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $name) {
$names[] = trim((string) $name);
}
}
$names = array_values(array_unique(array_filter($names, static fn (string $name): bool => $name !== '')));
if ($names === []) {
return [];
@@ -362,18 +430,22 @@ SQL;
return;
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds = self::channelTagIds($channel);
if ($tagIds !== []) {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$contactTable = self::tableWithPrefix('qywx_external_contact');
$tagPredicate = count($tagIds) === 1
? 'channel_tag.tag_id = ?'
: 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')';
// 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。
$query->whereRaw(
"{$field} IN ("
. "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag "
. 'WHERE channel_tag.tag_id = ? '
"EXISTS (SELECT 1 FROM {$tagTable} channel_tag "
. "WHERE channel_tag.external_userid = {$field} "
. "AND {$tagPredicate} "
. "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact "
. 'WHERE active_channel_contact.external_userid = channel_tag.external_userid '
. 'AND active_channel_contact.delete_time IS NULL))',
[$tagId]
$tagIds
);
return;
@@ -768,16 +840,24 @@ SQL;
private static function buildLikePatterns(array $channel): array
{
$patterns = [];
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
$tagName = trim((string) ($channel['source_tag_name'] ?? ''));
if ($tagId !== '') {
foreach (self::channelTagIds($channel) as $tagId) {
$escapedTagId = addcslashes($tagId, '%_\\');
$patterns[] = '%"tag_id":"' . $escapedTagId . '"%';
$patterns[] = '%"id":"' . $escapedTagId . '"%';
}
if ($tagName !== '') {
$tagNames = [trim((string) ($channel['source_tag_name'] ?? ''))];
if (isset($channel['channel_names']) && is_array($channel['channel_names'])) {
foreach ($channel['channel_names'] as $name) {
$tagNames[] = trim((string) $name);
}
}
if (isset($channel['source_tag_names']) && is_array($channel['source_tag_names'])) {
foreach ($channel['source_tag_names'] as $name) {
$tagNames[] = trim((string) $name);
}
}
foreach (array_unique(array_filter($tagNames, static fn (string $name): bool => $name !== '')) as $tagName) {
$escapedTagName = addcslashes($tagName, '%_\\');
$patterns[] = '%"name":"' . $escapedTagName . '"%';
$patterns[] = '%"tag_name":"' . $escapedTagName . '"%';
@@ -786,6 +866,86 @@ SQL;
return array_values(array_unique($patterns));
}
/**
* @param array<string, mixed> $channel
* @return string[]
*/
private static function channelTagIds(array $channel): array
{
$tagIds = [];
if (isset($channel['source_tag_ids']) && is_array($channel['source_tag_ids'])) {
foreach ($channel['source_tag_ids'] as $tagId) {
$tagId = trim((string) $tagId);
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
}
}
$tagId = trim((string) ($channel['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
return array_values($tagIds);
}
/**
* @return array<string, mixed>|null
*/
private static function buildCurrentTagGroupChannel(string $groupName): ?array
{
$groupName = trim($groupName);
if ($groupName === '') {
return null;
}
$rows = [];
foreach (self::getCurrentTagChannelRows() as $row) {
if (trim((string) ($row['source_group_name'] ?? '')) === $groupName) {
$rows[] = $row;
}
}
if ($rows === []) {
return null;
}
$tagIds = [];
$codes = [];
$names = [];
$customerCount = 0;
foreach ($rows as $row) {
$tagId = trim((string) ($row['source_tag_id'] ?? ''));
if ($tagId !== '') {
$tagIds[$tagId] = $tagId;
}
$code = trim((string) ($row['channel_code'] ?? ''));
if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
foreach (['channel_name', 'source_tag_name', 'legacy_channel_name', 'legacy_source_tag_name'] as $nameKey) {
$name = trim((string) ($row[$nameKey] ?? ''));
if ($name !== '') {
$names[$name] = $name;
}
}
$customerCount = max($customerCount, (int) ($row['customer_count'] ?? 0));
}
return [
'channel_code' => self::buildGroupCode($groupName),
'channel_name' => $groupName,
'source_group_name' => $groupName,
'source_tag_id' => '',
'source_tag_name' => $groupName,
'source_tag_ids' => array_values($tagIds),
'channel_codes' => array_values($codes),
'channel_names' => array_values($names),
'customer_count' => $customerCount,
'is_group' => true,
'status' => 1,
];
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
@@ -0,0 +1,42 @@
-- 一诊 / 综合数据转化:现金成本与 ROI 可见权限
-- 权限:firstvisit.conversion/viewFinance
-- 经理、管理员默认可见;诊室组长、医助需在角色里勾选本权限后才可见。
START TRANSACTION;
SET @first_visit_conversion_menu_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/overview'
OR TRIM(`component`) = 'first_visit/conversion/index'
ORDER BY CASE WHEN `perms` = 'firstvisit.conversion/overview' THEN 0 ELSE 1 END, `id`
LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@first_visit_conversion_menu_id, 'A', '查看现金成本与ROI', '', 10,
'firstvisit.conversion/viewFinance', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @first_visit_conversion_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/viewFinance'
);
SET @first_visit_conversion_finance_menu_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `perms` = 'firstvisit.conversion/viewFinance'
ORDER BY `id`
LIMIT 1
);
INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`)
SELECT `id`, @first_visit_conversion_finance_menu_id
FROM `zyt_system_role`
WHERE @first_visit_conversion_finance_menu_id IS NOT NULL
AND `delete_time` IS NULL
AND `name` IN ('经理', '管理员', '系统管理员');
COMMIT;
@@ -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";