This commit is contained in:
Your Name
2026-08-06 10:57:35 +08:00
parent 2c0b9c5afa
commit 079e50006d
400 changed files with 3046 additions and 714 deletions
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\stats\YejiStatsLogic;
require dirname(__DIR__) . '/vendor/autoload.php';
$query = new class() {
/** @var string[] */
public array $whereRawClauses = [];
public function whereRaw(string $sql): self
{
$this->whereRawClauses[] = $sql;
return $this;
}
};
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'po');
if (count($query->whereRawClauses) !== 2) {
throw new RuntimeException('有效金额过滤条件数量不正确');
}
if (!str_contains($query->whereRawClauses[0], 'po.fulfillment_status NOT IN (4,9,10)')) {
throw new RuntimeException('未排除已取消、拒收和全额退款状态');
}
if (!str_contains($query->whereRawClauses[1], 'po.refund_amount <= 0')) {
throw new RuntimeException('未排除已发生部分退款的订单');
}
echo "FIRST_VISIT_EFFECTIVE_AMOUNT_FILTER_OK\n";
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use think\facade\Config;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new think\App();
$app->initialize();
Config::set([
'corp_id' => 'ww_test_corp',
'secret' => 'test_secret',
'base_uri' => 'https://qyapi.weixin.qq.com',
'timeout' => 5,
], 'qywx_customer_acquisition');
$json = static fn (array $data): Response => new Response(200, ['Content-Type' => 'application/json'], json_encode($data));
$mock = new MockHandler([
$json(['errcode' => 0, 'link_id_list' => ['link_1'], 'next_cursor' => 'cursor_2']),
$json(['errcode' => 0, 'link' => ['link_id' => 'link_1', 'link_name' => '官网获客', 'url' => 'https://work.weixin.qq.com/ca/test']]),
$json(['errcode' => 0, 'link_id' => 'link_2', 'url' => 'https://work.weixin.qq.com/ca/new']),
$json(['errcode' => 0]),
$json(['errcode' => 0]),
$json(['errcode' => 0, 'customer_list' => [[
'external_userid' => 'wm_customer_1',
'userid' => 'zhangsan',
'chat_status' => 1,
'state' => 'landing-page',
]], 'next_cursor' => 'customer_cursor_2']),
$json(['errcode' => 0, 'external_userid' => 'wm_customer_1', 'userid' => 'zhangsan', 'chat_info' => [
'link_id' => 'link_1',
'state' => 'landing-page',
'recv_msg_cnt' => 3,
]]),
$json(['errcode' => 0, 'link_id_list' => []]),
]);
$history = [];
$stack = HandlerStack::create($mock);
$stack->push(Middleware::history($history));
$service = new QywxCustomerAcquisitionApiService(new Client([
'base_uri' => 'https://qyapi.weixin.qq.com/',
'handler' => $stack,
'http_errors' => false,
]), static fn (): string => 'mock_token');
$list = $service->listLinks('', 100);
$detail = $service->getLink('link_1');
$created = $service->createLink(['link_name' => '官网获客', 'range' => ['user_list' => ['zhangsan']]]);
$service->updateLink(['link_id' => 'link_1', 'link_name' => '官网获客-更新']);
$service->deleteLink('link_1');
$customers = $service->listCustomers('link_1', '', 1000);
$chat = $service->getChatInfo('chat_key_1');
$permission = $service->checkPermission();
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$assert($list['link_id_list'] === ['link_1'] && $list['next_cursor'] === 'cursor_2', 'list_link 返回解析失败');
$assert(($detail['link']['link_id'] ?? '') === 'link_1', 'get 返回解析失败');
$assert(($created['link_id'] ?? '') === 'link_2', 'create_link 返回解析失败');
$assert(($customers['customer_list'][0]['external_userid'] ?? '') === 'wm_customer_1', 'customer 客户列表解析失败');
$assert(($customers['next_cursor'] ?? '') === 'customer_cursor_2', 'customer 游标解析失败');
$assert(($chat['chat_info']['recv_msg_cnt'] ?? 0) === 3, 'get_chat_info 累计消息数解析失败');
$assert(($permission['ok'] ?? false) === true, '权限验证失败');
$assert(($permission['has_link'] ?? true) === false, '空链接列表应明确返回 has_link=false');
$assert(str_contains((string) ($permission['message'] ?? ''), '尚未'), '空链接列表应返回可操作的诊断提示');
$expectedPaths = [
'/cgi-bin/externalcontact/customer_acquisition/list_link',
'/cgi-bin/externalcontact/customer_acquisition/get',
'/cgi-bin/externalcontact/customer_acquisition/create_link',
'/cgi-bin/externalcontact/customer_acquisition/update_link',
'/cgi-bin/externalcontact/customer_acquisition/delete_link',
'/cgi-bin/externalcontact/customer_acquisition/customer',
'/cgi-bin/externalcontact/customer_acquisition/get_chat_info',
'/cgi-bin/externalcontact/customer_acquisition/list_link',
];
$actualPaths = array_map(static fn (array $entry): string => $entry['request']->getUri()->getPath(), $history);
$assert($actualPaths === $expectedPaths, '请求端点不正确:' . implode(', ', $actualPaths));
$createPayload = json_decode((string) $history[2]['request']->getBody(), true);
$assert(($createPayload['range']['user_list'][0] ?? '') === 'zhangsan', 'create_link 成员范围请求体不正确');
$customerPayload = json_decode((string) $history[5]['request']->getBody(), true);
$assert(($customerPayload['link_id'] ?? '') === 'link_1' && ($customerPayload['limit'] ?? 0) === 1000, 'customer 请求体不正确');
$chatPayload = json_decode((string) $history[6]['request']->getBody(), true);
$assert(($chatPayload['chat_key'] ?? '') === 'chat_key_1', 'get_chat_info 请求体不正确');
echo "QYWX_CUSTOMER_ACQUISITION_API_TEST_OK\n";
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
require dirname(__DIR__) . '/vendor/autoload.php';
$assert = static function (bool $condition, string $message): void {
if (!$condition) {
throw new RuntimeException($message);
}
};
$now = 2_000_000_000;
$active = QywxCustomerAcquisitionCustomerService::retryDecision($now - 120, $now);
$assert($active['expired'] === false, '有效期内事件不应标记过期');
$assert($active['expire_time'] === $now + 1680, 'ChatKey 截止时间必须是事件时间 + 30 分钟');
$assert($active['next_retry'] === $now + 30, '失败事件应安排 30 秒后重试');
$nearDeadline = QywxCustomerAcquisitionCustomerService::retryDecision($now - 1790, $now);
$assert($nearDeadline['expired'] === false, '截止前事件仍应允许重试');
$assert($nearDeadline['next_retry'] === $now + 9, '重试时间不得越过 ChatKey 硬截止');
$expired = QywxCustomerAcquisitionCustomerService::retryDecision($now - 1800, $now);
$assert($expired['expired'] === true, '满 30 分钟必须明确过期');
$assert($expired['next_retry'] === 0, '过期事件不得继续安排重试');
$message = ['MsgId' => 'm1', 'LinkID' => 'l1', 'UserID' => 'u1'];
$keyA = QywxCustomerAcquisitionCustomerService::eventKey($message, 'message_from_customer', 'chat-1', $now);
$keyB = QywxCustomerAcquisitionCustomerService::eventKey($message, 'message_from_customer', 'chat-1', $now);
$assert($keyA === $keyB && strlen($keyA) === 64, '事件幂等键必须稳定且为 SHA-256');
$console = require dirname(__DIR__) . '/config/console.php';
$assert(
($console['commands']['qywx:retry-customer-acquisition-events'] ?? '') === 'app\\command\\QywxRetryCustomerAcquisitionEvents',
'获客回调重试命令未注册'
);
echo "QYWX_CUSTOMER_ACQUISITION_RETRY_POLICY_TEST_OK\n";
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\WecomAcquisitionCustomerLogic;
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
use think\facade\Db;
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new think\App();
$app->initialize();
$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 管理员,无法执行获客客户统计冒烟测试');
}
$assert(
Db::name('dev_crontab')
->where('command', 'qywx:retry-customer-acquisition-events')
->whereNull('delete_time')
->count() === 1,
'获客回调每分钟重试任务未写入数据库'
);
$suffix = bin2hex(random_bytes(5));
$linkId = '__smoke_link_' . $suffix;
$externalUserId = '__smoke_external_' . $suffix;
$userId = '__smoke_user_' . $suffix;
$eventTime = time();
$api = new class($linkId, $externalUserId, $userId) extends QywxCustomerAcquisitionApiService {
public function __construct(
private string $testLinkId,
private string $testExternalUserId,
private string $testUserId
) {
}
public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array
{
return [
'customer_list' => [[
'external_userid' => $this->testExternalUserId,
'userid' => $this->testUserId,
'chat_status' => 2,
'state' => 'smoke-sync',
]],
'next_cursor' => '',
];
}
public function getChatInfo(string $chatKey): array
{
return [
'external_userid' => $this->testExternalUserId,
'userid' => $this->testUserId,
'chat_info' => [
'link_id' => $this->testLinkId,
'state' => 'smoke-chat',
'recv_msg_cnt' => 5,
],
];
}
};
$service = new QywxCustomerAcquisitionCustomerService($api);
Db::startTrans();
try {
$startMessage = [
'MsgId' => 'smoke-start-' . $suffix,
'ChangeType' => 'customer_start_chat',
'CreateTime' => $eventTime,
'LinkID' => $linkId,
'ExternalUserID' => $externalUserId,
'UserID' => $userId,
'State' => 'smoke-start',
];
$service->handleCallback($startMessage);
$chatMessage = [
'MsgId' => 'smoke-chat-' . $suffix,
'ChangeType' => 'message_from_customer',
'CreateTime' => $eventTime + 1,
'ChatKey' => 'smoke-chat-key-' . $suffix,
];
$service->handleCallback($chatMessage);
$duplicate = $service->handleCallback($chatMessage);
$assert(($duplicate['duplicate'] ?? false) === true, '相同 message_from_customer 回调必须幂等');
// 远端客户列表中的 chat_status=2 是“未知”,不能把已由回调确认的状态 1 回退。
$service->syncLink($linkId);
$customer = Db::name('qywx_customer_acquisition_customer')
->where('link_id', $linkId)
->where('external_userid', $externalUserId)
->where('userid', $userId)
->find();
$assert((int) ($customer['chat_status'] ?? -1) === 1, '列表同步错误回退了已确认的聊天状态');
$assert((int) ($customer['recv_msg_cnt'] ?? -1) === 5, '累计接收消息数不正确或被重复累加');
$assert((int) ($customer['message_count_known'] ?? 0) === 1, '精确消息次数标识未保存');
$stats = WecomAcquisitionCustomerLogic::statistics(
['keyword' => $externalUserId, 'page_size' => 20],
(int) $admin['id'],
$admin
);
$summary = $stats['summary'] ?? [];
$assert((int) ($summary['customer_count'] ?? 0) === 1, '获客客户汇总数量不正确');
$assert((int) ($summary['started_chat_count'] ?? 0) === 1, '已发消息客户数不正确');
$assert((int) ($summary['received_message_count'] ?? 0) === 5, '接收消息汇总不正确');
$assert((int) ($summary['message_count_known_count'] ?? 0) === 1, '精确消息统计覆盖数不正确');
$row = $stats['lists'][0] ?? [];
$assert(!array_key_exists('external_userid', $row), '接口不应返回原始客户 ExternalUserID');
$assert(str_contains((string) ($row['external_userid_masked'] ?? ''), '*'), '客户标识没有脱敏');
$eventKeys = [
QywxCustomerAcquisitionCustomerService::eventKey($startMessage, 'customer_start_chat', '', $eventTime),
QywxCustomerAcquisitionCustomerService::eventKey(
$chatMessage,
'message_from_customer',
(string) $chatMessage['ChatKey'],
$eventTime + 1
),
];
$events = Db::name('qywx_customer_acquisition_event')
->whereIn('event_key', $eventKeys)
->select()->toArray();
$assert(count($events) === 2, '回调事件审计数量不正确');
foreach ($events as $event) {
$assert((int) ($event['status'] ?? 0) === 1, '成功回调未进入成功终态');
$assert((string) ($event['chat_key'] ?? '') === '', '成功后仍保存了敏感 ChatKey');
$assert(($event['raw_json'] ?? null) === null, '成功后仍保存了原始回调');
}
echo "WECOM_ACQUISITION_CUSTOMER_STATISTICS_SMOKE_OK messages=5\n";
} finally {
Db::rollback();
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
use app\common\service\DataScope\DataScopeService;
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) {
throw new RuntimeException('未找到 root 管理员,无法执行数据范围冒烟测试');
}
$overview = WecomPromotionLogic::overview((int) $admin['id'], $admin, 'https://example.test');
foreach (['meta', 'config', 'summary', 'pools', 'links', 'member_options'] as $key) {
if (!array_key_exists($key, $overview)) {
throw new RuntimeException("overview 缺少 {$key}");
}
}
if (!str_ends_with((string) ($overview['config']['callback_url'] ?? ''), '/api/qywx/external-contact/notify')) {
throw new RuntimeException('overview 未返回正确的获客消息回调地址');
}
foreach ($overview['member_options'] as $member) {
if (empty($member['id']) || empty($member['userid'])) {
throw new RuntimeException('member_options 返回了未绑定企业微信 userid 的成员');
}
}
$scopedAdmin = Db::name('admin')->where('root', 0)->whereNull('delete_time')->order('id', 'asc')->find();
if ($scopedAdmin) {
$visibleIds = DataScopeService::getVisibleAdminIds((int) $scopedAdmin['id'], $scopedAdmin);
$scopedOverview = WecomPromotionLogic::overview((int) $scopedAdmin['id'], $scopedAdmin, 'https://example.test');
if ($visibleIds !== null) {
foreach ($scopedOverview['member_options'] as $member) {
if (!in_array((int) $member['id'], $visibleIds, true)) {
throw new RuntimeException('member_options 泄露了当前角色或部门范围外的成员');
}
}
foreach ($scopedOverview['pools'] as $pool) {
if (!in_array((int) $pool['owner_admin_id'], $visibleIds, true)) {
throw new RuntimeException('pools 泄露了当前角色或部门范围外的数据');
}
}
}
}
echo sprintf(
"WECOM_PROMOTION_OVERVIEW_SMOKE_OK configured=%d callback=%d pools=%d links=%d members=%d\n",
!empty($overview['config']['ready']) ? 1 : 0,
!empty($overview['config']['callback_ready']) ? 1 : 0,
count($overview['pools']),
count($overview['links']),
count($overview['member_options'])
);