*/ public array $likes = []; /** @var string[] */ public array $raw = []; public function where($field, $operator = null, $value = null): self { if ($field instanceof Closure) { $field($this); return $this; } $this->likes[] = [ 'field' => (string) $field, 'operator' => (string) $operator, 'value' => (string) $value, 'logic' => 'and', ]; return $this; } public function whereOr($field, $operator = null, $value = null): self { $this->likes[] = [ 'field' => (string) $field, 'operator' => (string) $operator, 'value' => (string) $value, 'logic' => 'or', ]; return $this; } public function whereRaw(string $condition): self { $this->raw[] = $condition; return $this; } } $method = new ReflectionMethod(CustomerLists::class, 'projectAddChannelEvents'); $method->setAccessible(true); $addWayLabelMethod = new ReflectionMethod(CustomerLists::class, 'addWayLabel'); $addWayLabelMethod->setAccessible(true); $normalizeAddWayMethod = new ReflectionMethod(CustomerLists::class, 'normalizeAddWay'); $normalizeAddWayMethod->setAccessible(true); $events = [ ['id' => 12, 'external_userid' => 'ext-a', 'user_id' => 'staff-2', 'state' => 'channel-b', 'event_time' => 300], ['id' => 11, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => ' ', 'event_time' => 300], ['id' => 10, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'zyt_pool:2', 'event_time' => 250], ['id' => 9, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-b', 'event_time' => 200], ['id' => 8, 'external_userid' => 'ext-a', 'user_id' => 'staff-1', 'state' => 'channel-a', 'event_time' => 100], ['id' => 7, 'external_userid' => 'ext-zero', 'user_id' => 'staff-1', 'state' => '0', 'event_time' => 100], ['id' => 6, 'external_userid' => '', 'user_id' => 'staff-1', 'state' => 'ignored', 'event_time' => 100], ]; /** @var array>> $projected */ $projected = $method->invoke(null, $events, [2 => '九月投放方案']); qywxChannelExpect( array_column($projected['ext-a'] ?? [], 'state') === ['channel-b', 'zyt_pool:2', 'channel-a'], '渠道必须按最近事件排序、排除空值并按 state 去重' ); qywxChannelExpect(($projected['ext-a'][0]['user_id'] ?? '') === 'staff-2', '重复渠道必须保留最近事件的员工'); qywxChannelExpect(($projected['ext-a'][0]['event_time'] ?? 0) === 300, '重复渠道必须保留最近事件时间'); qywxChannelExpect(($projected['ext-a'][1]['label'] ?? '') === '九月投放方案', '获客助手 state 必须映射方案名称'); qywxChannelExpect(($projected['ext-a'][1]['source_type'] ?? '') === 'promotion_pool', '获客助手渠道类型错误'); qywxChannelExpect(($projected['ext-a'][1]['pool_id'] ?? 0) === 2, '获客助手方案 ID 解析错误'); qywxChannelExpect(array_column($projected['ext-zero'] ?? [], 'state') === ['0'], '字符串 0 是有效渠道,不能被 empty/filter 丢弃'); qywxChannelExpect(!isset($projected['']), '空 external_userid 不得生成渠道投影'); $fallback = $method->invoke(null, [ ['id' => 1, 'external_userid' => 'ext-b', 'user_id' => '', 'state' => 'zyt_pool:99', 'event_time' => 1], ], []); qywxChannelExpect( ($fallback['ext-b'][0]['label'] ?? '') === '获客助手方案 #99', '已删除或缺失的获客助手方案应保留可读兜底名称' ); $knownAddWays = [ 0 => '未知添加方式', 1 => '通过扫描二维码添加', 2 => '通过搜索手机号添加', 3 => '通过名片分享添加', 4 => '通过群聊添加', 5 => '通过手机通讯录添加', 6 => '通过微信联系人添加', 8 => '安装第三方应用时自动添加', 9 => '通过搜索邮箱添加', 10 => '通过视频号添加', 11 => '通过日程参与人添加', 12 => '通过会议参与人添加', 13 => '通过微信好友添加', 14 => '通过智慧硬件专属客服添加', 15 => '通过上门服务客服添加', 16 => '通过获客链接添加', 17 => '通过定制开发添加', 18 => '通过需求回复添加', 21 => '通过第三方售前客服添加', 22 => '通过可能的商务伙伴添加', 24 => '通过接受微信好友申请添加', 201 => '通过内部成员共享添加', 202 => '通过管理员或负责人分配添加', ]; foreach ($knownAddWays as $addWay => $expectedLabel) { qywxChannelExpect( $addWayLabelMethod->invoke(null, $addWay) === $expectedLabel, "add_way={$addWay} 缺少正确的可读文案" ); } qywxChannelExpect( $addWayLabelMethod->invoke(null, 999) === '其他添加方式(999)', '未知的新 add_way 必须保留编号作为可读兜底' ); qywxChannelExpect($normalizeAddWayMethod->invoke(null, '16') === 16, '数字字符串 add_way 应被规范化'); qywxChannelExpect($normalizeAddWayMethod->invoke(null, '1future') === null, '异常 add_way 不得被强转成错误来源'); qywxChannelExpect($normalizeAddWayMethod->invoke(null, null) === null, '缺失 add_way 应保持未记录'); $filterMethod = new ReflectionMethod(CustomerLists::class, 'applyAddWayFilter'); $filterMethod->setAccessible(true); $list = (new ReflectionClass(CustomerLists::class))->newInstanceWithoutConstructor(); $paramsProperty = new ReflectionProperty(app\common\lists\BaseDataLists::class, 'params'); $paramsProperty->setAccessible(true); $paramsProperty->setValue($list, ['add_way' => 1]); $filterQuery = new QywxChannelFilterQueryFake(); $filterMethod->invoke($list, $filterQuery); qywxChannelExpect( array_column($filterQuery->likes, 'value') === [ '%"add_way":1,%', '%"add_way":1}%', '%"add_way":"1",%', '%"add_way":"1"}%', ], '渠道筛选必须精确匹配数字或字符串 add_way,不能让 1 误命中 16' ); $paramsProperty->setValue($list, ['add_way' => 0]); $zeroFilterQuery = new QywxChannelFilterQueryFake(); $filterMethod->invoke($list, $zeroFilterQuery); qywxChannelExpect(count($zeroFilterQuery->likes) === 4, 'add_way=0 是有效渠道筛选,不能按空值忽略'); $paramsProperty->setValue($list, ['add_way' => 'invalid']); $invalidFilterQuery = new QywxChannelFilterQueryFake(); $filterMethod->invoke($list, $invalidFilterQuery); qywxChannelExpect($invalidFilterQuery->raw === ['1=0'], '非法渠道参数必须返回空结果,不能泄露全量客户'); $paramsProperty->setValue($list, ['add_way' => '']); $emptyFilterQuery = new QywxChannelFilterQueryFake(); $filterMethod->invoke($list, $emptyFilterQuery); qywxChannelExpect($emptyFilterQuery->likes === [] && $emptyFilterQuery->raw === [], '空渠道参数应表示不限'); $source = file_get_contents(__DIR__ . '/../app/adminapi/lists/qywx/CustomerLists.php'); qywxChannelExpect(is_string($source), '无法读取 CustomerLists.php'); foreach ([ "->where('change_type', 'add_external_contact')", "->where('state', '<>', '')", "->whereIn('external_userid', \$ids)", "->order('event_time', 'desc')", "->order('id', 'desc')", "\$item['add_channels']", "\$item['add_channel_states']", "\$fu['add_way_label'] = self::addWayLabel(\$addWay)", '$this->applyAddWayFilter($query)', ] as $needle) { qywxChannelExpect(str_contains($source, $needle), "客户渠道投影缺少契约:{$needle}"); } echo "QYWX_CUSTOMER_CHANNEL_PROJECTION_OK\n";