This commit is contained in:
Your Name
2026-08-25 11:40:58 +08:00
parent b8ccbaf567
commit f24afa116f
350 changed files with 1910 additions and 371 deletions
+3 -2
View File
@@ -13,7 +13,7 @@ function conversionFanRuleExpect(bool $condition, string $message): void
}
}
$method = new ReflectionMethod(ConversionLogic::class, 'loadFanRows');
$method = new ReflectionMethod(ConversionLogic::class, 'loadFanDetailRows');
$sourceLines = file($method->getFileName());
if ($sourceLines === false) {
throw new RuntimeException('无法读取加粉统计源码');
@@ -33,7 +33,8 @@ conversionFanRuleExpect(
'会话存档同意是独立能力,不能再次成为加粉统计的硬性条件'
);
conversionFanRuleExpect(
str_contains($methodSource, "['del_external_contact', \$endTimestamp]"),
str_contains($methodSource, "'del_external_contact'")
&& str_contains($methodSource, 'surviving_del.event_time >= surviving_e.event_time'),
'加粉统计必须继续识别区间内已删除客户'
);
conversionFanRuleExpect(
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\adminapi\logic\stats\ConversionLogic;
use think\facade\Db;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new think\App();
$app->initialize();
$admin = Db::name('admin')->where('root', 1)->whereNull('delete_time')->find();
if (!$admin) {
echo "FAN_DETAIL_DB_SMOKE_SKIP no_root\n";
exit(0);
}
$params = [
'time_type' => 'custom',
'start_date' => '2026-08-01',
'end_date' => '2026-08-25',
];
$overviewStartedAt = microtime(true);
$overview = FirstVisitConversionLogic::overview($params, (int) $admin['id'], $admin);
$overviewElapsedMs = (microtime(true) - $overviewStartedAt) * 1000;
$candidates = [];
$collect = static function (array $rows, array $path = []) use (&$collect, &$candidates): void {
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$rowPath = array_merge($path, [(string) ($row['name'] ?? $row['id'] ?? '')]);
if ((int) ($row['add_fans_count'] ?? 0) > 0) {
$row['_smoke_path'] = implode('/', $rowPath);
$candidates[] = $row;
}
$collect(is_array($row['children'] ?? null) ? $row['children'] : [], $rowPath);
}
};
$collect(is_array($overview['rows'] ?? null) ? $overview['rows'] : []);
if ($candidates === []) {
echo "FAN_DETAIL_DB_SMOKE_SKIP no_fans\n";
exit(0);
}
usort($candidates, static function (array $left, array $right): int {
$leftCenter = str_contains((string) ($left['_smoke_path'] ?? ''), '郑州二中心') ? 0 : 1;
$rightCenter = str_contains((string) ($right['_smoke_path'] ?? ''), '郑州二中心') ? 0 : 1;
$leftMember = in_array((string) ($left['type'] ?? ''), ['member', 'unbound'], true) ? 0 : 1;
$rightMember = in_array((string) ($right['type'] ?? ''), ['member', 'unbound'], true) ? 0 : 1;
return ($leftCenter <=> $rightCenter)
?: ($leftMember <=> $rightMember)
?: ((int) ($left['add_fans_count'] ?? 0) <=> (int) ($right['add_fans_count'] ?? 0));
});
$row = $candidates[0];
$entityType = in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)
? 'member'
: 'dept';
$detailStartedAt = microtime(true);
$detail = FirstVisitConversionLogic::fansDetail($params + [
'entity_type' => $entityType,
'entity_id' => (string) ($row['id'] ?? ''),
'admin_id' => (int) ($row['admin_id'] ?? 0),
'page_no' => 1,
'page_size' => 100,
], (int) $admin['id'], $admin);
$detailElapsedMs = (microtime(true) - $detailStartedAt) * 1000;
if ((int) ($detail['count'] ?? -1) !== (int) ($row['add_fans_count'] ?? 0)) {
throw new RuntimeException('Fan detail count does not match the clicked add_fans_count row');
}
if ((int) ($detail['entity']['deleted_fans_count'] ?? -1) !== (int) ($row['deleted_fans_count'] ?? 0)) {
throw new RuntimeException('Fan detail entity metadata lost deleted_fans_count');
}
foreach ($detail['lists'] ?? [] as $fan) {
foreach (['external_userid', 'customer_name', 'wecom_userid', 'wecom_staff_name', 'add_time', 'is_deleted', 'delete_time'] as $field) {
if (!array_key_exists($field, $fan)) {
throw new RuntimeException("Fan detail row is missing {$field}");
}
}
}
$forgedDetail = FirstVisitConversionLogic::fansDetail($params + [
'entity_type' => 'member',
'entity_id' => 'M999999_-2',
'admin_id' => 999999,
'page_no' => 1,
'page_size' => 1,
], (int) $admin['id'], $admin);
if ((int) ($forgedDetail['count'] ?? -1) !== 0 || ($forgedDetail['entity'] ?? null) !== null) {
throw new RuntimeException('Forged fan-detail entity did not fail closed');
}
$centerDetailElapsedMs = 0.0;
$centerDetailCount = -1;
$centerRows = array_values(array_filter($candidates, static fn (array $candidate): bool =>
!in_array((string) ($candidate['type'] ?? ''), ['member', 'unbound'], true)
&& str_contains((string) ($candidate['name'] ?? ''), '郑州二中心')
));
if ($centerRows !== []) {
$centerRow = $centerRows[0];
$centerStartedAt = microtime(true);
$centerDetail = FirstVisitConversionLogic::fansDetail($params + [
'entity_type' => 'dept',
'entity_id' => (string) ($centerRow['id'] ?? ''),
'page_no' => 1,
'page_size' => 100,
], (int) $admin['id'], $admin);
$centerDetailElapsedMs = (microtime(true) - $centerStartedAt) * 1000;
$centerDetailCount = (int) ($centerDetail['count'] ?? -1);
if ($centerDetailCount !== (int) ($centerRow['add_fans_count'] ?? 0)
|| (int) ($centerDetail['entity']['deleted_fans_count'] ?? -1)
!== (int) ($centerRow['deleted_fans_count'] ?? 0)
) {
throw new RuntimeException('郑州二中心 detail count does not match overview');
}
}
$parentCandidates = array_values(array_filter($candidates, static function (array $candidate): bool {
foreach (is_array($candidate['children'] ?? null) ? $candidate['children'] : [] as $child) {
if (is_array($child) && !in_array((string) ($child['type'] ?? ''), ['member', 'unbound'], true)) {
return true;
}
}
return false;
}));
$parentChecked = false;
if ($parentCandidates !== []) {
usort(
$parentCandidates,
static fn (array $left, array $right): int => ((int) ($left['add_fans_count'] ?? 0))
<=> ((int) ($right['add_fans_count'] ?? 0))
);
$parentRow = $parentCandidates[0];
$parentDeptIds = [];
$collectDeptIds = static function (array $node) use (&$collectDeptIds, &$parentDeptIds): void {
if (in_array((string) ($node['type'] ?? ''), ['member', 'unbound'], true)) {
return;
}
$parentDeptIds[(int) ($node['id'] ?? 0)] = true;
foreach (is_array($node['children'] ?? null) ? $node['children'] : [] as $child) {
if (is_array($child)) {
$collectDeptIds($child);
}
}
};
$collectDeptIds($parentRow);
$parentDetail = ConversionLogic::fanDetails([
'dimension' => 'dept',
'time_type' => 'custom',
'start_date' => (string) ($overview['meta']['start_date'] ?? ''),
'end_date' => (string) ($overview['meta']['end_date'] ?? ''),
'page_no' => 1,
'page_size' => 1,
], [
'type' => 'dept',
'dept_ids' => array_keys($parentDeptIds),
], (int) $admin['id'], $admin);
if ((int) ($parentDetail['count'] ?? -1) !== (int) ($parentRow['add_fans_count'] ?? 0)) {
throw new RuntimeException('Parent department fan detail does not include the same descendant total as overview');
}
$parentChecked = true;
}
echo sprintf(
"FAN_DETAIL_DB_SMOKE_OK range=2026-08-01..2026-08-25 type=%s entity=%s path=%s count=%d deleted=%d parent=%d overview_ms=%.1f detail_ms=%.1f center_count=%d center_detail_ms=%.1f forged=0\n",
$entityType,
(string) ($row['name'] ?? $row['id'] ?? ''),
(string) ($row['_smoke_path'] ?? ''),
(int) $detail['count'],
(int) ($detail['entity']['deleted_fans_count'] ?? 0),
$parentChecked ? 1 : 0,
$overviewElapsedMs,
$detailElapsedMs,
$centerDetailCount,
$centerDetailElapsedMs
);
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
use app\adminapi\logic\stats\ConversionLogic;
require dirname(__DIR__) . '/vendor/autoload.php';
function conversionFanDetailExpect(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
$firstVisitReflection = new ReflectionClass(FirstVisitConversionLogic::class);
$conversionSource = file_get_contents((new ReflectionClass(ConversionLogic::class))->getFileName());
conversionFanDetailExpect(is_string($conversionSource), 'Unable to read ConversionLogic source');
$buildDetailRows = (new ReflectionClass(ConversionLogic::class))->getMethod('buildFanDetailRows');
$buildDetailRows->setAccessible(true);
$detailRows = $buildDetailRows->invoke(null, [
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 100],
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 200],
['user_id' => 'staff-b', 'external_userid' => 'deleted', 'add_time' => 300],
], [
['user_id' => 'staff-a', 'external_userid' => 'readded', 'add_time' => 200],
]);
conversionFanDetailExpect(
count($detailRows) === 2
&& (int) ($detailRows[0]['add_time'] ?? 0) === 100
&& empty($detailRows[0]['is_deleted'])
&& !empty($detailRows[1]['is_deleted']),
'Delete/re-add details must retain the first counted add time and final deletion state'
);
conversionFanDetailExpect(
str_contains($conversionSource, 'self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds)')
&& str_contains($conversionSource, 'MIN(e.event_time) AS add_time')
&& str_contains($conversionSource, "->where('e.event_time', 'between', [\$startTimestamp, \$endTimestamp])")
&& str_contains($conversionSource, 'EXISTS (SELECT 1 FROM `')
&& str_contains($conversionSource, 'MAX(event_time) AS delete_time'),
'Aggregate and detail results must share the same distinct fan-pair loader and expose event times'
);
conversionFanDetailExpect(
str_contains($conversionSource, "'external_userid' => \$externalUserId")
&& str_contains($conversionSource, "'customer_name'")
&& str_contains($conversionSource, "'wecom_staff_name'")
&& str_contains($conversionSource, "'is_deleted'")
&& str_contains($conversionSource, "'delete_time'"),
'Fan detail response must expose customer, employee, add and deletion fields'
);
$firstVisitSource = file_get_contents($firstVisitReflection->getFileName());
conversionFanDetailExpect(
is_string($firstVisitSource)
&& !str_contains($firstVisitSource, '$dashboard = self::overview($params, $adminId, $adminInfo);')
&& str_contains($firstVisitSource, 'resolveFanDetailEntity(')
&& str_contains($firstVisitSource, 'resolveFanDetailMemberDeptId(')
&& str_contains($firstVisitSource, "['channel_dept_ids']")
&& str_contains($firstVisitSource, "preg_match('/^M([1-9]\\d*)_(-?\\d+)$/', \$entityId")
&& str_contains($firstVisitSource, "MediaChannelService::getCurrentTagChannelByCode"),
'Fan detail must authorize the exact structural entity without recomputing overview and retain current-tag channel scope'
);
conversionFanDetailExpect(
str_contains($conversionSource, '$targetWorkWechatUserIds')
&& str_contains($conversionSource, 'fanDetailChannelDeptIds($mediaChannel)')
&& str_contains($conversionSource, 'filterEntitiesByChannelDeptScope(')
&& str_contains($conversionSource, 'NOT EXISTS (SELECT 1 FROM `')
&& str_contains($conversionSource, "':wecom:'")
&& str_contains($conversionSource, '$matched = $pairs;'),
'Fan detail must narrow the event query to the clicked employees/WeCom user before sorting and pagination'
);
$controllerSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/firstvisit/ConversionController.php');
conversionFanDetailExpect(
is_string($controllerSource)
&& str_contains($controllerSource, 'public function fansDetail()')
&& str_contains($controllerSource, 'FirstVisitConversionLogic::fansDetail('),
'First-visit conversion controller must expose the fansDetail endpoint'
);
echo "FirstVisitConversionFanDetailTest passed\n";
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use app\common\service\qywx\QywxPromotionMemberRange;
use app\common\service\qywx\QywxPromotionMemberSchedulerService;
require dirname(__DIR__) . '/vendor/autoload.php';
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$today = '2026-08-25';
$now = strtotime($today . ' 12:00:00');
$range = QywxPromotionMemberRange::evaluate([
['id' => 1, 'userid' => 'A', 'enabled' => 1, 'weight' => 100, 'current_weight' => 9, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
['id' => 2, 'userid' => 'B', 'enabled' => 0, 'weight' => 1, 'current_weight' => -3, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
['id' => 3, 'userid' => 'C', 'enabled' => 1, 'weight' => 1, 'current_weight' => 4, 'daily_limit' => 3, 'today_count' => 3, 'today_date' => $today],
['id' => 4, 'userid' => 'D', 'enabled' => 1, 'weight' => 1, 'current_weight' => 1, 'daily_limit' => 1, 'today_count' => 1, 'today_date' => '2026-08-24'],
['id' => 5, 'userid' => 'E', 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today, 'active_start' => $now + 1],
['id' => 6, 'userid' => 'F', 'enabled' => 1, 'weight' => 1, 'current_weight' => 0, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today, 'active_end' => $now - 1],
], $today, $now);
$assert($range['userids'] === ['A', 'D'], '官方多人路由范围没有正确排除禁用或达到上限的成员');
$assert($range['userids'] === ['A', 'D'], '逐成员权重不得改变企业微信原生多人范围');
$assert($range['eligible_count'] === 2, '可用成员数量错误');
$assert($range['members'][3]['today_count'] === 0, '跨日数量没有自动重置');
$assert(array_column($range['members'], 'current_weight') === [0, 0, 0, 0, 0, 0], '旧版调度游标没有归零');
$assert(QywxPromotionMemberRange::same(['B', 'A', 'A'], ['A', 'B']), '成员范围比较不应受顺序和重复值影响');
$assert(!QywxPromotionMemberRange::same(['A'], ['A', 'B']), '不同成员范围被错误判定为相同');
$assert(QywxPromotionMemberSchedulerService::poolIdFromState('zyt_pool:123') === 123, 'customer_channel 方案 ID 解析失败');
$assert(QywxPromotionMemberSchedulerService::poolIdFromState('qywx_ca:123') === 0, '不应接管其他系统的 customer_channel');
$empty = QywxPromotionMemberRange::evaluate([
['id' => 7, 'userid' => 'G', 'enabled' => 0, 'today_date' => $today],
], $today, $now);
$assert($empty['userids'] === [] && $empty['eligible_count'] === 0, '零可用成员必须返回空范围并交由同步层阻止远端更新');
echo "QYWX_PROMOTION_MEMBER_RANGE_OK\n";
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use think\facade\Db;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new think\App();
$app->initialize();
final class PromotionDeleteApiFake extends QywxCustomerAcquisitionApiService
{
/** @var list<string> */
public array $deleted = [];
public function __construct(private string $failLinkId = '')
{
}
public function deleteLink(string $linkId): void
{
$this->deleted[] = $linkId;
if ($linkId === $this->failLinkId) {
throw new RuntimeException('mock remote deletion failed');
}
}
}
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$admin = Db::name('admin')->where('root', 1)->whereNull('delete_time')->find();
if (!$admin) {
throw new RuntimeException('未找到 root 管理员,无法执行删除行为测试');
}
$now = time();
$suffix = bin2hex(random_bytes(6));
$firstRemoteId = 'contract_first_' . $suffix;
$secondRemoteId = 'contract_second_' . $suffix;
Db::startTrans();
try {
$poolId = (int) Db::name('qywx_promotion_pool')->insertGetId([
'name' => '删除契约测试',
'public_key' => bin2hex(random_bytes(16)),
'status' => 1,
'owner_admin_id' => (int) $admin['id'],
'create_time' => $now,
'update_time' => $now,
]);
$firstLinkId = (int) Db::name('qywx_promotion_link')->insertGetId([
'pool_id' => $poolId,
'name' => '当前官方链接',
'wecom_url' => 'https://work.weixin.qq.com/ca/' . $firstRemoteId,
'remote_link_id' => $firstRemoteId,
'remote_status' => 1,
'status' => 1,
'owner_admin_id' => (int) $admin['id'],
'create_time' => $now,
'update_time' => $now,
]);
$secondLinkId = (int) Db::name('qywx_promotion_link')->insertGetId([
'pool_id' => $poolId,
'name' => '历史已软删官方链接',
'wecom_url' => 'https://work.weixin.qq.com/ca/' . $secondRemoteId,
'remote_link_id' => $secondRemoteId,
'remote_status' => 1,
'status' => 0,
'owner_admin_id' => (int) $admin['id'],
'create_time' => $now,
'update_time' => $now,
'delete_time' => $now - 10,
]);
$memberId = (int) Db::name('qywx_promotion_pool_member')->insertGetId([
'pool_id' => $poolId,
'admin_id' => (int) $admin['id'],
'userid' => 'contract_user_' . $suffix,
'enabled' => 1,
'create_time' => $now,
'update_time' => $now,
]);
Db::name('qywx_promotion_range_sync')->insert([
'pool_id' => $poolId,
'promotion_link_id' => $firstLinkId,
'desired_version' => 1,
'applied_version' => 1,
'status' => 0,
'create_time' => $now,
'update_time' => $now,
]);
$firstAttempt = new PromotionDeleteApiFake($secondRemoteId);
$message = '';
try {
WecomPromotionLogic::deletePool($poolId, (int) $admin['id'], $admin, $firstAttempt);
} catch (RuntimeException $e) {
$message = $e->getMessage();
}
$assert(str_contains($message, '本地方案已保留'), '远端部分删除失败没有明确保留本地方案');
$assert($firstAttempt->deleted === [$firstRemoteId, $secondRemoteId], '首次删除没有按顺序处理全部远端链接');
$assert(Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNull('delete_time')->count() === 1, '远端失败时错误删除了本地方案');
$assert((int) Db::name('qywx_promotion_link')->where('id', $firstLinkId)->value('remote_status') === 2, '部分成功的远端删除进度没有保存');
$assert((int) Db::name('qywx_promotion_link')->where('id', $secondLinkId)->value('remote_status') === 1, '失败的远端链接被错误标记为已删除');
$assert((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 1, '远端失败时错误禁用了方案成员');
$retry = new PromotionDeleteApiFake();
WecomPromotionLogic::deletePool($poolId, (int) $admin['id'], $admin, $retry);
$assert($retry->deleted === [$secondRemoteId], '重试时重复删除了已经成功的远端链接');
$assert(Db::name('qywx_promotion_pool')->where('id', $poolId)->whereNotNull('delete_time')->count() === 1, '远端全部成功后没有软删除本地方案');
$assert((int) Db::name('qywx_promotion_link')->where('id', $secondLinkId)->value('remote_status') === 2, '历史软删链接没有同步删除企业微信端');
$assert((int) Db::name('qywx_promotion_pool_member')->where('id', $memberId)->value('enabled') === 0, '方案删除后成员仍处于启用状态');
$sync = Db::name('qywx_promotion_range_sync')->where('pool_id', $poolId)->find();
$assert((int) ($sync['status'] ?? 0) === 4 && (string) ($sync['lock_token'] ?? '') === '', '方案删除后同步租约没有终止');
} finally {
Db::rollback();
}
echo "WECOM_PROMOTION_DELETE_POOL_BEHAVIOR_OK\n";
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
$logicPath = dirname(__DIR__) . '/app/adminapi/logic/firstvisit/WecomPromotionLogic.php';
$viewPath = dirname(__DIR__, 2) . '/admin/src/views/first_visit/wecom_promotion/index.vue';
$logic = file_get_contents($logicPath);
$view = file_get_contents($viewPath);
if (!is_string($logic) || !is_string($view)) {
throw new RuntimeException('无法读取获客助手删除链路源码');
}
$start = strpos($logic, 'public static function deletePool(');
$end = strpos($logic, 'public static function saveMember(', $start === false ? 0 : $start);
if ($start === false || $end === false || $end <= $start) {
throw new RuntimeException('无法定位 deletePool 方法');
}
$method = substr($logic, $start, $end - $start);
$remoteDeleteAt = strpos($method, '$api->deleteLink($remoteLinkId)');
$localDeleteAt = strpos($method, "Db::transaction(function () use (\$id, \$now)");
if ($remoteDeleteAt === false || $localDeleteAt === false || $remoteDeleteAt >= $localDeleteAt) {
throw new RuntimeException('删除方案必须先永久删除企业微信官方链接,再删除本地记录');
}
if (!str_contains($method, '本地方案已保留') || !str_contains($method, "'remote_status' => 2")) {
throw new RuntimeException('企业微信删除失败保护或成功状态记录缺失');
}
if (!str_contains($view, '已投放的链接会失效且无法恢复') || !str_contains($view, "confirmButtonText: '永久删除'")) {
throw new RuntimeException('前端没有明确提示官方链接将被永久删除');
}
echo "WECOM_PROMOTION_DELETE_POOL_CONTRACT_OK\n";