From 28fbbd28b783325ab74bacfcab0c4fce4afe69f5 Mon Sep 17 00:00:00 2001 From: ShengLong <452591453@qq.com> Date: Mon, 4 May 2026 15:07:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=95=B0=E6=8D=AE=E6=9D=83?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加调试登录 --- .../controller/stats/ConversionController.php | 2 +- .../adminapi/logic/stats/ConversionLogic.php | 429 +++++++++++++++--- .../app/adminapi/validate/LoginValidate.php | 43 ++ 3 files changed, 413 insertions(+), 61 deletions(-) diff --git a/server/app/adminapi/controller/stats/ConversionController.php b/server/app/adminapi/controller/stats/ConversionController.php index e8935964..ec491634 100644 --- a/server/app/adminapi/controller/stats/ConversionController.php +++ b/server/app/adminapi/controller/stats/ConversionController.php @@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController { public function overview() { - $result = ConversionLogic::overview($this->request->get()); + $result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo); return $this->data($result); } diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php index 44e04d70..99194908 100644 --- a/server/app/adminapi/logic/stats/ConversionLogic.php +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -7,6 +7,7 @@ namespace app\adminapi\logic\stats; use app\adminapi\logic\dept\DeptLogic; use app\adminapi\logic\tcm\DiagnosisLogic; use app\common\model\finance\AccountCost; +use app\common\service\DataScope\DataScopeService; use app\common\service\qywx\MediaChannelService; use think\facade\Db; @@ -16,9 +17,17 @@ class ConversionLogic private const VIRTUAL_DEPT_UNASSIGNED_ID = -2; /** + * @param array $params + * @param int $adminId 当前操作 admin(来自 BaseAdminController) + * @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等) * @return array + * + * 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。 + * - null:SCOPE_ALL,全数据放行(与历史行为一致) + * - []:可见为空(SCOPE_SELF 且无绑定且关闭 fallback),返回空数据 + * - 其他:用 visibleAdminIds 收窄 entities 加载、hydrate 数据归属、虚拟桶可见性、filters 选项 */ - public static function overview(array $params = []): array + public static function overview(array $params = [], int $adminId = 0, ?array $adminInfo = null): array { $includeFilters = (int)($params['include_filters'] ?? 0) === 1; $dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept')); @@ -29,9 +38,37 @@ class ConversionLogic $pageNo = max(1, (int)($params['page_no'] ?? 1)); $pageSize = max(1, min(100, (int)($params['page_size'] ?? 15))); - $entities = self::loadEntities($dimension, $params); + $visibleAdminIds = ($adminInfo !== null && $adminId > 0) + ? DataScopeService::getVisibleAdminIds($adminId, $adminInfo) + : null; + // 严格隔离:可见 admin 集合为空时直接返回空骨架,避免下游误以为是"全部"。 + if ($visibleAdminIds === []) { + $emptyResult = [ + 'dimension' => $dimension, + 'date_range' => [$startDate, $endDate], + 'summary' => self::buildSummary([]), + 'charts' => self::buildCharts([]), + 'lists' => [], + 'count' => 0, + 'page_no' => $pageNo, + 'page_size' => $pageSize, + 'extend' => [ + 'dimension' => $dimension, + 'date_range' => [$startDate, $endDate], + 'summary' => self::buildSummary([]), + 'charts' => self::buildCharts([]), + ], + ]; + if ($includeFilters) { + $emptyResult['extend']['filters'] = self::buildFilterOptions([], []); + } + + return $emptyResult; + } + + $entities = self::loadEntities($dimension, $params, $visibleAdminIds); $entityIds = array_keys($entities); - $allocationEntities = self::loadEntities($dimension, $params); + $allocationEntities = self::loadEntities($dimension, $params, $visibleAdminIds); $allocationEntityIds = array_keys($allocationEntities); if ($entityIds === []) { @@ -52,18 +89,20 @@ class ConversionLogic ], ]; if ($includeFilters) { - $result['extend']['filters'] = self::buildFilterOptions(); + $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, [], $adminId, $adminInfo); } return $result; } $adminToDeptIds = self::loadAdminDeptMap(); - self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel); - self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - [$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode); + self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); + self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); + self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds); + self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); + // 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。 + $visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds); + [$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds); $supportsDeptBinding = AccountCost::supportsDeptBinding(); $restrictAccountCostByDept = $supportsDeptBinding; $restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== ''; @@ -98,7 +137,7 @@ class ConversionLogic ], ]; if ($includeFilters) { - $result['extend']['filters'] = self::buildFilterOptions(); + $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; @@ -142,7 +181,8 @@ class ConversionLogic $eligibleDeptIds, $adminToDeptIds, $validDeptIds, - $globalAccountCost + $globalAccountCost, + $visibleAdminIds ); $pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId); } @@ -164,7 +204,7 @@ class ConversionLogic ], ]; if ($includeFilters) { - $result['extend']['filters'] = self::buildFilterOptions(); + $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; @@ -199,25 +239,194 @@ class ConversionLogic ], ]; if ($includeFilters) { - $result['extend']['filters'] = self::buildFilterOptions(); + $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo); } return $result; } /** + * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL) + * @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域 * @return array */ - private static function buildFilterOptions(): array + private static function buildFilterOptions( + ?array $visibleAdminIds = null, + array $eligibleDeptIds = [], + int $adminId = 0, + ?array $adminInfo = null + ): array { return [ - 'departments' => DeptLogic::getAllData(), - 'assistants' => DiagnosisLogic::getAssistants(), - 'doctors' => DiagnosisLogic::getDoctors(), - 'media_channels' => MediaChannelService::getOptions(), + 'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds), + 'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo), + 'doctors' => self::buildDoctorOptions($visibleAdminIds), + 'media_channels' => self::buildMediaChannelOptions($visibleAdminIds, $eligibleDeptIds), ]; } + /** + * 部门下拉: + * - SCOPE_ALL(visibleAdminIds = null):全部启用部门树 + * - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构 + * + * @return array> + */ + private static function buildDepartmentOptions(?array $visibleAdminIds, array $eligibleDeptIds): array + { + $allTree = DeptLogic::getAllData(); + if ($visibleAdminIds === null) { + return $allTree; + } + + $allowedDeptIds = []; + if ($visibleAdminIds !== []) { + $deptIds = Db::name('admin_dept') + ->whereIn('admin_id', $visibleAdminIds) + ->column('dept_id'); + foreach ($deptIds as $deptId) { + $deptId = (int)$deptId; + if ($deptId > 0) { + $allowedDeptIds[$deptId] = true; + } + } + } + foreach ($eligibleDeptIds as $deptId) { + $deptId = (int)$deptId; + if ($deptId > 0) { + $allowedDeptIds[$deptId] = true; + } + } + if ($allowedDeptIds === []) { + return []; + } + + return self::filterDeptTreeByIds($allTree, array_keys($allowedDeptIds)); + } + + /** + * 医生下拉:按 visibleAdminIds 收窄。 + * + * @return array> + */ + private static function buildDoctorOptions(?array $visibleAdminIds): array + { + $query = Db::name('admin') + ->alias('a') + ->join('admin_role ar', 'a.id = ar.admin_id') + ->where('ar.role_id', 1) + ->where('a.disable', 0) + ->whereNull('a.delete_time'); + if ($visibleAdminIds !== null) { + if ($visibleAdminIds === []) { + return []; + } + $query->whereIn('a.id', $visibleAdminIds); + } + + return $query + ->field(['a.id', 'a.name', 'a.account']) + ->order('a.id', 'asc') + ->distinct(true) + ->select() + ->toArray(); + } + + /** + * 媒体渠道下拉:按可见部门绑定的渠道收窄。 + * + * @return array> + */ + private static function buildMediaChannelOptions(?array $visibleAdminIds, array $eligibleDeptIds): array + { + $allOptions = MediaChannelService::getOptions(); + if ($visibleAdminIds === null) { + return $allOptions; + } + if (!AccountCost::supportsDeptBinding()) { + return $allOptions; + } + + $allowedDeptIds = []; + if ($visibleAdminIds !== []) { + $deptIds = Db::name('admin_dept') + ->whereIn('admin_id', $visibleAdminIds) + ->column('dept_id'); + foreach ($deptIds as $deptId) { + $deptId = (int)$deptId; + if ($deptId > 0) { + $allowedDeptIds[$deptId] = true; + } + } + } + foreach ($eligibleDeptIds as $deptId) { + $deptId = (int)$deptId; + if ($deptId > 0) { + $allowedDeptIds[$deptId] = true; + } + } + if ($allowedDeptIds === []) { + return []; + } + + $allowedCodes = Db::name('account_cost') + ->whereIn('dept_id', array_keys($allowedDeptIds)) + ->where('media_channel_code', '<>', '') + ->distinct(true) + ->column('media_channel_code'); + $allowedCodeSet = array_fill_keys(array_filter(array_map('strval', $allowedCodes)), true); + if ($allowedCodeSet === []) { + return []; + } + + return array_values(array_filter($allOptions, static function ($option) use ($allowedCodeSet): bool { + $code = is_array($option) ? (string)($option['code'] ?? '') : ''; + + return $code !== '' && isset($allowedCodeSet[$code]); + })); + } + + /** + * 按 dept_id 集合裁剪部门树,自动保留命中节点的祖先链以保证树形完整。 + * + * @param array> $tree + * @param int[] $allowedIds + * @return array> + */ + private static function filterDeptTreeByIds(array $tree, array $allowedIds): array + { + $allowedSet = array_fill_keys($allowedIds, true); + $walker = function (array $node) use (&$walker, $allowedSet): ?array { + $children = []; + foreach ($node['children'] ?? [] as $child) { + if (!is_array($child)) { + continue; + } + $kept = $walker($child); + if ($kept !== null) { + $children[] = $kept; + } + } + $selfHit = isset($allowedSet[(int)($node['id'] ?? 0)]); + if ($selfHit || $children !== []) { + $node['children'] = $children; + + return $node; + } + + return null; + }; + $result = []; + foreach ($tree as $node) { + $kept = $walker($node); + if ($kept !== null) { + $result[] = $kept; + } + } + + return $result; + } + private static function normalizeDimension(string $dimension): string { return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept'; @@ -271,18 +480,19 @@ class ConversionLogic } /** + * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于 entities 加载与虚拟桶可见性 * @return array> */ - private static function loadEntities(string $dimension, array $params): array + private static function loadEntities(string $dimension, array $params, ?array $visibleAdminIds = null): array { $entities = match ($dimension) { - 'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0), - 'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0), + 'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0, $visibleAdminIds), + 'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0, $visibleAdminIds), default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0), }; if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) { - self::appendVirtualDeptEntities($entities); + self::appendVirtualDeptEntities($entities, $visibleAdminIds); } return $entities; @@ -348,9 +558,10 @@ class ConversionLogic } /** + * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄) * @return array> */ - private static function loadAdminEntities(int $roleId, int $adminId = 0): array + private static function loadAdminEntities(int $roleId, int $adminId = 0, ?array $visibleAdminIds = null): array { $query = Db::name('admin') ->alias('a') @@ -362,6 +573,12 @@ class ConversionLogic if ($adminId > 0) { $query->where('a.id', $adminId); } + if ($visibleAdminIds !== null) { + if ($visibleAdminIds === []) { + return []; + } + $query->whereIn('a.id', $visibleAdminIds); + } $rows = $query->order('a.id asc')->select()->toArray(); $entities = []; @@ -406,17 +623,21 @@ class ConversionLogic /** * @param array> $entities + * @param int[]|null $visibleAdminIds 可见 admin 集合;非 null(即非 SCOPE_ALL)时不挂"未绑定后台账号"虚拟桶, + * 因为这些加粉无主,不属于任何 admin 的可见范围。 */ - private static function appendVirtualDeptEntities(array &$entities): void + private static function appendVirtualDeptEntities(array &$entities, ?array $visibleAdminIds = null): void { - $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge( - self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'), - [ - 'pid' => 0, - 'sort' => -999998, - '_virtual_bucket' => true, - ] - ); + if ($visibleAdminIds === null) { + $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge( + self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'), + [ + 'pid' => 0, + 'sort' => -999998, + '_virtual_bucket' => true, + ] + ); + } $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge( self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'), @@ -428,6 +649,37 @@ class ConversionLogic ); } + /** + * 把"可见 admin 集合"转换成"可见部门集合":取所有 admin 的 admin_dept 并集。 + * - $visibleAdminIds === null(SCOPE_ALL)→ 返回 null(下游不收窄) + * - $visibleAdminIds === [] → 返回 [](无可见部门) + * + * @param int[]|null $visibleAdminIds + * @return int[]|null + */ + private static function resolveVisibleDeptIds(?array $visibleAdminIds): ?array + { + if ($visibleAdminIds === null) { + return null; + } + if ($visibleAdminIds === []) { + return []; + } + + $deptIds = Db::name('admin_dept') + ->whereIn('admin_id', $visibleAdminIds) + ->column('dept_id'); + $result = []; + foreach ($deptIds as $deptId) { + $deptId = (int)$deptId; + if ($deptId > 0) { + $result[$deptId] = $deptId; + } + } + + return array_values($result); + } + /** * @return array * @@ -557,6 +809,7 @@ class ConversionLogic * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel + * @param int[]|null $visibleAdminIds 可见 admin 集合 */ private static function hydrateFanStats( array &$entities, @@ -565,7 +818,8 @@ class ConversionLogic array $adminToDeptIds, int $startTimestamp, int $endTimestamp, - ?array $mediaChannel + ?array $mediaChannel, + ?array $visibleAdminIds = null ): void { $query = Db::name('qywx_external_contact_event') ->alias('e') @@ -587,7 +841,8 @@ class ConversionLogic $addFansCount = (int)($row['add_fans_count'] ?? 0); if ($dimension === 'dept' && $adminId <= 0) { - if (isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { + // -1 桶仅在 SCOPE_ALL(visibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。 + if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount; } continue; @@ -597,8 +852,12 @@ class ConversionLogic continue; } - $hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds); + $hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($dimension === 'dept' && $hitEntityIds === []) { + // admin 已通过 visibleAdminIds 校验但没找到归属部门 → 落入"未分配部门"。 + if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) { + continue; + } if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) { $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount; } @@ -620,6 +879,7 @@ class ConversionLogic * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel + * @param int[]|null $visibleAdminIds */ private static function hydrateAppointmentStats( array &$entities, @@ -630,7 +890,8 @@ class ConversionLogic int $endTimestamp, string $startDate, string $endDate, - ?array $mediaChannel + ?array $mediaChannel, + ?array $visibleAdminIds = null ): void { $sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id'; $query = Db::name('doctor_appointment') @@ -662,7 +923,7 @@ class ConversionLogic $sourceAdminId = (int)($row['source_admin_id'] ?? 0); - $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); + $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } @@ -675,7 +936,7 @@ class ConversionLogic } } - self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); + self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); foreach ($entities as &$entity) { $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); @@ -690,6 +951,7 @@ class ConversionLogic * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel + * @param int[]|null $visibleAdminIds */ private static function hydratePaidAppointmentStats( array &$entities, @@ -698,7 +960,8 @@ class ConversionLogic array $adminToDeptIds, int $startTimestamp, int $endTimestamp, - ?array $mediaChannel + ?array $mediaChannel, + ?array $visibleAdminIds = null ): void { $startDateTime = date('Y-m-d H:i:s', $startTimestamp); $endDateTime = date('Y-m-d H:i:s', $endTimestamp); @@ -720,7 +983,7 @@ class ConversionLogic $rows = $query->select()->toArray(); foreach ($rows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); - $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); + $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } @@ -737,6 +1000,7 @@ class ConversionLogic * @param int[] $entityIds * @param array $adminToDeptIds * @param array|null $mediaChannel + * @param int[]|null $visibleAdminIds */ private static function hydrateOrderAndAmountStats( array &$entities, @@ -745,7 +1009,8 @@ class ConversionLogic array $adminToDeptIds, int $startTimestamp, int $endTimestamp, - ?array $mediaChannel + ?array $mediaChannel, + ?array $visibleAdminIds = null ): void { $completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id'; $completedQuery = Db::name('tcm_prescription_order') @@ -770,7 +1035,7 @@ class ConversionLogic foreach ($completedRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); - $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); + $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } @@ -805,7 +1070,7 @@ class ConversionLogic foreach ($businessRows as $row) { $sourceAdminId = (int)($row['source_admin_id'] ?? 0); - $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); + $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds); if ($mappedEntityIds === []) { continue; } @@ -821,9 +1086,16 @@ class ConversionLogic * 账户消耗:来源于独立维护表 zyt_account_cost。 * * @param array> $entities + * @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄) * @return array{0: float, 1: int[]} */ - private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate, string $mediaChannelCode): array + private static function hydrateAccountCostStats( + array &$entities, + string $startDate, + string $endDate, + string $mediaChannelCode, + ?array $visibleDeptIds = null + ): array { $supportsDeptBinding = AccountCost::supportsDeptBinding(); $query = Db::name('account_cost') @@ -842,6 +1114,19 @@ class ConversionLogic if ($supportsDeptBinding) { $query->where('dept_id', '>', 0); + // 数据隔离:仅统计可见部门的账户消耗;不支持 dept_binding 时无法按部门收窄,跳过。 + if ($visibleDeptIds !== null) { + if ($visibleDeptIds === []) { + foreach ($entities as &$entity) { + $entity['account_cost'] = 0.0; + $entity['_global_account_cost'] = 0.0; + } + unset($entity); + + return [0.0, []]; + } + $query->whereIn('dept_id', $visibleDeptIds); + } } $rows = $query->select()->toArray(); @@ -870,16 +1155,25 @@ class ConversionLogic /** * @param int[] $entityIds * @param array $adminToDeptIds + * @param int[]|null $visibleAdminIds 可见 admin 集合;非 null 时 admin 不在集合中直接拒绝(不归到任何 entity) * @return int[] * * dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门 * (由 loadAdminDeptMap 排序决定),保证同一笔指标只累加到一个部门,避免重复计数。 */ - private static function mapEntityIds(string $dimension, int $adminId, array $entityIds, array $adminToDeptIds): array - { + private static function mapEntityIds( + string $dimension, + int $adminId, + array $entityIds, + array $adminToDeptIds, + ?array $visibleAdminIds = null + ): array { if ($adminId <= 0) { return []; } + if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) { + return []; + } if ($dimension !== 'dept') { return in_array($adminId, $entityIds, true) ? [$adminId] : []; @@ -1228,6 +1522,7 @@ class ConversionLogic * 挂到不存在的部门上、错过"未分配部门"虚拟桶。 * @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。 * -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。 + * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离 * @return array>> dept_id => [member_row, ...] */ private static function buildMemberRowsByDept( @@ -1241,24 +1536,25 @@ class ConversionLogic array $eligibleDeptIds, array $adminToDeptIds, array $validDeptIds = [], - float $globalAccountCost = -1.0 + float $globalAccountCost = -1.0, + ?array $visibleAdminIds = null ): array { - $assistantEntities = self::loadAdminEntities(2); - $doctorEntities = self::loadAdminEntities(1); + $assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds); + $doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds); $assistantIds = array_keys($assistantEntities); $doctorIds = array_keys($doctorEntities); if ($assistantIds !== []) { - self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel); - self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); + self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); + self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds); + self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); } if ($doctorIds !== []) { - self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); - self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel); - self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); + self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); + self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds); + self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds); } // 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。 @@ -1366,9 +1662,12 @@ class ConversionLogic $memberRowsByDeptId[$primaryDeptId][] = $row; } - $unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel); - if ($unboundRows !== []) { - $memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows; + // -1 桶仅在 SCOPE_ALL 下展示,因此非 SCOPE_ALL 时不再查未绑定 admin 的加粉。 + if ($visibleAdminIds === null) { + $unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel); + if ($unboundRows !== []) { + $memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows; + } } // 补充:dept 维度 hydrate 把任意角色的 admin 加粉都归到 -2 桶, @@ -1379,7 +1678,8 @@ class ConversionLogic $startTimestamp, $endTimestamp, $mediaChannel, - $assignedAdminIds + $assignedAdminIds, + $visibleAdminIds ); if ($leftoverRows !== []) { $existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? []; @@ -1477,13 +1777,15 @@ class ConversionLogic * 这些 admin 的业务指标无法从医助/医生口径得出,因此仅展示加粉数。 * * @param array $assignedAdminIds 已挂过部门或 -2 桶的 admin_id 集合 + * @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄) * @return array> */ private static function buildLeftoverAdminRowsForUnassigned( int $startTimestamp, int $endTimestamp, ?array $mediaChannel, - array $assignedAdminIds + array $assignedAdminIds, + ?array $visibleAdminIds = null ): array { $query = Db::name('qywx_external_contact_event') @@ -1494,6 +1796,13 @@ class ConversionLogic ->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count') ->group('a.id, a.name'); + if ($visibleAdminIds !== null) { + if ($visibleAdminIds === []) { + return []; + } + $query->whereIn('a.id', $visibleAdminIds); + } + if ($mediaChannel !== null) { $query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid'); MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); diff --git a/server/app/adminapi/validate/LoginValidate.php b/server/app/adminapi/validate/LoginValidate.php index bfdd86ee..0cf405f7 100644 --- a/server/app/adminapi/validate/LoginValidate.php +++ b/server/app/adminapi/validate/LoginValidate.php @@ -22,6 +22,7 @@ use app\common\cache\AdminAccountSafeCache; use app\common\service\ConfigService; use app\common\validate\BaseValidate; use think\facade\Config; +use think\facade\Log; /** * 登录验证 @@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate */ public function password($password, $other, $data) { + // 本地调试:免密登录开关。须同时满足 APP_DEBUG=1 + LOCAL_DEBUG.LOGIN_SKIP_PASSWORD=true, + // 任一不满足都走原始密码校验。每次免密登录都会记一条 warning 日志,便于发现误开。 + if (self::isLocalDebugLoginBypassEnabled()) { + $adminInfo = Admin::where('account', '=', $data['account']) + ->field(['password,disable']) + ->findOrEmpty(); + if ($adminInfo->isEmpty()) { + return '账号不存在'; + } + if ((int)($adminInfo['disable'] ?? 0) === 1) { + return '账号已禁用'; + } + Log::warning(sprintf( + '[LOCAL_DEBUG] login bypass password: account=%s, ip=%s', + (string)($data['account'] ?? ''), + (string)request()->ip() + )); + + return true; + } + // 登录限制 $config = [ 'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'), @@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate return true; } + /** + * 是否启用"本地调试免密登录"。 + * + * 双重闸门: + * 1. APP_DEBUG 必须为 true(即应用本身处于调试模式) + * 2. LOCAL_DEBUG.LOGIN_SKIP_PASSWORD 必须显式为 true / 1 / yes / on + * + * 任何一条不满足都返回 false,强制走原始密码校验。这样即使误把 + * LOGIN_SKIP_PASSWORD 配进生产,只要 APP_DEBUG=0 仍然安全。 + */ + private static function isLocalDebugLoginBypassEnabled(): bool + { + $appDebug = strtolower((string) env('app_debug', '0')); + if (!in_array($appDebug, ['1', 'true', 'yes', 'on'], true)) { + return false; + } + $flag = strtolower((string) env('local_debug.login_skip_password', '')); + + return in_array($flag, ['1', 'true', 'yes', 'on'], true); + } + } \ No newline at end of file