'channel', 'name' => '来源']]; } public static function getCurrentTagChannelByCode(string $code): ?array { return null; } } } namespace app\adminapi\logic\auth { class AuthLogic { public static array $permissions = []; public static function getAuthByAdminId(int $adminId): array { return self::$permissions; } } } namespace app\adminapi\logic\dept { class DeptLogic { public static function getAllDataScoped(int $adminId, array $info): array { return [['id' => 1, 'name' => '门诊']]; } } } namespace app\common\model\stats { class PersonalYeji { public static function whereBetween(string $field, array $range): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); } } } namespace app\adminapi\logic\stats { class YejiStatsLogic { public static function applyPrescriptionOrderEffectiveAmountQuery(mixed $query, string $alias): void {} } class ConversionLogic { public static array $overview = []; public static array $detail = []; public static array $detailParams = []; public static function overview(array $params, int $adminId, array $info, ?array $visibleIds, ?array $costIds, ?array $channel): array { return self::$overview; } public static function fanDetailChannelDeptIds(?array $channel): ?array { return null; } public static function fanDetails(array $params, array $target, int $adminId, array $info, ?array $visibleIds, ?array $channel): array { self::$detailParams = $params; return self::$detail; } } } namespace { use app\adminapi\logic\auth\AuthLogic; use app\adminapi\logic\firstvisit\FirstVisitConversionLogic; use app\adminapi\logic\stats\ConversionLogic; require dirname(__DIR__) . '/vendor/autoload.php'; function deletedFansExpect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } ConversionLogic::$overview = [ 'summary' => ['add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9, 'paid_appointment_count' => 15, 'account_cost' => 234.5, 'cash_cost' => 100.0, 'roi' => 2.0], 'lists' => [[ 'id' => 1, 'name' => '门诊', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9, 'completed_order_amount' => 468.0, 'account_cost' => 234.5, 'children' => [[ 'id' => 2, 'name' => '一组', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9, 'completed_order_amount' => 468.0, 'children' => [[ 'id' => 'M12_2', 'admin_id' => 12, 'name' => '医助', 'type' => 'member', 'add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9, 'completed_order_amount' => 468.0, ]], ]], ]], ]; $detailFixture = [ 'lists' => [ ['external_userid' => 'existing', 'customer_name' => '在册客户', 'wecom_userid' => 'staff', 'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 09:00:00', 'is_deleted' => false, 'delete_time' => null], ['external_userid' => 'removed', 'customer_name' => '原有客户', 'wecom_userid' => 'staff', 'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 08:00:00', 'is_deleted' => true, 'delete_time' => '2026-08-31 10:00:00'], ], 'count' => 57, 'deleted_count' => 4, 'page_no' => 3, 'page_size' => 2, 'date_range' => ['2026-08-01', '2026-08-31'], ]; ConversionLogic::$detail = $detailFixture; $params = ['time_type' => 'custom', 'start_date' => '2026-08-01', 'end_date' => '2026-08-31', // 模拟HTTP伪造字段;不能覆盖认证身份。 'account' => 'admin', 'root' => 1, 'can_view_deleted_fans' => true]; $detailParams = $params + ['entity_type' => 'dept', 'entity_id' => '-2', 'page_no' => 3, 'page_size' => 2]; $cases = [ ['admin root', 1, ['account' => 'admin', 'root' => 1, 'role_name' => '系统管理员'], true], ['admin ordinary', 93, ['account' => 'admin', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], true], ['admin finance', 23, ['account' => 'admin', 'root' => 0, 'role_name' => '财务', 'scope_value' => 3], true], ['other root id1', 1, ['account' => 'superuser', 'root' => 1, 'role_name' => '系统管理员'], false], ['administrator', 5, ['account' => 'manager', 'root' => 0, 'role_name' => '管理员'], false], ['finance permission', 6, ['account' => 'finance', 'root' => 0, 'role_name' => '财务'], false], ['ordinary all scope', 7, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 1], false], ['ordinary self scope', 8, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], false], ['missing account root', 1, ['root' => 1, 'name' => 'admin', 'role_name' => '系统管理员'], false], ['uppercase', 1, ['account' => 'ADMIN', 'root' => 1], false], ['mixed case', 1, ['account' => 'Admin', 'root' => 1], false], ['leading space', 1, ['account' => ' admin', 'root' => 1], false], ['trailing space', 1, ['account' => 'admin ', 'root' => 1], false], ['empty', 1, ['account' => '', 'root' => 1], false], ['null', 1, ['account' => null, 'root' => 1], false], ['boolean', 1, ['account' => true, 'root' => 1], false], ['array', 1, ['account' => ['admin'], 'root' => 1], false], ]; foreach ($cases as [$label, $adminId, $adminInfo, $allowed]) { AuthLogic::$permissions = ($adminInfo['role_name'] ?? '') === '财务' ? ['firstvisit.conversion/viewFinance'] : []; $adminEquivalent = array_replace($adminInfo, ['account' => 'admin']); $baseline = FirstVisitConversionLogic::overview($params, $adminId, $adminEquivalent); $overview = FirstVisitConversionLogic::overview($params, $adminId, $adminInfo); deletedFansExpect($overview['meta']['can_view_deleted_fans'] === $allowed, $label . ': overview permission flag must be boolean'); // 明确指定预期删除的字段,不根据实现递归生成预期结果。 $expectedOverview = $baseline; $expectedOverview['meta']['can_view_deleted_fans'] = $allowed; if (!$allowed) { unset($expectedOverview['summary']['deleted_fans_count'], $expectedOverview['rows'][0]['deleted_fans_count'], $expectedOverview['rows'][0]['children'][0]['deleted_fans_count'], $expectedOverview['rows'][0]['children'][0]['children'][0]['deleted_fans_count']); } unset($expectedOverview['meta']['generated_at'], $overview['meta']['generated_at']); deletedFansExpect($overview === $expectedOverview, $label . ': other statistics, finance permissions, ranking, filters and structure must stay identical'); deletedFansExpect($overview['summary']['add_fans_count'] === 57 && $overview['rows'][0]['children'][0]['children'][0]['add_fans_count'] === 57, $label . ': add-fans count unchanged'); $detail = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo); $expectedDetail = $detailFixture; unset($expectedDetail['deleted_count']); // 原接口已有的转换:删除合计只放entity。 $expectedDetail['entity'] = ['type' => 'dept', 'id' => -2, 'admin_id' => 0, 'name' => '未分配部门', 'add_fans_count' => 57, 'deleted_fans_count' => 4]; $expectedDetail['can_view_deleted_fans'] = $allowed; if (!$allowed) { unset($expectedDetail['entity']['deleted_fans_count'], $expectedDetail['lists'][0]['is_deleted'], $expectedDetail['lists'][0]['delete_time'], $expectedDetail['lists'][1]['is_deleted'], $expectedDetail['lists'][1]['delete_time']); } deletedFansExpect($detail === $expectedDetail, $label . ': all fan rows/order/names/add dates and pagination must be preserved'); deletedFansExpect(array_column($detail['lists'], 'external_userid') === ['existing', 'removed'], $label . ': deleted customer row must remain'); deletedFansExpect(!isset(ConversionLogic::$detailParams['account'], ConversionLogic::$detailParams['root'], ConversionLogic::$detailParams['can_view_deleted_fans']), $label . ': forged permission params must not enter stats query'); foreach ([['entity_type' => 'unknown'], ['entity_id' => 'not-a-dept']] as $invalidEntity) { $empty = FirstVisitConversionLogic::fansDetail(array_replace($detailParams, $invalidEntity), $adminId, $adminInfo); deletedFansExpect($empty === ['lists' => [], 'count' => 0, 'page_no' => 3, 'page_size' => 2, 'date_range' => ['2026-08-01', '2026-08-31'], 'entity' => null, 'can_view_deleted_fans' => $allowed], $label . ': early empty branch flag and pagination'); } ConversionLogic::$detail = array_replace($detailFixture, ['lists' => [], 'count' => 0, 'deleted_count' => 0]); $empty = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo); deletedFansExpect($empty['can_view_deleted_fans'] === $allowed && $empty['count'] === 0 && $empty['lists'] === [] && $empty['entity'] === null && $empty['page_no'] === 3 && $empty['page_size'] === 2, $label . ': upstream empty branch flag'); ConversionLogic::$detail = $detailFixture; } $originalOverviewFixture = ConversionLogic::$overview; ConversionLogic::$overview = ['summary' => ['add_fans_count' => 0, 'deleted_fans_count' => 0], 'lists' => []]; foreach (['admin' => true, 'other' => false] as $account => $allowed) { $emptyOverview = FirstVisitConversionLogic::overview($params, 1, ['account' => $account, 'root' => 1]); deletedFansExpect($emptyOverview['meta']['can_view_deleted_fans'] === $allowed && $emptyOverview['rows'] === [] && $emptyOverview['summary']['add_fans_count'] === 0 && array_key_exists('deleted_fans_count', $emptyOverview['summary']) === $allowed, 'empty overview keeps explicit boolean visibility'); } ConversionLogic::$overview = $originalOverviewFixture; // 防未来新增的嵌套/排名位置意外泄露;键、空值和非敏感字段均原样保留。 $filter = (new ReflectionClass(FirstVisitConversionLogic::class))->getMethod('withDeletedFansVisibility'); $filter->setAccessible(true); $nested = ['meta' => ['note' => '保持'], 'summary' => ['add_fans_count' => 2, 'deleted_count' => 1], 'rankings' => ['orders' => [3 => ['value' => 9, 'deleted_fans_count' => 1]]], 'rows' => [9 => ['name' => '员工', 'more' => ['deleted_fans_count' => 1, 'nullable' => null]]]]; $expectedNested = ['meta' => ['note' => '保持', 'can_view_deleted_fans' => false], 'summary' => ['add_fans_count' => 2], 'rankings' => ['orders' => [3 => ['value' => 9]]], 'rows' => [9 => ['name' => '员工', 'more' => ['nullable' => null]]]]; deletedFansExpect($filter->invoke(null, $nested, ['account' => 'other']) === $expectedNested, 'whole overview including nested ranking must be filtered without reindexing'); $nestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['is_deleted' => true, 'delete_time' => 'date', 'note' => 'keep']]], 'count' => 4, 'page_no' => 2, 'page_size' => 1, 'deleted_count' => 1, 'entity' => ['deleted_fans_count' => 1, 'name' => 'dept']]; $expectedNestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['note' => 'keep']]], 'count' => 4, 'page_no' => 2, 'page_size' => 1, 'entity' => ['name' => 'dept'], 'can_view_deleted_fans' => false]; deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'other'], true) === $expectedNestedDetail, 'detail nested deletion state and aliases must not leak'); $adminNested = $nestedDetail + ['can_view_deleted_fans' => true]; deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'admin', 'root' => 0], true) === $adminNested, 'admin payload unchanged except visibility flag'); // 身份来源契约:只读源文件,既不获取真实token,也不触发用户/会话查询。 $server = dirname(__DIR__); $cacheSource = file_get_contents($server . '/app/common/cache/AdminTokenCache.php'); $loginSource = file_get_contents($server . '/app/adminapi/http/middleware/LoginMiddleware.php'); $controllerSource = file_get_contents($server . '/app/adminapi/controller/firstvisit/ConversionController.php'); deletedFansExpect(str_contains($cacheSource, "'account' => \$admin->account") && str_contains($cacheSource, "AdminSession::where([['token', '=', \$token]") && str_contains($cacheSource, "Admin::where('id', '=', \$adminSession->admin_id)"), 'account must originate from valid token session and authenticated admin row'); deletedFansExpect(str_contains($loginSource, '(new AdminTokenCache())->getAdminInfo($token)') && str_contains($loginSource, '$request->adminInfo = $adminInfo;') && substr_count($controllerSource, '$this->adminInfo') >= 2, 'controller must pass authenticated cached adminInfo'); echo "FirstVisitConversionDeletedFansPermissionTest passed\n"; }