更新
This commit is contained in:
@@ -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'])
|
||||
);
|
||||
Reference in New Issue
Block a user