;rgb:0000/0000/0000
This commit is contained in:
@@ -23,16 +23,11 @@ function conversionPrivateMethod(string $name): ReflectionMethod
|
||||
|
||||
$buildFanCountRows = conversionPrivateMethod('buildFanCountRows');
|
||||
$fanRows = $buildFanCountRows->invoke(null, [
|
||||
['user_id' => 'alice', 'external_userid' => 'live'],
|
||||
['user_id' => 'alice', 'external_userid' => 'deleted'],
|
||||
['user_id' => 'alice', 'external_userid' => 'deleted'], // distinct pair only
|
||||
['user_id' => 'bob', 'external_userid' => 'deleted-only'],
|
||||
['user_id' => 'carol', 'external_userid' => 'delete-readded'],
|
||||
], [
|
||||
['user_id' => 'alice', 'external_userid' => 'live'],
|
||||
// A candidate pair that survived a delete/re-add cycle remains effective and is not deleted.
|
||||
['user_id' => 'carol', 'external_userid' => 'delete-readded'],
|
||||
['user_id' => 'nobody', 'external_userid' => 'not-a-candidate'],
|
||||
['add_event_id' => 1, 'user_id' => 'alice', 'external_userid' => 'shared-customer', 'is_deleted' => false],
|
||||
['add_event_id' => 2, 'user_id' => 'alice', 'external_userid' => 'shared-customer', 'is_deleted' => false], // same pair only once
|
||||
['add_event_id' => 3, 'user_id' => 'alice', 'external_userid' => 'deleted-only', 'is_deleted' => true],
|
||||
['add_event_id' => 4, 'user_id' => 'bob', 'external_userid' => 'shared-customer', 'is_deleted' => false],
|
||||
['add_event_id' => 5, 'user_id' => 'carol', 'external_userid' => 'live', 'is_deleted' => false],
|
||||
]);
|
||||
$fanRowsByUser = [];
|
||||
foreach ($fanRows as $row) {
|
||||
@@ -42,21 +37,17 @@ foreach ($fanRows as $row) {
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['alice']['add_fans_count'] ?? null) === 2
|
||||
&& ($fanRowsByUser['alice']['deleted_fans_count'] ?? null) === 1,
|
||||
'All distinct candidate pairs must count as add fans, with deleted fans retained as a subset'
|
||||
'The same employee/customer pair must count once, while another customer remains a separate pair'
|
||||
);
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['bob']['add_fans_count'] ?? null) === 1
|
||||
&& ($fanRowsByUser['bob']['deleted_fans_count'] ?? null) === 1,
|
||||
'A deleted-only candidate must count once in add fans and once in its deleted subset'
|
||||
&& ($fanRowsByUser['bob']['deleted_fans_count'] ?? null) === 0,
|
||||
'The same customer under a different employee must count as a new employee/customer pair'
|
||||
);
|
||||
deletedFansExpect(
|
||||
($fanRowsByUser['carol']['add_fans_count'] ?? null) === 1
|
||||
&& ($fanRowsByUser['carol']['deleted_fans_count'] ?? null) === 0,
|
||||
'A delete/re-add pair that remains effective at period end must not count as deleted'
|
||||
);
|
||||
deletedFansExpect(
|
||||
!isset($fanRowsByUser['nobody']),
|
||||
'Effective rows outside the source-filtered candidate set must not be counted'
|
||||
'A live add event must not count as deleted'
|
||||
);
|
||||
|
||||
$newEntityRow = conversionPrivateMethod('newEntityRow');
|
||||
@@ -146,10 +137,12 @@ deletedFansExpect(
|
||||
'Dual-role merging and virtual department buckets must propagate deleted_fans_count'
|
||||
);
|
||||
deletedFansExpect(
|
||||
str_contains($logicSource, 'applyHistoricalExternalUserChannelFilter')
|
||||
str_contains($logicSource, 'applyExternalUserEventChannelFilter')
|
||||
&& str_contains($logicSource, "'e.id'")
|
||||
&& str_contains($logicSource, "'e.user_id'")
|
||||
&& str_contains($logicSource, 'surviving_e.event_time >= ?')
|
||||
&& str_contains($logicSource, 'surviving_del.event_time >= surviving_e.event_time'),
|
||||
'Deleted pairs must use historical channel attribution and exclude pairs with a surviving re-add'
|
||||
&& str_contains($logicSource, 'surviving_del.event_time > surviving_e.event_time'),
|
||||
'Deleted pairs must use event/employee channel snapshots and recognize a surviving re-add'
|
||||
);
|
||||
|
||||
$pageSource = file_get_contents(
|
||||
|
||||
@@ -33,17 +33,32 @@ conversionFanRuleExpect(
|
||||
'会话存档同意是独立能力,不能再次成为加粉统计的硬性条件'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "'del_external_contact'")
|
||||
&& str_contains($methodSource, 'surviving_del.event_time >= surviving_e.event_time'),
|
||||
'加粉统计必须继续识别区间内已删除客户'
|
||||
str_contains($methodSource, 'surviving_del.change_type = ?')
|
||||
&& str_contains($methodSource, 'surviving_del.id > surviving_e.id'),
|
||||
'加粉统计必须继续识别员工+客户组合的期末删除状态'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, 'e.id AS add_event_id')
|
||||
&& str_contains($methodSource, 'e.event_time AS add_time'),
|
||||
'加粉统计必须保留组合最早新增事件用于渠道和时间归属'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, 'prev_e.user_id = e.user_id')
|
||||
&& str_contains($methodSource, 'prev_e.external_userid = e.external_userid')
|
||||
&& str_contains($methodSource, 'earlier_e.user_id = e.user_id')
|
||||
&& str_contains($methodSource, 'earlier_e.external_userid = e.external_userid'),
|
||||
'加粉统计必须以员工+客户为组合键,不能只按客户跨员工去重'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "['add_external_contact', \$startTimestamp]"),
|
||||
'加粉统计必须继续排除区间开始前已添加的重加客户'
|
||||
'同一员工+客户在区间开始前已经存在时不能再次算新增组合'
|
||||
);
|
||||
conversionFanRuleExpect(
|
||||
str_contains($methodSource, "->group('e.user_id, e.external_userid')"),
|
||||
'加粉统计必须继续按员工和客户去重'
|
||||
substr_count($methodSource, 'MediaChannelService::applyExternalUserEventChannelFilter(') === 2
|
||||
&& str_contains($methodSource, "'e.id'")
|
||||
&& str_contains($methodSource, "'e.external_userid'")
|
||||
&& str_contains($methodSource, "'e.user_id'"),
|
||||
'有效与已删加粉都必须按事件ID及同一员工读取渠道快照'
|
||||
);
|
||||
|
||||
$excludeMethod = new ReflectionMethod(ConversionLogic::class, 'excludeUncountedFanPairs');
|
||||
@@ -75,8 +90,9 @@ $pageSource = file_get_contents(
|
||||
conversionFanRuleExpect(
|
||||
is_string($pageSource)
|
||||
&& !str_contains($pageSource, '须会话同意')
|
||||
&& str_contains($pageSource, '区间新增加粉(含已删除)'),
|
||||
'页面口径须说明加粉包含已删除客户,且不能继续宣称依赖会话存档同意'
|
||||
&& str_contains($pageSource, '同一员工的同一客户只计一次')
|
||||
&& str_contains($pageSource, '同一客户进入不同员工分别计数'),
|
||||
'页面口径须说明按员工+客户去重且不同员工分别计数'
|
||||
);
|
||||
|
||||
echo "Conversion fan event rule: OK\n";
|
||||
|
||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\setting\DesktopWorkstationLogic;
|
||||
use app\adminapi\logic\setting\DesktopWorkstationLogic;
|
||||
use app\common\service\DirectUploadService;
|
||||
|
||||
function desktopUpdateExpect(bool $condition, string $message): void
|
||||
{
|
||||
@@ -92,14 +93,69 @@ desktopUpdateExpect(
|
||||
|
||||
$adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/desktop_workstation/index.vue');
|
||||
desktopUpdateExpect(is_string($adminView), 'admin view source is readable');
|
||||
desktopUpdateExpect(
|
||||
desktopUpdateExpect(
|
||||
str_contains($adminView, 'setting.desktop_workstation/setConfig')
|
||||
&& str_contains($adminView, 'force_update')
|
||||
&& str_contains($adminView, 'inno_setup'),
|
||||
'admin page can save force-update and Inno Setup configuration'
|
||||
&& str_contains($adminView, 'inno_setup')
|
||||
&& str_contains($adminView, 'type="desktop_package"')
|
||||
&& str_contains($adminView, 'direct'),
|
||||
'admin page saves update settings and sends installers through direct upload'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
|
||||
$directUploadReflection = new ReflectionClass(DirectUploadService::class);
|
||||
$validateExtension = $directUploadReflection->getMethod('validateFileExtension');
|
||||
$validateExtension->invoke(
|
||||
null,
|
||||
DirectUploadService::TYPE_DESKTOP_PACKAGE,
|
||||
'uploads/desktop_package/7/' . date('Ymd') . '/package.exe',
|
||||
'DoctorWorkstation.EXE'
|
||||
);
|
||||
$invalidExtensionRejected = false;
|
||||
try {
|
||||
$validateExtension->invoke(
|
||||
null,
|
||||
DirectUploadService::TYPE_DESKTOP_PACKAGE,
|
||||
'uploads/desktop_package/7/' . date('Ymd') . '/package.php',
|
||||
'package.php'
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$invalidExtensionRejected = true;
|
||||
}
|
||||
desktopUpdateExpect($invalidExtensionRejected, 'desktop direct upload rejects non-EXE/ZIP files');
|
||||
|
||||
$validateObjectKey = $directUploadReflection->getMethod('isAllowedObjectKey');
|
||||
$ownedKey = 'uploads/desktop_package/7/' . date('Ymd') . '/package.exe';
|
||||
desktopUpdateExpect(
|
||||
$validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 7) === true
|
||||
&& $validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 8) === false,
|
||||
'desktop package keys are bound to the issuing admin'
|
||||
);
|
||||
|
||||
$uploadController = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/UploadController.php');
|
||||
$qcloudEngine = file_get_contents(dirname(__DIR__) . '/app/common/service/storage/engine/Qcloud.php');
|
||||
$directUploadService = file_get_contents(
|
||||
dirname(__DIR__) . '/app/common/service/DirectUploadService.php'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($uploadController)
|
||||
&& str_contains($uploadController, 'assertDirectUploadPermission')
|
||||
&& str_contains($uploadController, 'setting.desktop_workstation/setconfig'),
|
||||
'desktop package credentials and confirmation require publish permission'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($qcloudEngine)
|
||||
&& str_contains($qcloudEngine, 'bool $exactObject = false')
|
||||
&& str_contains($qcloudEngine, '$exactObject ? $scope : $scope .'),
|
||||
'COS credentials can be restricted to one server-issued object key'
|
||||
);
|
||||
desktopUpdateExpect(
|
||||
is_string($directUploadService)
|
||||
&& str_contains($directUploadService, "trim(\$name) !== ''")
|
||||
&& str_contains($directUploadService, '$objectKey !== \'\''),
|
||||
'desktop credentials remain compatible with uploaders that do not send a filename'
|
||||
);
|
||||
|
||||
$migration = file_get_contents(
|
||||
dirname(__DIR__) . '/sql/1.9.20260821/add_desktop_workstation_update_menu.sql'
|
||||
);
|
||||
desktopUpdateExpect(is_string($migration), 'menu migration is readable');
|
||||
|
||||
@@ -59,6 +59,14 @@ $logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/Diagnosis
|
||||
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
|
||||
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
|
||||
assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable');
|
||||
assistantStreamExpect(
|
||||
str_contains($controller, 'diagnosis ai assistant sse failed'),
|
||||
'unexpected SSE failures retain a privacy-safe server log entry'
|
||||
);
|
||||
assistantStreamExpect(
|
||||
!str_contains($controller, "'exception_message' => \$e->getMessage()"),
|
||||
'unexpected SSE failures never log raw exception messages'
|
||||
);
|
||||
|
||||
$actionStart = strpos($controller, 'public function aiAssistantStream()');
|
||||
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
|
||||
@@ -100,11 +108,14 @@ assistantStreamExpect(
|
||||
);
|
||||
assistantStreamExpect(
|
||||
str_contains($controller, "'text' => \$delta")
|
||||
&& str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'")
|
||||
&& str_contains($controller, 'DiagnosisAiLogic::getAssistantErrorCode()')
|
||||
&& str_contains($controller, 'DiagnosisAiLogic::getError()')
|
||||
&& str_contains($controller, 'ignore_user_abort(true)')
|
||||
&& str_contains($controller, 'connection_aborted() === 1')
|
||||
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
|
||||
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
|
||||
&& str_contains($logic, '!$deliveredDelta')
|
||||
&& str_contains($logic, "['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE']")
|
||||
&& str_contains($logic, 'DifyChatService::chat('),
|
||||
'delta carries text, disconnects abort upstream, and pre-delta stream failures safely fall back once'
|
||||
);
|
||||
assistantStreamExpect(
|
||||
str_contains($logic, 'DifyChatService::chat(')
|
||||
|
||||
@@ -95,6 +95,63 @@ $explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [
|
||||
difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol');
|
||||
difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol');
|
||||
|
||||
$progressOption = callDifyStreamPrivate('curlProgressOption', []);
|
||||
$expectedProgressOption = null;
|
||||
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $optionName) {
|
||||
if (defined($optionName)) {
|
||||
$expectedProgressOption = (int) constant($optionName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
difyStreamExpect(
|
||||
$progressOption === $expectedProgressOption,
|
||||
'stream cancellation selects the newest cURL progress callback available at runtime'
|
||||
);
|
||||
$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php');
|
||||
difyStreamExpect(
|
||||
is_string($serviceSource)
|
||||
&& str_contains($serviceSource, "'CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'"),
|
||||
'stream cancellation retains the libcurl 7.29 progress callback fallback'
|
||||
);
|
||||
|
||||
$retryableStream = [
|
||||
'errno' => 0,
|
||||
'http_code' => 200,
|
||||
'emitted' => false,
|
||||
'upstream_error' => true,
|
||||
'finished' => false,
|
||||
];
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === true,
|
||||
'a terminal-free stream error before any delta tries the alternate protocol'
|
||||
);
|
||||
$retryableStream['emitted'] = true;
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [$retryableStream, true]) === false,
|
||||
'an emitted delta forbids protocol retry'
|
||||
);
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||
'errno' => 0,
|
||||
'http_code' => 400,
|
||||
], false]) === false,
|
||||
'business input rejection is not hidden by an alternate protocol attempt'
|
||||
);
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||
'errno' => 0,
|
||||
'http_code' => 404,
|
||||
], false]) === true,
|
||||
'an unavailable path tries the alternate protocol'
|
||||
);
|
||||
difyStreamExpect(
|
||||
callDifyStreamPrivate('shouldTryNextProtocol', [[
|
||||
'errno' => 0,
|
||||
'http_code' => 401,
|
||||
], true]) === false,
|
||||
'credential failures are not hidden by protocol retry'
|
||||
);
|
||||
|
||||
$difyWire = ": ping\r\n\r\n"
|
||||
. "data: {\"event\":\"message\",\"answer\":\"你\",\"message_id\":\"msg-safe\"}\r\n\r\n"
|
||||
. "data: {\"event\":\"agent_message\",\"answer\":\"好\"}\r\n\r\n"
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 所有读库/统计依赖使用内存替身。测试实际overview/fansDetail输出,不初始化App或业务数据库。
|
||||
namespace first_visit_deleted_fans_test {
|
||||
final class Query
|
||||
{
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
if (!in_array($name, ['where', 'whereLike', 'whereNull', 'whereIn', 'whereBetween', 'field', 'fieldRaw', 'group', 'alias', 'join', 'distinct', 'order'], true)) {
|
||||
throw new \RuntimeException('Unexpected stub query method: ' . $name);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
public function select(): self { return $this; }
|
||||
public function toArray(): array { return []; }
|
||||
public function column(string $field): array { return []; }
|
||||
public function value(string $field): mixed { return null; }
|
||||
public function find(): ?array { return null; }
|
||||
public function count(): int { return 0; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace think\facade {
|
||||
class Db
|
||||
{
|
||||
public static function name(string $name): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\service\DataScope {
|
||||
class DataScopeService
|
||||
{
|
||||
public const SCOPE_ALL = 1;
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
public static function getVisibleAdminIds(int $adminId, array $info): ?array { return null; }
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $info): ?array { return null; }
|
||||
public static function getEffectiveScope(array $info): int { return (int) ($info['scope_value'] ?? self::SCOPE_ALL); }
|
||||
public static function scopeLabel(int $scope): string { return 'scope-' . $scope; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\service\qywx {
|
||||
class MediaChannelService
|
||||
{
|
||||
public const GROUP_CODE_PREFIX = 'group:';
|
||||
public static function getCurrentTagOptions(): array { return [['id' => 'channel', 'name' => '来源']]; }
|
||||
public static function getCurrentTagChannelByCode(string $code): ?array { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\adminapi\logic\auth {
|
||||
class AuthLogic
|
||||
{
|
||||
public static array $permissions = [];
|
||||
public static function getAuthByAdminId(int $adminId): array { return self::$permissions; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\adminapi\logic\dept {
|
||||
class DeptLogic
|
||||
{
|
||||
public static function getAllDataScoped(int $adminId, array $info): array { return [['id' => 1, 'name' => '门诊']]; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\model\stats {
|
||||
class PersonalYeji
|
||||
{
|
||||
public static function whereBetween(string $field, array $range): \first_visit_deleted_fans_test\Query { return new \first_visit_deleted_fans_test\Query(); }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\adminapi\logic\stats {
|
||||
class YejiStatsLogic
|
||||
{
|
||||
public static function applyPrescriptionOrderEffectiveAmountQuery(mixed $query, string $alias): void {}
|
||||
}
|
||||
class ConversionLogic
|
||||
{
|
||||
public static array $overview = [];
|
||||
public static array $detail = [];
|
||||
public static array $detailParams = [];
|
||||
public static function overview(array $params, int $adminId, array $info, ?array $visibleIds, ?array $costIds, ?array $channel): array { return self::$overview; }
|
||||
public static function fanDetailChannelDeptIds(?array $channel): ?array { return null; }
|
||||
public static function fanDetails(array $params, array $target, int $adminId, array $info, ?array $visibleIds, ?array $channel): array
|
||||
{
|
||||
self::$detailParams = $params;
|
||||
return self::$detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\firstvisit\FirstVisitConversionLogic;
|
||||
use app\adminapi\logic\stats\ConversionLogic;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
function deletedFansExpect(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }
|
||||
|
||||
ConversionLogic::$overview = [
|
||||
'summary' => ['add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9,
|
||||
'paid_appointment_count' => 15, 'account_cost' => 234.5, 'cash_cost' => 100.0, 'roi' => 2.0],
|
||||
'lists' => [[
|
||||
'id' => 1, 'name' => '门诊', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4,
|
||||
'completed_order_count' => 9, 'completed_order_amount' => 468.0, 'account_cost' => 234.5,
|
||||
'children' => [[
|
||||
'id' => 2, 'name' => '一组', 'type' => 'dept', 'add_fans_count' => 57, 'deleted_fans_count' => 4,
|
||||
'completed_order_count' => 9, 'completed_order_amount' => 468.0,
|
||||
'children' => [[
|
||||
'id' => 'M12_2', 'admin_id' => 12, 'name' => '医助', 'type' => 'member',
|
||||
'add_fans_count' => 57, 'deleted_fans_count' => 4, 'completed_order_count' => 9,
|
||||
'completed_order_amount' => 468.0,
|
||||
]],
|
||||
]],
|
||||
]],
|
||||
];
|
||||
$detailFixture = [
|
||||
'lists' => [
|
||||
['external_userid' => 'existing', 'customer_name' => '在册客户', 'wecom_userid' => 'staff',
|
||||
'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 09:00:00', 'is_deleted' => false, 'delete_time' => null],
|
||||
['external_userid' => 'removed', 'customer_name' => '原有客户', 'wecom_userid' => 'staff',
|
||||
'wecom_staff_name' => '医助', 'add_time' => '2026-08-31 08:00:00', 'is_deleted' => true, 'delete_time' => '2026-08-31 10:00:00'],
|
||||
],
|
||||
'count' => 57, 'deleted_count' => 4, 'page_no' => 3, 'page_size' => 2,
|
||||
'date_range' => ['2026-08-01', '2026-08-31'],
|
||||
];
|
||||
ConversionLogic::$detail = $detailFixture;
|
||||
$params = ['time_type' => 'custom', 'start_date' => '2026-08-01', 'end_date' => '2026-08-31',
|
||||
// 模拟HTTP伪造字段;不能覆盖认证身份。
|
||||
'account' => 'admin', 'root' => 1, 'can_view_deleted_fans' => true];
|
||||
$detailParams = $params + ['entity_type' => 'dept', 'entity_id' => '-2', 'page_no' => 3, 'page_size' => 2];
|
||||
$cases = [
|
||||
['admin root', 1, ['account' => 'admin', 'root' => 1, 'role_name' => '系统管理员'], true],
|
||||
['admin ordinary', 93, ['account' => 'admin', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], true],
|
||||
['admin finance', 23, ['account' => 'admin', 'root' => 0, 'role_name' => '财务', 'scope_value' => 3], true],
|
||||
['other root id1', 1, ['account' => 'superuser', 'root' => 1, 'role_name' => '系统管理员'], false],
|
||||
['administrator', 5, ['account' => 'manager', 'root' => 0, 'role_name' => '管理员'], false],
|
||||
['finance permission', 6, ['account' => 'finance', 'root' => 0, 'role_name' => '财务'], false],
|
||||
['ordinary all scope', 7, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 1], false],
|
||||
['ordinary self scope', 8, ['account' => 'assistant', 'root' => 0, 'role_name' => '医助', 'scope_value' => 4], false],
|
||||
['missing account root', 1, ['root' => 1, 'name' => 'admin', 'role_name' => '系统管理员'], false],
|
||||
['uppercase', 1, ['account' => 'ADMIN', 'root' => 1], false],
|
||||
['mixed case', 1, ['account' => 'Admin', 'root' => 1], false],
|
||||
['leading space', 1, ['account' => ' admin', 'root' => 1], false],
|
||||
['trailing space', 1, ['account' => 'admin ', 'root' => 1], false],
|
||||
['empty', 1, ['account' => '', 'root' => 1], false],
|
||||
['null', 1, ['account' => null, 'root' => 1], false],
|
||||
['boolean', 1, ['account' => true, 'root' => 1], false],
|
||||
['array', 1, ['account' => ['admin'], 'root' => 1], false],
|
||||
];
|
||||
|
||||
foreach ($cases as [$label, $adminId, $adminInfo, $allowed]) {
|
||||
AuthLogic::$permissions = ($adminInfo['role_name'] ?? '') === '财务' ? ['firstvisit.conversion/viewFinance'] : [];
|
||||
$adminEquivalent = array_replace($adminInfo, ['account' => 'admin']);
|
||||
$baseline = FirstVisitConversionLogic::overview($params, $adminId, $adminEquivalent);
|
||||
$overview = FirstVisitConversionLogic::overview($params, $adminId, $adminInfo);
|
||||
deletedFansExpect($overview['meta']['can_view_deleted_fans'] === $allowed, $label . ': overview permission flag must be boolean');
|
||||
// 明确指定预期删除的字段,不根据实现递归生成预期结果。
|
||||
$expectedOverview = $baseline;
|
||||
$expectedOverview['meta']['can_view_deleted_fans'] = $allowed;
|
||||
if (!$allowed) {
|
||||
unset($expectedOverview['summary']['deleted_fans_count'], $expectedOverview['rows'][0]['deleted_fans_count'],
|
||||
$expectedOverview['rows'][0]['children'][0]['deleted_fans_count'],
|
||||
$expectedOverview['rows'][0]['children'][0]['children'][0]['deleted_fans_count']);
|
||||
}
|
||||
unset($expectedOverview['meta']['generated_at'], $overview['meta']['generated_at']);
|
||||
deletedFansExpect($overview === $expectedOverview, $label . ': other statistics, finance permissions, ranking, filters and structure must stay identical');
|
||||
deletedFansExpect($overview['summary']['add_fans_count'] === 57 && $overview['rows'][0]['children'][0]['children'][0]['add_fans_count'] === 57, $label . ': add-fans count unchanged');
|
||||
|
||||
$detail = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo);
|
||||
$expectedDetail = $detailFixture;
|
||||
unset($expectedDetail['deleted_count']); // 原接口已有的转换:删除合计只放entity。
|
||||
$expectedDetail['entity'] = ['type' => 'dept', 'id' => -2, 'admin_id' => 0, 'name' => '未分配部门', 'add_fans_count' => 57, 'deleted_fans_count' => 4];
|
||||
$expectedDetail['can_view_deleted_fans'] = $allowed;
|
||||
if (!$allowed) {
|
||||
unset($expectedDetail['entity']['deleted_fans_count'], $expectedDetail['lists'][0]['is_deleted'],
|
||||
$expectedDetail['lists'][0]['delete_time'], $expectedDetail['lists'][1]['is_deleted'], $expectedDetail['lists'][1]['delete_time']);
|
||||
}
|
||||
deletedFansExpect($detail === $expectedDetail, $label . ': all fan rows/order/names/add dates and pagination must be preserved');
|
||||
deletedFansExpect(array_column($detail['lists'], 'external_userid') === ['existing', 'removed'], $label . ': deleted customer row must remain');
|
||||
deletedFansExpect(!isset(ConversionLogic::$detailParams['account'], ConversionLogic::$detailParams['root'], ConversionLogic::$detailParams['can_view_deleted_fans']), $label . ': forged permission params must not enter stats query');
|
||||
|
||||
foreach ([['entity_type' => 'unknown'], ['entity_id' => 'not-a-dept']] as $invalidEntity) {
|
||||
$empty = FirstVisitConversionLogic::fansDetail(array_replace($detailParams, $invalidEntity), $adminId, $adminInfo);
|
||||
deletedFansExpect($empty === ['lists' => [], 'count' => 0, 'page_no' => 3, 'page_size' => 2,
|
||||
'date_range' => ['2026-08-01', '2026-08-31'], 'entity' => null, 'can_view_deleted_fans' => $allowed], $label . ': early empty branch flag and pagination');
|
||||
}
|
||||
ConversionLogic::$detail = array_replace($detailFixture, ['lists' => [], 'count' => 0, 'deleted_count' => 0]);
|
||||
$empty = FirstVisitConversionLogic::fansDetail($detailParams, $adminId, $adminInfo);
|
||||
deletedFansExpect($empty['can_view_deleted_fans'] === $allowed && $empty['count'] === 0 && $empty['lists'] === []
|
||||
&& $empty['entity'] === null && $empty['page_no'] === 3 && $empty['page_size'] === 2, $label . ': upstream empty branch flag');
|
||||
ConversionLogic::$detail = $detailFixture;
|
||||
}
|
||||
|
||||
$originalOverviewFixture = ConversionLogic::$overview;
|
||||
ConversionLogic::$overview = ['summary' => ['add_fans_count' => 0, 'deleted_fans_count' => 0], 'lists' => []];
|
||||
foreach (['admin' => true, 'other' => false] as $account => $allowed) {
|
||||
$emptyOverview = FirstVisitConversionLogic::overview($params, 1, ['account' => $account, 'root' => 1]);
|
||||
deletedFansExpect($emptyOverview['meta']['can_view_deleted_fans'] === $allowed && $emptyOverview['rows'] === []
|
||||
&& $emptyOverview['summary']['add_fans_count'] === 0
|
||||
&& array_key_exists('deleted_fans_count', $emptyOverview['summary']) === $allowed, 'empty overview keeps explicit boolean visibility');
|
||||
}
|
||||
ConversionLogic::$overview = $originalOverviewFixture;
|
||||
|
||||
// 防未来新增的嵌套/排名位置意外泄露;键、空值和非敏感字段均原样保留。
|
||||
$filter = (new ReflectionClass(FirstVisitConversionLogic::class))->getMethod('withDeletedFansVisibility');
|
||||
$filter->setAccessible(true);
|
||||
$nested = ['meta' => ['note' => '保持'], 'summary' => ['add_fans_count' => 2, 'deleted_count' => 1],
|
||||
'rankings' => ['orders' => [3 => ['value' => 9, 'deleted_fans_count' => 1]]],
|
||||
'rows' => [9 => ['name' => '员工', 'more' => ['deleted_fans_count' => 1, 'nullable' => null]]]];
|
||||
$expectedNested = ['meta' => ['note' => '保持', 'can_view_deleted_fans' => false], 'summary' => ['add_fans_count' => 2],
|
||||
'rankings' => ['orders' => [3 => ['value' => 9]]], 'rows' => [9 => ['name' => '员工', 'more' => ['nullable' => null]]]];
|
||||
deletedFansExpect($filter->invoke(null, $nested, ['account' => 'other']) === $expectedNested, 'whole overview including nested ranking must be filtered without reindexing');
|
||||
$nestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['is_deleted' => true, 'delete_time' => 'date', 'note' => 'keep']]],
|
||||
'count' => 4, 'page_no' => 2, 'page_size' => 1, 'deleted_count' => 1, 'entity' => ['deleted_fans_count' => 1, 'name' => 'dept']];
|
||||
$expectedNestedDetail = ['lists' => [5 => ['external_userid' => 'fan', 'extra' => ['note' => 'keep']]],
|
||||
'count' => 4, 'page_no' => 2, 'page_size' => 1, 'entity' => ['name' => 'dept'], 'can_view_deleted_fans' => false];
|
||||
deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'other'], true) === $expectedNestedDetail, 'detail nested deletion state and aliases must not leak');
|
||||
$adminNested = $nestedDetail + ['can_view_deleted_fans' => true];
|
||||
deletedFansExpect($filter->invoke(null, $nestedDetail, ['account' => 'admin', 'root' => 0], true) === $adminNested, 'admin payload unchanged except visibility flag');
|
||||
|
||||
// 身份来源契约:只读源文件,既不获取真实token,也不触发用户/会话查询。
|
||||
$server = dirname(__DIR__);
|
||||
$cacheSource = file_get_contents($server . '/app/common/cache/AdminTokenCache.php');
|
||||
$loginSource = file_get_contents($server . '/app/adminapi/http/middleware/LoginMiddleware.php');
|
||||
$controllerSource = file_get_contents($server . '/app/adminapi/controller/firstvisit/ConversionController.php');
|
||||
deletedFansExpect(str_contains($cacheSource, "'account' => \$admin->account")
|
||||
&& str_contains($cacheSource, "AdminSession::where([['token', '=', \$token]")
|
||||
&& str_contains($cacheSource, "Admin::where('id', '=', \$adminSession->admin_id)"), 'account must originate from valid token session and authenticated admin row');
|
||||
deletedFansExpect(str_contains($loginSource, '(new AdminTokenCache())->getAdminInfo($token)')
|
||||
&& str_contains($loginSource, '$request->adminInfo = $adminInfo;')
|
||||
&& substr_count($controllerSource, '$this->adminInfo') >= 2, 'controller must pass authenticated cached adminInfo');
|
||||
echo "FirstVisitConversionDeletedFansPermissionTest passed\n";
|
||||
}
|
||||
@@ -24,6 +24,10 @@ $params = [
|
||||
];
|
||||
$overviewStartedAt = microtime(true);
|
||||
$overview = FirstVisitConversionLogic::overview($params, (int) $admin['id'], $admin);
|
||||
$canViewDeletedFans = ($admin['account'] ?? null) === 'admin';
|
||||
if (($overview['meta']['can_view_deleted_fans'] ?? null) !== $canViewDeletedFans) {
|
||||
throw new RuntimeException('Deleted-fan visibility must follow exact authenticated account, not root');
|
||||
}
|
||||
$overviewElapsedMs = (microtime(true) - $overviewStartedAt) * 1000;
|
||||
$candidates = [];
|
||||
$collect = static function (array $rows, array $path = []) use (&$collect, &$candidates): void {
|
||||
@@ -73,16 +77,29 @@ $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)) {
|
||||
if (($detail['can_view_deleted_fans'] ?? null) !== $canViewDeletedFans) {
|
||||
throw new RuntimeException('Fan detail visibility differs from overview');
|
||||
}
|
||||
if ($canViewDeletedFans && (int) ($detail['entity']['deleted_fans_count'] ?? -1) !== (int) ($row['deleted_fans_count'] ?? 0)) {
|
||||
throw new RuntimeException('Fan detail entity metadata lost deleted_fans_count');
|
||||
}
|
||||
if (!$canViewDeletedFans && (array_key_exists('deleted_fans_count', $detail['entity']) || array_key_exists('deleted_fans_count', $row))) {
|
||||
throw new RuntimeException('Other root accounts must not receive 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) {
|
||||
$requiredFields = ['external_userid', 'customer_name', 'wecom_userid', 'wecom_staff_name', 'add_time'];
|
||||
if ($canViewDeletedFans) {
|
||||
$requiredFields = array_merge($requiredFields, ['is_deleted', 'delete_time']);
|
||||
}
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!array_key_exists($field, $fan)) {
|
||||
throw new RuntimeException("Fan detail row is missing {$field}");
|
||||
}
|
||||
}
|
||||
if (!$canViewDeletedFans && (array_key_exists('is_deleted', $fan) || array_key_exists('delete_time', $fan))) {
|
||||
throw new RuntimeException('Other accounts must not receive deletion state through fan detail');
|
||||
}
|
||||
}
|
||||
|
||||
$forgedDetail = FirstVisitConversionLogic::fansDetail($params + [
|
||||
@@ -114,8 +131,9 @@ if ($centerRows !== []) {
|
||||
$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)
|
||||
|| ($canViewDeletedFans && (int) ($centerDetail['entity']['deleted_fans_count'] ?? -1)
|
||||
!== (int) ($centerRow['deleted_fans_count'] ?? 0))
|
||||
|| (!$canViewDeletedFans && array_key_exists('deleted_fans_count', $centerDetail['entity'] ?? []))
|
||||
) {
|
||||
throw new RuntimeException('郑州二中心 detail count does not match overview');
|
||||
}
|
||||
@@ -169,12 +187,12 @@ if ($parentCandidates !== []) {
|
||||
}
|
||||
|
||||
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",
|
||||
"FAN_DETAIL_DB_SMOKE_OK range=2026-08-01..2026-08-25 type=%s entity=%s path=%s count=%d deleted=%s 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),
|
||||
$canViewDeletedFans ? (string) ($detail['entity']['deleted_fans_count'] ?? 0) : 'hidden',
|
||||
$parentChecked ? 1 : 0,
|
||||
$overviewElapsedMs,
|
||||
$detailElapsedMs,
|
||||
|
||||
@@ -21,26 +21,25 @@ conversionFanDetailExpect(is_string($conversionSource), 'Unable to read Conversi
|
||||
$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],
|
||||
['add_event_id' => 10, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 100, 'is_deleted' => false],
|
||||
['add_event_id' => 11, 'user_id' => 'staff-a', 'external_userid' => 'shared', 'add_time' => 200, 'is_deleted' => false],
|
||||
['add_event_id' => 12, 'user_id' => 'staff-b', 'external_userid' => 'shared', 'add_time' => 300, 'is_deleted' => true],
|
||||
]);
|
||||
conversionFanDetailExpect(
|
||||
count($detailRows) === 2
|
||||
&& (int) ($detailRows[0]['add_time'] ?? 0) === 100
|
||||
&& empty($detailRows[0]['is_deleted'])
|
||||
&& (string) ($detailRows[1]['user_id'] ?? '') === 'staff-b'
|
||||
&& !empty($detailRows[1]['is_deleted']),
|
||||
'Delete/re-add details must retain the first counted add time and final deletion state'
|
||||
'Details must deduplicate the same employee/customer while retaining the same customer under another employee'
|
||||
);
|
||||
conversionFanDetailExpect(
|
||||
str_contains($conversionSource, 'self::loadFanDetailRows($startTimestamp, $endTimestamp, $mediaChannel, $adminIds)')
|
||||
&& str_contains($conversionSource, 'MIN(e.event_time) AS add_time')
|
||||
&& str_contains($conversionSource, 'e.id AS add_event_id')
|
||||
&& 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'
|
||||
'Aggregate and detail results must share the same employee/customer loader and expose event times'
|
||||
);
|
||||
conversionFanDetailExpect(
|
||||
str_contains($conversionSource, "'external_userid' => \$externalUserId")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\MediaChannelService;
|
||||
use app\common\service\qywx\QywxExternalContactEventTagSnapshotService;
|
||||
use think\facade\Db;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
@@ -10,6 +11,13 @@ require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
if (!str_contains(
|
||||
(string) file_get_contents(dirname(__DIR__) . '/app/common/service/qywx/MediaChannelService.php'),
|
||||
'$tagIds === [] || !QywxExternalContactEventTagSnapshotService::installed()'
|
||||
)) {
|
||||
throw new RuntimeException('事件渠道快照缺表时未降级,代码先于迁移发布会导致接口 500');
|
||||
}
|
||||
|
||||
$tagQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$tagQuery,
|
||||
@@ -17,7 +25,8 @@ MediaChannelService::applyExternalUserChannelFilter(
|
||||
[
|
||||
'source_tag_id' => 'tag-regression-id',
|
||||
'source_tag_name' => '回归渠道',
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$tagSql = (string)$tagQuery->fetchSql()->select();
|
||||
if (!str_contains($tagSql, 'qywx_external_contact_tag')) {
|
||||
@@ -29,6 +38,9 @@ if (!str_contains($tagSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
if (!str_contains($tagSql, 'channel_tag.external_userid = e.external_userid')) {
|
||||
throw new RuntimeException('tag 渠道未按事实表 external_userid 相关查询');
|
||||
}
|
||||
if (!str_contains($tagSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('tag 渠道未限定为产生事件的同一企微员工');
|
||||
}
|
||||
if (!str_contains($tagSql, 'tag_id = ')) {
|
||||
throw new RuntimeException('单标签渠道应使用 tag_id = 走组合索引');
|
||||
}
|
||||
@@ -39,6 +51,21 @@ if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) {
|
||||
throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
|
||||
$customerLevelTagQuery = Db::name('order')->alias('o');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$customerLevelTagQuery,
|
||||
'o.payer_external_userid',
|
||||
[
|
||||
'source_tag_id' => 'tag-order-id',
|
||||
'source_tag_name' => '订单渠道',
|
||||
]
|
||||
);
|
||||
$customerLevelTagSql = (string)$customerLevelTagQuery->fetchSql()->select();
|
||||
if (!str_contains($customerLevelTagSql, 'channel_tag.external_userid = o.payer_external_userid')
|
||||
|| str_contains($customerLevelTagSql, 'channel_tag.follow_user_id')) {
|
||||
throw new RuntimeException('没有员工维度的订单事实不应被强行关联不存在的企微员工字段');
|
||||
}
|
||||
|
||||
$legacyQuery = Db::name('order')->alias('o');
|
||||
MediaChannelService::applyExternalUserChannelFilter(
|
||||
$legacyQuery,
|
||||
@@ -65,7 +92,8 @@ MediaChannelService::applyExternalUserChannelFilter(
|
||||
'source_tag_ids' => ['tag-group-a', 'tag-group-b'],
|
||||
'channel_name' => '自媒体4',
|
||||
'is_group' => true,
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$groupSql = (string)$groupQuery->fetchSql()->select();
|
||||
if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
@@ -74,6 +102,9 @@ if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) {
|
||||
if (!str_contains($groupSql, 'tag_id IN (')) {
|
||||
throw new RuntimeException('分组渠道未按多个 tag_id 过滤');
|
||||
}
|
||||
if (!str_contains($groupSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('分组渠道未限定为产生事件的同一企微员工');
|
||||
}
|
||||
if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE')) {
|
||||
throw new RuntimeException('分组渠道仍在扫描 follow_users JSON');
|
||||
}
|
||||
@@ -85,17 +116,86 @@ MediaChannelService::applyHistoricalExternalUserChannelFilter(
|
||||
[
|
||||
'source_tag_id' => 'deleted-fan-channel-id',
|
||||
'source_tag_name' => '已删粉丝渠道',
|
||||
]
|
||||
],
|
||||
'e.user_id'
|
||||
);
|
||||
$historicalSql = (string)$historicalQuery->fetchSql()->select();
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.external_userid = e.external_userid')) {
|
||||
throw new RuntimeException('已删粉丝渠道未按 external_userid 关联历史客户快照');
|
||||
}
|
||||
if (!str_contains($historicalSql, 'historical_channel_contact.follow_users LIKE')) {
|
||||
throw new RuntimeException('已删粉丝渠道未使用保留的 follow_users 快照');
|
||||
if (!str_contains($historicalSql, 'JSON_SEARCH')
|
||||
|| !str_contains($historicalSql, 'e.user_id')
|
||||
|| !str_contains($historicalSql, 'JSON_CONTAINS')) {
|
||||
throw new RuntimeException('已删粉丝渠道未在保留的 follow_users 中精确匹配事件员工标签');
|
||||
}
|
||||
if (str_contains($historicalSql, 'historical_channel_contact.delete_time IS NULL')) {
|
||||
throw new RuntimeException('已删粉丝渠道错误排除了软删客户');
|
||||
}
|
||||
|
||||
$installedProperty = (new ReflectionClass(QywxExternalContactEventTagSnapshotService::class))
|
||||
->getProperty('installed');
|
||||
$installedProperty->setAccessible(true);
|
||||
$installedProperty->setValue(null, true);
|
||||
|
||||
$eventSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$eventSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
[
|
||||
'source_tag_id' => '',
|
||||
'source_tag_ids' => ['tag-event-a', 'tag-event-b'],
|
||||
'is_group' => true,
|
||||
]
|
||||
);
|
||||
$eventSnapshotSql = (string)$eventSnapshotQuery->fetchSql()->select();
|
||||
foreach ([
|
||||
'qywx_external_contact_event_tag',
|
||||
'event_channel_tag.event_id = e.id',
|
||||
'event_channel_tag.follow_user_id = e.user_id',
|
||||
'captured_event_channel.event_id = e.id',
|
||||
"captured_event_channel.tag_id = ''",
|
||||
'channel_tag.follow_user_id = e.user_id',
|
||||
] as $needle) {
|
||||
if (!str_contains($eventSnapshotSql, $needle)) {
|
||||
throw new RuntimeException('新增事件渠道快照查询缺少条件:' . $needle);
|
||||
}
|
||||
}
|
||||
if (!str_contains($eventSnapshotSql, 'event_channel_tag.tag_id IN (')) {
|
||||
throw new RuntimeException('新增事件分组渠道未按多个快照 tag_id 过滤');
|
||||
}
|
||||
|
||||
$deletedEventSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$deletedEventSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
['source_tag_id' => 'tag-deleted-event'],
|
||||
true
|
||||
);
|
||||
$deletedEventSnapshotSql = (string)$deletedEventSnapshotQuery->fetchSql()->select();
|
||||
if (!str_contains($deletedEventSnapshotSql, 'event_channel_tag.event_id = e.id')
|
||||
|| !str_contains($deletedEventSnapshotSql, 'JSON_SEARCH')
|
||||
|| !str_contains($deletedEventSnapshotSql, 'JSON_CONTAINS')) {
|
||||
throw new RuntimeException('已删新增事件未优先用事件快照,并按同一员工执行老数据回退');
|
||||
}
|
||||
|
||||
$installedProperty->setValue(null, false);
|
||||
$missingSnapshotQuery = Db::name('qywx_external_contact_event')->alias('e');
|
||||
MediaChannelService::applyExternalUserEventChannelFilter(
|
||||
$missingSnapshotQuery,
|
||||
'e.id',
|
||||
'e.external_userid',
|
||||
'e.user_id',
|
||||
['source_tag_id' => 'tag-before-migration']
|
||||
);
|
||||
$missingSnapshotSql = (string)$missingSnapshotQuery->fetchSql()->select();
|
||||
if (str_contains($missingSnapshotSql, 'qywx_external_contact_event_tag')
|
||||
|| !str_contains($missingSnapshotSql, 'channel_tag.follow_user_id = e.user_id')) {
|
||||
throw new RuntimeException('快照表未安装时没有安全降级到同员工当前标签口径');
|
||||
}
|
||||
$installedProperty->setValue(null, null);
|
||||
|
||||
echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\validate\order\OrderValidate;
|
||||
use app\adminapi\logic\order\OrderLogic;
|
||||
|
||||
$testApp = new think\App();
|
||||
$testLang = new think\Lang($testApp);
|
||||
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
|
||||
|
||||
function orderEditTimeExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
fwrite(STDERR, "FAIL: {$message}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$valid = [
|
||||
'id' => 1,
|
||||
'patient_id' => 0,
|
||||
'order_type' => 3,
|
||||
'payment_time' => '2026-08-22 11:37:27',
|
||||
'create_time' => '2026-08-22 11:40:03',
|
||||
];
|
||||
orderEditTimeExpect(
|
||||
(new OrderValidate())->scene('edit_time')->check($valid),
|
||||
'valid payment and creation times pass validation'
|
||||
);
|
||||
|
||||
$withoutPaymentTime = $valid;
|
||||
$withoutPaymentTime['payment_time'] = '';
|
||||
orderEditTimeExpect(
|
||||
(new OrderValidate())->scene('edit_time')->check($withoutPaymentTime),
|
||||
'unpaid orders may keep an empty payment time'
|
||||
);
|
||||
|
||||
$invalidPaymentTime = $valid;
|
||||
$invalidPaymentTime['payment_time'] = '2026-99-99 25:61:61';
|
||||
orderEditTimeExpect(
|
||||
!(new OrderValidate())->scene('edit_time')->check($invalidPaymentTime),
|
||||
'invalid payment time is rejected'
|
||||
);
|
||||
|
||||
$missingCreateTime = $valid;
|
||||
$missingCreateTime['create_time'] = '';
|
||||
orderEditTimeExpect(
|
||||
!(new OrderValidate())->scene('edit_time')->check($missingCreateTime),
|
||||
'creation time is required'
|
||||
);
|
||||
|
||||
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/order/OrderLogic.php');
|
||||
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/order/OrderController.php');
|
||||
orderEditTimeExpect(is_string($logic), 'order logic source is readable');
|
||||
orderEditTimeExpect(
|
||||
str_contains($logic, "array_key_exists('payment_time', \$params)")
|
||||
&& str_contains($logic, "in_array((int)\$order->status, [2, 4], true)")
|
||||
&& str_contains($logic, 'bool $canEditTime = false')
|
||||
&& str_contains($logic, 'normalizeEditedCreateTime('),
|
||||
'order edit protects payment status and supports legacy creation timestamps'
|
||||
);
|
||||
orderEditTimeExpect(
|
||||
is_string($controller)
|
||||
&& str_contains($controller, "EDIT_TIME_PERMISSION = 'order.order/editTime'")
|
||||
&& str_contains($controller, 'AuthLogic::getAuthByAdminId($this->adminId)')
|
||||
&& str_contains($controller, "goCheck('edit_time')"),
|
||||
'controller enforces the dedicated order time permission'
|
||||
);
|
||||
|
||||
$logicReflection = new ReflectionClass(OrderLogic::class);
|
||||
$normalizeCreateTime = $logicReflection->getMethod('normalizeEditedCreateTime');
|
||||
orderEditTimeExpect(
|
||||
$normalizeCreateTime->invoke(null, 1724300000, '2026-08-22 11:40:03')
|
||||
=== strtotime('2026-08-22 11:40:03'),
|
||||
'legacy integer creation times remain Unix timestamps'
|
||||
);
|
||||
orderEditTimeExpect(
|
||||
$normalizeCreateTime->invoke(null, '2026-08-22 10:00:00', '2026-08-22 11:40:03')
|
||||
=== '2026-08-22 11:40:03',
|
||||
'datetime creation times remain canonical strings'
|
||||
);
|
||||
|
||||
$adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/order/index.vue');
|
||||
orderEditTimeExpect(is_string($adminView), 'admin order view source is readable');
|
||||
orderEditTimeExpect(
|
||||
str_contains($adminView, 'v-model="editOrderForm.payment_time"')
|
||||
&& str_contains($adminView, 'v-model="editOrderForm.create_time"')
|
||||
&& str_contains($adminView, "hasPermission(['order.order/editTime'])")
|
||||
&& str_contains($adminView, 'v-if="canEditOrderTime"')
|
||||
&& str_contains($adminView, 'payload.payment_time = editOrderForm.value.payment_time')
|
||||
&& str_contains($adminView, 'payload.create_time = editOrderForm.value.create_time'),
|
||||
'admin edit dialog submits payment and creation times'
|
||||
);
|
||||
|
||||
$menuSql = file_get_contents(
|
||||
dirname(__DIR__) . '/sql/1.9.20260901/add_order_edit_time_menu.sql'
|
||||
);
|
||||
orderEditTimeExpect(
|
||||
is_string($menuSql)
|
||||
&& str_contains($menuSql, "'order.order/editTime'")
|
||||
&& str_contains($menuSql, "'修改支付单时间'")
|
||||
&& !str_contains($menuSql, 'system_role_menu'),
|
||||
'role management exposes the dedicated order time permission'
|
||||
);
|
||||
|
||||
echo "Order edit time contract: OK\n";
|
||||
@@ -186,12 +186,120 @@ expectSame([], $plan[1]['files'], 'the fallback attempt sends no attachments');
|
||||
expectSame(9, count($plan[1]['omitted']), 'the fallback attempt declares every attachment');
|
||||
expectSame(1, count(callPrivate('buildAttemptPlan', [[], []])), 'a request without attachments is attempted once');
|
||||
|
||||
$inputPlan = callPrivate('buildInputAttemptPlan', [['prompt_version' => 'v2']]);
|
||||
expectSame(2, count($inputPlan), 'structured Dify inputs get one compatibility fallback');
|
||||
expectSame([], $inputPlan[1], 'the compatibility fallback uses an empty inputs object');
|
||||
expectSame([[]], callPrivate('buildInputAttemptPlan', [[]]), 'empty inputs are not retried twice');
|
||||
|
||||
$difyInputSpec = ['protocol' => 'dify'];
|
||||
$openAiInputSpec = ['protocol' => 'openai'];
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isInputRejection', [
|
||||
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
||||
$difyInputSpec,
|
||||
['prompt_version' => 'v2'],
|
||||
]),
|
||||
'Dify invalid_param retries with query-only input'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isInputRejection', [
|
||||
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
||||
$difyInputSpec,
|
||||
[],
|
||||
]),
|
||||
'an already empty inputs object is never retried'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isInputRejection', [
|
||||
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'],
|
||||
$openAiInputSpec,
|
||||
['prompt_version' => 'v2'],
|
||||
]),
|
||||
'OpenAI protocol does not use the Dify input fallback'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isInputRejection', [
|
||||
['errno' => 0, 'http_code' => 400, 'body' => '{"code":"provider_quota_exceeded"}'],
|
||||
$difyInputSpec,
|
||||
['prompt_version' => 'v2'],
|
||||
]),
|
||||
'quota and provider failures are not submitted twice'
|
||||
);
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isInputRejection', [
|
||||
[
|
||||
'errno' => 0,
|
||||
'http_code' => 200,
|
||||
'upstream_error' => true,
|
||||
'upstream_code' => 'invalid_param',
|
||||
'emitted' => false,
|
||||
],
|
||||
$difyInputSpec,
|
||||
['prompt_version' => 'v2'],
|
||||
]),
|
||||
'a streaming invalid_param before any delta also retries without inputs'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isInputRejection', [
|
||||
[
|
||||
'errno' => 0,
|
||||
'http_code' => 200,
|
||||
'upstream_error' => true,
|
||||
'upstream_code' => 'invalid_param',
|
||||
'emitted' => true,
|
||||
],
|
||||
$difyInputSpec,
|
||||
['prompt_version' => 'v2'],
|
||||
]),
|
||||
'a stream that already emitted content is never replayed'
|
||||
);
|
||||
|
||||
expectSame(true, callPrivate('shouldRetryWithoutFiles', [400, $capped['kept']]), 'invalid_param retries without attachments');
|
||||
expectSame(true, callPrivate('shouldRetryWithoutFiles', [413, $capped['kept']]), 'oversized attachments retry without attachments');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-only rejection is not retried');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [401, $capped['kept']]), 'a credential failure is not retried');
|
||||
expectSame(false, callPrivate('shouldRetryWithoutFiles', [500, $capped['kept']]), 'an upstream outage is not retried here');
|
||||
|
||||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码(Dify 400 -> OpenAI 404),
|
||||
// 降级判断必须按每次响应累计,否则去掉附件的重试永远不会发生。
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 400], $capped['kept']]),
|
||||
'an attachment rejection is recognised on the response that carried it'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 404], $capped['kept']]),
|
||||
'the fallback protocol 404 is not itself an attachment rejection'
|
||||
);
|
||||
expectSame(
|
||||
true,
|
||||
callPrivate('isFileRejection', [
|
||||
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
||||
$capped['kept'],
|
||||
]),
|
||||
'a 200 stream carrying event:error counts as an attachment rejection'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [
|
||||
['errno' => 0, 'http_code' => 200, 'upstream_error' => true],
|
||||
[],
|
||||
]),
|
||||
'a text-only request never degrades further'
|
||||
);
|
||||
expectSame(
|
||||
false,
|
||||
callPrivate('isFileRejection', [['errno' => 28, 'http_code' => 0], $capped['kept']]),
|
||||
'a transport failure is not mistaken for an attachment rejection'
|
||||
);
|
||||
|
||||
// Dify 的 inputs 必须是 JSON 对象。PHP 空数组会被编码成 [],上游以
|
||||
// invalid_param 拒绝整单——空 inputs 的调用方会 100% 失败。
|
||||
$emptyInputs = callPrivate('buildRequestSpecs', [
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Real ORM/transaction regression test, using a disposable local MySQL database only.
|
||||
* Run with ZYT_UNLINK_TEST_MYSQL_PORT pointing at an isolated, empty-password root instance.
|
||||
* Never loads the application's database configuration or touches business data.
|
||||
*/
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\lists\tcm\PrescriptionOrderLists;
|
||||
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
|
||||
use app\adminapi\validate\tcm\PrescriptionOrderValidate;
|
||||
use think\Container;
|
||||
use think\DbManager;
|
||||
use think\facade\Db;
|
||||
|
||||
$port = (int) getenv('ZYT_UNLINK_TEST_MYSQL_PORT');
|
||||
if ($port <= 0) {
|
||||
fwrite(STDERR, "Set ZYT_UNLINK_TEST_MYSQL_PORT to an isolated local MySQL instance.\n");
|
||||
exit(1);
|
||||
}
|
||||
$isWorker = ($argv[1] ?? '') === '--worker';
|
||||
$database = $isWorker ? (string) getenv('ZYT_UNLINK_TEST_DATABASE') : 'prescription_unlink_test_' . bin2hex(random_bytes(6));
|
||||
if (!preg_match('/^prescription_unlink_test_[a-f0-9]{12}$/', $database)) {
|
||||
throw new RuntimeException('Only this test\'s disposable database names are allowed');
|
||||
}
|
||||
$pdo = new PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', '', [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
if (!$isWorker) {
|
||||
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||
}
|
||||
$pdo->exec("USE `{$database}`");
|
||||
$testApp = new think\App(); // Do not initialize: production config/services must never be loaded.
|
||||
$manager = new DbManager();
|
||||
$manager->setConfig([
|
||||
'default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false,
|
||||
'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
||||
'database' => $database, 'username' => 'root', 'password' => '',
|
||||
'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true,
|
||||
]],
|
||||
]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
Container::getInstance()->instance('config', new think\Config());
|
||||
$testLang = new think\Lang($testApp);
|
||||
think\Validate::maker(static fn (think\Validate $validator) => $validator->setLang($testLang));
|
||||
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message . ' | ' . PrescriptionOrderLogic::getError());
|
||||
}
|
||||
$checks++;
|
||||
};
|
||||
$admin = ['root' => 1, 'admin_id' => 1, 'name' => '隔离测试管理员'];
|
||||
if ($isWorker) {
|
||||
echo "ready\n";
|
||||
flush();
|
||||
$operation = $argv[2];
|
||||
$params = ['id' => (int) $argv[3], 'pay_order_id' => (int) $argv[4], 'order_type' => 3, 'pay_amount' => 300];
|
||||
$result = PrescriptionOrderLogic::$operation($params, 1, $admin);
|
||||
echo json_encode(['success' => is_array($result), 'error' => PrescriptionOrderLogic::getError(),
|
||||
'paid' => $result['paid'] ?? null, 'linked_paid' => $result['linked_pay_paid_total'] ?? null]) . "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), diagnosis_id INT DEFAULT 1,
|
||||
prescription_id INT DEFAULT 1, creator_id INT DEFAULT 1, amount DECIMAL(10,2) NOT NULL,
|
||||
agency_collect_amount DECIMAL(10,2) NULL, linked_pay_order_id INT NULL,
|
||||
prescription_audit_status INT DEFAULT 1, payment_slip_audit_status INT DEFAULT 1,
|
||||
payment_slip_audit_remark VARCHAR(500) DEFAULT "", fulfillment_status INT DEFAULT 6,
|
||||
completion_request INT DEFAULT 0, completion_request_time INT DEFAULT 0,
|
||||
completion_request_by INT DEFAULT 0, completion_request_by_name VARCHAR(100) DEFAULT "",
|
||||
paid DECIMAL(10,2) DEFAULT 0, refund_amount DECIMAL(10,2) DEFAULT 0, internal_cost DECIMAL(10,2) DEFAULT 0,
|
||||
remark_extra VARCHAR(500) DEFAULT "", create_time INT DEFAULT 0,
|
||||
update_time INT DEFAULT 0, delete_time INT NULL
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_order (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50), patient_id INT DEFAULT 1,
|
||||
creator_id INT DEFAULT 0, order_type INT DEFAULT 3, amount DECIMAL(10,2), status INT,
|
||||
is_exempt INT DEFAULT 0, remark VARCHAR(200) DEFAULT "", payment_method VARCHAR(50) DEFAULT "",
|
||||
create_type VARCHAR(50) DEFAULT "", payment_time INT NULL,
|
||||
create_time INT DEFAULT 0, update_time INT DEFAULT 0, delete_time INT NULL
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_pay_order (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, pay_order_id INT,
|
||||
create_time INT, UNIQUE KEY uk_po_pay (prescription_order_id,pay_order_id)
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_prescription_order_log (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, prescription_order_id INT, admin_id INT,
|
||||
admin_name VARCHAR(64), action VARCHAR(32), summary VARCHAR(500), create_time INT
|
||||
) ENGINE=InnoDB');
|
||||
$pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY, name VARCHAR(50), delete_time INT NULL)');
|
||||
$pdo->exec("INSERT INTO zyt_admin VALUES (1, '隔离测试管理员', NULL)");
|
||||
$pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)');
|
||||
$pdo->exec('CREATE TABLE zyt_system_menu (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT, pid INT, type VARCHAR(5), name VARCHAR(100), icon VARCHAR(50),
|
||||
sort INT, perms VARCHAR(100), paths VARCHAR(100), component VARCHAR(100), selected VARCHAR(100),
|
||||
params VARCHAR(100), is_cache INT, is_show INT, is_disable INT DEFAULT 0, create_time INT, update_time INT
|
||||
)');
|
||||
$pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, assistant_id INT, delete_time INT NULL)');
|
||||
$pdo->exec('INSERT INTO zyt_tcm_diagnosis VALUES (1,1,NULL)');
|
||||
|
||||
$fixture = static function (string $amount, array $payments, array $orderFields = []): array {
|
||||
$id = (int) Db::name('tcm_prescription_order')->insertGetId(array_merge([
|
||||
'order_no' => 'TEST-' . bin2hex(random_bytes(4)), 'amount' => $amount,
|
||||
'create_time' => time(),
|
||||
], $orderFields));
|
||||
$payIds = [];
|
||||
foreach ($payments as $payment) {
|
||||
$payId = (int) Db::name('order')->insertGetId(array_merge([
|
||||
'order_no' => 'PAY-' . bin2hex(random_bytes(4)), 'status' => 2,
|
||||
'create_time' => time(),
|
||||
], $payment));
|
||||
Db::name('tcm_prescription_order_pay_order')->insert([
|
||||
'prescription_order_id' => $id, 'pay_order_id' => $payId, 'create_time' => time(),
|
||||
]);
|
||||
$payIds[] = $payId;
|
||||
}
|
||||
Db::name('tcm_prescription_order')->where('id', $id)->update(['linked_pay_order_id' => $payIds[0] ?? null]);
|
||||
return [$id, $payIds];
|
||||
};
|
||||
$remove = static fn (int $id, int $payId) => PrescriptionOrderLogic::unlinkPayOrder([
|
||||
'id' => $id, 'pay_order_id' => $payId,
|
||||
], 1, $admin);
|
||||
$snapshot = static fn (int $id): array => [
|
||||
Db::name('tcm_prescription_order')->where('id', $id)->find(),
|
||||
Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->order('id')->select()->toArray(),
|
||||
Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->order('id')->select()->toArray(),
|
||||
];
|
||||
$amounts = static function (array $result, float $total, float $paid, float $collect, bool $checkStoredPaid = true) use ($expect): void {
|
||||
$expect((float) $result['amount'] === $total, 'Unlink must leave the business total unchanged');
|
||||
$expect((float) $result['linked_pay_paid_total'] === $paid, 'Paid total must use remaining active payments');
|
||||
$expect((float) $result['agency_collect_amount'] === $collect, 'Collection snapshot must equal unchanged total minus remaining payments');
|
||||
if ($checkStoredPaid) {
|
||||
$expect((float) $result['paid'] === $paid, 'Response paid must match remaining effective receipts');
|
||||
$expect((float) Db::name('tcm_prescription_order')->where('id', $result['id'])->value('paid') === $paid,
|
||||
'The paid field must be persisted, not only changed in the response');
|
||||
}
|
||||
};
|
||||
|
||||
// Missing permission is denied even before the new menu has been installed.
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00'], ['amount' => '400.00', 'status' => 5]], ['completion_request' => 1]);
|
||||
$before = $snapshot($id);
|
||||
$expect(PrescriptionOrderLogic::unlinkPayOrder(['id' => $id, 'pay_order_id' => $payIds[0]], 1,
|
||||
['root' => 0, 'admin_id' => 1, 'role_id' => [], 'name' => 'No permission']) === false, 'Viewing/owning an order must not imply unlink permission');
|
||||
$expect($snapshot($id) === $before, 'Permission failure must not change data');
|
||||
|
||||
$originalPayments = Db::name('order')->whereIn('id', $payIds)->select()->toArray();
|
||||
$out = $remove($id, $payIds[0]);
|
||||
$expect(is_array($out), 'Paid payment removal must succeed');
|
||||
$amounts($out, 1000.0, 400.0, 600.0);
|
||||
$expect((int) $out['fulfillment_status'] === 6 && (int) $out['payment_slip_audit_status'] === 1,
|
||||
'Signed order and approved audit must remain in the same statistics scope');
|
||||
$expect((int) $out['completion_request'] === 1, 'Existing completion request must be preserved');
|
||||
$expect($out['pay_order_ids'] === [$payIds[1]] && (int) $out['linked_pay_order_id'] === $payIds[1], 'Primary link must move to the first remaining payment');
|
||||
$expect(Db::name('order')->whereIn('id', $payIds)->select()->toArray() === $originalPayments, 'Unlink must never delete/refund/edit original payments');
|
||||
$log = (string) Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->value('summary');
|
||||
$expect(str_contains($log, '总金额 ¥1000.00 不变') && str_contains($log, '(paid)¥0.00 → ¥400.00'),
|
||||
'Audit log must record unchanged total and the paid correction');
|
||||
$before = $snapshot($id);
|
||||
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Repeated request must not deduct again');
|
||||
$amounts($remove($id, $payIds[1]), 1000.0, 0.0, 1000.0);
|
||||
$expect(Db::name('tcm_prescription_order')->where('id', $id)->value('linked_pay_order_id') === null, 'Removing last payment must clear legacy primary link');
|
||||
|
||||
[$id, $payIds] = $fixture('1950.00', [['amount' => '100.00', 'is_exempt' => 1], ['amount' => '1850.00']]);
|
||||
$amounts($remove($id, $payIds[0]), 1950.0, 1850.0, 100.0);
|
||||
$out = $remove($id, $payIds[1]);
|
||||
$amounts($out, 1950.0, 0.0, 1950.0);
|
||||
$expect($out['linked_pay_orders'] === [] && $out['pay_order_ids'] === [], 'All payments can be removed without stale rows');
|
||||
|
||||
[$id, $payIds] = $fixture('1.03', [['amount' => '0.29'], ['amount' => '0.14']]);
|
||||
$amounts($remove($id, $payIds[0]), 1.03, 0.14, 0.89);
|
||||
[$otherId, $otherPayIds] = $fixture('2.00', [['amount' => '1.00']]);
|
||||
$before = $snapshot($id);
|
||||
$expect($remove($id, $otherPayIds[0]) === false && $snapshot($id) === $before, 'Foreign payment ID must not affect either order');
|
||||
$expect(count($snapshot($otherId)[1]) === 1, 'Foreign order must retain its payment');
|
||||
|
||||
foreach ([3, 4] as $status) {
|
||||
[$id, $payIds] = $fixture('100.00', [['amount' => '100.00']], ['fulfillment_status' => $status, 'paid' => '100.00']);
|
||||
$before = $snapshot($id);
|
||||
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Completed/cancelled orders must retain amount and paid snapshot');
|
||||
}
|
||||
foreach ([['amount' => '100.00', 'status' => 4], ['amount' => '100.00', 'status' => 1],
|
||||
['amount' => '100.00', 'delete_time' => time()], ['amount' => '-1.00']] as $payment) {
|
||||
[$id, $payIds] = $fixture('100.00', [$payment]);
|
||||
$before = $snapshot($id);
|
||||
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Refunded/deleted/unpaid/invalid amount must not be removed');
|
||||
}
|
||||
[$id, $payIds] = $fixture('100.00', [['amount' => '100.00']], ['delete_time' => time()]);
|
||||
$before = $snapshot($id);
|
||||
$expect($remove($id, $payIds[0]) === false && $snapshot($id) === $before, 'Deleted order must not be changed');
|
||||
|
||||
// Overpayment and stale paid values do not prevent unlinking: total must not be reduced.
|
||||
foreach (['0.00', '9999.00', '101.00'] as $stalePaid) {
|
||||
[$id, $payIds] = $fixture('100.00', [['amount' => '101.00'], ['amount' => '20.00']], ['paid' => $stalePaid]);
|
||||
$amounts($remove($id, $payIds[0]), 100.0, 20.0, 80.0);
|
||||
}
|
||||
[$id, $payIds] = $fixture('0.00', [['amount' => '10.00']]);
|
||||
$amounts($remove($id, $payIds[0]), 0.0, 0.0, 0.0);
|
||||
|
||||
// Refund balances can be lower than receipt face values. Never resurrect refunded money.
|
||||
[$id, $payIds] = $fixture('1500.00', [['amount' => '1000.00'], ['amount' => '500.00']],
|
||||
['paid' => '1300.00', 'refund_amount' => '200.00']);
|
||||
$out = $remove($id, $payIds[1]);
|
||||
$amounts($out, 1500.0, 1000.0, 500.0, false);
|
||||
$expect((float) $out['paid'] === 800.0 && (float) Db::name('tcm_prescription_order')->where('id', $id)->value('paid') === 800.0,
|
||||
'Partial refunds must retain the net paid balance when removing a receipt');
|
||||
$amounts($remove($id, $payIds[0]), 1500.0, 0.0, 1500.0);
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00', 'status' => 4], ['amount' => '500.00'], ['amount' => '300.00']],
|
||||
['paid' => '800.00', 'refund_amount' => '200.00']);
|
||||
$amounts($remove($id, $payIds[1]), 1000.0, 300.0, 700.0);
|
||||
[$id, $payIds] = $fixture('1500.00', [['amount' => '1000.00'], ['amount' => '500.00']],
|
||||
['fulfillment_status' => 10, 'paid' => '0.00', 'refund_amount' => '1500.00']);
|
||||
$out = $remove($id, $payIds[1]);
|
||||
$amounts($out, 1500.0, 1000.0, 500.0, false);
|
||||
$expect((float) $out['paid'] === 0.0 && (int) $out['fulfillment_status'] === 10, 'Full refund must not acquire a paid balance again');
|
||||
|
||||
// Remaining refunds and soft-deleted payments must not reappear in paid sums.
|
||||
[$id, $payIds] = $fixture('1000.00', [
|
||||
['amount' => '200.00', 'status' => 4], ['amount' => '300.00'],
|
||||
['amount' => '100.00', 'delete_time' => time()], ['amount' => '50.00', 'status' => 5],
|
||||
]);
|
||||
$amounts($remove($id, $payIds[1]), 1000.0, 50.0, 950.0);
|
||||
$listsReflection = new ReflectionClass(PrescriptionOrderLists::class);
|
||||
$sum = $listsReflection->getMethod('sumLinkedPayForPrescriptionOrderIds');
|
||||
$expect($sum->invoke($listsReflection->newInstanceWithoutConstructor(), [$id]) === 50.0,
|
||||
'List summary and detail must use the same remaining paid amount');
|
||||
|
||||
// A failed audit-log insert must roll back the removed link, paid and collection snapshot.
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']], ['paid' => '200.00', 'agency_collect_amount' => '800.00']);
|
||||
$before = $snapshot($id);
|
||||
$pdo->exec("CREATE TRIGGER reject_unlink_log BEFORE INSERT ON zyt_tcm_prescription_order_log
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced audit log failure'");
|
||||
$expect($remove($id, $payIds[0]) === false, 'Audit log failure must reject the operation');
|
||||
$expect($snapshot($id) === $before, 'Transaction must roll back link, amount, snapshot and log');
|
||||
$pdo->exec('DROP TRIGGER reject_unlink_log');
|
||||
$amounts($remove($id, $payIds[0]), 1000.0, 0.0, 1000.0);
|
||||
|
||||
// Permission migration is idempotent, grants no roles, and enables explicitly granted access.
|
||||
$pdo->exec("INSERT INTO zyt_system_menu (perms,is_disable) VALUES ('tcm.prescriptionOrder/lists',0)");
|
||||
$migration = file_get_contents(dirname(__DIR__) . '/sql/1.9.20260831/add_prescription_order_unlink_pay_order_menu.sql');
|
||||
foreach ([1, 2] as $_) {
|
||||
$pdo->exec($migration);
|
||||
}
|
||||
$menuId = (int) Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/unlinkPayOrder')->value('id');
|
||||
$expect(Db::name('system_menu')->where('perms', 'tcm.prescriptionOrder/unlinkPayOrder')->count() === 1, 'Menu migration must be repeatable');
|
||||
$expect(Db::name('system_role_menu')->count() === 0, 'Migration must not grant permissions automatically');
|
||||
Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 2]);
|
||||
Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $menuId]);
|
||||
[$id, $payIds] = $fixture('100.00', [['amount' => '50.00']]);
|
||||
$out = PrescriptionOrderLogic::unlinkPayOrder(['id' => $id, 'pay_order_id' => $payIds[0]], 2,
|
||||
['root' => 0, 'admin_id' => 2, 'role_id' => [2], 'name' => '获授权测试员']);
|
||||
$expect(is_array($out) && !array_key_exists('internal_cost', $out) && !array_key_exists('remark_extra', $out), 'Explicit permission must work while preserving financial/remark masking');
|
||||
|
||||
// Existing add/link operations also participate in the same transaction/row lock.
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
|
||||
$out = PrescriptionOrderLogic::addPayOrder(['id' => $id, 'order_type' => 3, 'pay_amount' => 300], 1, $admin);
|
||||
$expect(is_array($out), 'Existing add payment flow must still succeed');
|
||||
$amounts($out, 1000.0, 500.0, 500.0, false);
|
||||
$amounts($remove($id, (int) end($out['pay_order_ids'])), 1000.0, 200.0, 800.0);
|
||||
$freeId = (int) Db::name('order')->insertGetId(['order_no' => 'FREE-PAYMENT', 'patient_id' => 1, 'status' => 2, 'amount' => 50, 'create_time' => time()]);
|
||||
$out = PrescriptionOrderLogic::linkPayOrder(['id' => $id, 'pay_order_id' => $freeId], 1, $admin);
|
||||
$expect(is_array($out), 'Existing link payment flow must still succeed');
|
||||
$amounts($out, 1000.0, 250.0, 750.0, false);
|
||||
|
||||
// Separate PHP/DB connections race against a held parent row lock. Both are ready
|
||||
// before release, so this tests the database lock rather than sequential double-clicks.
|
||||
$race = static function (int $id, array $operations) use ($database): array {
|
||||
putenv('ZYT_UNLINK_TEST_DATABASE=' . $database);
|
||||
Db::startTrans();
|
||||
Db::name('tcm_prescription_order')->where('id', $id)->lock(true)->find();
|
||||
$workers = [];
|
||||
try {
|
||||
foreach ($operations as [$operation, $payId]) {
|
||||
$process = proc_open([PHP_BINARY, __FILE__, '--worker', $operation, (string) $id, (string) $payId],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) throw new RuntimeException('Unable to start concurrency worker');
|
||||
fclose($pipes[0]);
|
||||
$workers[] = [$process, $pipes];
|
||||
if (trim((string) fgets($pipes[1])) !== 'ready') throw new RuntimeException('Concurrency worker did not initialize');
|
||||
}
|
||||
} finally {
|
||||
Db::commit();
|
||||
}
|
||||
$results = [];
|
||||
foreach ($workers as [$process, $pipes]) {
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
if (proc_close($process) !== 0) throw new RuntimeException('Concurrency worker failed: ' . $error . $output);
|
||||
$results[] = json_decode(trim($output), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
return $results;
|
||||
};
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
|
||||
$results = $race($id, [['unlinkPayOrder', $payIds[0]], ['unlinkPayOrder', $payIds[0]]]);
|
||||
$expect(count(array_filter($results, static fn (array $r): bool => $r['success'])) === 1, 'Concurrent duplicate removal must succeed exactly once');
|
||||
$expect((float) Db::name('tcm_prescription_order')->where('id', $id)->value('amount') === 1000.0, 'Concurrent duplicate removal must never change total');
|
||||
$expect((float) Db::name('tcm_prescription_order')->where('id', $id)->value('paid') === 0.0, 'Concurrent duplicate removal must persist the remaining paid balance');
|
||||
$expect(Db::name('tcm_prescription_order_log')->where('prescription_order_id', $id)->count() === 1, 'Concurrent duplicate must write one audit log');
|
||||
foreach (['addPayOrder', 'linkPayOrder'] as $operation) {
|
||||
[$id, $payIds] = $fixture('1000.00', [['amount' => '200.00']]);
|
||||
$freeId = (int) Db::name('order')->insertGetId(['order_no' => 'RACE-' . $id, 'patient_id' => 1, 'status' => 2, 'amount' => 300, 'create_time' => time()]);
|
||||
$results = $race($id, [['unlinkPayOrder', $payIds[0]], [$operation, $freeId]]);
|
||||
$expect($results[0]['success'] && $results[1]['success'], 'Adding/linking during removal must retain both changes');
|
||||
$row = Db::name('tcm_prescription_order')->where('id', $id)->find();
|
||||
$expect((float) $row['amount'] === 1000.0 && (float) $row['agency_collect_amount'] === 700.0, 'Concurrent add/remove must preserve the total and update the collection snapshot');
|
||||
$expect((float) $results[0]['paid'] === (float) $results[0]['linked_paid'], 'Removal must persist paid using the receipts visible within its transaction');
|
||||
$remaining = Db::name('tcm_prescription_order_pay_order')->where('prescription_order_id', $id)->column('pay_order_id');
|
||||
$expect(count($remaining) === 1 && !in_array($payIds[0], array_map('intval', $remaining), true), 'Concurrent link replacement must not resurrect a removed payment');
|
||||
}
|
||||
|
||||
foreach ([[], ['id' => 1], ['id' => 0, 'pay_order_id' => 1], ['id' => 1, 'pay_order_id' => -1],
|
||||
['id' => 1, 'pay_order_id' => 1.5], ['id' => 1, 'pay_order_id' => [1]]] as $params) {
|
||||
$expect(!(new PrescriptionOrderValidate())->scene('unlinkPayOrder')->check($params), 'Request must require two positive integer IDs');
|
||||
}
|
||||
$expect((new PrescriptionOrderValidate())->scene('unlinkPayOrder')->check(['id' => 1, 'pay_order_id' => 2]), 'Valid IDs must pass validation');
|
||||
echo "PrescriptionOrderUnlinkPayOrderTest: {$checks} assertions passed\n";
|
||||
} finally {
|
||||
$manager->connect()->close();
|
||||
$pdo->exec("DROP DATABASE `{$database}`");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function eventTagSnapshotExpect(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$snapshotSource = file_get_contents(
|
||||
$root . '/app/common/service/qywx/QywxExternalContactEventTagSnapshotService.php'
|
||||
);
|
||||
$customerSource = file_get_contents($root . '/app/adminapi/logic/qywx/CustomerLogic.php');
|
||||
$callbackSource = file_get_contents($root . '/app/api/controller/QywxExternalContactCallbackController.php');
|
||||
$storeSource = file_get_contents($root . '/app/common/service/qywx/QywxPromotionAutomationStore.php');
|
||||
$datedMigration = file_get_contents($root . '/sql/1.9.20260904/add_qywx_external_contact_event_tag.sql');
|
||||
$baseMigration = file_get_contents($root . '/database/migrations/create_qywx_external_contact_event_tag.sql');
|
||||
|
||||
foreach ([$snapshotSource, $customerSource, $callbackSource, $storeSource, $datedMigration, $baseMigration] as $source) {
|
||||
eventTagSnapshotExpect(is_string($source), '无法读取事件标签快照实现或迁移文件');
|
||||
}
|
||||
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($snapshotSource, "'tag_id' => ''")
|
||||
&& str_contains($snapshotSource, 'Db::transaction(')
|
||||
&& str_contains($snapshotSource, 'INSERT IGNORE INTO')
|
||||
&& str_contains($snapshotSource, 'if ($inserted === 0)'),
|
||||
'事件标签快照必须原子写入完成标记,并对重复回调保持幂等'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($snapshotSource, 'public static function installed(): bool')
|
||||
&& str_contains($snapshotSource, "str_contains(\$message, '1146')"),
|
||||
'快照服务必须能识别迁移缺表,供读取侧安全降级'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
!str_contains($snapshotSource, '->delete(')
|
||||
&& !str_contains($snapshotSource, '->update('),
|
||||
'历史事件标签快照只能追加,不能随当前客户关系删除或改写'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($snapshotSource, "trim((string) (\$followUser['userid'] ?? '')) !== \$followUserId")
|
||||
&& str_contains($snapshotSource, "['tags']['status']")
|
||||
&& str_contains($snapshotSource, "\$tagStatus !== 'success'")
|
||||
&& str_contains($snapshotSource, 'self::appendTags('),
|
||||
'快照必须只读取事件员工标签,推广成功标签只能追加证据而不能提前冻结完整快照'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($customerSource, 'public static function recordExternalContactEvent(array $data): int')
|
||||
&& str_contains($customerSource, 'QywxExternalContactEventTagSnapshotService::captureFromFollowUsers('),
|
||||
'事件入库必须返回事件ID,并在客户详情同步后按员工保存快照'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($callbackSource, '$eventId = CustomerLogic::recordExternalContactEvent([')
|
||||
&& str_contains($callbackSource, 'QywxExternalContactEventTagSnapshotService::captureForPromotionEvent($eventId)')
|
||||
&& str_contains($callbackSource, "\$changeType === 'add_external_contact' ? \$eventId : 0"),
|
||||
'普通新增与推广新增回调都必须把标签快照绑定到实际事件ID'
|
||||
);
|
||||
eventTagSnapshotExpect(
|
||||
str_contains($storeSource, 'QywxExternalContactEventTagSnapshotService::captureFromPromotionTask($row)'),
|
||||
'推广标签动作异步重试成功后必须补写事件标签快照'
|
||||
);
|
||||
|
||||
foreach ([$datedMigration, $baseMigration] as $migration) {
|
||||
foreach ([
|
||||
'CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_event_tag`',
|
||||
'UNIQUE KEY `uk_event_user_tag` (`event_id`, `follow_user_id`, `tag_id`)',
|
||||
'KEY `idx_tag_event_user` (`tag_id`, `event_id`, `follow_user_id`)',
|
||||
"t.`change_type` = 'add_external_contact'",
|
||||
"'$.tags.status'",
|
||||
'current_tag.`follow_user_id` = e.`user_id`',
|
||||
'existing_snapshot.`event_id` = e.`id`',
|
||||
"existing_snapshot.`tag_id` = ''",
|
||||
] as $needle) {
|
||||
eventTagSnapshotExpect(str_contains($migration, $needle), '事件标签快照迁移缺少契约:' . $needle);
|
||||
}
|
||||
eventTagSnapshotExpect(
|
||||
!str_contains($migration, "IN ('success', 'skipped', 'failed')"),
|
||||
'推广标签未启用或失败不能生成空完成标记,否则会遮蔽客户实际渠道标签'
|
||||
);
|
||||
}
|
||||
|
||||
echo "QYWX_EXTERNAL_CONTACT_EVENT_TAG_SNAPSHOT_OK\n";
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionConfig;
|
||||
use app\common\service\qywx\QywxPromotionMemberRange;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
function promotionCheck(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$slot = ['weekdays' => [1], 'start' => '22:00', 'end' => '02:00', 'member_admin_ids' => [1]];
|
||||
$monday = strtotime('2026-08-31 22:00:00 Asia/Shanghai');
|
||||
$tuesday = strtotime('2026-09-01 01:59:00 Asia/Shanghai');
|
||||
$end = strtotime('2026-09-01 02:00:00 Asia/Shanghai');
|
||||
promotionCheck(QywxPromotionConfig::matches($slot, $monday), '开始边界应包含');
|
||||
promotionCheck(QywxPromotionConfig::matches($slot, $tuesday), '跨日时段应按开始星期匹配');
|
||||
promotionCheck(!QywxPromotionConfig::matches($slot, $end), '结束边界应排除');
|
||||
promotionCheck(!QywxPromotionConfig::matches($slot, strtotime('2026-08-31 01:00:00 Asia/Shanghai')), '不能把周一凌晨算入周一晚班');
|
||||
|
||||
$config = QywxPromotionConfig::normalize([
|
||||
'reception_mode' => 'scheduled', 'reception_schedule' => [$slot],
|
||||
'backup_member_admin_ids' => [2],
|
||||
'welcome_mode' => 'channel', 'welcome' => ['text' => '你好 {customer_name}'],
|
||||
]);
|
||||
$config['backup_userids'] = ['backup'];
|
||||
$config['reception_schedule'][0]['member_userids'] = ['main'];
|
||||
$members = [
|
||||
['userid' => 'main', 'enabled' => 1, 'today_date' => '2026-08-31', 'today_count' => 0, 'daily_limit' => 1],
|
||||
['userid' => 'backup', 'enabled' => 1],
|
||||
];
|
||||
$range = QywxPromotionMemberRange::evaluate($members, '2026-08-31', $monday, $config);
|
||||
promotionCheck($range['userids'] === ['main'], '主接待在线时不能分给备用员工');
|
||||
$range = QywxPromotionMemberRange::evaluate($members, '2026-09-01', $end, $config);
|
||||
promotionCheck($range['userids'] === ['backup'] && $range['using_backup'], '下班后切换备用员工');
|
||||
$members[0]['today_count'] = 1;
|
||||
$range = QywxPromotionMemberRange::evaluate($members, '2026-08-31', $monday, $config);
|
||||
promotionCheck($range['userids'] === ['backup'], '达到上限后切换备用员工');
|
||||
$range = QywxPromotionMemberRange::evaluate($members, '2026-09-01', $tuesday, $config);
|
||||
promotionCheck($range['userids'] === ['main'], '跨日清零上限后主接待应恢复');
|
||||
$members[1]['enabled'] = 0;
|
||||
$range = QywxPromotionMemberRange::evaluate($members, '2026-08-31', $monday, $config);
|
||||
promotionCheck($range['userids'] === [] && !$range['using_backup'], '备用停用后不能偷偷恢复超额主成员');
|
||||
$sunday = ['weekdays' => [7], 'start' => '22:00', 'end' => '02:00'];
|
||||
promotionCheck(QywxPromotionConfig::matches($sunday, strtotime('2026-08-31 01:00:00 Asia/Shanghai')), '跨周午夜规则应回到周日');
|
||||
|
||||
foreach ([
|
||||
['reception_mode' => 'scheduled', 'reception_schedule' => [$slot]],
|
||||
['reception_schedule' => [array_replace($slot, ['start' => '25:00'])]],
|
||||
['reception_schedule' => [array_replace($slot, ['weekdays' => [8]])]],
|
||||
['tags_enabled' => true],
|
||||
['tags_enabled' => true, 'tag_ids' => ['tag_1', 'tag_2']],
|
||||
['tags_enabled' => false, 'tag_ids' => ['tag_1', 'tag_2']],
|
||||
['tag_ids' => ['tag_1', 'tag_1']],
|
||||
['welcome_mode' => 'channel'],
|
||||
['welcome_mode' => 'invalid'],
|
||||
['welcome' => ['text' => str_repeat('😀', 1001)]],
|
||||
['welcome_schedule' => [
|
||||
$slot + ['text' => '晚班'],
|
||||
['weekdays' => [2], 'start' => '01:00', 'end' => '03:00', 'text' => '冲突'],
|
||||
]],
|
||||
] as $invalid) {
|
||||
$failed = false;
|
||||
try {
|
||||
QywxPromotionConfig::normalize($invalid);
|
||||
} catch (RuntimeException) {
|
||||
$failed = true;
|
||||
}
|
||||
promotionCheck($failed, '非法配置必须被拒绝:' . json_encode($invalid));
|
||||
}
|
||||
$rendered = QywxPromotionConfig::render('{customer_name}-{employee_name}-{add_time}', '小王', '李医生', $monday, 20);
|
||||
promotionCheck($rendered === '小王-李医生-2026-08-31', '模板变量和中国时区');
|
||||
promotionCheck(mb_strlen(QywxPromotionConfig::render('{customer_name}', str_repeat('王', 30), '', $monday, 20)) === 20, '备注不能超过企微长度限制');
|
||||
$singleTag = QywxPromotionConfig::normalize(['tags_enabled' => true, 'tag_ids' => [' tag_1 ']]);
|
||||
promotionCheck($singleTag['tag_ids'] === ['tag_1'], '单选仍保留tag_ids数组契约');
|
||||
promotionCheck(QywxPromotionConfig::normalize(['tags_enabled' => false, 'tag_ids' => []])['tag_ids'] === [], '关闭标签允许空选择');
|
||||
$legacyTags = QywxPromotionConfig::decode(['tags_enabled' => true, 'tag_ids' => ['tag_1', 'tag_2']]);
|
||||
promotionCheck($legacyTags['tag_ids'] === ['tag_1', 'tag_2'], '读取旧方案不得静默截掉多选,保存时明确拒绝');
|
||||
echo "QYWX_PROMOTION_AUTOMATION_CONFIG_OK\n";
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
$paths = [
|
||||
'callback' => $root . '/server/app/api/controller/QywxExternalContactCallbackController.php',
|
||||
'logic' => $root . '/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php',
|
||||
'initialMigration' => $root . '/server/sql/1.9.20260831/add_wecom_promotion_automation.sql',
|
||||
'upgradeMigration' => $root . '/server/sql/1.9.20260901/upgrade_qywx_promotion_automation_runtime.sql',
|
||||
'compose' => $root . '/docker/docker-compose.yml',
|
||||
];
|
||||
|
||||
$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['callback'], 'enqueueVerifiedEvent($event, true)')) {
|
||||
throw new RuntimeException('企微回调未启用欢迎语和标签即时通道');
|
||||
}
|
||||
if (!str_contains($sources['logic'], "'automation_saved' => \$automation !== null")) {
|
||||
throw new RuntimeException('保存接口未向前端确认自动化配置已落库');
|
||||
}
|
||||
foreach (['initialMigration', 'upgradeMigration'] as $name) {
|
||||
foreach (['qywx:retry-promotion-automation', 'qywx:refresh-promotion-media'] as $command) {
|
||||
if (!str_contains($sources[$name], $command)) {
|
||||
throw new RuntimeException("{$name} 缺少 {$command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!str_contains($sources['compose'], 'qywx:work-promotion-automation')) {
|
||||
throw new RuntimeException('Docker 部署未启动企微欢迎语常驻进程');
|
||||
}
|
||||
|
||||
echo "QYWX_PROMOTION_AUTOMATION_RUNTIME_CONTRACT_OK\n";
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionAutomationService;
|
||||
use app\common\service\qywx\QywxPromotionAutomationStore;
|
||||
use app\common\service\qywx\QywxPromotionCodeCipher;
|
||||
use app\common\service\qywx\QywxPromotionConfig;
|
||||
use app\common\service\qywx\QywxPromotionContactApiException;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionEnqueueException;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
new think\App();
|
||||
function automationCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
final class AutomationApiFake extends QywxPromotionContactApiService
|
||||
{
|
||||
public array $calls = [];
|
||||
public ?QywxPromotionContactApiException $welcomeError = null;
|
||||
public bool $tagsFail = false;
|
||||
public function __construct() {}
|
||||
public function sendWelcome(string $code, string $text, array $attachments): void {
|
||||
$this->calls[] = ['welcome', $text, $attachments];
|
||||
if ($this->welcomeError) { $e = $this->welcomeError; $this->welcomeError = null; throw $e; }
|
||||
}
|
||||
public function markTags(string $userId, string $externalUserId, array $tagIds): void {
|
||||
$this->calls[] = ['tags', $userId, $externalUserId, $tagIds];
|
||||
if ($this->tagsFail) { $this->tagsFail = false; throw new QywxPromotionContactApiException('mock tags failure', 45009); }
|
||||
}
|
||||
public function remark(string $userId, string $externalUserId, array $fields): void { $this->calls[] = ['remark', $fields]; }
|
||||
public function getExternalContact(string $externalUserId, string $cursor = ''): array { return ['external_contact' => ['name' => '客户昵称']]; }
|
||||
public function getUser(string $userId): array { return ['name' => '企微员工']; }
|
||||
}
|
||||
final class AutomationMediaFake extends QywxPromotionMediaService
|
||||
{
|
||||
public bool $expired = false;
|
||||
public function __construct() {}
|
||||
public function materialize(array $attachments, array $config): array {
|
||||
if ($this->expired) { throw new RuntimeException('mock media expired'); }
|
||||
return $attachments;
|
||||
}
|
||||
}
|
||||
final class AutomationMemoryStore extends QywxPromotionAutomationStore
|
||||
{
|
||||
public array $rows = [];
|
||||
public array $logs = [];
|
||||
public array $syncs = [];
|
||||
public array $config;
|
||||
public bool $schemaInstalled = true;
|
||||
public bool $schemaCheckFail = false;
|
||||
public bool $enqueueFail = false;
|
||||
public int $now;
|
||||
public function installed(): bool {
|
||||
if ($this->schemaCheckFail) { throw new RuntimeException('mock DB unavailable during schema check'); }
|
||||
return $this->schemaInstalled;
|
||||
}
|
||||
public function attribution(string $state, string $linkId, string $userId): ?array {
|
||||
if (($state === 'zyt_pool:1' || ($state === '' && $linkId === 'real_link')) && $userId === 'staff') {
|
||||
return ['pool_id' => 1, 'member_admin_id' => 7, 'config' => $this->config];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public function enqueue(array $row): int {
|
||||
if ($this->enqueueFail) { throw new RuntimeException('mock DB failure'); }
|
||||
foreach ($this->rows as $id => $existing) { if ($existing['event_key'] === $row['event_key']) { return $id; } }
|
||||
$row['id'] = count($this->rows) + 1;
|
||||
$this->rows[$row['id']] = $row;
|
||||
return $row['id'];
|
||||
}
|
||||
public function due(string $lane, int $now, int $limit): array {
|
||||
return array_keys(array_filter($this->rows, static function (array $r) use ($lane, $now): bool {
|
||||
$pending = in_array($r['welcome_status'], ['pending', 'retry', 'running'], true);
|
||||
if ($r['status'] === 'done' || $r['lock_until'] > $now) { return false; }
|
||||
if ($lane === 'welcome') { return $pending && $r['welcome_next_retry'] <= $now; }
|
||||
if ($lane === 'inline_metadata') { return $r['next_retry'] <= $now; }
|
||||
return (!$pending || $r['welcome_expires_at'] <= $now) && $r['next_retry'] <= $now;
|
||||
}));
|
||||
}
|
||||
public function claim(int $id, string $lane, int $now): ?array {
|
||||
if (!in_array($id, $this->due($lane, $now, 100), true)) { return null; }
|
||||
$this->rows[$id]['lock_token'] = 'lease'; $this->rows[$id]['lock_until'] = $now + 30;
|
||||
return $this->rows[$id];
|
||||
}
|
||||
public function save(array $row, ?array $log = null): void {
|
||||
automationCheck($row['lock_token'] === $this->rows[$row['id']]['lock_token'], 'lease token');
|
||||
$this->rows[$row['id']] = $row;
|
||||
if ($log) { $this->logs[] = $log; }
|
||||
}
|
||||
public function localNames(array $task): array { return ['customer' => '', 'employee' => '本地员工']; }
|
||||
public function dispatch(array $task): void { $this->syncs[] = 'dispatch'; }
|
||||
public function syncRange(array $task): void { $this->syncs[] = 'range'; }
|
||||
public function syncCustomer(array $task): void { $this->syncs[] = 'sync'; }
|
||||
}
|
||||
$now = strtotime('2026-08-31 10:00:00 Asia/Shanghai');
|
||||
$baseConfig = array_replace(QywxPromotionConfig::defaults(), [
|
||||
'welcome_mode' => 'channel', 'welcome' => ['text' => '您好 {customer_name},我是{employee_name}', 'attachments' => []],
|
||||
'tags_enabled' => true, 'tag_ids' => ['tag1'], 'remark_enabled' => true, 'remark_template' => '{customer_name}-{employee_name}',
|
||||
'description_enabled' => true, 'description' => '推广客户',
|
||||
]);
|
||||
$fixture = static function () use (&$now, $baseConfig): array {
|
||||
$store = new AutomationMemoryStore(); $store->config = $baseConfig; $store->now = $now;
|
||||
$api = new AutomationApiFake(); $media = new AutomationMediaFake();
|
||||
$cipher = new QywxPromotionCodeCipher(str_repeat('test-secret-key-', 4));
|
||||
$service = new QywxPromotionAutomationService($api, $media, $store, $cipher, static function () use (&$now): int { return $now; });
|
||||
return [$service, $store, $api, $media, $cipher];
|
||||
};
|
||||
$event = ['ToUserName' => 'corp', 'ChangeType' => 'add_external_contact', 'State' => 'zyt_pool:1', 'UserID' => 'staff',
|
||||
'ExternalUserID' => 'customer', 'CreateTime' => $now, 'WelcomeCode' => 'NEVER_LOG_THIS_CODE'];
|
||||
[$service, $store, $api, $media, $cipher] = $fixture();
|
||||
automationCheck($service->enqueueVerifiedEvent($event), 'valid promotion event handled');
|
||||
automationCheck($api->calls === [] && $store->syncs === [], 'enqueue contains no API or slow sync');
|
||||
automationCheck(!str_contains(json_encode($store->rows), 'NEVER_LOG_THIS_CODE'), 'code encrypted at rest');
|
||||
automationCheck($cipher->decrypt($store->rows[1]['welcome_cipher']) === $event['WelcomeCode'], 'AES round trip');
|
||||
$service->enqueueVerifiedEvent($event);
|
||||
automationCheck(count($store->rows) === 1, 'duplicate event idempotency');
|
||||
$service->retryPending();
|
||||
automationCheck($api->calls === [], 'minute lane cannot lock active welcome');
|
||||
$service->processWelcomes();
|
||||
automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '客户昵称') && str_contains($api->calls[0][1], '企微员工'), 'welcome first and template names');
|
||||
automationCheck($store->syncs === [] && $store->rows[1]['welcome_status'] === 'sent' && $store->rows[1]['welcome_cipher'] === '', 'clear code on success and defer slow sync');
|
||||
$api->tagsFail = true;
|
||||
$service->retryPending();
|
||||
$actions = json_decode($store->rows[1]['actions_json'], true);
|
||||
automationCheck($actions['tags']['status'] === 'retry' && $actions['remark']['status'] === 'success' && $actions['description']['status'] === 'success', 'failed tags do not block enabled remarks');
|
||||
automationCheck($store->syncs === ['dispatch', 'range', 'sync'], 'original sync preserved despite tag failure');
|
||||
$now += 60; $service->retryPending(); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['status'] === 'done', 'failed action retried to completion');
|
||||
automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'welcome')) === 1, 'never resend success');
|
||||
automationCheck(count(array_filter($api->calls, static fn (array $v): bool => $v[0] === 'remark')) === 2, 'do not repeat successful remark/description');
|
||||
automationCheck(!str_contains(json_encode($store->logs), 'NEVER_LOG_THIS_CODE'), 'audit contains no code');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096);
|
||||
automationCheck($service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]), true), 'callback immediate lane handled');
|
||||
$actions = json_decode($store->rows[1]['actions_json'], true);
|
||||
automationCheck(array_column($api->calls, 0) === ['welcome', 'tags'], 'callback immediately attempts welcome then tags');
|
||||
automationCheck(
|
||||
$actions['welcome']['status'] === 'retry' && $actions['tags']['status'] === 'success',
|
||||
'tag send is independent from retryable welcome: ' . json_encode([$actions['welcome'], $actions['tags']])
|
||||
);
|
||||
automationCheck($store->syncs === [], 'callback immediate lane leaves slow actions to background job');
|
||||
|
||||
foreach (['default', 'none'] as $mode) {
|
||||
[$service, $store, $api] = $fixture(); $store->config['welcome_mode'] = $mode;
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
|
||||
$service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'skipped' && $api->calls === [] && $store->rows[1]['welcome_cipher'] === '', 'default/none do not send or retain code');
|
||||
}
|
||||
[$service, $store, $api] = $fixture();
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => 'zyt_pool:999'])), 'untrusted state cannot authorize');
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['UserID' => 'outsider'])), 'nonmember cannot authorize');
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => ''])), 'missing state and link stays legacy');
|
||||
$store->schemaInstalled = false;
|
||||
automationCheck(!$service->enqueueVerifiedEvent($event), 'missing migration stays legacy');
|
||||
$store->schemaInstalled = true; $store->enqueueFail = true;
|
||||
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
|
||||
automationCheck($failed, 'enqueue failure propagates for callback retry');
|
||||
[$service, $store] = $fixture(); $store->schemaCheckFail = true;
|
||||
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
|
||||
automationCheck($failed, 'schema lookup failure propagates for callback retry instead of acknowledging lost event');
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'WelcomeCode' => '']));
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'skipped', 'missing code explicit skip');
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now - 21]));
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'expired', 'expired code not queued for send');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'ChangeType' => 'add_half_external_contact']));
|
||||
$service->processWelcomes(); $service->retryPending();
|
||||
automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '您好 您'), 'half-contact welcome uses safe nickname fallback');
|
||||
automationCheck($store->syncs === [] && $store->rows[1]['status'] === 'done', 'half-contact does not create customer or modify relation');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$api->welcomeError = new QywxPromotionContactApiException('network unknown', 0, true);
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && count($api->calls) === 1 && $store->rows[1]['welcome_cipher'] === '', 'uncertain network never retried');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096);
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'retry', 'explicit 41096 can retry within window');
|
||||
$now++; $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'sent' && count($api->calls) === 2, 'safe explicit retry succeeded');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
|
||||
$actions = json_decode($store->rows[1]['actions_json'], true); $actions['welcome']['status'] = 'running';
|
||||
$store->rows[1]['actions_json'] = json_encode($actions); $store->rows[1]['welcome_status'] = 'running';
|
||||
$service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && $api->calls === [], 'crashed running send is never replayed');
|
||||
|
||||
[$service, $store, $api, $media] = $fixture(); $media->expired = true;
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'retry' && $api->calls === [], 'unprepared media never sends partial payload');
|
||||
$now += 21; $service->retryPending();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'expired' && $store->rows[1]['welcome_cipher'] === '', 'minute job expires and clears code');
|
||||
$config = $baseConfig; $config['welcome_schedule_enabled'] = true;
|
||||
$config['welcome_schedule'] = [['weekdays' => [1], 'start' => '10:00', 'end' => '11:00', 'text' => '上午', 'attachments' => []]];
|
||||
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 10:00 Asia/Shanghai'))['text'] === '上午', 'schedule uses event time');
|
||||
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 11:00 Asia/Shanghai'))['text'] === $baseConfig['welcome']['text'], 'schedule fallback explicit base welcome');
|
||||
echo "QYWX_PROMOTION_AUTOMATION_OK\n";
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionCodeCipher;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
// 独立临时App根目录,不加载项目配置或业务数据库。
|
||||
if (($argv[1] ?? '') === '--worker') {
|
||||
new think\App($argv[2]);
|
||||
echo (new QywxPromotionCodeCipher())->encrypt('cipher-concurrency-test');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
function cipherCheck(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'qywx_cipher_test_' . bin2hex(random_bytes(8));
|
||||
mkdir($root, 0700);
|
||||
new think\App($root);
|
||||
$processes = [];
|
||||
try {
|
||||
// 多个进程首次启动必须共享同一完整密钥,不能读到空文件或覆盖对方的密钥。
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$pipes = [];
|
||||
$process = proc_open([PHP_BINARY, __FILE__, '--worker', $root],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes,
|
||||
null, null, ['bypass_shell' => true]);
|
||||
cipherCheck(is_resource($process), 'start isolated cipher worker');
|
||||
fclose($pipes[0]);
|
||||
$processes[] = [$process, $pipes];
|
||||
}
|
||||
$cipher = new QywxPromotionCodeCipher();
|
||||
$encrypted = [];
|
||||
foreach ($processes as [$process, $pipes]) {
|
||||
$value = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
cipherCheck(proc_close($process) === 0 && $error === '', 'cipher worker completed without error');
|
||||
cipherCheck($cipher->decrypt($value) === 'cipher-concurrency-test', 'concurrent processes share one persisted key');
|
||||
$encrypted[] = $value;
|
||||
}
|
||||
$processes = [];
|
||||
cipherCheck(count(array_unique($encrypted)) === 6, 'fresh nonce for every encryption');
|
||||
$raw = base64_decode($encrypted[0], true);
|
||||
$raw[15] = chr(ord($raw[15]) ^ 1);
|
||||
$rejected = false;
|
||||
try { $cipher->decrypt(base64_encode($raw)); } catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'tampered authentication tag rejected');
|
||||
$rejected = false;
|
||||
try { (new QywxPromotionCodeCipher(str_repeat('wrong-key', 8)))->decrypt($encrypted[0]); }
|
||||
catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'wrong key cannot decrypt');
|
||||
file_put_contents($root . '/runtime/qywx_promotion_private/welcome.key', 'incomplete-key');
|
||||
$rejected = false;
|
||||
try { (new QywxPromotionCodeCipher())->encrypt('test'); } catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'damaged persisted key fails closed instead of silently rotating');
|
||||
} finally {
|
||||
foreach ($processes as [$process, $pipes]) {
|
||||
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
|
||||
if (is_resource($process)) { proc_close($process); }
|
||||
}
|
||||
$keyPath = $root . '/runtime/qywx_promotion_private/welcome.key';
|
||||
if (is_file($keyPath)) { unlink($keyPath); }
|
||||
if (is_dir(dirname($keyPath))) { rmdir(dirname($keyPath)); }
|
||||
if (is_dir($root . '/runtime')) { rmdir($root . '/runtime'); }
|
||||
rmdir($root);
|
||||
}
|
||||
echo "QYWX_PROMOTION_CODE_CIPHER_OK\n";
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiException;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use think\facade\Config;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// 不initialize,不加载环境数据库;所有HTTP必须经过MockHandler。
|
||||
new think\App();
|
||||
Config::set(['corp_id' => 'ww_fake', 'secret' => 'acquisition_application_secret'], 'qywx_customer_acquisition');
|
||||
Config::set(['wechat_work' => ['external_pay_secret' => 'never_use_payment_secret']], 'pay');
|
||||
function apiCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
$json = static fn (array $value): Response => new Response(200, [], json_encode($value));
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler([
|
||||
$json(['errcode' => 0, 'tag_group' => [
|
||||
['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广'], ['id' => 'deleted', 'deleted' => true]]],
|
||||
['group_id' => 'removed_group', 'deleted' => true],
|
||||
]]),
|
||||
$json(['errcode' => 0, 'external_contact' => ['name' => '小李'], 'next_cursor' => 'next']),
|
||||
$json(['errcode' => 0, 'name' => '张医助']),
|
||||
$json(['errcode' => 0]), $json(['errcode' => 0]), $json(['errcode' => 0]),
|
||||
$json(['media_id' => 'media_1', 'created_at' => time(), 'type' => 'image']),
|
||||
$json(['errcode' => 40014, 'errmsg' => 'sensitive mock token']),
|
||||
$json(['errcode' => 0]),
|
||||
]));
|
||||
$stack->push(Middleware::history($history));
|
||||
$calls = 0;
|
||||
$api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack]),
|
||||
static function () use (&$calls): string { return 'mock_token_' . ++$calls; });
|
||||
apiCheck($api->credentialFingerprint() === hash('sha256', 'ww_fake|acquisition_application_secret'), 'must use acquisition app, not payment secret');
|
||||
apiCheck($api->tagOptions() === ['tag_groups' => [['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广']]]]], 'tag option mapping/deleted filter');
|
||||
apiCheck($api->getExternalContact('external', 'cursor')['next_cursor'] === 'next', 'customer detail');
|
||||
apiCheck($api->getUser('staff')['name'] === '张医助', 'staff name');
|
||||
$api->markTags('staff', 'external', ['tag', 'tag']);
|
||||
$api->remark('staff', 'external', ['remark' => '备注', 'remark_mobiles' => ['do-not-send']]);
|
||||
$api->sendWelcome('one_time_code', '您好', [['msgtype' => 'image', 'image' => ['media_id' => 'media']]]);
|
||||
$stream = fopen('php://temp', 'w+'); fwrite($stream, 'mock_image_bytes'); rewind($stream);
|
||||
apiCheck($api->uploadMedia($stream, 'image', 'cover.png')['media_id'] === 'media_1', 'upload media response');
|
||||
$api->sendWelcome('other_code', 'hello', []);
|
||||
$paths = array_map(static fn (array $h): string => $h['request']->getUri()->getPath(), $history);
|
||||
apiCheck($paths === [
|
||||
'/cgi-bin/externalcontact/get_corp_tag_list', '/cgi-bin/externalcontact/get', '/cgi-bin/user/get',
|
||||
'/cgi-bin/externalcontact/mark_tag', '/cgi-bin/externalcontact/remark', '/cgi-bin/externalcontact/send_welcome_msg',
|
||||
'/cgi-bin/media/upload', '/cgi-bin/externalcontact/send_welcome_msg', '/cgi-bin/externalcontact/send_welcome_msg',
|
||||
], 'official endpoints');
|
||||
apiCheck((string) $history[0]['request']->getBody() === '{}', 'empty tag request must be JSON object');
|
||||
parse_str($history[1]['request']->getUri()->getQuery(), $query);
|
||||
apiCheck($query['external_userid'] === 'external' && $query['cursor'] === 'cursor', 'GET query fields');
|
||||
$body = json_decode((string) $history[3]['request']->getBody(), true);
|
||||
apiCheck($body['add_tag'] === ['tag'] && !isset($body['remove_tag']), 'only add configured tags');
|
||||
$body = json_decode((string) $history[4]['request']->getBody(), true);
|
||||
apiCheck($body === ['userid' => 'staff', 'external_userid' => 'external', 'remark' => '备注'], 'only enabled remark fields');
|
||||
$body = (string) $history[6]['request']->getBody();
|
||||
apiCheck(str_contains($body, 'name="media"') && str_contains($body, 'filename="cover.png"'), 'multipart file field');
|
||||
fclose($stream);
|
||||
apiCheck($calls === 9, 'explicit token invalid response refreshes once');
|
||||
|
||||
$uploadRetry = new QywxPromotionContactApiService(new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create(new MockHandler([
|
||||
$json(['errcode' => 40014]), $json(['media_id' => 'retry_media', 'created_at' => time()]),
|
||||
])),
|
||||
]), static fn (): string => 'mock');
|
||||
$retryStream = fopen('php://temp', 'w+'); fwrite($retryStream, 'mock_image_bytes'); rewind($retryStream);
|
||||
apiCheck($uploadRetry->uploadMedia($retryStream, 'image', 'retry.png')['media_id'] === 'retry_media', 'multipart stream survives explicit token refresh');
|
||||
if (is_resource($retryStream)) { fclose($retryStream); }
|
||||
|
||||
foreach ([
|
||||
[new ConnectException('secret=should_never_escape', new Request('POST', 'https://mock/?access_token=secret')), true, 0],
|
||||
[new Response(502, [], 'secret'), true, 0],
|
||||
[$json(['errcode' => 41051, 'errmsg' => 'welcome_code=secret']), false, 41051],
|
||||
[$json(['errcode' => 41096, 'errmsg' => 'secret']), false, 41096],
|
||||
] as [$response, $uncertain, $code]) {
|
||||
$mock = new MockHandler([$response]);
|
||||
$service = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock), 'http_errors' => false]), static fn (): string => 'mock');
|
||||
try { $service->sendWelcome('code', 'hello', []); throw new RuntimeException('expected failure'); }
|
||||
catch (QywxPromotionContactApiException $e) {
|
||||
apiCheck($e->uncertain === $uncertain && $e->getCode() === $code, 'uncertain result classification');
|
||||
apiCheck(!str_contains($e->getMessage(), 'secret') && $e->getPrevious() === null, 'do not leak secrets in exception chain');
|
||||
apiCheck(count($mock) === 0, 'network error is not retried');
|
||||
}
|
||||
}
|
||||
echo "QYWX_PROMOTION_CONTACT_API_OK\n";
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// 不initialize,不加载业务数据库;每个HTTP请求都由MockHandler处理。
|
||||
new think\App();
|
||||
function createTagCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
$json = static fn (array $data): Response => new Response(200, [], json_encode($data));
|
||||
$group = static fn (array $tags = [], string $id = 'promotion_group', string $name = '推广渠道'): array => [
|
||||
'group_id' => $id, 'group_name' => $name, 'tag' => $tags,
|
||||
];
|
||||
$listing = static fn (array $groups): Response => $json(['errcode' => 0, 'tag_group' => $groups]);
|
||||
$created = static fn (string $id, string $name, string $groupId = 'promotion_group'): Response => $json([
|
||||
'errcode' => 0, 'tag_group' => $group([['id' => $id, 'name' => $name]], $groupId),
|
||||
]);
|
||||
$fixture = static function (array $responses, array &$history): QywxPromotionContactApiService {
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler($responses));
|
||||
$stack->push(Middleware::history($history));
|
||||
return new QywxPromotionContactApiService(new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack, 'http_errors' => false,
|
||||
]), static fn (): string => 'mock_token');
|
||||
};
|
||||
$addCount = static fn (array $history): int => count(array_filter($history,
|
||||
static fn (array $entry): bool => $entry['request']->getUri()->getPath() === '/cgi-bin/externalcontact/add_corp_tag'));
|
||||
|
||||
$history = [];
|
||||
$name30 = str_repeat('渠', 30);
|
||||
$api = $fixture([$listing([]), $created('new_tag', $name30)], $history);
|
||||
$result = $api->createTag(' ' . $name30 . ' ');
|
||||
createTagCheck($result === ['tag' => ['id' => 'new_tag', 'name' => $name30], 'group_id' => 'promotion_group', 'group_name' => '推广渠道', 'reused' => false], 'new tag response and 30 Unicode characters');
|
||||
$body = json_decode((string) $history[1]['request']->getBody(), true);
|
||||
createTagCheck($body === ['tag' => [['name' => $name30]], 'group_name' => '推广渠道'], 'new group uses group_name plus nonempty tag array');
|
||||
createTagCheck($history[1]['request']->getMethod() === 'POST' && $addCount($history) === 1, 'official add endpoint called once');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'old', 'name' => '旧标签']])]), $created('tag2', '直播')], $history);
|
||||
$result = $api->createTag('直播');
|
||||
$body = json_decode((string) $history[1]['request']->getBody(), true);
|
||||
createTagCheck($body === ['tag' => [['name' => '直播']], 'group_id' => 'promotion_group'], 'existing group uses ID and does not rename/reorder it');
|
||||
createTagCheck($result['reused'] === false && $result['tag']['id'] === 'tag2', 'existing-group creation returns actual ID');
|
||||
|
||||
$api = $fixture([$listing([
|
||||
$group([['id' => 'other_group_tag', 'name' => '直播']], 'other_group', '其他分组'),
|
||||
$group([['id' => 'existing_tag', 'name' => '直播']]),
|
||||
])], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'existing_tag' && count($history) === 1 && $addCount($history) === 0, 'reuse only exact name in fixed group without write');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'other', 'name' => '直播']], 'other_group', '其他分组')]), $created('fixed_group_tag', '直播')], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'fixed_group_tag' && $addCount($history) === 1, 'same name in different group is not reused');
|
||||
|
||||
$api = $fixture([
|
||||
$listing([$group()]),
|
||||
$json(['errcode' => 40058, 'errmsg' => 'mock concurrent creation conflict']),
|
||||
$listing([$group([['id' => 'concurrent_tag', 'name' => '直播']])]),
|
||||
], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'concurrent_tag' && $addCount($history) === 1 && count($history) === 3, 'concurrent conflict resolved by one read-back');
|
||||
|
||||
$networkError = static fn (): ConnectException => new ConnectException('secret=do_not_expose', new Request('POST', 'https://example.invalid/?access_token=secret'));
|
||||
$api = $fixture([$listing([]), $networkError(), $listing([$group([['id' => 'committed_tag', 'name' => '直播']])])], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'committed_tag' && $addCount($history) === 1, 'uncertain transport uses read-back, never duplicate creation');
|
||||
|
||||
$api = $fixture([$listing([]), $networkError(), $listing([])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && str_contains($error->getMessage(), '勿重复提交'), 'unknown result must not invent an ID or claim success');
|
||||
createTagCheck($addCount($history) === 1 && count($history) === 3 && !str_contains($error->getMessage(), 'secret') && $error->getPrevious() === null, 'uncertain result no resend/no secret chain');
|
||||
|
||||
$api = $fixture([$listing([]), $json(['errcode' => 48002, 'errmsg' => 'secret']), $listing([])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && $error->getCode() === 48002 && $addCount($history) === 1, 'explicit failure retained after read-back absent');
|
||||
|
||||
$api = $fixture([$listing([]), $json(['errcode' => 0, 'tag_group' => $group([['name' => '直播']])]), $listing([$group([['id' => 'verified_tag', 'name' => '直播']])])], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'verified_tag' && $addCount($history) === 1, 'success without ID still requires authoritative read-back');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'deleted_tag', 'name' => '直播', 'deleted' => true]])]), $created('recreated', '直播')], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'recreated' && $addCount($history) === 1, 'deleted tag is not reused');
|
||||
|
||||
$api = $fixture([$listing([]), $networkError(), new Response(503, [], '')], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && $addCount($history) === 1, 'failed read-back retains uncertainty without write retry');
|
||||
|
||||
$api = $fixture([$json(['errcode' => 48002])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && count($history) === 1 && $addCount($history) === 0, 'initial read failure cannot proceed to creation');
|
||||
|
||||
foreach (['', ' ', "\u{3000}", str_repeat('字', 31), str_repeat('😀', 31), "标签\n", "标\t签", "\0标签", "\u{200B}标签", "\xFF"] as $invalid) {
|
||||
$api = $fixture([], $history);
|
||||
$failed = false;
|
||||
try { $api->createTag($invalid); } catch (RuntimeException) { $failed = true; }
|
||||
createTagCheck($failed && $history === [], 'invalid name rejected before HTTP');
|
||||
}
|
||||
echo "QYWX_PROMOTION_CREATE_TAG_OK\n";
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use app\common\service\qywx\QywxPromotionMediaStore;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use think\file\UploadedFile;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
new think\App();
|
||||
function mediaCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
final class MemoryPromotionMediaStore extends QywxPromotionMediaStore
|
||||
{
|
||||
public array $rows = [];
|
||||
public array $references = [];
|
||||
public function find(string $assetId): ?array { return $this->rows[$assetId] ?? null; }
|
||||
public function insert(array $row): void { $this->rows[$row['asset_id']] = $row; }
|
||||
public function update(string $assetId, array $fields): void { $this->rows[$assetId] = array_replace($this->rows[$assetId], $fields); }
|
||||
public function referencedAssetIds(): array { return $this->references; }
|
||||
}
|
||||
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'qywx_media_test_' . bin2hex(random_bytes(6));
|
||||
mkdir($root, 0700);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], json_encode(['media_id' => 'prepared', 'created_at' => time()])),
|
||||
new Response(200, [], json_encode(['media_id' => 'refreshed', 'created_at' => time()])),
|
||||
]);
|
||||
$api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock)]), static fn (): string => 'mock');
|
||||
$store = new MemoryPromotionMediaStore();
|
||||
$service = new QywxPromotionMediaService($api, $store, $root . DIRECTORY_SEPARATOR . 'private');
|
||||
try {
|
||||
$path = $root . DIRECTORY_SEPARATOR . 'upload.png';
|
||||
file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2l9sAAAAASUVORK5CYII='));
|
||||
$asset = $service->upload(new UploadedFile($path, '../../cover.png', null, null, true), 'image', 7);
|
||||
mediaCheck(preg_match('/^[0-9a-f]{48}$/', $asset['asset_id']) === 1 && $asset['name'] === 'cover.png', 'random asset and safe name');
|
||||
mediaCheck(array_keys($asset) === ['asset_id', 'name', 'type'], 'no private path returned');
|
||||
$attachment = ['msgtype' => 'image', 'image' => ['asset_id' => $asset['asset_id']]];
|
||||
$config = ['welcome' => ['text' => '你好', 'attachments' => [$attachment]], 'welcome_schedule' => [], 'untouched' => 'retained'];
|
||||
mediaCheck($service->validateConfig($config, 7)['untouched'] === 'retained', 'keep unrelated config');
|
||||
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'prepared', 'cached materialization');
|
||||
mediaCheck(count($mock) === 1, 'welcome preparation never uploads');
|
||||
$denied = false;
|
||||
try { $service->validateConfig($config, 8); } catch (RuntimeException) { $denied = true; }
|
||||
mediaCheck($denied, 'cross-admin asset must be denied');
|
||||
mediaCheck($service->validateConfig($config, 8, $config)['welcome']['attachments'] === [$attachment], 'shared editor may retain existing authorized asset');
|
||||
foreach ([
|
||||
[['msgtype' => 'image', 'image' => ['asset_id' => '../secret']]],
|
||||
[['msgtype' => 'image', 'image' => ['pic_url' => 'http://127.0.0.1/private']]],
|
||||
[['msgtype' => 'video', 'video' => ['asset_id' => $asset['asset_id']]]],
|
||||
[['msgtype' => 'link', 'link' => ['title' => 'x', 'url' => 'javascript:alert(1)']]],
|
||||
[['msgtype' => 'link', 'link' => ['title' => str_repeat('字', 43), 'url' => 'https://example.com']]],
|
||||
[['msgtype' => 'miniprogram', 'miniprogram' => ['title' => 'test', 'appid' => 'bad', 'page' => '/pages/a', 'pic_asset_id' => $asset['asset_id']]]],
|
||||
] as $invalid) {
|
||||
$failed = false;
|
||||
try { $service->validateAttachments($invalid, 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'invalid attachment denied');
|
||||
}
|
||||
$valid = $service->validateAttachments([
|
||||
['msgtype' => 'link', 'link' => ['title' => '就诊', 'url' => 'https://example.com', 'desc' => '说明', 'picurl' => 'https://example.com/p.png']],
|
||||
['msgtype' => 'miniprogram', 'miniprogram' => ['title' => '预约', 'appid' => 'wx0123456789abcdef', 'page' => 'pages/index?a=1', 'pic_asset_id' => $asset['asset_id']]],
|
||||
], 7);
|
||||
mediaCheck(count($valid) === 2, 'link/miniprogram normalize');
|
||||
$store->rows[$asset['asset_id']]['media_expires_at'] = time() - 1;
|
||||
$failed = false;
|
||||
try { $service->materialize([$attachment], $config); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed && count($mock) === 1, 'expired media fails without upload during welcome');
|
||||
$store->references = [$asset['asset_id']];
|
||||
mediaCheck($service->refreshReferenced()['refreshed'] === 1, 'scheduled refresh restores expired media');
|
||||
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'refreshed', 'use refreshed media id');
|
||||
file_put_contents($path, '<?php echo "not image";');
|
||||
$failed = false;
|
||||
try { $service->upload(new UploadedFile($path, 'fake.png', null, null, true), 'image', 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'MIME spoofed image rejected');
|
||||
$failed = false;
|
||||
try { $service->upload($path, 'file', 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'arbitrary server path rejected');
|
||||
} finally {
|
||||
foreach (glob($root . DIRECTORY_SEPARATOR . 'private' . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { unlink($file); }
|
||||
if (is_dir($root . DIRECTORY_SEPARATOR . 'private')) { rmdir($root . DIRECTORY_SEPARATOR . 'private'); }
|
||||
foreach (glob($root . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
|
||||
rmdir($root);
|
||||
}
|
||||
echo "QYWX_PROMOTION_MEDIA_OK\n";
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import ts from '../../admin/node_modules/typescript/lib/typescript.js'
|
||||
|
||||
const source = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/components/promotion-automation.ts', import.meta.url), 'utf8')
|
||||
const formSource = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/components/PromotionAutomationForm.vue', import.meta.url), 'utf8')
|
||||
const pageSource = fs.readFileSync(new URL('../../admin/src/views/first_visit/wecom_promotion/index.vue', import.meta.url), 'utf8')
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||
const { defaultAutomationConfig, cloneAutomationConfig, serializeAutomationConfig, validateAutomationConfig, validateCustomTagName, validateWelcomeMessage, welcomeScheduleOverlap, previewTemplate } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||
|
||||
const defaults = defaultAutomationConfig()
|
||||
assert.equal(validateAutomationConfig(defaults, [1]), '')
|
||||
const configured = {
|
||||
...defaults,
|
||||
reception_mode: 'scheduled',
|
||||
reception_schedule: [{ weekdays: [1], start: '22:00', end: '02:00', member_admin_ids: [1], member_userids: ['must-not-submit'] }],
|
||||
backup_member_admin_ids: [2], backup_userids: ['must-not-submit'],
|
||||
welcome_mode: 'channel', welcome: { text: '您好,{customer_name}', attachments: [] }
|
||||
}
|
||||
assert.equal(validateAutomationConfig(configured, [1]), '')
|
||||
const copy = cloneAutomationConfig(configured)
|
||||
assert.equal(copy.backup_userids, undefined)
|
||||
assert.equal(copy.reception_schedule[0].member_userids, undefined)
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(serializeAutomationConfig({
|
||||
...copy,
|
||||
tags_enabled: true,
|
||||
remark_enabled: false,
|
||||
description_enabled: true,
|
||||
welcome_schedule_enabled: false
|
||||
})).filter(([key]) => key.endsWith('_enabled'))),
|
||||
{ tags_enabled: 1, remark_enabled: 0, description_enabled: 1, welcome_schedule_enabled: 0 }
|
||||
)
|
||||
copy.reception_schedule[0].weekdays.push(2)
|
||||
assert.deepEqual(configured.reception_schedule[0].weekdays, [1])
|
||||
assert.match(validateAutomationConfig({ ...configured, backup_member_admin_ids: [] }, [1]), /备用/)
|
||||
assert.match(validateAutomationConfig({ ...configured, backup_member_admin_ids: [1] }, [1]), /重复/)
|
||||
assert.match(validateAutomationConfig({ ...configured, reception_schedule: [{ ...copy.reception_schedule[0], member_admin_ids: [3] }] }, [1]), /主接待/)
|
||||
assert.match(validateWelcomeMessage({ text: '😀'.repeat(1001), attachments: [] }, ''), /4000/)
|
||||
assert.match(validateWelcomeMessage({ text: '', attachments: [] }, ''), /正文或添加附件/)
|
||||
assert.match(validateWelcomeMessage({ text: 'hello', attachments: [{ msgtype: 'link', link: { title: 'test', url: 'javascript:alert(1)', desc: '' } }] }, ''), /HTTP/)
|
||||
assert.match(validateAutomationConfig({ ...defaults, tags_enabled: true }, [1]), /标签/)
|
||||
assert.equal(validateAutomationConfig({ ...defaults, tags_enabled: true, tag_ids: ['tag1'] }, [1]), '')
|
||||
assert.match(validateAutomationConfig({ ...defaults, tags_enabled: true, tag_ids: ['tag1', 'tag2'] }, [1]), /只能选择一个/)
|
||||
assert.match(validateAutomationConfig({ ...defaults, tags_enabled: false, tag_ids: ['tag1', 'tag2'] }, [1]), /只能选择一个/)
|
||||
assert.deepEqual(cloneAutomationConfig({ ...defaults, tag_ids: ['tag1', 'tag2'] }).tag_ids, ['tag1', 'tag2'])
|
||||
assert.match(validateCustomTagName(' '), /名称/)
|
||||
assert.match(validateCustomTagName('名称\n换行'), /控制字符/)
|
||||
assert.match(validateCustomTagName('名称\u200b'), /不可见/)
|
||||
assert.match(validateCustomTagName('标'.repeat(31)), /30/)
|
||||
assert.equal(validateCustomTagName('标'.repeat(30)), '')
|
||||
assert.equal(validateCustomTagName(' 官网咨询 '), '')
|
||||
assert.equal(welcomeScheduleOverlap([
|
||||
{ weekdays: [7], start: '22:00', end: '02:00' },
|
||||
{ weekdays: [1], start: '01:00', end: '03:00' }
|
||||
]), true)
|
||||
assert.equal(welcomeScheduleOverlap([
|
||||
{ weekdays: [7], start: '22:00', end: '02:00' },
|
||||
{ weekdays: [1], start: '02:00', end: '03:00' }
|
||||
]), false)
|
||||
assert.match(previewTemplate('{customer_name}-{employee_name}-{add_time}', '小陈'), /^张女士-小陈-\d{4}-\d{2}-\d{2}$/)
|
||||
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,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\firstvisit\WecomPromotionLogic;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$assert = static function (bool $condition, string $message): void {
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
};
|
||||
$preview = new ReflectionMethod(WecomPromotionLogic::class, 'previewPoolMemberStatus');
|
||||
$preview->setAccessible(true);
|
||||
$assertDispatchReady = new ReflectionMethod(WecomPromotionLogic::class, 'assertMemberDispatchReady');
|
||||
$assertDispatchReady->setAccessible(true);
|
||||
$today = date('Y-m-d');
|
||||
$members = [
|
||||
['id' => 1, 'admin_id' => 11, 'userid' => 'A', 'enabled' => 1, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
|
||||
['id' => 2, 'admin_id' => 12, 'userid' => 'B', 'enabled' => 1, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
|
||||
];
|
||||
|
||||
$result = $preview->invoke(null, $members, [11], 0, []);
|
||||
$assert($result['matched_ids'] === [1], '批量下线没有正确匹配员工规则');
|
||||
$assert($result['update_ids'] === [1], '批量下线没有标记需要更新的规则');
|
||||
$assert((int) $result['members'][0]['enabled'] === 0, '批量下线预览没有更新目标状态');
|
||||
$assert((int) $result['members'][1]['enabled'] === 1, '批量下线错误修改了未选员工');
|
||||
|
||||
$result = $preview->invoke(null, [
|
||||
[
|
||||
'id' => 3,
|
||||
'admin_id' => 13,
|
||||
'userid' => 'C',
|
||||
'enabled' => 0,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => $today,
|
||||
'active_start' => time() + 3600,
|
||||
],
|
||||
], [13], 1, []);
|
||||
$assert(
|
||||
$result['update_ids'] === [3] && (int) $result['members'][0]['enabled'] === 1,
|
||||
'尚未到生效时间的员工也应允许提前批量上线'
|
||||
);
|
||||
|
||||
try {
|
||||
$preview->invoke(null, $members, [11, 12], 0, [], '全员方案');
|
||||
throw new RuntimeException('批量下线不应允许方案变成零上线员工');
|
||||
} catch (ReflectionException $error) {
|
||||
throw $error;
|
||||
} catch (Throwable $error) {
|
||||
$assert(str_contains($error->getMessage(), '至少需要保留一名上线员工'), '零上线员工错误提示不正确');
|
||||
}
|
||||
|
||||
try {
|
||||
$preview->invoke(null, [
|
||||
['id' => 4, 'admin_id' => 14, 'userid' => 'D', 'enabled' => 1, 'daily_limit' => 0, 'today_count' => 0, 'today_date' => $today],
|
||||
['id' => 5, 'admin_id' => 15, 'userid' => 'E', 'enabled' => 1, 'daily_limit' => 1, 'today_count' => 1, 'today_date' => $today],
|
||||
], [14], 0, [], '额度方案');
|
||||
throw new RuntimeException('批量下线不应保留零个当前可用员工');
|
||||
} catch (ReflectionException $error) {
|
||||
throw $error;
|
||||
} catch (Throwable $error) {
|
||||
$assert(str_contains($error->getMessage(), '当前可用'), '零可用员工错误提示不正确');
|
||||
}
|
||||
|
||||
$result = $preview->invoke(null, $members, [999], 0, []);
|
||||
$assert($result['matched_ids'] === [] && $result['update_ids'] === [], '不存在的员工不应产生状态更新');
|
||||
|
||||
$assertDispatchReady->invoke(null, ['blocked' => false, 'queued' => true]);
|
||||
try {
|
||||
$assertDispatchReady->invoke(null, ['blocked' => true, 'queued' => false]);
|
||||
throw new RuntimeException('同步阻塞时不应提交员工状态');
|
||||
} catch (ReflectionException $error) {
|
||||
throw $error;
|
||||
} catch (Throwable $error) {
|
||||
$assert(str_contains($error->getMessage(), '无法同步'), '同步阻塞错误提示不正确');
|
||||
}
|
||||
|
||||
echo "WECOM_PROMOTION_BATCH_MEMBER_STATUS_OK\n";
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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_key_exists('member_status', \$changes)",
|
||||
'array_replace($currentAutomation, $automationPatch)',
|
||||
'self::savePool($saveParams, $adminId, $adminInfo, false)',
|
||||
'self::updatePoolMemberStatuses(',
|
||||
'self::previewPoolMemberStatus(',
|
||||
'self::assertMemberDispatchReady(',
|
||||
'if ($dispatch !== null && $status === 0)',
|
||||
'至少需要保留一名当前可用的上线员工',
|
||||
'员工上下线需要单独批量保存',
|
||||
"'sync_error_count' => \$syncErrorCount",
|
||||
"'sync_queued_count' => \$syncQueuedCount",
|
||||
"'member_updated' => \$memberUpdated",
|
||||
"'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('批量修改不得允许共享操作人绕过原管理范围批量编辑');
|
||||
}
|
||||
if (!str_contains($method, "Db::name('qywx_promotion_range_sync')->where('pool_id', \$poolId)->lock(true)->find()")
|
||||
|| !str_contains($sources['logic'], "'_status_only' => true")
|
||||
|| !str_contains($sources['logic'], '$eligibleUserIds = self::eligibleSelectedUserIds(')
|
||||
|| !str_contains($sources['logic'], '$createdRangeChanged = $createdRemote')
|
||||
|| !str_contains($sources['logic'], '$needsQueuedSync = !$createdRemote || $createdRangeChanged')
|
||||
|| !str_contains($sources['logic'], '$rangeShrank = array_diff($currentRange[\'userids\'], $nextRange[\'userids\']) !== []')
|
||||
|| !str_contains($sources['logic'], 'if ($isGoingOffline || $rangeShrank)')) {
|
||||
throw new RuntimeException('单个与批量员工状态更新未使用一致的事务锁和字段保留策略');
|
||||
}
|
||||
|
||||
$page = $sources['page'];
|
||||
foreach ([
|
||||
'批量修改方案',
|
||||
'全选可管理的分流方案',
|
||||
'allManageablePoolsSelected',
|
||||
'someManageablePoolsSelected',
|
||||
'toggleAllPoolSelection',
|
||||
'仅勾选的项目会覆盖到所选方案',
|
||||
'batchConfigApply.skip_verify',
|
||||
'batchConfigApply.fallback_url',
|
||||
'batchConfigApply.status',
|
||||
'batchConfigApply.member_status',
|
||||
'batchConfigForm.member_admin_ids',
|
||||
'batchMemberStatusById',
|
||||
'batchMemberDepartmentTree',
|
||||
'batchMemberTreeDefaultExpandedKeys',
|
||||
'selectableDepartments: true',
|
||||
'勾选部门可全选其下员工',
|
||||
'batchMemberOfflineBlockedPools',
|
||||
'批量修改员工上线状态',
|
||||
'员工上下线需单独批量保存',
|
||||
'batchConfigApply.reception',
|
||||
'batchConfigApply.customer',
|
||||
'batchConfigApply.welcome',
|
||||
'batchSharedPrimaryMemberIds',
|
||||
'batchPrimaryMemberUnion',
|
||||
'wecomPromotionBatchUpdatePools',
|
||||
] as $needle) {
|
||||
if (!str_contains($page, $needle)) {
|
||||
throw new RuntimeException("前端批量修改交互缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
if (!str_contains($sources['api'], 'member_status?: {')
|
||||
|| !str_contains($sources['api'], 'member_updated: number')) {
|
||||
throw new RuntimeException('前端批量修改 API 缺少员工上下线契约');
|
||||
}
|
||||
if (!str_contains($sources['automationForm'], 'disabledSections?: AutomationSection[]')
|
||||
|| !str_contains($sources['automationForm'], 'backupExcludedMemberIds?: number[]')) {
|
||||
throw new RuntimeException('自动化表单未支持批量场景的分组禁用或主成员冲突约束');
|
||||
}
|
||||
|
||||
echo "WECOM_PROMOTION_BATCH_UPDATE_CONTRACT_OK\n";
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\auth {
|
||||
// 用内存权限列表替代真实AuthLogic,确保控制器测试不连接业务数据库。
|
||||
class AuthLogic
|
||||
{
|
||||
public static array $permissions = [];
|
||||
public static function getAuthByAdminId(int $adminId): array { return self::$permissions; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\service\qywx {
|
||||
// API行为由MockHandler测试;此处只验证控制器权限、HTTP方法及参数边界。
|
||||
class QywxPromotionOperatorAccess
|
||||
{
|
||||
public static function hasBasePagePermission(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
return !empty($adminInfo['root'])
|
||||
|| in_array('firstvisit.wecomPromotion/overview', \app\adminapi\logic\auth\AuthLogic::getAuthByAdminId($adminId), true);
|
||||
}
|
||||
public static function hasPagePermission(int $adminId, array $adminInfo): bool
|
||||
{
|
||||
return self::hasBasePagePermission($adminId, $adminInfo);
|
||||
}
|
||||
}
|
||||
class QywxPromotionContactApiService
|
||||
{
|
||||
public static array $calls = [];
|
||||
public static bool $fail = false;
|
||||
public function createTag(string $name): array
|
||||
{
|
||||
self::$calls[] = $name;
|
||||
if (self::$fail) { throw new \RuntimeException('模拟企微标签失败'); }
|
||||
return ['tag' => ['id' => 'remote_tag', 'name' => $name],
|
||||
'group_id' => 'remote_group', 'group_name' => '推广渠道', 'reused' => false];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use app\adminapi\controller\firstvisit\WecomPromotionController;
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
new think\App();
|
||||
final class CreateTagControllerFixture extends WecomPromotionController
|
||||
{
|
||||
public function __construct(think\Request $request, bool $root = false)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->adminId = 12;
|
||||
$this->adminInfo = ['root' => $root ? 1 : 0];
|
||||
}
|
||||
protected function data($data) { return ['code' => 1, 'data' => $data]; }
|
||||
protected function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1)
|
||||
{
|
||||
return ['code' => $code, 'msg' => $msg, 'data' => $data];
|
||||
}
|
||||
}
|
||||
function tagControllerCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
$request = static function (string $method = 'POST', mixed $name = '直播'): think\Request {
|
||||
return (new think\Request())->setMethod($method)->withPost(['name' => $name, 'group_name' => '不允许客户端改组']);
|
||||
};
|
||||
$result = (new CreateTagControllerFixture($request()))->createTag();
|
||||
tagControllerCheck($result['code'] === 0 && QywxPromotionContactApiService::$calls === [], 'no page permission cannot create');
|
||||
AuthLogic::$permissions = ['firstvisit.wecomPromotion/overview'];
|
||||
$result = (new CreateTagControllerFixture($request('GET')))->createTag();
|
||||
tagControllerCheck($result['code'] === 0 && QywxPromotionContactApiService::$calls === [], 'GET cannot create');
|
||||
foreach ([['array_name'], 123, true] as $invalid) {
|
||||
$result = (new CreateTagControllerFixture($request('POST', $invalid)))->createTag();
|
||||
tagControllerCheck($result['code'] === 0 && QywxPromotionContactApiService::$calls === [], 'non-string name rejected before API');
|
||||
}
|
||||
$result = (new CreateTagControllerFixture($request()))->createTag();
|
||||
tagControllerCheck($result['code'] === 1 && $result['data']['tag']['id'] === 'remote_tag'
|
||||
&& $result['data']['group_name'] === '推广渠道' && QywxPromotionContactApiService::$calls === ['直播'], 'page permission and response envelope');
|
||||
AuthLogic::$permissions = [];
|
||||
$result = (new CreateTagControllerFixture($request('POST', '自定义'), true))->createTag();
|
||||
tagControllerCheck($result['code'] === 1 && $result['data']['tag']['name'] === '自定义', 'root permitted through same controller guard');
|
||||
QywxPromotionContactApiService::$fail = true;
|
||||
$result = (new CreateTagControllerFixture($request(), true))->createTag();
|
||||
tagControllerCheck($result['code'] === 0 && $result['msg'] === '模拟企微标签失败', 'upstream failure not reported as success');
|
||||
echo "WECOM_PROMOTION_CREATE_TAG_CONTROLLER_OK\n";
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\adminapi\logic\auth\AuthLogic;
|
||||
use app\adminapi\logic\auth\MenuLogic;
|
||||
use app\common\service\qywx\QywxPromotionOperatorAccess;
|
||||
use think\facade\Db;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$app = new think\App();
|
||||
$app->initialize();
|
||||
|
||||
$pool = Db::name('qywx_promotion_pool')->whereNull('delete_time')->order('id', 'asc')->find();
|
||||
$grantorId = (int) (Db::name('admin')->where('root', 1)->whereNull('delete_time')->value('id') ?? 0);
|
||||
if (!$pool || $grantorId <= 0) {
|
||||
throw new RuntimeException('缺少分流方案或 root 管理员,无法验证动态共享权限');
|
||||
}
|
||||
|
||||
$candidate = null;
|
||||
foreach (Db::name('admin')
|
||||
->where('root', 0)
|
||||
->where('disable', 0)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray() as $admin) {
|
||||
$adminId = (int) ($admin['id'] ?? 0);
|
||||
if ($adminId > 0
|
||||
&& !QywxPromotionOperatorAccess::hasBasePagePermission($adminId, $admin)
|
||||
&& !QywxPromotionOperatorAccess::hasSharedPagePermission($adminId)) {
|
||||
$candidate = $admin;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($candidate === null) {
|
||||
throw new RuntimeException('未找到无基础页面权限且无现有共享的启用账号');
|
||||
}
|
||||
|
||||
$candidateId = (int) $candidate['id'];
|
||||
$poolId = (int) $pool['id'];
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$relation = Db::name('qywx_promotion_pool_operator')
|
||||
->where('pool_id', $poolId)
|
||||
->where('admin_id', $candidateId)
|
||||
->find();
|
||||
if ($relation) {
|
||||
Db::name('qywx_promotion_pool_operator')->where('id', (int) $relation['id'])->update([
|
||||
'granted_by_admin_id' => $grantorId,
|
||||
'delete_time' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
} else {
|
||||
Db::name('qywx_promotion_pool_operator')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'admin_id' => $candidateId,
|
||||
'granted_by_admin_id' => $grantorId,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!QywxPromotionOperatorAccess::hasSharedPagePermission($candidateId)
|
||||
|| !QywxPromotionOperatorAccess::hasPagePermission($candidateId, $candidate)) {
|
||||
throw new RuntimeException('共享关系未生成动态页面权限');
|
||||
}
|
||||
if (QywxPromotionOperatorAccess::visibleAdminIds($candidateId, $candidate) !== []) {
|
||||
throw new RuntimeException('纯共享账号错误继承了普通角色数据范围');
|
||||
}
|
||||
if (!in_array($poolId, QywxPromotionOperatorAccess::activePoolIds($candidateId), true)) {
|
||||
throw new RuntimeException('共享方案未进入操作人专用数据范围');
|
||||
}
|
||||
if (!in_array(QywxPromotionOperatorAccess::PAGE_PERMISSION, AuthLogic::getAuthByAdminId($candidateId), true)) {
|
||||
throw new RuntimeException('共享账号的接口权限列表缺少获客助手页面权限');
|
||||
}
|
||||
$menuJson = json_encode(MenuLogic::getMenuByAdminId($candidateId), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($menuJson) || !str_contains($menuJson, QywxPromotionOperatorAccess::PAGE_PERMISSION)) {
|
||||
throw new RuntimeException('共享账号的导航菜单缺少获客助手页面');
|
||||
}
|
||||
} finally {
|
||||
Db::rollback();
|
||||
}
|
||||
|
||||
if (QywxPromotionOperatorAccess::hasSharedPagePermission($candidateId)) {
|
||||
throw new RuntimeException('测试事务回滚后仍残留共享权限');
|
||||
}
|
||||
|
||||
echo sprintf(
|
||||
"WECOM_PROMOTION_OPERATOR_DYNAMIC_ACCESS_OK admin=%d pool=%d\n",
|
||||
$candidateId,
|
||||
$poolId
|
||||
);
|
||||
@@ -16,7 +16,7 @@ if (!$admin) {
|
||||
throw new RuntimeException('未找到 root 管理员,无法执行数据范围冒烟测试');
|
||||
}
|
||||
$overview = WecomPromotionLogic::overview((int) $admin['id'], $admin, 'https://example.test');
|
||||
foreach (['meta', 'config', 'summary', 'pools', 'links', 'member_options', 'department_options'] as $key) {
|
||||
foreach (['meta', 'config', 'summary', 'pools', 'links', 'member_options', 'operator_options', 'department_options'] as $key) {
|
||||
if (!array_key_exists($key, $overview)) {
|
||||
throw new RuntimeException("overview 缺少 {$key}");
|
||||
}
|
||||
@@ -32,6 +32,16 @@ foreach ($overview['member_options'] as $member) {
|
||||
throw new RuntimeException('member_options 缺少树形下拉展示部门');
|
||||
}
|
||||
}
|
||||
$enabledOperatorCount = 0;
|
||||
foreach ($overview['operator_options'] as $operator) {
|
||||
$isEnabled = (int) ($operator['disable'] ?? 0) === 0;
|
||||
if ($isEnabled) {
|
||||
$enabledOperatorCount++;
|
||||
}
|
||||
if ((bool) ($operator['can_grant'] ?? false) !== $isEnabled) {
|
||||
throw new RuntimeException('操作人可授权状态不应依赖目标账号预先拥有页面权限');
|
||||
}
|
||||
}
|
||||
$memberIds = array_values(array_unique(array_map('intval', array_column($overview['member_options'], 'id'))));
|
||||
if ($memberIds !== []) {
|
||||
$disabledMemberCount = (int) Db::name('admin')->whereIn('id', $memberIds)->where('disable', '<>', 0)->count();
|
||||
@@ -42,19 +52,40 @@ if ($memberIds !== []) {
|
||||
if (!is_array($overview['department_options'])) {
|
||||
throw new RuntimeException('department_options 必须是部门树数组');
|
||||
}
|
||||
foreach ($overview['pools'] as $pool) {
|
||||
foreach (['operators', 'operator_admin_ids', 'can_operate', 'can_manage_access', 'can_delete'] as $key) {
|
||||
if (!array_key_exists($key, $pool)) {
|
||||
throw new RuntimeException("pools 缺少共享操作权限字段 {$key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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) {
|
||||
$scopedPoolIds = array_values(array_filter(array_map('intval', array_column($scopedOverview['pools'], 'id'))));
|
||||
$sharedPoolMemberIds = $scopedPoolIds === []
|
||||
? []
|
||||
: array_map('intval', Db::name('qywx_promotion_pool_member')
|
||||
->whereIn('pool_id', $scopedPoolIds)
|
||||
->whereNull('delete_time')
|
||||
->column('admin_id'));
|
||||
foreach ($scopedOverview['member_options'] as $member) {
|
||||
if (!in_array((int) $member['id'], $visibleIds, true)) {
|
||||
if (!in_array((int) $member['id'], $visibleIds, true)
|
||||
&& !in_array((int) $member['id'], $sharedPoolMemberIds, true)) {
|
||||
throw new RuntimeException('member_options 泄露了当前角色或部门范围外的成员');
|
||||
}
|
||||
}
|
||||
foreach ($scopedOverview['operator_options'] as $operator) {
|
||||
if (!in_array((int) $operator['id'], $visibleIds, true)) {
|
||||
throw new RuntimeException('operator_options 泄露了当前角色或部门范围外的账号');
|
||||
}
|
||||
}
|
||||
foreach ($scopedOverview['pools'] as $pool) {
|
||||
if (!in_array((int) $pool['owner_admin_id'], $visibleIds, true)) {
|
||||
if (!in_array((int) $pool['owner_admin_id'], $visibleIds, true)
|
||||
&& !in_array((int) $scopedAdmin['id'], array_map('intval', $pool['operator_admin_ids'] ?? []), true)) {
|
||||
throw new RuntimeException('pools 泄露了当前角色或部门范围外的数据');
|
||||
}
|
||||
}
|
||||
@@ -62,10 +93,11 @@ if ($scopedAdmin) {
|
||||
}
|
||||
|
||||
echo sprintf(
|
||||
"WECOM_PROMOTION_OVERVIEW_SMOKE_OK configured=%d callback=%d pools=%d links=%d members=%d\n",
|
||||
"WECOM_PROMOTION_OVERVIEW_SMOKE_OK configured=%d callback=%d pools=%d links=%d members=%d operators=%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'])
|
||||
count($overview['member_options']),
|
||||
$enabledOperatorCount
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__, 2);
|
||||
$logicPath = __DIR__ . '/../app/adminapi/logic/firstvisit/WecomPromotionLogic.php';
|
||||
$customerLogicPath = __DIR__ . '/../app/adminapi/logic/firstvisit/WecomAcquisitionCustomerLogic.php';
|
||||
$controllerPath = __DIR__ . '/../app/adminapi/controller/firstvisit/WecomPromotionController.php';
|
||||
$operatorAccessPath = __DIR__ . '/../app/common/service/qywx/QywxPromotionOperatorAccess.php';
|
||||
$authLogicPath = __DIR__ . '/../app/adminapi/logic/auth/AuthLogic.php';
|
||||
$menuLogicPath = __DIR__ . '/../app/adminapi/logic/auth/MenuLogic.php';
|
||||
$authMiddlewarePath = __DIR__ . '/../app/adminapi/http/middleware/AuthMiddleware.php';
|
||||
$apiPath = $root . '/admin/src/api/first_visit.ts';
|
||||
$viewPath = $root . '/admin/src/views/first_visit/wecom_promotion/index.vue';
|
||||
$migrationPath = $root . '/server/sql/1.9.20260828/add_wecom_promotion_pool_operators.sql';
|
||||
|
||||
$sources = [];
|
||||
foreach (compact(
|
||||
'logicPath',
|
||||
'customerLogicPath',
|
||||
'controllerPath',
|
||||
'operatorAccessPath',
|
||||
'authLogicPath',
|
||||
'menuLogicPath',
|
||||
'authMiddlewarePath',
|
||||
'apiPath',
|
||||
'viewPath',
|
||||
'migrationPath'
|
||||
) as $name => $path) {
|
||||
$source = file_get_contents($path);
|
||||
if (!is_string($source)) {
|
||||
throw new RuntimeException("无法读取 {$name}: {$path}");
|
||||
}
|
||||
$sources[$name] = $source;
|
||||
}
|
||||
|
||||
$migration = $sources['migrationPath'];
|
||||
foreach (['qywx_promotion_pool_operator', 'uk_pool_admin', 'granted_by_admin_id'] as $needle) {
|
||||
if (!str_contains($migration, $needle)) {
|
||||
throw new RuntimeException("共享操作人迁移缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
|
||||
$logic = $sources['logicPath'];
|
||||
foreach (['batchSetOperators', 'applyPoolAccessScope', 'isPoolOperator', 'can_manage_access', 'operator_options', 'clearOperatorAuthCaches'] as $needle) {
|
||||
if (!str_contains($logic, $needle)) {
|
||||
throw new RuntimeException("获客助手共享权限逻辑缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
$deleteStart = strpos($logic, 'public static function deletePool(');
|
||||
$deleteEnd = strpos($logic, 'public static function batchSetOperators(', $deleteStart === false ? 0 : $deleteStart);
|
||||
if ($deleteStart === false || $deleteEnd === false) {
|
||||
throw new RuntimeException('无法定位 deletePool/batchSetOperators');
|
||||
}
|
||||
$deleteMethod = substr($logic, $deleteStart, $deleteEnd - $deleteStart);
|
||||
if (!str_contains($deleteMethod, "assertScopedRow('qywx_promotion_pool', \$id, \$adminId, \$adminInfo, false)")) {
|
||||
throw new RuntimeException('共享操作人不得永久删除分流方案');
|
||||
}
|
||||
if (!str_contains($deleteMethod, 'assertBasePagePermission($adminId, $adminInfo)')
|
||||
|| !str_contains($deleteMethod, 'clearOperatorAuthCaches($operatorAdminIds)')) {
|
||||
throw new RuntimeException('删除方案必须校验基础权限并清理共享账号权限缓存');
|
||||
}
|
||||
$batchStart = strpos($logic, 'public static function batchSetOperators(');
|
||||
$batchEnd = strpos($logic, 'private static function isRemoteLinkAlreadyMissing(', $batchStart === false ? 0 : $batchStart);
|
||||
if ($batchStart === false || $batchEnd === false) {
|
||||
throw new RuntimeException('无法定位 batchSetOperators');
|
||||
}
|
||||
$batchMethod = substr($logic, $batchStart, $batchEnd - $batchStart);
|
||||
foreach (['assertBasePagePermission($adminId, $adminInfo)', 'clearOperatorAuthCaches($operatorAdminIds)'] as $needle) {
|
||||
if (!str_contains($batchMethod, $needle)) {
|
||||
throw new RuntimeException("批量授权缺少安全边界 {$needle}");
|
||||
}
|
||||
}
|
||||
if (str_contains($logic, 'promotionPagePermissionAdminIdSet')
|
||||
|| str_contains($batchMethod, '尚未获得企业微信获客助手页面权限')
|
||||
|| str_contains($batchMethod, '$operatorAdminId === $ownerAdminId')) {
|
||||
throw new RuntimeException('批量授权仍存在预先页面权限或归属人跳过条件');
|
||||
}
|
||||
|
||||
$operatorAccess = $sources['operatorAccessPath'];
|
||||
foreach (['hasBasePagePermission', 'hasSharedPagePermission', 'hasPagePermission', 'visibleAdminIds', 'activePoolIds'] as $needle) {
|
||||
if (!str_contains($operatorAccess, $needle)) {
|
||||
throw new RuntimeException("共享页面权限服务缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
if (!str_contains($operatorAccess, "join('qywx_promotion_pool p', 'p.id = po.pool_id')")
|
||||
|| !str_contains($operatorAccess, "whereNull('p.delete_time')")) {
|
||||
throw new RuntimeException('共享页面权限未排除已删除方案');
|
||||
}
|
||||
|
||||
if (!str_contains($sources['authLogicPath'], 'appendSharedPromotionPermission')
|
||||
|| !str_contains($sources['menuLogicPath'], 'sharedPromotionMenuIds')) {
|
||||
throw new RuntimeException('共享账号未动态获得页面权限或导航菜单');
|
||||
}
|
||||
if (!str_contains($sources['authMiddlewarePath'], "str_starts_with(\$accessUri, 'firstvisit.wecompromotion/')")
|
||||
|| !str_contains($sources['authMiddlewarePath'], 'isKnownWecomPromotionAction')) {
|
||||
throw new RuntimeException('获客助手子接口未统一纳入页面权限中间件');
|
||||
}
|
||||
|
||||
if (!str_contains($sources['customerLogicPath'], 'operatorPoolIds($adminId)')
|
||||
|| !str_contains($sources['customerLogicPath'], "whereOr('p.id', 'in', \$operatorPoolIds)")
|
||||
|| !str_contains($sources['customerLogicPath'], 'QywxPromotionOperatorAccess::visibleAdminIds')) {
|
||||
throw new RuntimeException('共享操作人尚未接入获客客户统计权限');
|
||||
}
|
||||
if (!str_contains($sources['controllerPath'], 'public function batchSetOperators()')) {
|
||||
throw new RuntimeException('控制器缺少批量设置操作人接口');
|
||||
}
|
||||
if (!str_contains($sources['apiPath'], '/firstvisit.wecomPromotion/batchSetOperators')) {
|
||||
throw new RuntimeException('前端 API 缺少批量设置操作人接口');
|
||||
}
|
||||
foreach (['批量设置访问操作', '添加操作人', '移除操作人', 'selectedPoolIds', 'can_manage_access'] as $needle) {
|
||||
if (!str_contains($sources['viewPath'], $needle)) {
|
||||
throw new RuntimeException("前端共享权限交互缺少 {$needle}");
|
||||
}
|
||||
}
|
||||
if (str_contains($sources['viewPath'], '无页面权限')
|
||||
|| !str_contains($sources['viewPath'], '授权后账号会自动获得本页面入口')) {
|
||||
throw new RuntimeException('前端仍将预先拥有页面权限作为授权条件');
|
||||
}
|
||||
|
||||
echo "WECOM_PROMOTION_POOL_OPERATOR_CONTRACT_OK\n";
|
||||
Reference in New Issue
Block a user