;rgb:0000/0000/0000

This commit is contained in:
gr
2026-09-09 10:02:43 +08:00
640 changed files with 51641 additions and 20099 deletions
+14 -21
View File
@@ -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(
+24 -8
View File
@@ -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";
+109
View File
@@ -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";
+109
View File
@@ -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";