i
This commit is contained in:
gr
2026-10-22 11:12:29 +08:00
480 changed files with 9840 additions and 953 deletions
+2
View File
@@ -20,6 +20,8 @@ return [
app\adminapi\http\middleware\LoginMiddleware::class,
// 权限认证
app\adminapi\http\middleware\AuthMiddleware::class,
// 医生网页面诊到期限制(须在登录及权限校验之后执行)
app\adminapi\http\middleware\DoctorWebConsultationMiddleware::class,
// 演示模式 - 禁止提交数据
app\adminapi\http\middleware\CheckDemoMiddleware::class,
// 演示模式 - 不返回敏感数据
@@ -16,6 +16,7 @@ namespace app\adminapi\controller;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\ConfigLogic;
use app\adminapi\service\DoctorWebConsultationPolicy;
/**
* 配置控制器
@@ -24,7 +25,13 @@ use app\adminapi\logic\ConfigLogic;
*/
class ConfigController extends BaseAdminController
{
public array $notNeedLogin = ['getConfig', 'dict'];
public array $notNeedLogin = ['getConfig', 'dict', 'doctorWebConsultation'];
/** 医生网页面诊迁移时间,只读且免登录,供已打开的网页同步配置。 */
public function doctorWebConsultation()
{
return $this->data(DoctorWebConsultationPolicy::publicPolicy());
}
/**
@@ -58,4 +65,4 @@ class ConfigController extends BaseAdminController
}
}
@@ -25,7 +25,13 @@ use app\adminapi\validate\setting\DesktopWorkstationValidate;
*/
class DesktopWorkstationController extends BaseAdminController
{
public array $notNeedLogin = ['check'];
public array $notNeedLogin = ['check', 'downloads'];
/** @notes 提供已发布的各平台安装包,医生无需系统设置权限即可下载 */
public function downloads()
{
return $this->data(DesktopWorkstationLogic::downloads());
}
/**
* @notes 读取升级策略与安装包
@@ -429,13 +429,17 @@ class DiagnosisController extends BaseAdminController
*/
public function getCallRecords()
{
$params = $this->request->get();
if (empty($params['diagnosis_id'])) {
return $this->fail('诊单ID不能为空');
$rawDiagnosisId = $this->request->get('diagnosis_id', 0);
$diagnosisId = is_int($rawDiagnosisId) || is_string($rawDiagnosisId)
? filter_var($rawDiagnosisId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]])
: false;
if ($diagnosisId === false) {
return $this->fail('诊单ID无效');
}
$result = DiagnosisLogic::getCallRecords($params);
if (!DiagnosisLogic::canViewReadonlyDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)) {
return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问');
}
$result = DiagnosisLogic::getCallRecords(['diagnosis_id' => $diagnosisId]);
return $this->data($result);
}
@@ -80,6 +80,16 @@ class AuthMiddleware
// 当前访问路径
$accessUri = strtolower($request->controller() . '/' . $request->action());
// 面诊回放与文字共用读取权限;未登记该接口时也不能走历史放行分支。
if ($accessUri === 'tcm.diagnosis/getcallrecords') {
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array('tcm.diagnosis/huifang', $uris, true)
|| in_array('tcm.diagnosis/getcallrecords', $uris, true)) {
return $next($request); // 控制器还会校验当前诊单的数据域。
}
return JsonService::fail('权限不足,无法查看面诊记录');
}
// This group must never use the legacy unregistered-route bypass.
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace app\adminapi\http\middleware;
use app\adminapi\service\DoctorWebConsultationPolicy;
use app\common\service\JsonService;
final class DoctorWebConsultationMiddleware
{
public function __construct(private ?array $configuration = null)
{
}
public function handle($request, \Closure $next)
{
if (DoctorWebConsultationPolicy::blocksRequest(
(array) ($request->adminInfo ?? []),
(string) $request->controller(),
(string) $request->action(),
(array) $request->header(),
null,
$this->configuration
)) {
return JsonService::fail(DoctorWebConsultationPolicy::message($this->configuration), [
'reason' => 'doctor_web_consultation_expired',
'cutoff_at' => DoctorWebConsultationPolicy::cutoffAt($this->configuration),
]);
}
return $next($request);
}
}
+10 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\lists\order;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\service\order\PaymentPrescriptionOrderService;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\Order;
@@ -218,12 +219,20 @@ class OrderLists extends BaseAdminDataLists implements ListsSearchInterface
$query = Order::where($where);
$this->applyDataScopeByOwner($query, 'creator_id');
return $query
$rows = $query
->with(['patient', 'creator'])
->order(['create_time' => 'desc'])
->limit($this->limitOffset, $this->pageSize)
->select()
->toArray();
$prescriptionOrders = PaymentPrescriptionOrderService::forPaymentIds(array_column($rows, 'id'));
foreach ($rows as &$row) {
$row['prescription_orders'] = $prescriptionOrders[(int) $row['id']] ?? [];
}
unset($row);
return $rows;
}
/**
@@ -15,6 +15,7 @@
namespace app\adminapi\logic\auth;
use app\adminapi\logic\LoginLogic;
use app\adminapi\service\DoctorWebConsultationPolicy;
use app\common\cache\AdminAuthCache;
use app\common\enum\YesNoEnum;
use app\common\logic\BaseLogic;
@@ -318,6 +319,7 @@ class AdminLogic extends BaseLogic
$authRoleIds = AdminRole::where('admin_id', $params['id'])->column('role_id');
$admin['role_ids'] = array_values(array_map('intval', $authRoleIds));
$admin['web_consultation_policy'] = DoctorWebConsultationPolicy::publicPolicy();
$admin['need_bind_work_wechat'] = LoginLogic::adminMustBindWorkWechat([
'root' => (int) ($admin['root'] ?? 0),
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\order;
use app\adminapi\service\order\PaymentPrescriptionOrderService;
use app\common\model\Order;
use app\common\model\OrderDetail;
use app\common\model\auth\Admin;
@@ -776,7 +777,11 @@ class OrderLogic
return null;
}
return $order->toArray();
$detail = $order->toArray();
$prescriptionOrders = PaymentPrescriptionOrderService::forPaymentIds([$id]);
$detail['prescription_orders'] = $prescriptionOrders[$id] ?? [];
return $detail;
}
private static $error = '';
@@ -37,6 +37,36 @@ class DesktopWorkstationLogic extends BaseLogic
public static function getConfig(): array
{
return self::present(self::loadStored());
}
/** 医生公告使用的安装包清单,与自动升级检测开关独立。 */
public static function downloads(): array
{
return self::downloadCatalog(self::getConfig());
}
public static function downloadCatalog(array $config): array
{
$labels = [
'windows_x64' => 'Windows 64位',
'macos_arm64' => 'macOS(Apple芯片)',
'macos_x64' => 'macOS(Intel芯片)',
];
$packages = [];
foreach (self::PLATFORMS as $platform) {
$package = self::plainPackage((array) ($config['packages'][$platform] ?? []), $platform);
$url = parse_url($package['url']);
if (!is_array($url) || !in_array(strtolower($url['scheme'] ?? ''), ['https', 'http'], true)
|| empty($url['host']) || isset($url['user']) || isset($url['pass'])
|| !preg_match('/^[a-f0-9]{64}$/', $package['sha256'])) {
continue;
}
$packages[] = ['platform' => $platform, 'label' => $labels[$platform]] + $package;
}
return [
'version' => self::normalizeVersion((string) ($config['latest_version'] ?? '')),
'packages' => $packages,
];
}
/**
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
/** 医生网页面诊迁移。客户端识别用于迁移兼容,原有登录和数据权限仍须独立校验。 */
final class DoctorWebConsultationPolicy
{
public static function cutoffTimestamp(?array $configuration = null): int
{
return self::schedule($configuration)['cutoff']->getTimestamp();
}
public static function cutoffAt(?array $configuration = null): string
{
return self::schedule($configuration)['cutoff']->format(DATE_ATOM);
}
public static function message(?array $configuration = null): string
{
return '网页端面诊已于' . self::schedule($configuration)['cutoff']->format('Y年n月j日H:i:s')
. '(北京时间)停用,请医生使用桌面应用进行面诊。';
}
public static function publicPolicy(?int $now = null, ?array $configuration = null): array
{
$schedule = self::schedule($configuration);
return [
'cutoff_at' => $schedule['cutoff']->format(DATE_ATOM),
'approaching_at' => $schedule['approaching']->format(DATE_ATOM),
'urgent_at' => $schedule['urgent']->format(DATE_ATOM),
'server_time' => $now ?? time(),
];
}
public static function blocksRequest(
array $adminInfo,
string $controller,
string $action,
array $headers,
?int $now = null,
?array $configuration = null
): bool {
if (strtolower($controller) !== 'tcm.diagnosis'
|| !in_array(strtolower($action), [
'getcallsignature', 'startcall', 'getdoctorsignature', 'getpatientsignature',
], true)
|| (int) ($adminInfo['root'] ?? 0) === 1) {
return false;
}
// 来自登录令牌缓存的角色,不能取请求参数;非 root 且含角色 1 即医生。
$roles = array_map('intval', (array) ($adminInfo['role_id'] ?? []));
if (!in_array(1, $roles, true)) {
return false;
}
$headers = array_change_key_case($headers, CASE_LOWER);
// 浏览器信号优先,防止浏览器误携带桌面标识后被放行。
foreach (['origin', 'referer', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-dest'] as $name) {
if (trim((string) ($headers[$name] ?? '')) !== '') {
return ($now ?? time()) >= self::cutoffTimestamp($configuration);
}
}
// 两端历史上都使用 terminal=1,不能据终端编号误停旧版医生工作站。
// 新版工作站使用明确 UA,旧版保留 HTTPX 默认 UA 的兼容性。
$userAgent = trim((string) ($headers['user-agent'] ?? ''));
if (preg_match('/^(?:ZytDoctorWorkstation|python-httpx)\/\d+(?:\.\d+){1,3}$/i', $userAgent) === 1) {
return false;
}
return ($now ?? time()) >= self::cutoffTimestamp($configuration);
}
/** 每次读取固定配置文件,绕过 ThinkPHP 配置缓存及长驻进程中的静态日期缓存。 */
private static function configuration(): array
{
$path = dirname(__DIR__, 3) . '/config/doctor_web_consultation.php';
if (!is_file($path) || !is_readable($path)) {
throw new \UnexpectedValueException('doctor_web_consultation 配置文件不存在或不可读');
}
// 即使 PHP 设置了 opcache.validate_timestamps=0,也让本次读取使用新内容。
if (function_exists('opcache_invalidate')) {
opcache_invalidate($path, true);
}
$configuration = require $path;
if (!is_array($configuration)) {
throw new \UnexpectedValueException('doctor_web_consultation 配置必须返回数组');
}
return $configuration;
}
/** @return array<string, \DateTimeImmutable> */
private static function schedule(?array $configuration): array
{
$configuration = $configuration ?? self::configuration();
$value = $configuration['cutoff_at'] ?? null;
$timezone = new \DateTimeZone('Asia/Shanghai');
$cutoff = is_string($value) && preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\z/', $value) === 1
? \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, $timezone)
: false;
$errors = \DateTimeImmutable::getLastErrors();
if ($cutoff === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))
|| $cutoff->format('Y-m-d H:i:s') !== $value || (int) $cutoff->format('Y') < 1) {
throw new \InvalidArgumentException('doctor_web_consultation.cutoff_at 必须是有效北京时间,格式为 YYYY-MM-DD HH:MM:SS');
}
$hours = [];
foreach (['approaching_hours', 'urgent_hours'] as $key) {
$hours[$key] = $configuration[$key] ?? null;
if (!is_int($hours[$key]) || $hours[$key] < 0 || $hours[$key] > intdiv(PHP_INT_MAX, 3600)) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $key . ' 必须是有效非负整数小时数');
}
}
if ($hours['approaching_hours'] < $hours['urgent_hours']) {
throw new \InvalidArgumentException('doctor_web_consultation.approaching_hours 必须大于或等于 urgent_hours');
}
$schedule = ['cutoff' => $cutoff];
foreach (['approaching', 'urgent'] as $phase) {
$seconds = $hours[$phase . '_hours'] * 3600;
if ($cutoff->getTimestamp() < PHP_INT_MIN + $seconds) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $phase . '_hours 超出有效日期范围');
}
$date = $cutoff->setTimestamp($cutoff->getTimestamp() - $seconds);
if ((int) $date->format('Y') < 1) {
throw new \InvalidArgumentException('doctor_web_consultation.' . $phase . '_hours 超出有效日期范围');
}
$schedule[$phase] = $date;
}
return $schedule;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\order;
use think\facade\Db;
/**
* 支付单反查处方业务订单。调用方先按原有权限和分页取得支付单,再批量补充关联。
*/
class PaymentPrescriptionOrderService
{
/**
* @param int[] $paymentIds
* @return array<int, list<array{id: int, order_no: string}>>
*/
public static function forPaymentIds(array $paymentIds): array
{
$paymentIds = array_values(array_unique(array_filter(
array_map('intval', $paymentIds),
static fn (int $id): bool => $id > 0
)));
if ($paymentIds === []) {
return [];
}
$result = array_fill_keys($paymentIds, []);
// 历史 linked_pay_order_id 已由迁移写入关联表;不能用旧字段恢复已移除或已拆分的关联。
// 不按履约/支付状态筛选,保留仍有关联的取消、退款订单历史。
$rows = Db::name('tcm_prescription_order_pay_order')->alias('link')
->join('tcm_prescription_order po', 'po.id = link.prescription_order_id')
->whereIn('link.pay_order_id', $paymentIds)
->whereNull('po.delete_time')
->field(['link.pay_order_id', 'po.id', 'po.order_no'])
->order('po.id', 'asc')
->select()
->toArray();
foreach ($rows as $row) {
$result[(int) $row['pay_order_id']][(int) $row['id']] = [
'id' => (int) $row['id'],
'order_no' => (string) $row['order_no'],
];
}
foreach ($result as &$orders) {
$orders = array_values($orders);
}
unset($orders);
return $result;
}
}