239 lines
15 KiB
PHP
239 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// 所有读库/统计依赖使用内存替身。测试实际overview/fansDetail输出,不初始化App或业务数据库。
|
|
namespace first_visit_deleted_fans_test {
|
|
final class Query
|
|
{
|
|
public function __call(string $name, array $arguments): self
|
|
{
|
|
if (!in_array($name, ['where', 'whereLike', 'whereNull', 'whereIn', 'whereBetween', 'field', 'fieldRaw', 'group', 'alias', 'join', 'distinct', 'order'], true)) {
|
|
throw new \RuntimeException('Unexpected stub query method: ' . $name);
|
|
}
|
|
return $this;
|
|
}
|
|
public function select(): self { return $this; }
|
|
public function toArray(): array { return []; }
|
|
public function column(string $field): array { return []; }
|
|
public function value(string $field): mixed { return null; }
|
|
public function find(): ?array { return null; }
|
|
public function count(): int { return 0; }
|
|
}
|
|
}
|
|
|
|
namespace think\facade {
|
|
class Db
|
|
{
|
|
public static function name(string $name): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); }
|
|
}
|
|
}
|
|
|
|
namespace app\common\service\DataScope {
|
|
class DataScopeService
|
|
{
|
|
public const SCOPE_ALL = 1;
|
|
public const SCOPE_DEPT = 3;
|
|
public const SCOPE_SELF = 4;
|
|
public static function getVisibleAdminIds(int $adminId, array $info): ?array { return null; }
|
|
public static function getAllowedDeptIdSet(int $adminId, array $info): ?array { return null; }
|
|
public static function getEffectiveScope(array $info): int { return (int) ($info['scope_value'] ?? self::SCOPE_ALL); }
|
|
public static function scopeLabel(int $scope): string { return 'scope-' . $scope; }
|
|
}
|
|
}
|
|
|
|
namespace app\common\service\qywx {
|
|
class MediaChannelService
|
|
{
|
|
public const GROUP_CODE_PREFIX = 'group:';
|
|
public static function getCurrentTagOptions(): array { return [['id' => '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";
|
|
}
|