This commit is contained in:
Your Name
2026-09-03 16:03:41 +08:00
parent b4c11881b4
commit 928f72ec3d
25 changed files with 1926 additions and 394 deletions
@@ -78,6 +78,19 @@ class WecomPromotionController extends BaseAdminController
)));
}
public function batchUpdatePools()
{
if (!$this->hasBasePagePermission()) {
return $this->fail('权限不足');
}
return $this->run(fn () => $this->success('分流方案配置已批量更新', WecomPromotionLogic::batchUpdatePools(
$this->request->post(),
$this->adminId,
$this->adminInfo
)));
}
public function saveWidget()
{
if (!$this->hasPagePermission()) {
@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace app\adminapi\controller\qywx;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\qywx\CustomerLists;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\qywx\CustomerLogic;
use app\adminapi\validate\qywx\CustomerValidate;
/**
* 企业微信客户管理控制器
*/
class CustomerController extends BaseAdminController
{
class CustomerController extends BaseAdminController
{
private const DELETE_PERMISSION = 'qywx.customer/delete';
/**
* @notes 客户列表
*/
@@ -25,16 +28,34 @@ class CustomerController extends BaseAdminController
/**
* @notes 同步企业微信客户
*/
public function sync()
{
public function sync()
{
$result = CustomerLogic::triggerBackgroundSync();
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
$msg = is_array($result) && isset($result['message']) ? (string) $result['message'] : '已提交同步';
return $this->success($msg, $result);
}
return $this->success($msg, $result);
}
/**
* @notes 删除一条本地企业微信客户同步记录
*/
public function delete()
{
// 显式鉴权,避免权限菜单迁移漏执行时被通用中间件当成“未受控 URI”放行。
if (!$this->canDeleteCustomer()) {
return $this->fail('权限不足,无法删除企业微信客户');
}
$params = (new CustomerValidate())->post()->goCheck('delete');
if (!CustomerLogic::deleteCustomer((int) $params['id'])) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('删除成功');
}
/**
* @notes 获取统计信息
@@ -84,13 +105,22 @@ class CustomerController extends BaseAdminController
/**
* @notes 保存同步设置
*/
public function saveSyncSettings()
public function saveSyncSettings()
{
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
$result = CustomerLogic::saveSyncSettings($params);
if ($result === false) {
return $this->fail(CustomerLogic::getError());
}
return $this->success('保存成功');
}
}
return $this->success('保存成功');
}
private function canDeleteCustomer(): bool
{
if ((int) ($this->adminInfo['root'] ?? 0) === 1) {
return true;
}
return in_array(self::DELETE_PERMISSION, AuthLogic::getAuthByAdminId($this->adminId), true);
}
}
@@ -227,6 +227,7 @@ class AuthMiddleware
'firstvisit.wecompromotion/uploadwelcomemedia',
'firstvisit.wecompromotion/overview',
'firstvisit.wecompromotion/savepool',
'firstvisit.wecompromotion/batchupdatepools',
'firstvisit.wecompromotion/savewidget',
'firstvisit.wecompromotion/batchsetoperators',
'firstvisit.wecompromotion/deletepool',
+210 -18
View File
@@ -230,20 +230,49 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
}
return false;
}
private function baseQuery()
return false;
}
/**
* 按客户当前跟进关系中的添加方式筛选。
*
* follow_users 由同步逻辑使用 json_encode 写入,匹配数字值及历史字符串值;
* 数字后必须紧跟逗号或对象结束符,避免 add_way=1 误命中 16。
*/
private function applyAddWayFilter($query): void
{
if (!array_key_exists('add_way', $this->params) || $this->params['add_way'] === '') {
return;
}
$addWay = self::normalizeAddWay($this->params['add_way']);
if ($addWay === null) {
$query->whereRaw('1=0');
return;
}
$numberPrefix = '%"add_way":' . $addWay;
$stringPrefix = '%"add_way":"' . $addWay;
$query->where(function ($q) use ($numberPrefix, $stringPrefix) {
$q->where('follow_users', 'like', $numberPrefix . ',%')
->whereOr('follow_users', 'like', $numberPrefix . '}%')
->whereOr('follow_users', 'like', $stringPrefix . '",%')
->whereOr('follow_users', 'like', $stringPrefix . '"}%');
});
}
private function baseQuery()
{
$query = QywxExternalContact::where($this->searchWhere);
// 添加时间可能已按「跟进人+事件流水」收窄;此时不必再 LIKE follow_users
$followAlreadyScoped = $this->applyAddTimeFilter($query);
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
if (!$followAlreadyScoped) {
$this->applyFollowUserFilter($query);
}
$this->applyAddWayFilter($query);
// 标签筛选:JOIN 关系表按 tag_id 过滤;多个标签为 OR(命中任一即返回)。
// 走 zyt_qywx_external_contact_tag.idx_tag 索引,比 LIKE follow_users 快得多
$tagIds = $this->normalizeTagIds();
if ($tagIds !== []) {
@@ -262,6 +291,154 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
return $query;
}
/**
* 批量读取当前页客户的加客渠道流水。
*
* 事件表是一对多关系,不能直接 JOIN 到分页主查询,否则会放大列表行数与 count。
* 同一客户可能被不同员工重复添加,因此保留所有不同的非空 state,并按最近事件排序。
*
* @param string[] $externalUserids
* @return array<string, array<int, array<string, mixed>>>
*/
private function loadAddChannelsByExternalUserid(array $externalUserids): array
{
$ids = [];
foreach ($externalUserids as $externalUserid) {
$externalUserid = trim((string) $externalUserid);
if ($externalUserid !== '') {
$ids[$externalUserid] = true;
}
}
$ids = array_keys($ids);
if ($ids === []) {
return [];
}
$events = Db::name('qywx_external_contact_event')
->where('change_type', 'add_external_contact')
->where('state', '<>', '')
->whereIn('external_userid', $ids)
->field(['id', 'external_userid', 'user_id', 'state', 'event_time'])
->order('event_time', 'desc')
->order('id', 'desc')
->select()
->toArray();
$poolIds = [];
foreach ($events as $event) {
$state = trim((string) ($event['state'] ?? ''));
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolIds[(int) $matches[1]] = true;
}
}
$poolNamesById = [];
if ($poolIds !== []) {
// 历史渠道仍应显示已删除方案原来的名称,因此这里不限制 delete_time。
$poolNamesById = Db::name('qywx_promotion_pool')
->whereIn('id', array_keys($poolIds))
->column('name', 'id');
}
return self::projectAddChannelEvents($events, $poolNamesById);
}
/**
* @param array<int, array<string, mixed>> $events 已按 event_time DESC, id DESC 排序
* @param array<int|string, string> $poolNamesById
* @return array<string, array<int, array<string, mixed>>>
*/
private static function projectAddChannelEvents(array $events, array $poolNamesById): array
{
$channelsByExternalUserid = [];
$seen = [];
foreach ($events as $event) {
$externalUserid = trim((string) ($event['external_userid'] ?? ''));
$state = trim((string) ($event['state'] ?? ''));
if ($externalUserid === '' || $state === '' || isset($seen[$externalUserid][$state])) {
continue;
}
$seen[$externalUserid][$state] = true;
$poolId = 0;
if (preg_match('/^zyt_pool:([1-9]\d*)$/D', $state, $matches) === 1) {
$poolId = (int) $matches[1];
}
$poolName = $poolId > 0 ? trim((string) ($poolNamesById[$poolId] ?? '')) : '';
$channelsByExternalUserid[$externalUserid][] = [
'state' => $state,
'label' => $poolName !== ''
? $poolName
: ($poolId > 0 ? '获客助手方案 #' . $poolId : $state),
'source_type' => $poolId > 0 ? 'promotion_pool' : 'state',
'pool_id' => $poolId,
'user_id' => trim((string) ($event['user_id'] ?? '')),
'event_time' => (int) ($event['event_time'] ?? 0),
];
}
return $channelsByExternalUserid;
}
/**
* 企业微信客户详情 follow_user.add_way 的可读文案。
*
* add_way 是固定添加方式,state 是企业自定义渠道参数,两者不能混用。
* 未识别的新枚举保留原值,避免后续企微扩展时页面退化成“未记录”。
*/
private static function addWayLabel(int $addWay): string
{
$labels = [
0 => '未知添加方式',
1 => '通过扫描二维码添加',
2 => '通过搜索手机号添加',
3 => '通过名片分享添加',
4 => '通过群聊添加',
5 => '通过手机通讯录添加',
6 => '通过微信联系人添加',
8 => '安装第三方应用时自动添加',
9 => '通过搜索邮箱添加',
10 => '通过视频号添加',
11 => '通过日程参与人添加',
12 => '通过会议参与人添加',
13 => '通过微信好友添加',
14 => '通过智慧硬件专属客服添加',
15 => '通过上门服务客服添加',
16 => '通过获客链接添加',
17 => '通过定制开发添加',
18 => '通过需求回复添加',
21 => '通过第三方售前客服添加',
22 => '通过可能的商务伙伴添加',
24 => '通过接受微信好友申请添加',
201 => '通过内部成员共享添加',
202 => '通过管理员或负责人分配添加',
];
return $labels[$addWay] ?? '其他添加方式(' . $addWay . '';
}
/**
* @param mixed $value
*/
private static function normalizeAddWay($value): ?int
{
if (is_int($value)) {
return $value >= 0 ? $value : null;
}
if (!is_string($value)) {
return null;
}
$value = trim($value);
if ($value === '' || preg_match('/^\d+$/D', $value) !== 1) {
return null;
}
return (int) $value;
}
/**
* @notes 获取列表
*/
@@ -278,7 +455,12 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
->toArray();
$wxUserids = [];
$externalUserids = [];
foreach ($lists as $item) {
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
if ($externalUserid !== '') {
$externalUserids[$externalUserid] = true;
}
$raw = json_decode($item['follow_users'] ?? '[]', true);
if (!is_array($raw)) {
continue;
@@ -298,19 +480,25 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
if ($wxUserids !== []) {
$adminNameByWx = Admin::whereIn('work_wechat_userid', $wxUserids)->column('name', 'work_wechat_userid');
}
$addChannelsByExternalUserid = $this->loadAddChannelsByExternalUserid(array_keys($externalUserids));
foreach ($lists as &$item) {
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
$followUsers = is_array($followUsers) ? $followUsers : [];
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
}
foreach ($followUsers as &$fu) {
if (!is_array($fu)) {
continue;
}
$wx = trim((string) ($fu['userid'] ?? ''));
if ($wx !== '' && isset($adminNameByWx[$wx]) && $adminNameByWx[$wx] !== '') {
$fu['admin_name'] = $adminNameByWx[$wx];
}
$addWay = self::normalizeAddWay($fu['add_way'] ?? $fu['AddWay'] ?? null);
if ($addWay !== null) {
$fu['add_way'] = $addWay;
$fu['add_way_label'] = self::addWayLabel($addWay);
}
}
unset($fu);
$item['follow_users'] = $followUsers;
$followAdminIds = json_decode($item['follow_admin_ids'] ?? '[]', true);
@@ -320,6 +508,10 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
$tags = json_decode((string) ($item['tags'] ?? '[]'), true);
$item['tags'] = is_array($tags) ? $tags : [];
$externalUserid = trim((string) ($item['external_userid'] ?? ''));
$item['add_channels'] = $addChannelsByExternalUserid[$externalUserid] ?? [];
$item['add_channel_states'] = array_column($item['add_channels'], 'state');
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
@@ -225,7 +225,12 @@ class WecomPromotionLogic
];
}
public static function savePool(array $params, int $adminId, array $adminInfo): array
public static function savePool(
array $params,
int $adminId,
array $adminInfo,
bool $syncImmediately = true
): array
{
self::assertMemberDispatchSchema();
$id = max(0, (int) ($params['id'] ?? 0));
@@ -425,11 +430,13 @@ class WecomPromotionLogic
$syncError = '';
if (!$createdRemote) {
QywxPromotionMemberSchedulerService::requestPoolSync($id, $linkId);
try {
(new QywxPromotionRangeSyncService())->syncPool($id);
} catch (\Throwable $e) {
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
$syncError = $e->getMessage();
if ($syncImmediately) {
try {
(new QywxPromotionRangeSyncService())->syncPool($id);
} catch (\Throwable $e) {
// 本地方案和成员规则已保存;后台分钟任务会继续重试最新完整范围。
$syncError = $e->getMessage();
}
}
}
$savedLink = Db::name('qywx_promotion_link')->where('id', $linkId)->find() ?: [];
@@ -448,6 +455,152 @@ class WecomPromotionLogic
// 前端据此确认标签、欢迎语等扩展配置已和方案一并提交并完成回读校验。
'automation_saved' => $automation !== null,
'sync_error' => $syncError,
'sync_queued' => !$createdRemote && !$syncImmediately,
];
}
/**
* 批量局部更新分流方案。changes 只覆盖显式传入的字段;每个方案仍复用
* savePool 的成员、自动化、素材和企业微信同步校验。
*
* @return array{pool_ids:list<int>,updated:int,failed:int,sync_error_count:int,sync_queued_count:int,results:list<array<string,mixed>>}
*/
public static function batchUpdatePools(array $params, int $adminId, array $adminInfo): array
{
self::assertMemberDispatchSchema();
self::assertBasePagePermission($adminId, $adminInfo);
$poolIds = self::normalizePositiveIds((array) ($params['pool_ids'] ?? []));
if ($poolIds === []) {
throw new RuntimeException('请至少选择一个分流方案');
}
if (count($poolIds) > 100) {
throw new RuntimeException('单次最多设置 100 个分流方案');
}
$changes = $params['changes'] ?? null;
if (!is_array($changes)) {
throw new RuntimeException('批量修改内容格式不正确');
}
$allowedFields = ['skip_verify', 'fallback_url', 'status', 'automation_config'];
$unknownFields = array_diff(array_keys($changes), $allowedFields);
if ($unknownFields !== []) {
throw new RuntimeException('批量修改包含不支持的字段');
}
if ($changes === []) {
throw new RuntimeException('请至少选择一项需要批量修改的配置');
}
if (array_key_exists('fallback_url', $changes) && !is_string($changes['fallback_url'])) {
throw new RuntimeException('兜底获客助手链接格式不正确');
}
$automationPatch = null;
if (array_key_exists('automation_config', $changes)) {
if (!is_array($changes['automation_config']) || $changes['automation_config'] === []) {
throw new RuntimeException('自动化配置格式不正确');
}
$allowedAutomationFields = [
'reception_mode', 'reception_schedule', 'backup_member_admin_ids',
'tags_enabled', 'tag_ids', 'remark_enabled', 'remark_template',
'description_enabled', 'description', 'welcome_mode', 'welcome',
'welcome_schedule_enabled', 'welcome_schedule',
];
if (array_diff(array_keys($changes['automation_config']), $allowedAutomationFields) !== []) {
throw new RuntimeException('自动化配置包含不支持的字段');
}
QywxPromotionConfig::assertInstalled();
$automationPatch = $changes['automation_config'];
}
// 必须在任何方案写入前完成整批权限校验,避免越权请求产生部分更新。
$pools = [];
foreach ($poolIds as $poolId) {
$pools[$poolId] = self::assertScopedRow(
'qywx_promotion_pool',
$poolId,
$adminId,
$adminInfo,
false
);
}
$results = [];
$updated = 0;
$failed = 0;
$syncErrorCount = 0;
$syncQueuedCount = 0;
foreach ($poolIds as $poolId) {
$pool = $pools[$poolId];
$currentAutomation = QywxPromotionConfig::forPool($poolId);
$memberAdminIds = self::normalizePositiveIds(Db::name('qywx_promotion_pool_member')
->where('pool_id', $poolId)
->whereNull('delete_time')
->column('admin_id'));
$primaryMemberAdminIds = array_values(array_diff(
$memberAdminIds,
self::normalizePositiveIds((array) ($currentAutomation['backup_member_admin_ids'] ?? []))
));
$officialLink = Db::name('qywx_promotion_link')
->where('pool_id', $poolId)
->whereNull('delete_time')
->where('remote_link_id', '<>', '')
->where('remote_status', '<>', 2)
->order('id', 'desc')
->find() ?: [];
$saveParams = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'fallback_url' => array_key_exists('fallback_url', $changes)
? trim($changes['fallback_url'])
: (string) ($pool['fallback_url'] ?? ''),
'status' => array_key_exists('status', $changes)
? ((int) $changes['status'] === 1 ? 1 : 0)
: (int) ($pool['status'] ?? 0),
'member_admin_ids' => $primaryMemberAdminIds,
'skip_verify' => array_key_exists('skip_verify', $changes)
? ((int) $changes['skip_verify'] === 1 ? 1 : 0)
: (int) ($officialLink['skip_verify'] ?? 0),
];
if ($automationPatch !== null) {
$saveParams['automation_config'] = array_replace($currentAutomation, $automationPatch);
}
try {
// 批量操作只落本地并入同步队列,避免大量企微请求阻塞管理端 HTTP 请求。
$saved = self::savePool($saveParams, $adminId, $adminInfo, false);
$syncError = trim((string) ($saved['sync_error'] ?? ''));
$updated++;
if ($syncError !== '') {
$syncErrorCount++;
}
$syncQueued = !empty($saved['sync_queued']);
if ($syncQueued) {
$syncQueuedCount++;
}
$results[] = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => true,
'sync_error' => $syncError,
'sync_queued' => $syncQueued,
];
} catch (\Throwable $error) {
$failed++;
$results[] = [
'id' => $poolId,
'name' => (string) ($pool['name'] ?? ''),
'success' => false,
'error' => $error->getMessage(),
];
}
}
return [
'pool_ids' => $poolIds,
'updated' => $updated,
'failed' => $failed,
'sync_error_count' => $syncErrorCount,
'sync_queued_count' => $syncQueuedCount,
'results' => $results,
];
}
@@ -722,6 +722,73 @@ class CustomerLogic extends BaseLogic
}
}
/**
* 后台手工删除一条本地同步记录。
*
* 仅按列表行主键软删除,不调用企业微信删除客户关系;兼容历史库中可能存在的重复
* external_userid。只有该客户已无其他有效行时才清理共享的标签关系。
*/
public static function deleteCustomer(int $id): bool
{
if ($id <= 0) {
self::$error = '客户参数错误';
return false;
}
try {
$externalUserId = Db::transaction(static function () use ($id): string {
$row = Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->find();
if (!$row) {
throw new \DomainException('客户不存在或已删除');
}
$now = time();
Db::name('qywx_external_contact')
->where('id', $id)
->whereNull('delete_time')
->update([
'delete_time' => $now,
'update_time' => $now,
]);
$externalUserId = trim((string) ($row['external_userid'] ?? ''));
if ($externalUserId !== '') {
$activeRows = (int) Db::name('qywx_external_contact')
->where('external_userid', $externalUserId)
->whereNull('delete_time')
->count();
if ($activeRows === 0) {
Db::name('qywx_external_contact_tag')
->where('external_userid', $externalUserId)
->delete();
}
}
return $externalUserId;
});
if ($externalUserId !== '') {
MediaChannelService::forgetCurrentTagCatalogCache();
}
return true;
} catch (\DomainException $e) {
self::$error = $e->getMessage();
return false;
} catch (\Throwable $e) {
Log::error('后台删除企业微信客户同步记录失败: ' . $e->getMessage());
self::$error = '删除失败,请稍后重试';
return false;
}
}
/**
* 客户联系「删除企业客户」等事件:本地软删除一行。
*/
@@ -12,11 +12,15 @@ use app\common\validate\BaseValidate;
class CustomerValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|integer|gt:0',
'auto_sync' => 'require|boolean',
'interval' => 'require|integer|between:3600,86400',
];
protected $message = [
'id.require' => '请选择要删除的客户',
'id.integer' => '客户参数格式错误',
'id.gt' => '客户参数格式错误',
'auto_sync.require' => '请选择是否自动同步',
'auto_sync.boolean' => '自动同步参数格式错误',
'interval.require' => '请选择同步间隔',
@@ -31,4 +35,12 @@ class CustomerValidate extends BaseValidate
{
return $this->only(['auto_sync', 'interval']);
}
/**
* @notes 删除客户场景
*/
public function sceneDelete()
{
return $this->only(['id']);
}
}
@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_change_user_ext_time` (`change_type`, `user_id`, `external_userid`, `event_time`),
KEY `idx_change_time` (`change_type`, `event_time`),
KEY `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`),
KEY `idx_event_time` (`event_time`),
KEY `idx_state` (`state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业微信外部联系人事件流水(用于进入计数)';
@@ -0,0 +1,19 @@
-- 客户列表按当前页 external_userid 批量读取加客渠道流水。
-- 覆盖 change_type + external_userid + event_time,避免事件量增长后反复扫描全量 add 事件。
SET @idx_change_ext_time_exists := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'zyt_qywx_external_contact_event'
AND INDEX_NAME = 'idx_change_ext_time'
);
SET @add_idx_change_ext_time_sql := IF(
@idx_change_ext_time_exists = 0,
'ALTER TABLE `zyt_qywx_external_contact_event` ADD INDEX `idx_change_ext_time` (`change_type`, `external_userid`, `event_time`, `id`)',
'SELECT 1'
);
PREPARE add_idx_change_ext_time_stmt FROM @add_idx_change_ext_time_sql;
EXECUTE add_idx_change_ext_time_stmt;
DEALLOCATE PREPARE add_idx_change_ext_time_stmt;
@@ -0,0 +1,36 @@
-- 企业微信客户管理:新增独立“删除”操作权限。
-- 仅创建权限节点,不自动授予已有角色;请在角色权限中按需勾选。
SET @qywx_customer_menu_id := (
SELECT `id`
FROM `zyt_system_menu`
WHERE `component` = 'fans/qywx'
OR `perms` = 'qywx.customer/lists'
ORDER BY CASE WHEN `component` = 'fans/qywx' THEN 0 ELSE 1 END, `id`
LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@qywx_customer_menu_id,
'A',
'删除',
'',
1,
'qywx.customer/delete',
'',
'',
'',
'',
1,
0,
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
FROM DUAL
WHERE @qywx_customer_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM `zyt_system_menu`
WHERE `perms` = 'qywx.customer/delete'
);
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use app\adminapi\lists\qywx\CustomerLists;
require dirname(__DIR__) . '/vendor/autoload.php';
function qywxChannelExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
final class QywxChannelFilterQueryFake
{
/** @var array<int, array{field:string,operator:string,value:string,logic:string}> */
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<string, array<int, array<string, mixed>>> $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";
@@ -0,0 +1,60 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pagePath = path.resolve(currentDir, '../../admin/src/views/fans/qywx.vue')
const source = fs.readFileSync(pagePath, 'utf8')
function expect(condition, message) {
if (!condition) throw new Error(message)
}
expect(source.includes('<el-table-column label="添加渠道"'), '客户主表缺少添加渠道列')
expect(source.includes('<el-descriptions-item label="添加渠道"'), '客户详情缺少添加渠道')
expect(source.includes('<el-form-item label="渠道"'), '客户检索区缺少渠道筛选')
expect(source.includes('v-model="queryParams.add_way"'), '渠道筛选未绑定 add_way 参数')
expect(source.includes('v-for="option in ADD_WAY_OPTIONS"'), '渠道筛选未复用完整添加方式枚举')
expect(source.includes('placeholder="选择或搜索添加渠道"'), '渠道筛选必须支持按可读文案检索')
expect(source.includes('queryParams.add_way ='), '重置操作未清空渠道筛选')
expect(source.includes('customerAddSources(row)'), '客户主表未读取可读添加方式')
expect(source.includes('customerAddSources(currentCustomer)'), '客户详情未读取可读添加方式')
expect(source.includes(':key="source.key"'), '添加方式循环必须使用成员关系级稳定 key')
expect(source.includes('add_channel_states'), '前端缺少原始渠道数组兼容逻辑')
expect(source.includes('未记录'), '无渠道客户必须明确显示未记录')
for (const [addWay, label] of [
[0, '未知添加方式'],
[1, '通过扫描二维码添加'],
[2, '通过搜索手机号添加'],
[3, '通过名片分享添加'],
[4, '通过群聊添加'],
[5, '通过手机通讯录添加'],
[6, '通过微信联系人添加'],
[8, '安装第三方应用时自动添加'],
[9, '通过搜索邮箱添加'],
[10, '通过视频号添加'],
[11, '通过日程参与人添加'],
[12, '通过会议参与人添加'],
[13, '通过微信好友添加'],
[14, '通过智慧硬件专属客服添加'],
[15, '通过上门服务客服添加'],
[16, '通过获客链接添加'],
[17, '通过定制开发添加'],
[18, '通过需求回复添加'],
[21, '通过第三方售前客服添加'],
[22, '通过可能的商务伙伴添加'],
[24, '通过接受微信好友申请添加'],
[201, '通过内部成员共享添加'],
[202, '通过管理员或负责人分配添加']
]) {
expect(source.includes(`${addWay}: '${label}'`), `add_way=${addWay} 未显示可读来源`)
}
expect(source.includes('add_way_label'), '前端未优先使用后端返回的添加方式文案')
expect(source.includes('添加方式:${source.label}'), '悬浮说明缺少直观添加方式')
expect(source.includes('获客助手方案:${source.channel_label}'), '悬浮说明缺少获客助手方案')
expect(source.includes('跟进人:${source.staff_name}'), '悬浮说明缺少对应跟进人')
expect(source.includes('添加时间:${formatTime(source.event_time)}'), '悬浮说明缺少添加时间')
expect(source.includes('渠道参数:${source.state}'), '原始 state 必须仅保留在悬浮说明')
expect(!source.includes('{{ addChannelText(channel) }}'), '列表不得继续直接展示原始渠道参数')
console.log('QYWX_CUSTOMER_CHANNEL_UI_CONTRACT_OK')
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__, 2);
$paths = [
'controller' => __DIR__ . '/../app/adminapi/controller/qywx/CustomerController.php',
'logic' => __DIR__ . '/../app/adminapi/logic/qywx/CustomerLogic.php',
'validate' => __DIR__ . '/../app/adminapi/validate/qywx/CustomerValidate.php',
'api' => $root . '/admin/src/api/qywx.ts',
'page' => $root . '/admin/src/views/fans/qywx.vue',
'migration' => __DIR__ . '/../sql/1.9.20260902/add_qywx_customer_delete_menu.sql',
];
$sources = [];
foreach ($paths as $name => $path) {
$source = file_get_contents($path);
if (!is_string($source)) {
throw new RuntimeException("无法读取 {$name}: {$path}");
}
$sources[$name] = $source;
}
function qywxDeleteExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$controller = $sources['controller'];
$permissionCheck = strpos($controller, 'if (!$this->canDeleteCustomer())');
$deleteCall = strpos($controller, 'CustomerLogic::deleteCustomer(');
qywxDeleteExpect(str_contains($controller, "private const DELETE_PERMISSION = 'qywx.customer/delete';"), '控制器缺少独立删除权限');
qywxDeleteExpect($permissionCheck !== false && $deleteCall !== false && $permissionCheck < $deleteCall, '控制器必须在删除前显式鉴权');
qywxDeleteExpect(str_contains($controller, "(int) (\$this->adminInfo['root'] ?? 0) === 1")
&& str_contains($controller, 'AuthLogic::getAuthByAdminId($this->adminId)')
&& str_contains($controller, 'in_array(self::DELETE_PERMISSION,'), '控制器删除权限必须仅放行 root 或显式授权账号');
qywxDeleteExpect(str_contains($sources['validate'], "'id' => 'require|integer|gt:0'")
&& str_contains($sources['validate'], 'public function sceneDelete()'), '删除请求缺少正整数 ID 校验');
$logic = $sources['logic'];
$logicStart = strpos($logic, 'public static function deleteCustomer(int $id): bool');
$logicEnd = strpos($logic, 'public static function softDeleteExternalContactRow(', $logicStart === false ? 0 : $logicStart);
qywxDeleteExpect($logicStart !== false && $logicEnd !== false, '无法定位客户删除逻辑');
$method = substr($logic, $logicStart, $logicEnd - $logicStart);
foreach ([
"->where('id', \$id)",
"->whereNull('delete_time')",
"'delete_time' => \$now",
"->where('external_userid', \$externalUserId)",
'if ($activeRows === 0)',
"Db::name('qywx_external_contact_tag')",
'MediaChannelService::forgetCurrentTagCatalogCache()',
] as $needle) {
qywxDeleteExpect(str_contains($method, $needle), "客户删除逻辑缺少契约:{$needle}");
}
qywxDeleteExpect(!str_contains($method, 'WechatWorkService'), '后台删除不得调用企微接口删除外部客户关系');
qywxDeleteExpect(str_contains($sources['api'], '/qywx.customer/delete')
&& str_contains($sources['api'], 'qywxCustomerDelete'), '前端缺少客户删除 API');
foreach ([
"v-perms=\"['qywx.customer/delete']\"",
'handleDelete(row)',
'仅删除系统内的同步记录',
'qywxCustomerDelete({ id })',
'Promise.all([getLists(), loadStats(), loadTagStats()])',
] as $needle) {
qywxDeleteExpect(str_contains($sources['page'], $needle), "客户列表删除交互缺少:{$needle}");
}
$migration = $sources['migration'];
qywxDeleteExpect(str_contains($migration, "`component` = 'fans/qywx'")
&& str_contains($migration, "'qywx.customer/delete'")
&& str_contains($migration, "'A'")
&& str_contains($migration, 'NOT EXISTS'), '删除权限迁移必须按页面定位并可重复执行');
qywxDeleteExpect(!str_contains(strtolower($migration), 'system_role_menu'), '删除权限不得自动授予已有角色');
echo "QYWX_CUSTOMER_DELETE_PERMISSION_CONTRACT_OK\n";
@@ -62,5 +62,9 @@ assert.match(previewTemplate('{customer_name}-{employee_name}-{add_time}', '小
assert.equal(Array.from(previewTemplate('王'.repeat(30), '小陈', 20)).length, 20)
assert.match(formSource, /不会写入企微获客链接详情中的“欢迎语\/客户标签”配置/)
assert.match(formSource, /客户添加回调中立即发送渠道欢迎语并添加标签/)
assert.match(formSource, /disabledSections\?: AutomationSection\[\]/)
assert.match(formSource, /backupExcludedMemberIds\?: number\[\]/)
assert.match(pageSource, /result\?\.automation_saved !== true/)
assert.match(pageSource, /仅勾选的项目会覆盖到所选方案/)
assert.match(pageSource, /batchSharedPrimaryMemberIds/)
console.log('WECOM_PROMOTION_AUTOMATION_UI_OK')
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__, 2);
$paths = [
'logic' => __DIR__ . '/../app/adminapi/logic/firstvisit/WecomPromotionLogic.php',
'controller' => __DIR__ . '/../app/adminapi/controller/firstvisit/WecomPromotionController.php',
'middleware' => __DIR__ . '/../app/adminapi/http/middleware/AuthMiddleware.php',
'api' => $root . '/admin/src/api/first_visit.ts',
'page' => $root . '/admin/src/views/first_visit/wecom_promotion/index.vue',
'automationForm' => $root . '/admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue',
];
$sources = [];
foreach ($paths as $name => $path) {
$source = file_get_contents($path);
if (!is_string($source)) {
throw new RuntimeException("无法读取 {$name}: {$path}");
}
$sources[$name] = $source;
}
if (!str_contains($sources['controller'], 'public function batchUpdatePools()')
|| !str_contains($sources['controller'], 'WecomPromotionLogic::batchUpdatePools(')) {
throw new RuntimeException('控制器缺少批量修改方案配置接口');
}
if (!str_contains($sources['middleware'], "'firstvisit.wecompromotion/batchupdatepools'")) {
throw new RuntimeException('批量修改方案接口未加入获客助手权限白名单');
}
if (!str_contains($sources['api'], '/firstvisit.wecomPromotion/batchUpdatePools')
|| !str_contains($sources['api'], 'WecomPromotionBatchUpdatePoolsParams')) {
throw new RuntimeException('前端缺少类型化批量修改 API');
}
$logic = $sources['logic'];
$start = strpos($logic, 'public static function batchUpdatePools(');
$end = strpos($logic, 'public static function saveWidget(', $start === false ? 0 : $start);
if ($start === false || $end === false) {
throw new RuntimeException('无法定位 batchUpdatePools 方法');
}
$method = substr($logic, $start, $end - $start);
foreach ([
'assertBasePagePermission($adminId, $adminInfo)',
"array_key_exists('fallback_url', \$changes)",
"array_key_exists('status', \$changes)",
"array_key_exists('skip_verify', \$changes)",
"array_key_exists('automation_config', \$changes)",
'array_replace($currentAutomation, $automationPatch)',
'self::savePool($saveParams, $adminId, $adminInfo, false)',
"'sync_error_count' => \$syncErrorCount",
"'sync_queued_count' => \$syncQueuedCount",
"'results' => \$results",
] as $needle) {
if (!str_contains($method, $needle)) {
throw new RuntimeException("批量修改方案逻辑缺少契约:{$needle}");
}
}
$permissionValidation = strpos($method, "self::assertScopedRow(");
$writeLoop = strpos($method, '$results = []');
if ($permissionValidation === false || $writeLoop === false || $permissionValidation > $writeLoop) {
throw new RuntimeException('批量修改必须在任何方案保存前完成全部方案权限校验');
}
if (!str_contains($method, "false\n );")) {
throw new RuntimeException('批量修改不得允许共享操作人绕过原管理范围批量编辑');
}
$page = $sources['page'];
foreach ([
'批量修改方案',
'仅勾选的项目会覆盖到所选方案',
'batchConfigApply.skip_verify',
'batchConfigApply.fallback_url',
'batchConfigApply.status',
'batchConfigApply.reception',
'batchConfigApply.customer',
'batchConfigApply.welcome',
'batchSharedPrimaryMemberIds',
'batchPrimaryMemberUnion',
'wecomPromotionBatchUpdatePools',
] as $needle) {
if (!str_contains($page, $needle)) {
throw new RuntimeException("前端批量修改交互缺少 {$needle}");
}
}
if (!str_contains($sources['automationForm'], 'disabledSections?: AutomationSection[]')
|| !str_contains($sources['automationForm'], 'backupExcludedMemberIds?: number[]')) {
throw new RuntimeException('自动化表单未支持批量场景的分组禁用或主成员冲突约束');
}
echo "WECOM_PROMOTION_BATCH_UPDATE_CONTRACT_OK\n";