增加数据权限

增加调试登录
This commit is contained in:
2026-05-04 15:07:44 +08:00
parent 22b8b38b5d
commit 28fbbd28b7
3 changed files with 413 additions and 61 deletions
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
{ {
public function overview() public function overview()
{ {
$result = ConversionLogic::overview($this->request->get()); $result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
return $this->data($result); return $this->data($result);
} }
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic; use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic; use app\adminapi\logic\tcm\DiagnosisLogic;
use app\common\model\finance\AccountCost; use app\common\model\finance\AccountCost;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService; use app\common\service\qywx\MediaChannelService;
use think\facade\Db; use think\facade\Db;
@@ -16,9 +17,17 @@ class ConversionLogic
private const VIRTUAL_DEPT_UNASSIGNED_ID = -2; private const VIRTUAL_DEPT_UNASSIGNED_ID = -2;
/** /**
* @param array $params
* @param int $adminId 当前操作 admin(来自 BaseAdminController
* @param array $adminInfo 当前 admin 完整信息(含 root / role_id 数组等)
* @return array<string, mixed> * @return array<string, mixed>
*
* 数据权限:通过 DataScopeService::getVisibleAdminIds 拿到当前用户的"可见 admin id 集合"。
* - nullSCOPE_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; $includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept')); $dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
@@ -29,9 +38,37 @@ class ConversionLogic
$pageNo = max(1, (int)($params['page_no'] ?? 1)); $pageNo = max(1, (int)($params['page_no'] ?? 1));
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15))); $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); $entityIds = array_keys($entities);
$allocationEntities = self::loadEntities($dimension, $params); $allocationEntities = self::loadEntities($dimension, $params, $visibleAdminIds);
$allocationEntityIds = array_keys($allocationEntities); $allocationEntityIds = array_keys($allocationEntities);
if ($entityIds === []) { if ($entityIds === []) {
@@ -52,18 +89,20 @@ class ConversionLogic
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, [], $adminId, $adminInfo);
} }
return $result; return $result;
} }
$adminToDeptIds = self::loadAdminDeptMap(); $adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateFanStats($allocationEntities, $dimension, $allocationEntityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel); self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateOrderAndAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode); // 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding(); $supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding; $restrictAccountCostByDept = $supportsDeptBinding;
$restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== ''; $restrictStatsByDept = $supportsDeptBinding && $mediaChannelCode !== '';
@@ -98,7 +137,7 @@ class ConversionLogic
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
} }
return $result; return $result;
@@ -142,7 +181,8 @@ class ConversionLogic
$eligibleDeptIds, $eligibleDeptIds,
$adminToDeptIds, $adminToDeptIds,
$validDeptIds, $validDeptIds,
$globalAccountCost $globalAccountCost,
$visibleAdminIds
); );
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId); $pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
} }
@@ -164,7 +204,7 @@ class ConversionLogic
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
} }
return $result; return $result;
@@ -199,25 +239,194 @@ class ConversionLogic
], ],
]; ];
if ($includeFilters) { if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions(); $result['extend']['filters'] = self::buildFilterOptions($visibleAdminIds, $eligibleDeptIds, $adminId, $adminInfo);
} }
return $result; return $result;
} }
/** /**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL
* @param int[] $eligibleDeptIds 当媒体渠道筛选生效时收窄过的部门作用域
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private static function buildFilterOptions(): array private static function buildFilterOptions(
?array $visibleAdminIds = null,
array $eligibleDeptIds = [],
int $adminId = 0,
?array $adminInfo = null
): array
{ {
return [ return [
'departments' => DeptLogic::getAllData(), 'departments' => self::buildDepartmentOptions($visibleAdminIds, $eligibleDeptIds),
'assistants' => DiagnosisLogic::getAssistants(), 'assistants' => DiagnosisLogic::getAssistants($adminId, $adminInfo),
'doctors' => DiagnosisLogic::getDoctors(), 'doctors' => self::buildDoctorOptions($visibleAdminIds),
'media_channels' => MediaChannelService::getOptions(), 'media_channels' => self::buildMediaChannelOptions($visibleAdminIds, $eligibleDeptIds),
]; ];
} }
/**
* 部门下拉:
* - SCOPE_ALLvisibleAdminIds = null):全部启用部门树
* - 其他:仅展示"可见 admin 所属部门 + 媒体渠道作用域部门"的并集,并保留祖先链以维持树形结构
*
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $tree
* @param int[] $allowedIds
* @return array<int, array<string, mixed>>
*/
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 private static function normalizeDimension(string $dimension): string
{ {
return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept'; 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<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
private static function loadEntities(string $dimension, array $params): array private static function loadEntities(string $dimension, array $params, ?array $visibleAdminIds = null): array
{ {
$entities = match ($dimension) { $entities = match ($dimension) {
'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_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), '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), default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0),
}; };
if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) { if ($dimension === 'dept' && (int)($params['dept_id'] ?? 0) <= 0) {
self::appendVirtualDeptEntities($entities); self::appendVirtualDeptEntities($entities, $visibleAdminIds);
} }
return $entities; return $entities;
@@ -348,9 +558,10 @@ class ConversionLogic
} }
/** /**
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
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') $query = Db::name('admin')
->alias('a') ->alias('a')
@@ -362,6 +573,12 @@ class ConversionLogic
if ($adminId > 0) { if ($adminId > 0) {
$query->where('a.id', $adminId); $query->where('a.id', $adminId);
} }
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $visibleAdminIds);
}
$rows = $query->order('a.id asc')->select()->toArray(); $rows = $query->order('a.id asc')->select()->toArray();
$entities = []; $entities = [];
@@ -406,17 +623,21 @@ class ConversionLogic
/** /**
* @param array<int, array<string, mixed>> $entities * @param array<int, array<string, mixed>> $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( if ($visibleAdminIds === null) {
self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'), $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = array_merge(
[ self::newEntityRow(self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID, '未绑定后台账号'),
'pid' => 0, [
'sort' => -999998, 'pid' => 0,
'_virtual_bucket' => true, 'sort' => -999998,
] '_virtual_bucket' => true,
); ]
);
}
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge( $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID] = array_merge(
self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'), self::newEntityRow(self::VIRTUAL_DEPT_UNASSIGNED_ID, '未分配部门'),
@@ -428,6 +649,37 @@ class ConversionLogic
); );
} }
/**
* 把"可见 admin 集合"转换成"可见部门集合":取所有 admin 的 admin_dept 并集。
* - $visibleAdminIds === nullSCOPE_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<int, int[]> * @return array<int, int[]>
* *
@@ -557,6 +809,7 @@ class ConversionLogic
* @param int[] $entityIds * @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds * @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds 可见 admin 集合
*/ */
private static function hydrateFanStats( private static function hydrateFanStats(
array &$entities, array &$entities,
@@ -565,7 +818,8 @@ class ConversionLogic
array $adminToDeptIds, array $adminToDeptIds,
int $startTimestamp, int $startTimestamp,
int $endTimestamp, int $endTimestamp,
?array $mediaChannel ?array $mediaChannel,
?array $visibleAdminIds = null
): void { ): void {
$query = Db::name('qywx_external_contact_event') $query = Db::name('qywx_external_contact_event')
->alias('e') ->alias('e')
@@ -587,7 +841,8 @@ class ConversionLogic
$addFansCount = (int)($row['add_fans_count'] ?? 0); $addFansCount = (int)($row['add_fans_count'] ?? 0);
if ($dimension === 'dept' && $adminId <= 0) { if ($dimension === 'dept' && $adminId <= 0) {
if (isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) { // -1 桶仅在 SCOPE_ALLvisibleAdminIds === null)时存在;其他范围下未绑定 admin 的加粉不可归属,跳过。
if ($visibleAdminIds === null && isset($entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID])) {
$entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount; $entities[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID]['add_fans_count'] += $addFansCount;
} }
continue; continue;
@@ -597,8 +852,12 @@ class ConversionLogic
continue; continue;
} }
$hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds); $hitEntityIds = self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($dimension === 'dept' && $hitEntityIds === []) { if ($dimension === 'dept' && $hitEntityIds === []) {
// admin 已通过 visibleAdminIds 校验但没找到归属部门 → 落入"未分配部门"。
if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) {
continue;
}
if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) { if (isset($entities[self::VIRTUAL_DEPT_UNASSIGNED_ID])) {
$entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount; $entities[self::VIRTUAL_DEPT_UNASSIGNED_ID]['add_fans_count'] += $addFansCount;
} }
@@ -620,6 +879,7 @@ class ConversionLogic
* @param int[] $entityIds * @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds * @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/ */
private static function hydrateAppointmentStats( private static function hydrateAppointmentStats(
array &$entities, array &$entities,
@@ -630,7 +890,8 @@ class ConversionLogic
int $endTimestamp, int $endTimestamp,
string $startDate, string $startDate,
string $endDate, string $endDate,
?array $mediaChannel ?array $mediaChannel,
?array $visibleAdminIds = null
): void { ): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id'; $sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$query = Db::name('doctor_appointment') $query = Db::name('doctor_appointment')
@@ -662,7 +923,7 @@ class ConversionLogic
$sourceAdminId = (int)($row['source_admin_id'] ?? 0); $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; 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) { foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0); $appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
@@ -690,6 +951,7 @@ class ConversionLogic
* @param int[] $entityIds * @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds * @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/ */
private static function hydratePaidAppointmentStats( private static function hydratePaidAppointmentStats(
array &$entities, array &$entities,
@@ -698,7 +960,8 @@ class ConversionLogic
array $adminToDeptIds, array $adminToDeptIds,
int $startTimestamp, int $startTimestamp,
int $endTimestamp, int $endTimestamp,
?array $mediaChannel ?array $mediaChannel,
?array $visibleAdminIds = null
): void { ): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp); $startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp); $endDateTime = date('Y-m-d H:i:s', $endTimestamp);
@@ -720,7 +983,7 @@ class ConversionLogic
$rows = $query->select()->toArray(); $rows = $query->select()->toArray();
foreach ($rows as $row) { foreach ($rows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0); $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
@@ -737,6 +1000,7 @@ class ConversionLogic
* @param int[] $entityIds * @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds * @param array<int, int[]> $adminToDeptIds
* @param array<string, mixed>|null $mediaChannel * @param array<string, mixed>|null $mediaChannel
* @param int[]|null $visibleAdminIds
*/ */
private static function hydrateOrderAndAmountStats( private static function hydrateOrderAndAmountStats(
array &$entities, array &$entities,
@@ -745,7 +1009,8 @@ class ConversionLogic
array $adminToDeptIds, array $adminToDeptIds,
int $startTimestamp, int $startTimestamp,
int $endTimestamp, int $endTimestamp,
?array $mediaChannel ?array $mediaChannel,
?array $visibleAdminIds = null
): void { ): void {
$completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id'; $completedSourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$completedQuery = Db::name('tcm_prescription_order') $completedQuery = Db::name('tcm_prescription_order')
@@ -770,7 +1035,7 @@ class ConversionLogic
foreach ($completedRows as $row) { foreach ($completedRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0); $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
@@ -805,7 +1070,7 @@ class ConversionLogic
foreach ($businessRows as $row) { foreach ($businessRows as $row) {
$sourceAdminId = (int)($row['source_admin_id'] ?? 0); $sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds); $mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds, $visibleAdminIds);
if ($mappedEntityIds === []) { if ($mappedEntityIds === []) {
continue; continue;
} }
@@ -821,9 +1086,16 @@ class ConversionLogic
* 账户消耗:来源于独立维护表 zyt_account_cost。 * 账户消耗:来源于独立维护表 zyt_account_cost。
* *
* @param array<int, array<string, mixed>> $entities * @param array<int, array<string, mixed>> $entities
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
* @return array{0: float, 1: int[]} * @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(); $supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost') $query = Db::name('account_cost')
@@ -842,6 +1114,19 @@ class ConversionLogic
if ($supportsDeptBinding) { if ($supportsDeptBinding) {
$query->where('dept_id', '>', 0); $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(); $rows = $query->select()->toArray();
@@ -870,16 +1155,25 @@ class ConversionLogic
/** /**
* @param int[] $entityIds * @param int[] $entityIds
* @param array<int, int[]> $adminToDeptIds * @param array<int, int[]> $adminToDeptIds
* @param int[]|null $visibleAdminIds 可见 admin 集合;非 null 时 admin 不在集合中直接拒绝(不归到任何 entity)
* @return int[] * @return int[]
* *
* dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门 * dept 维度下 admin 跨部门时,仅返回 admin_dept 中第一个落在当前 entityIds 内的部门
* (由 loadAdminDeptMap 排序决定),保证同一笔指标只累加到一个部门,避免重复计数。 * (由 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) { if ($adminId <= 0) {
return []; return [];
} }
if ($visibleAdminIds !== null && !in_array($adminId, $visibleAdminIds, true)) {
return [];
}
if ($dimension !== 'dept') { if ($dimension !== 'dept') {
return in_array($adminId, $entityIds, true) ? [$adminId] : []; return in_array($adminId, $entityIds, true) ? [$adminId] : [];
@@ -1228,6 +1522,7 @@ class ConversionLogic
* 挂到不存在的部门上、错过"未分配部门"虚拟桶。 * 挂到不存在的部门上、错过"未分配部门"虚拟桶。
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。 * @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
* -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。 * -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...] * @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
*/ */
private static function buildMemberRowsByDept( private static function buildMemberRowsByDept(
@@ -1241,24 +1536,25 @@ class ConversionLogic
array $eligibleDeptIds, array $eligibleDeptIds,
array $adminToDeptIds, array $adminToDeptIds,
array $validDeptIds = [], array $validDeptIds = [],
float $globalAccountCost = -1.0 float $globalAccountCost = -1.0,
?array $visibleAdminIds = null
): array ): array
{ {
$assistantEntities = self::loadAdminEntities(2); $assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
$doctorEntities = self::loadAdminEntities(1); $doctorEntities = self::loadAdminEntities(1, 0, $visibleAdminIds);
$assistantIds = array_keys($assistantEntities); $assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities); $doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) { if ($assistantIds !== []) {
self::hydrateFanStats($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); self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
} }
if ($doctorIds !== []) { if ($doctorIds !== []) {
self::hydrateFanStats($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); self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel); self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
} }
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。 // 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
@@ -1366,9 +1662,12 @@ class ConversionLogic
$memberRowsByDeptId[$primaryDeptId][] = $row; $memberRowsByDeptId[$primaryDeptId][] = $row;
} }
$unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel); // -1 桶仅在 SCOPE_ALL 下展示,因此非 SCOPE_ALL 时不再查未绑定 admin 的加粉。
if ($unboundRows !== []) { if ($visibleAdminIds === null) {
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows; $unboundRows = self::buildUnboundFansRows($startTimestamp, $endTimestamp, $mediaChannel);
if ($unboundRows !== []) {
$memberRowsByDeptId[self::VIRTUAL_DEPT_UNBOUND_ADMIN_ID] = $unboundRows;
}
} }
// 补充:dept 维度 hydrate 把任意角色的 admin 加粉都归到 -2 桶, // 补充:dept 维度 hydrate 把任意角色的 admin 加粉都归到 -2 桶,
@@ -1379,7 +1678,8 @@ class ConversionLogic
$startTimestamp, $startTimestamp,
$endTimestamp, $endTimestamp,
$mediaChannel, $mediaChannel,
$assignedAdminIds $assignedAdminIds,
$visibleAdminIds
); );
if ($leftoverRows !== []) { if ($leftoverRows !== []) {
$existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? []; $existing = $memberRowsByDeptId[self::VIRTUAL_DEPT_UNASSIGNED_ID] ?? [];
@@ -1477,13 +1777,15 @@ class ConversionLogic
* 这些 admin 的业务指标无法从医助/医生口径得出,因此仅展示加粉数。 * 这些 admin 的业务指标无法从医助/医生口径得出,因此仅展示加粉数。
* *
* @param array<int, true> $assignedAdminIds 已挂过部门或 -2 桶的 admin_id 集合 * @param array<int, true> $assignedAdminIds 已挂过部门或 -2 桶的 admin_id 集合
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL,不收窄)
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
private static function buildLeftoverAdminRowsForUnassigned( private static function buildLeftoverAdminRowsForUnassigned(
int $startTimestamp, int $startTimestamp,
int $endTimestamp, int $endTimestamp,
?array $mediaChannel, ?array $mediaChannel,
array $assignedAdminIds array $assignedAdminIds,
?array $visibleAdminIds = null
): array ): array
{ {
$query = Db::name('qywx_external_contact_event') $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') ->fieldRaw('a.id AS admin_id, a.name AS admin_name, COUNT(*) AS add_fans_count')
->group('a.id, a.name'); ->group('a.id, a.name');
if ($visibleAdminIds !== null) {
if ($visibleAdminIds === []) {
return [];
}
$query->whereIn('a.id', $visibleAdminIds);
}
if ($mediaChannel !== null) { if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid'); $query->leftJoin('qywx_external_contact q', 'q.external_userid = e.external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel); MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
@@ -22,6 +22,7 @@ use app\common\cache\AdminAccountSafeCache;
use app\common\service\ConfigService; use app\common\service\ConfigService;
use app\common\validate\BaseValidate; use app\common\validate\BaseValidate;
use think\facade\Config; use think\facade\Config;
use think\facade\Log;
/** /**
* 登录验证 * 登录验证
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
*/ */
public function password($password, $other, $data) 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 = [ $config = [
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'), 'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
return true; 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);
}
} }