更新
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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,192 @@
|
||||
<?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);
|
||||
return $r['status'] !== 'done' && $r['lock_until'] <= $now && ($lane === 'welcome'
|
||||
? $pending && $r['welcome_next_retry'] <= $now
|
||||
: (!$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');
|
||||
|
||||
foreach (['default', 'none'] as $mode) {
|
||||
[$service, $store, $api] = $fixture(); $store->config['welcome_mode'] = $mode;
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
|
||||
$service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'skipped' && $api->calls === [] && $store->rows[1]['welcome_cipher'] === '', 'default/none do not send or retain code');
|
||||
}
|
||||
[$service, $store, $api] = $fixture();
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => 'zyt_pool:999'])), 'untrusted state cannot authorize');
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['UserID' => 'outsider'])), 'nonmember cannot authorize');
|
||||
automationCheck(!$service->enqueueVerifiedEvent(array_replace($event, ['State' => ''])), 'missing state and link stays legacy');
|
||||
$store->schemaInstalled = false;
|
||||
automationCheck(!$service->enqueueVerifiedEvent($event), 'missing migration stays legacy');
|
||||
$store->schemaInstalled = true; $store->enqueueFail = true;
|
||||
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
|
||||
automationCheck($failed, 'enqueue failure propagates for callback retry');
|
||||
[$service, $store] = $fixture(); $store->schemaCheckFail = true;
|
||||
$failed = false; try { $service->enqueueVerifiedEvent($event); } catch (QywxPromotionEnqueueException) { $failed = true; }
|
||||
automationCheck($failed, 'schema lookup failure propagates for callback retry instead of acknowledging lost event');
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'WelcomeCode' => '']));
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'skipped', 'missing code explicit skip');
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now - 21]));
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'expired', 'expired code not queued for send');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now, 'ChangeType' => 'add_half_external_contact']));
|
||||
$service->processWelcomes(); $service->retryPending();
|
||||
automationCheck($api->calls[0][0] === 'welcome' && str_contains($api->calls[0][1], '您好 您'), 'half-contact welcome uses safe nickname fallback');
|
||||
automationCheck($store->syncs === [] && $store->rows[1]['status'] === 'done', 'half-contact does not create customer or modify relation');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$api->welcomeError = new QywxPromotionContactApiException('network unknown', 0, true);
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes(); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && count($api->calls) === 1 && $store->rows[1]['welcome_cipher'] === '', 'uncertain network never retried');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$api->welcomeError = new QywxPromotionContactApiException('other app currently sending', 41096);
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'retry', 'explicit 41096 can retry within window');
|
||||
$now++; $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'sent' && count($api->calls) === 2, 'safe explicit retry succeeded');
|
||||
|
||||
[$service, $store, $api] = $fixture();
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now]));
|
||||
$actions = json_decode($store->rows[1]['actions_json'], true); $actions['welcome']['status'] = 'running';
|
||||
$store->rows[1]['actions_json'] = json_encode($actions); $store->rows[1]['welcome_status'] = 'running';
|
||||
$service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'uncertain' && $api->calls === [], 'crashed running send is never replayed');
|
||||
|
||||
[$service, $store, $api, $media] = $fixture(); $media->expired = true;
|
||||
$service->enqueueVerifiedEvent(array_replace($event, ['CreateTime' => $now])); $service->processWelcomes();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'retry' && $api->calls === [], 'unprepared media never sends partial payload');
|
||||
$now += 21; $service->retryPending();
|
||||
automationCheck($store->rows[1]['welcome_status'] === 'expired' && $store->rows[1]['welcome_cipher'] === '', 'minute job expires and clears code');
|
||||
$config = $baseConfig; $config['welcome_schedule_enabled'] = true;
|
||||
$config['welcome_schedule'] = [['weekdays' => [1], 'start' => '10:00', 'end' => '11:00', 'text' => '上午', 'attachments' => []]];
|
||||
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 10:00 Asia/Shanghai'))['text'] === '上午', 'schedule uses event time');
|
||||
automationCheck(QywxPromotionAutomationService::selectWelcome($config, strtotime('2026-08-31 11:00 Asia/Shanghai'))['text'] === $baseConfig['welcome']['text'], 'schedule fallback explicit base welcome');
|
||||
echo "QYWX_PROMOTION_AUTOMATION_OK\n";
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionCodeCipher;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
// 独立临时App根目录,不加载项目配置或业务数据库。
|
||||
if (($argv[1] ?? '') === '--worker') {
|
||||
new think\App($argv[2]);
|
||||
echo (new QywxPromotionCodeCipher())->encrypt('cipher-concurrency-test');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
function cipherCheck(bool $ok, string $message): void
|
||||
{
|
||||
if (!$ok) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'qywx_cipher_test_' . bin2hex(random_bytes(8));
|
||||
mkdir($root, 0700);
|
||||
new think\App($root);
|
||||
$processes = [];
|
||||
try {
|
||||
// 多个进程首次启动必须共享同一完整密钥,不能读到空文件或覆盖对方的密钥。
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$pipes = [];
|
||||
$process = proc_open([PHP_BINARY, __FILE__, '--worker', $root],
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes,
|
||||
null, null, ['bypass_shell' => true]);
|
||||
cipherCheck(is_resource($process), 'start isolated cipher worker');
|
||||
fclose($pipes[0]);
|
||||
$processes[] = [$process, $pipes];
|
||||
}
|
||||
$cipher = new QywxPromotionCodeCipher();
|
||||
$encrypted = [];
|
||||
foreach ($processes as [$process, $pipes]) {
|
||||
$value = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
cipherCheck(proc_close($process) === 0 && $error === '', 'cipher worker completed without error');
|
||||
cipherCheck($cipher->decrypt($value) === 'cipher-concurrency-test', 'concurrent processes share one persisted key');
|
||||
$encrypted[] = $value;
|
||||
}
|
||||
$processes = [];
|
||||
cipherCheck(count(array_unique($encrypted)) === 6, 'fresh nonce for every encryption');
|
||||
$raw = base64_decode($encrypted[0], true);
|
||||
$raw[15] = chr(ord($raw[15]) ^ 1);
|
||||
$rejected = false;
|
||||
try { $cipher->decrypt(base64_encode($raw)); } catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'tampered authentication tag rejected');
|
||||
$rejected = false;
|
||||
try { (new QywxPromotionCodeCipher(str_repeat('wrong-key', 8)))->decrypt($encrypted[0]); }
|
||||
catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'wrong key cannot decrypt');
|
||||
file_put_contents($root . '/runtime/qywx_promotion_private/welcome.key', 'incomplete-key');
|
||||
$rejected = false;
|
||||
try { (new QywxPromotionCodeCipher())->encrypt('test'); } catch (RuntimeException) { $rejected = true; }
|
||||
cipherCheck($rejected, 'damaged persisted key fails closed instead of silently rotating');
|
||||
} finally {
|
||||
foreach ($processes as [$process, $pipes]) {
|
||||
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
|
||||
if (is_resource($process)) { proc_close($process); }
|
||||
}
|
||||
$keyPath = $root . '/runtime/qywx_promotion_private/welcome.key';
|
||||
if (is_file($keyPath)) { unlink($keyPath); }
|
||||
if (is_dir(dirname($keyPath))) { rmdir(dirname($keyPath)); }
|
||||
if (is_dir($root . '/runtime')) { rmdir($root . '/runtime'); }
|
||||
rmdir($root);
|
||||
}
|
||||
echo "QYWX_PROMOTION_CODE_CIPHER_OK\n";
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiException;
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use think\facade\Config;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// 不initialize,不加载环境数据库;所有HTTP必须经过MockHandler。
|
||||
new think\App();
|
||||
Config::set(['corp_id' => 'ww_fake', 'secret' => 'acquisition_application_secret'], 'qywx_customer_acquisition');
|
||||
Config::set(['wechat_work' => ['external_pay_secret' => 'never_use_payment_secret']], 'pay');
|
||||
function apiCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
$json = static fn (array $value): Response => new Response(200, [], json_encode($value));
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler([
|
||||
$json(['errcode' => 0, 'tag_group' => [
|
||||
['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广'], ['id' => 'deleted', 'deleted' => true]]],
|
||||
['group_id' => 'removed_group', 'deleted' => true],
|
||||
]]),
|
||||
$json(['errcode' => 0, 'external_contact' => ['name' => '小李'], 'next_cursor' => 'next']),
|
||||
$json(['errcode' => 0, 'name' => '张医助']),
|
||||
$json(['errcode' => 0]), $json(['errcode' => 0]), $json(['errcode' => 0]),
|
||||
$json(['media_id' => 'media_1', 'created_at' => time(), 'type' => 'image']),
|
||||
$json(['errcode' => 40014, 'errmsg' => 'sensitive mock token']),
|
||||
$json(['errcode' => 0]),
|
||||
]));
|
||||
$stack->push(Middleware::history($history));
|
||||
$calls = 0;
|
||||
$api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack]),
|
||||
static function () use (&$calls): string { return 'mock_token_' . ++$calls; });
|
||||
apiCheck($api->credentialFingerprint() === hash('sha256', 'ww_fake|acquisition_application_secret'), 'must use acquisition app, not payment secret');
|
||||
apiCheck($api->tagOptions() === ['tag_groups' => [['group_id' => 'group', 'group_name' => '来源', 'tag' => [['id' => 'tag', 'name' => '推广']]]]], 'tag option mapping/deleted filter');
|
||||
apiCheck($api->getExternalContact('external', 'cursor')['next_cursor'] === 'next', 'customer detail');
|
||||
apiCheck($api->getUser('staff')['name'] === '张医助', 'staff name');
|
||||
$api->markTags('staff', 'external', ['tag', 'tag']);
|
||||
$api->remark('staff', 'external', ['remark' => '备注', 'remark_mobiles' => ['do-not-send']]);
|
||||
$api->sendWelcome('one_time_code', '您好', [['msgtype' => 'image', 'image' => ['media_id' => 'media']]]);
|
||||
$stream = fopen('php://temp', 'w+'); fwrite($stream, 'mock_image_bytes'); rewind($stream);
|
||||
apiCheck($api->uploadMedia($stream, 'image', 'cover.png')['media_id'] === 'media_1', 'upload media response');
|
||||
$api->sendWelcome('other_code', 'hello', []);
|
||||
$paths = array_map(static fn (array $h): string => $h['request']->getUri()->getPath(), $history);
|
||||
apiCheck($paths === [
|
||||
'/cgi-bin/externalcontact/get_corp_tag_list', '/cgi-bin/externalcontact/get', '/cgi-bin/user/get',
|
||||
'/cgi-bin/externalcontact/mark_tag', '/cgi-bin/externalcontact/remark', '/cgi-bin/externalcontact/send_welcome_msg',
|
||||
'/cgi-bin/media/upload', '/cgi-bin/externalcontact/send_welcome_msg', '/cgi-bin/externalcontact/send_welcome_msg',
|
||||
], 'official endpoints');
|
||||
apiCheck((string) $history[0]['request']->getBody() === '{}', 'empty tag request must be JSON object');
|
||||
parse_str($history[1]['request']->getUri()->getQuery(), $query);
|
||||
apiCheck($query['external_userid'] === 'external' && $query['cursor'] === 'cursor', 'GET query fields');
|
||||
$body = json_decode((string) $history[3]['request']->getBody(), true);
|
||||
apiCheck($body['add_tag'] === ['tag'] && !isset($body['remove_tag']), 'only add configured tags');
|
||||
$body = json_decode((string) $history[4]['request']->getBody(), true);
|
||||
apiCheck($body === ['userid' => 'staff', 'external_userid' => 'external', 'remark' => '备注'], 'only enabled remark fields');
|
||||
$body = (string) $history[6]['request']->getBody();
|
||||
apiCheck(str_contains($body, 'name="media"') && str_contains($body, 'filename="cover.png"'), 'multipart file field');
|
||||
fclose($stream);
|
||||
apiCheck($calls === 9, 'explicit token invalid response refreshes once');
|
||||
|
||||
$uploadRetry = new QywxPromotionContactApiService(new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create(new MockHandler([
|
||||
$json(['errcode' => 40014]), $json(['media_id' => 'retry_media', 'created_at' => time()]),
|
||||
])),
|
||||
]), static fn (): string => 'mock');
|
||||
$retryStream = fopen('php://temp', 'w+'); fwrite($retryStream, 'mock_image_bytes'); rewind($retryStream);
|
||||
apiCheck($uploadRetry->uploadMedia($retryStream, 'image', 'retry.png')['media_id'] === 'retry_media', 'multipart stream survives explicit token refresh');
|
||||
if (is_resource($retryStream)) { fclose($retryStream); }
|
||||
|
||||
foreach ([
|
||||
[new ConnectException('secret=should_never_escape', new Request('POST', 'https://mock/?access_token=secret')), true, 0],
|
||||
[new Response(502, [], 'secret'), true, 0],
|
||||
[$json(['errcode' => 41051, 'errmsg' => 'welcome_code=secret']), false, 41051],
|
||||
[$json(['errcode' => 41096, 'errmsg' => 'secret']), false, 41096],
|
||||
] as [$response, $uncertain, $code]) {
|
||||
$mock = new MockHandler([$response]);
|
||||
$service = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock), 'http_errors' => false]), static fn (): string => 'mock');
|
||||
try { $service->sendWelcome('code', 'hello', []); throw new RuntimeException('expected failure'); }
|
||||
catch (QywxPromotionContactApiException $e) {
|
||||
apiCheck($e->uncertain === $uncertain && $e->getCode() === $code, 'uncertain result classification');
|
||||
apiCheck(!str_contains($e->getMessage(), 'secret') && $e->getPrevious() === null, 'do not leak secrets in exception chain');
|
||||
apiCheck(count($mock) === 0, 'network error is not retried');
|
||||
}
|
||||
}
|
||||
echo "QYWX_PROMOTION_CONTACT_API_OK\n";
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// 不initialize,不加载业务数据库;每个HTTP请求都由MockHandler处理。
|
||||
new think\App();
|
||||
function createTagCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
$json = static fn (array $data): Response => new Response(200, [], json_encode($data));
|
||||
$group = static fn (array $tags = [], string $id = 'promotion_group', string $name = '推广渠道'): array => [
|
||||
'group_id' => $id, 'group_name' => $name, 'tag' => $tags,
|
||||
];
|
||||
$listing = static fn (array $groups): Response => $json(['errcode' => 0, 'tag_group' => $groups]);
|
||||
$created = static fn (string $id, string $name, string $groupId = 'promotion_group'): Response => $json([
|
||||
'errcode' => 0, 'tag_group' => $group([['id' => $id, 'name' => $name]], $groupId),
|
||||
]);
|
||||
$fixture = static function (array $responses, array &$history): QywxPromotionContactApiService {
|
||||
$history = [];
|
||||
$stack = HandlerStack::create(new MockHandler($responses));
|
||||
$stack->push(Middleware::history($history));
|
||||
return new QywxPromotionContactApiService(new Client([
|
||||
'base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => $stack, 'http_errors' => false,
|
||||
]), static fn (): string => 'mock_token');
|
||||
};
|
||||
$addCount = static fn (array $history): int => count(array_filter($history,
|
||||
static fn (array $entry): bool => $entry['request']->getUri()->getPath() === '/cgi-bin/externalcontact/add_corp_tag'));
|
||||
|
||||
$history = [];
|
||||
$name30 = str_repeat('渠', 30);
|
||||
$api = $fixture([$listing([]), $created('new_tag', $name30)], $history);
|
||||
$result = $api->createTag(' ' . $name30 . ' ');
|
||||
createTagCheck($result === ['tag' => ['id' => 'new_tag', 'name' => $name30], 'group_id' => 'promotion_group', 'group_name' => '推广渠道', 'reused' => false], 'new tag response and 30 Unicode characters');
|
||||
$body = json_decode((string) $history[1]['request']->getBody(), true);
|
||||
createTagCheck($body === ['tag' => [['name' => $name30]], 'group_name' => '推广渠道'], 'new group uses group_name plus nonempty tag array');
|
||||
createTagCheck($history[1]['request']->getMethod() === 'POST' && $addCount($history) === 1, 'official add endpoint called once');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'old', 'name' => '旧标签']])]), $created('tag2', '直播')], $history);
|
||||
$result = $api->createTag('直播');
|
||||
$body = json_decode((string) $history[1]['request']->getBody(), true);
|
||||
createTagCheck($body === ['tag' => [['name' => '直播']], 'group_id' => 'promotion_group'], 'existing group uses ID and does not rename/reorder it');
|
||||
createTagCheck($result['reused'] === false && $result['tag']['id'] === 'tag2', 'existing-group creation returns actual ID');
|
||||
|
||||
$api = $fixture([$listing([
|
||||
$group([['id' => 'other_group_tag', 'name' => '直播']], 'other_group', '其他分组'),
|
||||
$group([['id' => 'existing_tag', 'name' => '直播']]),
|
||||
])], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'existing_tag' && count($history) === 1 && $addCount($history) === 0, 'reuse only exact name in fixed group without write');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'other', 'name' => '直播']], 'other_group', '其他分组')]), $created('fixed_group_tag', '直播')], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'fixed_group_tag' && $addCount($history) === 1, 'same name in different group is not reused');
|
||||
|
||||
$api = $fixture([
|
||||
$listing([$group()]),
|
||||
$json(['errcode' => 40058, 'errmsg' => 'mock concurrent creation conflict']),
|
||||
$listing([$group([['id' => 'concurrent_tag', 'name' => '直播']])]),
|
||||
], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'concurrent_tag' && $addCount($history) === 1 && count($history) === 3, 'concurrent conflict resolved by one read-back');
|
||||
|
||||
$networkError = static fn (): ConnectException => new ConnectException('secret=do_not_expose', new Request('POST', 'https://example.invalid/?access_token=secret'));
|
||||
$api = $fixture([$listing([]), $networkError(), $listing([$group([['id' => 'committed_tag', 'name' => '直播']])])], $history);
|
||||
$result = $api->createTag('直播');
|
||||
createTagCheck($result['reused'] && $result['tag']['id'] === 'committed_tag' && $addCount($history) === 1, 'uncertain transport uses read-back, never duplicate creation');
|
||||
|
||||
$api = $fixture([$listing([]), $networkError(), $listing([])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && str_contains($error->getMessage(), '勿重复提交'), 'unknown result must not invent an ID or claim success');
|
||||
createTagCheck($addCount($history) === 1 && count($history) === 3 && !str_contains($error->getMessage(), 'secret') && $error->getPrevious() === null, 'uncertain result no resend/no secret chain');
|
||||
|
||||
$api = $fixture([$listing([]), $json(['errcode' => 48002, 'errmsg' => 'secret']), $listing([])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && $error->getCode() === 48002 && $addCount($history) === 1, 'explicit failure retained after read-back absent');
|
||||
|
||||
$api = $fixture([$listing([]), $json(['errcode' => 0, 'tag_group' => $group([['name' => '直播']])]), $listing([$group([['id' => 'verified_tag', 'name' => '直播']])])], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'verified_tag' && $addCount($history) === 1, 'success without ID still requires authoritative read-back');
|
||||
|
||||
$api = $fixture([$listing([$group([['id' => 'deleted_tag', 'name' => '直播', 'deleted' => true]])]), $created('recreated', '直播')], $history);
|
||||
createTagCheck($api->createTag('直播')['tag']['id'] === 'recreated' && $addCount($history) === 1, 'deleted tag is not reused');
|
||||
|
||||
$api = $fixture([$listing([]), $networkError(), new Response(503, [], '')], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && str_contains($error->getMessage(), '无法确认') && $addCount($history) === 1, 'failed read-back retains uncertainty without write retry');
|
||||
|
||||
$api = $fixture([$json(['errcode' => 48002])], $history);
|
||||
$error = null;
|
||||
try { $api->createTag('直播'); } catch (RuntimeException $e) { $error = $e; }
|
||||
createTagCheck($error !== null && count($history) === 1 && $addCount($history) === 0, 'initial read failure cannot proceed to creation');
|
||||
|
||||
foreach (['', ' ', "\u{3000}", str_repeat('字', 31), str_repeat('😀', 31), "标签\n", "标\t签", "\0标签", "\u{200B}标签", "\xFF"] as $invalid) {
|
||||
$api = $fixture([], $history);
|
||||
$failed = false;
|
||||
try { $api->createTag($invalid); } catch (RuntimeException) { $failed = true; }
|
||||
createTagCheck($failed && $history === [], 'invalid name rejected before HTTP');
|
||||
}
|
||||
echo "QYWX_PROMOTION_CREATE_TAG_OK\n";
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\common\service\qywx\QywxPromotionContactApiService;
|
||||
use app\common\service\qywx\QywxPromotionMediaService;
|
||||
use app\common\service\qywx\QywxPromotionMediaStore;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use think\file\UploadedFile;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
new think\App();
|
||||
function mediaCheck(bool $ok, string $message): void { if (!$ok) { throw new RuntimeException($message); } }
|
||||
final class MemoryPromotionMediaStore extends QywxPromotionMediaStore
|
||||
{
|
||||
public array $rows = [];
|
||||
public array $references = [];
|
||||
public function find(string $assetId): ?array { return $this->rows[$assetId] ?? null; }
|
||||
public function insert(array $row): void { $this->rows[$row['asset_id']] = $row; }
|
||||
public function update(string $assetId, array $fields): void { $this->rows[$assetId] = array_replace($this->rows[$assetId], $fields); }
|
||||
public function referencedAssetIds(): array { return $this->references; }
|
||||
}
|
||||
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'qywx_media_test_' . bin2hex(random_bytes(6));
|
||||
mkdir($root, 0700);
|
||||
$mock = new MockHandler([
|
||||
new Response(200, [], json_encode(['media_id' => 'prepared', 'created_at' => time()])),
|
||||
new Response(200, [], json_encode(['media_id' => 'refreshed', 'created_at' => time()])),
|
||||
]);
|
||||
$api = new QywxPromotionContactApiService(new Client(['base_uri' => 'https://qyapi.weixin.qq.com/', 'handler' => HandlerStack::create($mock)]), static fn (): string => 'mock');
|
||||
$store = new MemoryPromotionMediaStore();
|
||||
$service = new QywxPromotionMediaService($api, $store, $root . DIRECTORY_SEPARATOR . 'private');
|
||||
try {
|
||||
$path = $root . DIRECTORY_SEPARATOR . 'upload.png';
|
||||
file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2l9sAAAAASUVORK5CYII='));
|
||||
$asset = $service->upload(new UploadedFile($path, '../../cover.png', null, null, true), 'image', 7);
|
||||
mediaCheck(preg_match('/^[0-9a-f]{48}$/', $asset['asset_id']) === 1 && $asset['name'] === 'cover.png', 'random asset and safe name');
|
||||
mediaCheck(array_keys($asset) === ['asset_id', 'name', 'type'], 'no private path returned');
|
||||
$attachment = ['msgtype' => 'image', 'image' => ['asset_id' => $asset['asset_id']]];
|
||||
$config = ['welcome' => ['text' => '你好', 'attachments' => [$attachment]], 'welcome_schedule' => [], 'untouched' => 'retained'];
|
||||
mediaCheck($service->validateConfig($config, 7)['untouched'] === 'retained', 'keep unrelated config');
|
||||
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'prepared', 'cached materialization');
|
||||
mediaCheck(count($mock) === 1, 'welcome preparation never uploads');
|
||||
$denied = false;
|
||||
try { $service->validateConfig($config, 8); } catch (RuntimeException) { $denied = true; }
|
||||
mediaCheck($denied, 'cross-admin asset must be denied');
|
||||
mediaCheck($service->validateConfig($config, 8, $config)['welcome']['attachments'] === [$attachment], 'shared editor may retain existing authorized asset');
|
||||
foreach ([
|
||||
[['msgtype' => 'image', 'image' => ['asset_id' => '../secret']]],
|
||||
[['msgtype' => 'image', 'image' => ['pic_url' => 'http://127.0.0.1/private']]],
|
||||
[['msgtype' => 'video', 'video' => ['asset_id' => $asset['asset_id']]]],
|
||||
[['msgtype' => 'link', 'link' => ['title' => 'x', 'url' => 'javascript:alert(1)']]],
|
||||
[['msgtype' => 'link', 'link' => ['title' => str_repeat('字', 43), 'url' => 'https://example.com']]],
|
||||
[['msgtype' => 'miniprogram', 'miniprogram' => ['title' => 'test', 'appid' => 'bad', 'page' => '/pages/a', 'pic_asset_id' => $asset['asset_id']]]],
|
||||
] as $invalid) {
|
||||
$failed = false;
|
||||
try { $service->validateAttachments($invalid, 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'invalid attachment denied');
|
||||
}
|
||||
$valid = $service->validateAttachments([
|
||||
['msgtype' => 'link', 'link' => ['title' => '就诊', 'url' => 'https://example.com', 'desc' => '说明', 'picurl' => 'https://example.com/p.png']],
|
||||
['msgtype' => 'miniprogram', 'miniprogram' => ['title' => '预约', 'appid' => 'wx0123456789abcdef', 'page' => 'pages/index?a=1', 'pic_asset_id' => $asset['asset_id']]],
|
||||
], 7);
|
||||
mediaCheck(count($valid) === 2, 'link/miniprogram normalize');
|
||||
$store->rows[$asset['asset_id']]['media_expires_at'] = time() - 1;
|
||||
$failed = false;
|
||||
try { $service->materialize([$attachment], $config); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed && count($mock) === 1, 'expired media fails without upload during welcome');
|
||||
$store->references = [$asset['asset_id']];
|
||||
mediaCheck($service->refreshReferenced()['refreshed'] === 1, 'scheduled refresh restores expired media');
|
||||
mediaCheck($service->materialize([$attachment], $config)[0]['image']['media_id'] === 'refreshed', 'use refreshed media id');
|
||||
file_put_contents($path, '<?php echo "not image";');
|
||||
$failed = false;
|
||||
try { $service->upload(new UploadedFile($path, 'fake.png', null, null, true), 'image', 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'MIME spoofed image rejected');
|
||||
$failed = false;
|
||||
try { $service->upload($path, 'file', 7); } catch (RuntimeException) { $failed = true; }
|
||||
mediaCheck($failed, 'arbitrary server path rejected');
|
||||
} finally {
|
||||
foreach (glob($root . DIRECTORY_SEPARATOR . 'private' . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { unlink($file); }
|
||||
if (is_dir($root . DIRECTORY_SEPARATOR . 'private')) { rmdir($root . DIRECTORY_SEPARATOR . 'private'); }
|
||||
foreach (glob($root . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
|
||||
rmdir($root);
|
||||
}
|
||||
echo "QYWX_PROMOTION_MEDIA_OK\n";
|
||||
@@ -0,0 +1,51 @@
|
||||
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 compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||
const { defaultAutomationConfig, cloneAutomationConfig, 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)
|
||||
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)
|
||||
console.log('WECOM_PROMOTION_AUTOMATION_UI_OK')
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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 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";
|
||||
}
|
||||
Reference in New Issue
Block a user