Initial commit: queue workspace
Made-with: Cursor
This commit is contained in:
480
pro_v3.5.1/tests/hjf/MemberUpgradeTest.php
Normal file
480
pro_v3.5.1/tests/hjf/MemberUpgradeTest.php
Normal file
@@ -0,0 +1,480 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\hjf;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* P5-05 会员等级升级逻辑测试
|
||||
*
|
||||
* 测试策略:纯逻辑单元测试,不依赖数据库,
|
||||
* 通过内存数组模拟用户、订单、会员等级状态,
|
||||
* 复现 MemberLevelServices 的核心升级判断逻辑。
|
||||
*
|
||||
* 覆盖点:
|
||||
* 1. 普通→创客:直推3单报单商品
|
||||
* 2. 直推2单不满足创客条件
|
||||
* 3. 创客→云店:伞下30单 + 至少3个直推人
|
||||
* 4. 伞下业绩分离:直推下级已是云店(level≥2),其团队业绩不计入
|
||||
* 5. 云店→服务商:伞下100单
|
||||
* 6. 服务商→分公司:伞下1000单
|
||||
* 7. 连续升级:满足条件时自动连升多级
|
||||
* 8. 最高等级(4)不再继续检查
|
||||
* 9. 缺少3个直推人时,即使业绩达标也不升级
|
||||
* 10. 业绩分离完整场景:3层树结构验证
|
||||
*
|
||||
* Class MemberUpgradeTest
|
||||
* @package tests\hjf
|
||||
*/
|
||||
class MemberUpgradeTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// 内存模拟的会员等级引擎(复现 MemberLevelServices 核心逻辑)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** @var array<int, array> 用户表:uid => {uid, spread_uid, member_level} */
|
||||
private array $users = [];
|
||||
|
||||
/** @var array[] 报单订单表 */
|
||||
private array $orders = [];
|
||||
|
||||
/** 升级门槛(默认值,对应 DEFAULT_DIRECT_REQUIRE / DEFAULT_UMBRELLA_REQUIRE) */
|
||||
private array $directRequire = [1 => 3];
|
||||
private array $umbrellaRequire = [2 => 30, 3 => 100, 4 => 1000];
|
||||
|
||||
/** 最低直推人数要求(云店及以上升级需 ≥3 直推) */
|
||||
private int $minDirectSpreadCount = 3;
|
||||
|
||||
// ---- 数据构建辅助 ---------------------------------------------------
|
||||
|
||||
private function addUser(int $uid, int $spreadUid = 0, int $memberLevel = 0): void
|
||||
{
|
||||
$this->users[$uid] = [
|
||||
'uid' => $uid,
|
||||
'spread_uid' => $spreadUid,
|
||||
'member_level' => $memberLevel,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定用户添加 N 笔已支付报单订单
|
||||
*/
|
||||
private function addQueueOrders(int $uid, int $count): void
|
||||
{
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->orders[] = [
|
||||
'uid' => $uid,
|
||||
'is_queue_goods' => 1,
|
||||
'paid' => 1,
|
||||
'is_del' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 核心逻辑(镜像 MemberLevelServices) ----------------------------
|
||||
|
||||
/**
|
||||
* 检查并执行升级(复现 checkUpgrade 逻辑)
|
||||
*
|
||||
* @return int 升级后的等级
|
||||
*/
|
||||
private function checkUpgrade(int $uid): int
|
||||
{
|
||||
if (!isset($this->users[$uid])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$currentLevel = (int)$this->users[$uid]['member_level'];
|
||||
$nextLevel = $currentLevel + 1;
|
||||
|
||||
if ($nextLevel > 4) {
|
||||
return $currentLevel;
|
||||
}
|
||||
|
||||
if ($this->checkLevelCondition($uid, $currentLevel, $nextLevel)) {
|
||||
$this->users[$uid]['member_level'] = $nextLevel;
|
||||
// 连续升级检查
|
||||
return $this->checkUpgrade($uid);
|
||||
}
|
||||
|
||||
return $currentLevel;
|
||||
}
|
||||
|
||||
private function checkLevelCondition(int $uid, int $currentLevel, int $nextLevel): bool
|
||||
{
|
||||
if ($nextLevel === 1) {
|
||||
$require = $this->directRequire[1];
|
||||
return $this->getDirectQueueOrderCount($uid) >= $require;
|
||||
}
|
||||
|
||||
$umbrellaRequire = $this->umbrellaRequire[$nextLevel] ?? PHP_INT_MAX;
|
||||
if ($this->getUmbrellaQueueOrderCount($uid) < $umbrellaRequire) {
|
||||
return false;
|
||||
}
|
||||
return $this->getDirectSpreadCount($uid) >= $this->minDirectSpreadCount;
|
||||
}
|
||||
|
||||
/** 直推报单订单数(仅统计直推1层) */
|
||||
private function getDirectQueueOrderCount(int $uid): int
|
||||
{
|
||||
$directUids = $this->getDirectUids($uid);
|
||||
if (empty($directUids)) {
|
||||
return 0;
|
||||
}
|
||||
return count(array_filter(
|
||||
$this->orders,
|
||||
fn($o) => in_array($o['uid'], $directUids, true)
|
||||
&& $o['is_queue_goods'] === 1
|
||||
&& $o['paid'] === 1
|
||||
&& $o['is_del'] === 0
|
||||
));
|
||||
}
|
||||
|
||||
/** 直推人数 */
|
||||
private function getDirectSpreadCount(int $uid): int
|
||||
{
|
||||
return count($this->getDirectUids($uid));
|
||||
}
|
||||
|
||||
/** 伞下总报单订单数(含业绩分离逻辑,DFS) */
|
||||
private function getUmbrellaQueueOrderCount(int $uid, int $maxDepth = 8): int
|
||||
{
|
||||
return $this->recursiveUmbrellaCount($uid, $maxDepth);
|
||||
}
|
||||
|
||||
private function recursiveUmbrellaCount(int $uid, int $remainDepth): int
|
||||
{
|
||||
if ($remainDepth <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$directChildren = $this->getDirectUids($uid);
|
||||
if (empty($directChildren)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($directChildren as $childUid) {
|
||||
$childLevel = (int)($this->users[$childUid]['member_level'] ?? 0);
|
||||
|
||||
// 业绩分离:直推下级已是云店(level≥2),跳过其团队
|
||||
if ($childLevel >= 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 统计该下级自身的报单订单
|
||||
$total += count(array_filter(
|
||||
$this->orders,
|
||||
fn($o) => $o['uid'] === $childUid
|
||||
&& $o['is_queue_goods'] === 1
|
||||
&& $o['paid'] === 1
|
||||
&& $o['is_del'] === 0
|
||||
));
|
||||
|
||||
// 递归统计下级的伞下
|
||||
$total += $this->recursiveUmbrellaCount($childUid, $remainDepth - 1);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/** 获取直推子用户 uid 列表 */
|
||||
private function getDirectUids(int $uid): array
|
||||
{
|
||||
return array_keys(array_filter(
|
||||
$this->users,
|
||||
fn($u) => (int)$u['spread_uid'] === $uid
|
||||
));
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->users = [];
|
||||
$this->orders = [];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 测试用例
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 普通→创客:直推3单满足条件
|
||||
*/
|
||||
public function testUpgradeToLevel1With3DirectOrders(): void
|
||||
{
|
||||
$this->addUser(1); // 普通会员
|
||||
$this->addUser(10, 1); // 直推下级A
|
||||
$this->addUser(11, 1); // 直推下级B
|
||||
$this->addUser(12, 1); // 直推下级C
|
||||
|
||||
// 直推下级各下1单报单订单
|
||||
$this->addQueueOrders(10, 1);
|
||||
$this->addQueueOrders(11, 1);
|
||||
$this->addQueueOrders(12, 1);
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(1, $level, '直推3单应升级到创客(level=1)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 直推2单不满足创客条件
|
||||
*/
|
||||
public function testNotUpgradeWithOnly2DirectOrders(): void
|
||||
{
|
||||
$this->addUser(1);
|
||||
$this->addUser(10, 1);
|
||||
$this->addUser(11, 1);
|
||||
|
||||
$this->addQueueOrders(10, 1);
|
||||
$this->addQueueOrders(11, 1); // 仅2单
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(0, $level, '直推2单不满足创客条件,仍为普通会员');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 创客→云店:伞下30单 + 3个直推人
|
||||
*/
|
||||
public function testUpgradeToLevel2With30UmbrellaOrders(): void
|
||||
{
|
||||
// uid=1 已是创客(level=1),有3个直推
|
||||
$this->addUser(1, 0, 1);
|
||||
$this->addUser(10, 1);
|
||||
$this->addUser(11, 1);
|
||||
$this->addUser(12, 1);
|
||||
|
||||
// 伞下合计30单
|
||||
$this->addQueueOrders(10, 10);
|
||||
$this->addQueueOrders(11, 10);
|
||||
$this->addQueueOrders(12, 10);
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(2, $level, '伞下30单+3直推 → 升级到云店(level=2)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 伞下29单不满足云店条件
|
||||
*/
|
||||
public function testNotUpgradeLevel2WithOnly29Orders(): void
|
||||
{
|
||||
$this->addUser(1, 0, 1);
|
||||
$this->addUser(10, 1);
|
||||
$this->addUser(11, 1);
|
||||
$this->addUser(12, 1);
|
||||
|
||||
$this->addQueueOrders(10, 10);
|
||||
$this->addQueueOrders(11, 10);
|
||||
$this->addQueueOrders(12, 9); // 总计29单
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(1, $level, '伞下29单不满足云店条件');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 仅2个直推人时,即使业绩达标也不升级云店
|
||||
*/
|
||||
public function testNotUpgradeLevel2Without3DirectSpreads(): void
|
||||
{
|
||||
$this->addUser(1, 0, 1);
|
||||
$this->addUser(10, 1); // 仅2个直推
|
||||
$this->addUser(11, 1);
|
||||
|
||||
$this->addQueueOrders(10, 20);
|
||||
$this->addQueueOrders(11, 20); // 总计40单 >= 30,但直推人只有2个
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(1, $level, '直推人数<3,即使业绩达标也不升级');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 业绩分离:直推下级已是云店(level=2),其团队业绩不计入上级伞下
|
||||
*/
|
||||
public function testUmbrellaPerformanceSeparation(): void
|
||||
{
|
||||
/*
|
||||
* 树结构:
|
||||
* uid=1 (创客 level=1)
|
||||
* ├─ uid=10 (云店 level=2) ← 业绩分离,其下单不计入uid=1
|
||||
* │ └─ uid=100 (普通) → 下了20单(不计入uid=1)
|
||||
* ├─ uid=11 (普通 level=0) → 下了15单(计入uid=1)
|
||||
* └─ uid=12 (普通 level=0) → 下了15单(计入uid=1)
|
||||
*/
|
||||
$this->addUser(1, 0, 1); // 待检查升级
|
||||
$this->addUser(10, 1, 2); // 已是云店,业绩分离
|
||||
$this->addUser(100, 10, 0); // 10的下级
|
||||
$this->addUser(11, 1, 0);
|
||||
$this->addUser(12, 1, 0);
|
||||
|
||||
$this->addQueueOrders(100, 20); // 这20单不应计入uid=1
|
||||
$this->addQueueOrders(11, 15); // 计入
|
||||
$this->addQueueOrders(12, 15); // 计入
|
||||
|
||||
$umbrella = $this->getUmbrellaQueueOrderCount(1);
|
||||
$this->assertEquals(30, $umbrella, '业绩分离后,仅计入11+12的30单');
|
||||
|
||||
// uid=1 有3个直推(10/11/12),伞下=30,满足云店条件
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(2, $level, '业绩分离+30单达标,升级到云店');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 业绩分离:整个已分离的子树递归不计入
|
||||
*/
|
||||
public function testUmbrellaPerformanceSeparationDeepTree(): void
|
||||
{
|
||||
/*
|
||||
* uid=1 (创客) 的伞下树:
|
||||
* uid=10 (云店 level=2) → 业绩分离,整个子树不计入
|
||||
* └─ uid=101 → 下50单
|
||||
* └─ uid=201 → 下50单
|
||||
* uid=11 (普通) → 下10单(计入)
|
||||
* uid=12 (普通) → 下10单(计入)
|
||||
* uid=13 (普通) → 下10单(计入)
|
||||
*/
|
||||
$this->addUser(1, 0, 1);
|
||||
$this->addUser(10, 1, 2); // 云店,分离
|
||||
$this->addUser(101, 10, 0); // 10的子
|
||||
$this->addUser(201, 101, 0); // 101的子
|
||||
$this->addUser(11, 1, 0);
|
||||
$this->addUser(12, 1, 0);
|
||||
$this->addUser(13, 1, 0);
|
||||
|
||||
$this->addQueueOrders(101, 50); // 不计入uid=1
|
||||
$this->addQueueOrders(201, 50); // 不计入uid=1
|
||||
$this->addQueueOrders(11, 10); // 计入
|
||||
$this->addQueueOrders(12, 10); // 计入
|
||||
$this->addQueueOrders(13, 10); // 计入
|
||||
|
||||
$umbrella = $this->getUmbrellaQueueOrderCount(1);
|
||||
$this->assertEquals(30, $umbrella, '深层分离树中,仅计入未分离分支的30单');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 云店→服务商:伞下100单 + 3个直推
|
||||
*/
|
||||
public function testUpgradeToLevel3(): void
|
||||
{
|
||||
$this->addUser(1, 0, 2); // 云店
|
||||
for ($i = 10; $i <= 13; $i++) {
|
||||
$this->addUser($i, 1, 0);
|
||||
}
|
||||
// 伞下合计100单
|
||||
$this->addQueueOrders(10, 40);
|
||||
$this->addQueueOrders(11, 30);
|
||||
$this->addQueueOrders(12, 20);
|
||||
$this->addQueueOrders(13, 10);
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(3, $level, '伞下100单 → 升级到服务商(level=3)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 服务商→分公司:伞下1000单 + 3个直推
|
||||
*/
|
||||
public function testUpgradeToLevel4(): void
|
||||
{
|
||||
$this->addUser(1, 0, 3); // 服务商
|
||||
for ($i = 10; $i <= 13; $i++) {
|
||||
$this->addUser($i, 1, 0);
|
||||
}
|
||||
// 伞下合计1000单
|
||||
$this->addQueueOrders(10, 400);
|
||||
$this->addQueueOrders(11, 300);
|
||||
$this->addQueueOrders(12, 200);
|
||||
$this->addQueueOrders(13, 100);
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(4, $level, '伞下1000单 → 升级到分公司(level=4)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 已是最高等级(level=4)时不再检查
|
||||
*/
|
||||
public function testMaxLevelNoFurtherUpgrade(): void
|
||||
{
|
||||
$this->addUser(1, 0, 4); // 已是分公司
|
||||
// 无论伞下多少单,等级不变
|
||||
for ($i = 10; $i <= 14; $i++) {
|
||||
$this->addUser($i, 1, 0);
|
||||
$this->addQueueOrders($i, 1000);
|
||||
}
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(4, $level, '已是最高等级,不再升级');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 连续升级:满足条件时一次 checkUpgrade 可连升多级
|
||||
* 场景:普通会员同时满足创客条件,
|
||||
* 但不满足云店条件(伞下不足30)→ 只升到创客
|
||||
*/
|
||||
public function testNoContinuousUpgradeWhenNextLevelNotMet(): void
|
||||
{
|
||||
$this->addUser(1, 0, 0); // 普通
|
||||
$this->addUser(10, 1, 0);
|
||||
$this->addUser(11, 1, 0);
|
||||
$this->addUser(12, 1, 0);
|
||||
|
||||
// 满足创客(直推3单),但伞下仅3单 < 30,不满足云店
|
||||
$this->addQueueOrders(10, 1);
|
||||
$this->addQueueOrders(11, 1);
|
||||
$this->addQueueOrders(12, 1);
|
||||
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(1, $level, '只连升到创客,云店条件不满足则停止');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 直推单数统计仅计算直推1层,不递归
|
||||
* 验证:直推的下级(孙子级)的订单不计入直推订单数
|
||||
*/
|
||||
public function testDirectOrderCountIsShallowOnly(): void
|
||||
{
|
||||
$this->addUser(1, 0, 0);
|
||||
$this->addUser(10, 1, 0); // 直推
|
||||
$this->addUser(100, 10, 0); // 孙子级(不是直推)
|
||||
|
||||
// 孙子级下2单,直推层无订单
|
||||
$this->addQueueOrders(100, 2);
|
||||
|
||||
$directCount = $this->getDirectQueueOrderCount(1);
|
||||
$this->assertEquals(0, $directCount, '孙子级订单不计入直推订单数');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 同一用户下多单,仅计算为1人的订单数(不影响升级统计正确性)
|
||||
*/
|
||||
public function testMultipleOrdersFromSameUser(): void
|
||||
{
|
||||
$this->addUser(1, 0, 0);
|
||||
$this->addUser(10, 1, 0);
|
||||
$this->addUser(11, 1, 0);
|
||||
$this->addUser(12, 1, 0);
|
||||
|
||||
// 直推下级每人多单,直推订单数 = 订单总数(按订单统计,非按人统计)
|
||||
$this->addQueueOrders(10, 3); // 3单
|
||||
$this->addQueueOrders(11, 0); // 0单
|
||||
$this->addQueueOrders(12, 0); // 0单
|
||||
|
||||
$directCount = $this->getDirectQueueOrderCount(1);
|
||||
$this->assertEquals(3, $directCount, '直推订单数按订单计算,同一人多单分别统计');
|
||||
|
||||
// 总计3单 ≥ 3,满足创客条件
|
||||
$level = $this->checkUpgrade(1);
|
||||
$this->assertEquals(1, $level, '直推3单(来自同一人)满足创客升级');
|
||||
}
|
||||
}
|
||||
232
pro_v3.5.1/tests/hjf/PointsCalculationTest.php
Normal file
232
pro_v3.5.1/tests/hjf/PointsCalculationTest.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\hjf;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 积分计算单元测试
|
||||
*
|
||||
* 覆盖点:
|
||||
* 1. 级差计算:直推 / 伞下 / 多层级差公式正确性
|
||||
* 2. 每日释放精度:bcmath 计算 frozen × rate / 1000
|
||||
* 3. 边界值:frozen_points=0 不释放;rate=0 不释放
|
||||
* 4. 伞下业绩分离:云店级别下级业绩不计入上级
|
||||
*
|
||||
* Class PointsCalculationTest
|
||||
* @package tests\hjf
|
||||
*/
|
||||
class PointsCalculationTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// 每日释放精度(bcmath)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 每日释放:1,000,000 frozen × 4‰ = 4,000(无浮点误差)
|
||||
*/
|
||||
public function testDailyReleaseCalculationPrecision(): void
|
||||
{
|
||||
$frozen = '1000000';
|
||||
$rate = '4';
|
||||
|
||||
// Formula: FLOOR(frozen × rate / 1000)
|
||||
$releaseAmount = (int)bcdiv(bcmul($frozen, $rate), '1000');
|
||||
|
||||
$this->assertEquals(4000, $releaseAmount, '每日释放精度测试:1000000×4‰=4000');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 每日释放:小数点精度 —— 999 × 4‰ = 3(FLOOR,不是3.996)
|
||||
*/
|
||||
public function testDailyReleaseFloorRounding(): void
|
||||
{
|
||||
$frozen = '999';
|
||||
$rate = '4';
|
||||
|
||||
$releaseAmount = (int)bcdiv(bcmul($frozen, $rate), '1000');
|
||||
|
||||
$this->assertEquals(3, $releaseAmount, '每日释放应使用 FLOOR 取整,999×4‰=3');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* frozen_points = 0 时不释放
|
||||
*/
|
||||
public function testDailyReleaseZeroFrozenPoints(): void
|
||||
{
|
||||
$frozen = '0';
|
||||
$rate = '4';
|
||||
|
||||
$releaseAmount = (int)bcdiv(bcmul($frozen, $rate), '1000');
|
||||
|
||||
$this->assertEquals(0, $releaseAmount, 'frozen_points=0 时释放量应为 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* rate = 0 时不释放(防止除零配置错误)
|
||||
*/
|
||||
public function testDailyReleaseZeroRate(): void
|
||||
{
|
||||
$frozen = '100000';
|
||||
$rate = '0';
|
||||
|
||||
$releaseAmount = (int)bcdiv(bcmul($frozen, $rate), '1000');
|
||||
|
||||
$this->assertEquals(0, $releaseAmount, 'rate=0 时释放量应为 0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 浮点陷阱验证:PHP 原生浮点 vs bcmath 结果对比
|
||||
*/
|
||||
public function testBcmathVsNativeFloat(): void
|
||||
{
|
||||
$frozen = 1234567;
|
||||
$rate = 4;
|
||||
|
||||
// Native float (may have precision errors)
|
||||
$nativeResult = (int)($frozen * $rate / 1000);
|
||||
|
||||
// bcmath (exact)
|
||||
$bcResult = (int)bcdiv(bcmul((string)$frozen, (string)$rate), '1000');
|
||||
|
||||
// Both should equal 4938 for this input
|
||||
$expected = (int)floor($frozen * $rate / 1000);
|
||||
|
||||
$this->assertEquals($expected, $bcResult, 'bcmath 结果与预期一致');
|
||||
$this->assertEquals($bcResult, $nativeResult, '此用例下两者结果应相同(验证无浮点偏差)');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 级差计算
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 直推奖励:level=1 (创客) 应获得 500 积分
|
||||
*/
|
||||
public function testDirectRewardForLevel1(): void
|
||||
{
|
||||
// Default config: level1 direct = 500
|
||||
$defaults = [0 => 0, 1 => 500, 2 => 800, 3 => 1000, 4 => 1300];
|
||||
$level = 1;
|
||||
|
||||
$reward = $defaults[$level];
|
||||
$this->assertEquals(500, $reward, '创客直推奖励默认应为 500');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 级差计算:上级 level=2(云店,800) — 下级已获得 level=1(创客,500) = 差额 300
|
||||
*/
|
||||
public function testUmbrellaRewardCascadeLevel2(): void
|
||||
{
|
||||
$directDefaults = [0 => 0, 1 => 500, 2 => 800, 3 => 1000, 4 => 1300];
|
||||
$umbrellaDefaults = [0 => 0, 1 => 0, 2 => 300, 3 => 200, 4 => 300];
|
||||
|
||||
$upperLevel = 2; // 云店
|
||||
$lowerReward = $directDefaults[1]; // 下级(创客)已获得的直推奖励 500
|
||||
|
||||
$umbrellaReward = $umbrellaDefaults[$upperLevel]; // 云店伞下奖励 300
|
||||
$actual = max(0, $umbrellaReward - $lowerReward);
|
||||
|
||||
// 级差:300 - 500 = -200 → max(0, -200) = 0
|
||||
$this->assertEquals(0, $actual, '级差为负数时实发为 0(云店伞下300 < 创客直推500)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 级差计算:上级 level=3(服务商, umbrella=200) — 下级已获得 umbrella(云店=300) → max(0, 200-300) = 0
|
||||
*/
|
||||
public function testUmbrellaRewardCascadeLevel3(): void
|
||||
{
|
||||
$umbrellaDefaults = [0 => 0, 1 => 0, 2 => 300, 3 => 200, 4 => 300];
|
||||
|
||||
$upperLevel = 3;
|
||||
$lowerReward = $umbrellaDefaults[2]; // 下级云店获得的伞下奖励 300
|
||||
|
||||
$actual = max(0, $umbrellaDefaults[$upperLevel] - $lowerReward);
|
||||
|
||||
$this->assertEquals(0, $actual, '服务商伞下200 < 云店伞下300,级差为0');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 级差计算:上级 level=4(分公司, umbrella=300) — 下级已获得 umbrella(服务商=200) = 100
|
||||
*/
|
||||
public function testUmbrellaRewardCascadeLevel4(): void
|
||||
{
|
||||
$umbrellaDefaults = [0 => 0, 1 => 0, 2 => 300, 3 => 200, 4 => 300];
|
||||
|
||||
$upperLevel = 4;
|
||||
$lowerReward = $umbrellaDefaults[3]; // 服务商已获得 200
|
||||
|
||||
$actual = max(0, $umbrellaDefaults[$upperLevel] - $lowerReward);
|
||||
|
||||
$this->assertEquals(100, $actual, '分公司伞下300 - 服务商伞下200 = 级差100');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 传递参数验证:propagateReward 向上传递的是"应得额"而非"实发额"
|
||||
*
|
||||
* 场景:level3 实发 0(因级差为负),但向上传递的 lowerReward 仍为其"应得额"200
|
||||
*/
|
||||
public function testCascadePropagatesExpectedNotActual(): void
|
||||
{
|
||||
$umbrellaDefaults = [0 => 0, 1 => 0, 2 => 300, 3 => 200, 4 => 300];
|
||||
|
||||
// Level3 应得 = 200, 下级已得 300 → 实发 = max(0, 200-300) = 0
|
||||
$level3Expected = $umbrellaDefaults[3]; // 200
|
||||
$level3Actual = max(0, $level3Expected - $umbrellaDefaults[2]); // max(0, 200-300) = 0
|
||||
|
||||
// 但向上传递时使用 level3Expected(200),而非 level3Actual(0)
|
||||
$level4Expected = $umbrellaDefaults[4]; // 300
|
||||
$level4Actual = max(0, $level4Expected - $level3Expected); // max(0, 300-200) = 100
|
||||
|
||||
$this->assertEquals(0, $level3Actual, 'Level3 实发为 0(级差为负)');
|
||||
$this->assertEquals(100, $level4Actual, 'Level4 基于 level3 应得额(200)计算,实发 100');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 每日释放批量处理
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 批量释放:多用户同时处理,每人独立计算(统计结果正确)
|
||||
*/
|
||||
public function testBatchReleaseAggregation(): void
|
||||
{
|
||||
$rate = 4;
|
||||
$users = [
|
||||
['uid' => 1, 'frozen_points' => 10000],
|
||||
['uid' => 2, 'frozen_points' => 5000],
|
||||
['uid' => 3, 'frozen_points' => 250], // 250×4/1000 = 1(FLOOR)
|
||||
['uid' => 4, 'frozen_points' => 0], // skip
|
||||
];
|
||||
|
||||
$totalReleased = 0;
|
||||
$processed = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
$frozen = $user['frozen_points'];
|
||||
$releaseAmount = (int)bcdiv(bcmul((string)$frozen, (string)$rate), '1000');
|
||||
|
||||
if ($releaseAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalReleased += $releaseAmount;
|
||||
$processed++;
|
||||
}
|
||||
|
||||
$this->assertEquals(3, $processed, '应处理3个用户(frozen>0且释放额>0)');
|
||||
$this->assertEquals(61, $totalReleased, '总释放积分应为 40+20+1=61');
|
||||
}
|
||||
}
|
||||
352
pro_v3.5.1/tests/hjf/QueueBoundaryTest.php
Normal file
352
pro_v3.5.1/tests/hjf/QueueBoundaryTest.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\hjf;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* P5-03 公排边界测试
|
||||
*
|
||||
* 测试策略:纯逻辑单元测试,不依赖数据库/Redis,
|
||||
* 通过内存数组模拟 QueuePool 状态,验证业务规则。
|
||||
*
|
||||
* 覆盖点:
|
||||
* 1. 第4单入队触发退款(triggerMultiple=4)
|
||||
* 2. 前3单入队不触发退款
|
||||
* 3. 退款对象是最早入队(queue_no最小)的记录
|
||||
* 4. 退款后该记录 status=1,不再参与后续触发统计
|
||||
* 5. 第5~8单仍旧触发下一批退款(第5单触发第2单退款)
|
||||
* 6. 并发重复提交同一 order_id 只入队一次(幂等性)
|
||||
* 7. 边界值:triggerMultiple=1(每单立即退款)
|
||||
* 8. 退款金额与入队金额一致(无精度损失)
|
||||
*
|
||||
* Class QueueBoundaryTest
|
||||
* @package tests\hjf
|
||||
*/
|
||||
class QueueBoundaryTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// 内存模拟的公排引擎(复现 QueuePoolServices 核心逻辑)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** @var array[] 公排记录 */
|
||||
private array $pool = [];
|
||||
|
||||
/** @var array[] 退款记录(模拟 QueueRefundJob 执行结果) */
|
||||
private array $refunds = [];
|
||||
|
||||
/** @var int 全局序号计数器 */
|
||||
private int $nextQueueNo = 1;
|
||||
|
||||
/** @var int 批次计数器 */
|
||||
private int $batchNo = 0;
|
||||
|
||||
/** @var int 并发锁(0=未锁,1=已锁) */
|
||||
private int $lock = 0;
|
||||
|
||||
/**
|
||||
* 入队(模拟 QueuePoolServices::enqueue)
|
||||
*
|
||||
* @param int $uid 用户 ID
|
||||
* @param string $orderId 订单号
|
||||
* @param float $amount 金额
|
||||
* @param int $multiple 触发倍数
|
||||
* @return array 入队记录
|
||||
* @throws \RuntimeException 并发锁冲突
|
||||
*/
|
||||
private function enqueue(int $uid, string $orderId, float $amount, int $multiple = 4): array
|
||||
{
|
||||
// 模拟 Redis SET NX 锁
|
||||
if ($this->lock === 1) {
|
||||
throw new \RuntimeException('公排入队繁忙,请稍后重试');
|
||||
}
|
||||
$this->lock = 1;
|
||||
|
||||
try {
|
||||
// 幂等检查:同一 orderId 不重复入队
|
||||
foreach ($this->pool as $r) {
|
||||
if ($r['order_id'] === $orderId) {
|
||||
throw new \RuntimeException("订单 {$orderId} 已入队,不可重复");
|
||||
}
|
||||
}
|
||||
|
||||
$record = [
|
||||
'id' => count($this->pool) + 1,
|
||||
'uid' => $uid,
|
||||
'order_id' => $orderId,
|
||||
'amount' => $amount,
|
||||
'queue_no' => $this->nextQueueNo++,
|
||||
'status' => 0, // 0=排队中
|
||||
'refund_time' => 0,
|
||||
'trigger_batch' => 0,
|
||||
'add_time' => time(),
|
||||
];
|
||||
$this->pool[] = $record;
|
||||
|
||||
// 触发退款检查
|
||||
$this->checkAndTriggerRefund($multiple);
|
||||
|
||||
return $record;
|
||||
} finally {
|
||||
$this->lock = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并触发退款(模拟 QueuePoolServices::checkAndTriggerRefund)
|
||||
*/
|
||||
private function checkAndTriggerRefund(int $multiple): void
|
||||
{
|
||||
$pending = $this->countPending();
|
||||
if ($pending < $multiple) {
|
||||
return;
|
||||
}
|
||||
$earliest = $this->getEarliestPending();
|
||||
if (!$earliest) {
|
||||
return;
|
||||
}
|
||||
$this->batchNo++;
|
||||
$this->executeRefund($earliest['id'], $earliest['uid'], $earliest['amount'], $this->batchNo);
|
||||
}
|
||||
|
||||
/** 统计排队中(status=0)的记录数 */
|
||||
private function countPending(): int
|
||||
{
|
||||
return count(array_filter($this->pool, fn($r) => $r['status'] === 0));
|
||||
}
|
||||
|
||||
/** 获取最早排队中记录(queue_no 最小) */
|
||||
private function getEarliestPending(): ?array
|
||||
{
|
||||
$pending = array_filter($this->pool, fn($r) => $r['status'] === 0);
|
||||
if (empty($pending)) {
|
||||
return null;
|
||||
}
|
||||
usort($pending, fn($a, $b) => $a['queue_no'] <=> $b['queue_no']);
|
||||
return reset($pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行退款(模拟 QueueRefundJob::doJob,二次检查幂等)
|
||||
*/
|
||||
private function executeRefund(int $id, int $uid, float $amount, int $batchNo): void
|
||||
{
|
||||
foreach ($this->pool as &$r) {
|
||||
if ($r['id'] === $id) {
|
||||
// 二次检查幂等
|
||||
if ($r['status'] === 1) {
|
||||
return;
|
||||
}
|
||||
$r['status'] = 1;
|
||||
$r['refund_time'] = time();
|
||||
$r['trigger_batch'] = $batchNo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($r);
|
||||
|
||||
$this->refunds[] = [
|
||||
'queue_id' => $id,
|
||||
'uid' => $uid,
|
||||
'amount' => $amount,
|
||||
'batch_no' => $batchNo,
|
||||
];
|
||||
}
|
||||
|
||||
/** 重置公排状态(每个测试独立) */
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->pool = [];
|
||||
$this->refunds = [];
|
||||
$this->nextQueueNo = 1;
|
||||
$this->batchNo = 0;
|
||||
$this->lock = 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 测试用例
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 前3单入队时不触发退款(triggerMultiple=4)
|
||||
*/
|
||||
public function testFirst3OrdersNoRefund(): void
|
||||
{
|
||||
$this->enqueue(1, 'ORDER-001', 3600.00);
|
||||
$this->enqueue(2, 'ORDER-002', 3600.00);
|
||||
$this->enqueue(3, 'ORDER-003', 3600.00);
|
||||
|
||||
$this->assertCount(0, $this->refunds, '前3单不触发退款');
|
||||
$this->assertEquals(3, $this->countPending(), '3单全部排队中');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 第4单入队后触发退款(退款最早的第1单)
|
||||
*/
|
||||
public function testFourthOrderTriggersRefundToFirst(): void
|
||||
{
|
||||
$this->enqueue(1, 'ORDER-001', 3600.00);
|
||||
$this->enqueue(2, 'ORDER-002', 3600.00);
|
||||
$this->enqueue(3, 'ORDER-003', 3600.00);
|
||||
$this->enqueue(4, 'ORDER-004', 3600.00);
|
||||
|
||||
$this->assertCount(1, $this->refunds, '第4单触发1次退款');
|
||||
$this->assertEquals(1, $this->refunds[0]['uid'], '退款对象是第1单用户(uid=1)');
|
||||
$this->assertEquals(3600.00, $this->refunds[0]['amount'], '退款金额正确');
|
||||
$this->assertEquals(1, $this->refunds[0]['batch_no'], '第1批次');
|
||||
$this->assertEquals(3, $this->countPending(), '退款后剩余3单排队中');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 退款触发后,被退款的记录 status=1,不再排队中
|
||||
*/
|
||||
public function testRefundedRecordStatusUpdated(): void
|
||||
{
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$this->enqueue($i, "ORDER-00{$i}", 3600.00);
|
||||
}
|
||||
|
||||
// 第1条记录(queue_no=1)应已退款
|
||||
$firstRecord = $this->pool[0];
|
||||
$this->assertEquals(1, $firstRecord['status'], '第1单 status=1(已退款)');
|
||||
$this->assertGreaterThan(0, $firstRecord['refund_time'], '有退款时间戳');
|
||||
$this->assertEquals(1, $firstRecord['trigger_batch'], '批次号=1');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 第5单入队后,pending=4,再次触发退款(退款最早排队中的第2单)
|
||||
*/
|
||||
public function testFifthOrderTriggersSecondRefund(): void
|
||||
{
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$this->enqueue($i, "ORDER-00{$i}", 3600.00);
|
||||
}
|
||||
|
||||
// 第4单触发退款1(退第1单),第5单入队时 pending=4,再触发退款2(退第2单)
|
||||
$this->assertCount(2, $this->refunds, '共发生2次退款');
|
||||
$this->assertEquals(1, $this->refunds[0]['uid'], '第1次退款是uid=1');
|
||||
$this->assertEquals(2, $this->refunds[1]['uid'], '第2次退款是uid=2');
|
||||
$this->assertEquals(2, $this->refunds[1]['batch_no'], '第2批次');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 8单入队共触发2次退款(每4单1次)
|
||||
*/
|
||||
public function testEightOrdersTriggerTwoRefunds(): void
|
||||
{
|
||||
for ($i = 1; $i <= 8; $i++) {
|
||||
$this->enqueue($i, "ORDER-00{$i}", 3600.00);
|
||||
}
|
||||
|
||||
$this->assertCount(2, $this->refunds, '8单触发2次退款');
|
||||
$this->assertEquals(1, $this->refunds[0]['uid'], '退款1:uid=1');
|
||||
$this->assertEquals(2, $this->refunds[1]['uid'], '退款2:uid=2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 同一 orderId 重复提交,第二次应抛出异常(幂等性保证)
|
||||
*/
|
||||
public function testDuplicateOrderIdRejected(): void
|
||||
{
|
||||
$this->enqueue(1, 'ORDER-DUP', 3600.00);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/已入队/');
|
||||
|
||||
$this->enqueue(1, 'ORDER-DUP', 3600.00);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 并发锁:锁已持有时入队应抛出异常
|
||||
*/
|
||||
public function testConcurrentLockPreventsDoubleEntry(): void
|
||||
{
|
||||
// 手动持锁,模拟另一个请求正在处理
|
||||
$this->lock = 1;
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/繁忙/');
|
||||
|
||||
$this->enqueue(1, 'ORDER-LOCK', 3600.00);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 退款金额精度:3600.00 与 3600 相同,浮点无损
|
||||
*/
|
||||
public function testRefundAmountPrecision(): void
|
||||
{
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$this->enqueue($i, "ORDER-A{$i}", 3600.00);
|
||||
}
|
||||
|
||||
$refundAmount = $this->refunds[0]['amount'];
|
||||
$this->assertSame(3600.00, $refundAmount, '退款金额为精确的 3600.00');
|
||||
// bcadd 精度验证
|
||||
$bcResult = bcadd((string)$refundAmount, '0', 2);
|
||||
$this->assertEquals('3600.00', $bcResult, 'bcadd 精度正确');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* triggerMultiple=1:每单立即退款(边界值测试)
|
||||
*/
|
||||
public function testTriggerMultipleEqualsOne(): void
|
||||
{
|
||||
$multiple = 1;
|
||||
$this->enqueue(1, 'ORDER-M1', 3600.00, $multiple);
|
||||
$this->assertCount(1, $this->refunds, 'triggerMultiple=1 时第1单即触发退款');
|
||||
$this->assertEquals(1, $this->refunds[0]['uid']);
|
||||
|
||||
$this->enqueue(2, 'ORDER-M2', 3600.00, $multiple);
|
||||
$this->assertCount(2, $this->refunds, '第2单也立即触发退款');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* QueueRefundJob 幂等性:对已退款记录重复执行不产生第二条退款记录
|
||||
*/
|
||||
public function testRefundJobIdempotent(): void
|
||||
{
|
||||
// 入队4单触发1次退款
|
||||
for ($i = 1; $i <= 4; $i++) {
|
||||
$this->enqueue($i, "ORDER-IDEM{$i}", 3600.00);
|
||||
}
|
||||
$this->assertCount(1, $this->refunds, '初始退款1次');
|
||||
|
||||
// 对已退款记录(id=1, status=1)再次调用 executeRefund
|
||||
$firstRecord = $this->pool[0];
|
||||
$this->assertEquals(1, $firstRecord['status'], '确认已退款');
|
||||
|
||||
$refundsBefore = count($this->refunds);
|
||||
$this->executeRefund($firstRecord['id'], $firstRecord['uid'], $firstRecord['amount'], 99);
|
||||
|
||||
$this->assertCount($refundsBefore, $this->refunds, '幂等:重复退款不增加退款记录');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 批量压力:100单入队,应恰好触发25次退款(100 / 4 = 25)
|
||||
*/
|
||||
public function testBulkEnqueueTriggerCount(): void
|
||||
{
|
||||
for ($i = 1; $i <= 100; $i++) {
|
||||
$this->enqueue($i % 10 + 1, sprintf('ORDER-%03d', $i), 3600.00);
|
||||
}
|
||||
|
||||
$this->assertCount(25, $this->refunds, '100单触发25次退款');
|
||||
$this->assertEquals(25, $this->countPending(), '剩余25单排队中');
|
||||
|
||||
// 验证退款批次连续
|
||||
$batches = array_column($this->refunds, 'batch_no');
|
||||
$this->assertEquals(range(1, 25), $batches, '退款批次从1连续递增到25');
|
||||
}
|
||||
}
|
||||
195
pro_v3.5.1/tests/hjf/QueueEngineTest.php
Normal file
195
pro_v3.5.1/tests/hjf/QueueEngineTest.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\hjf;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 公排引擎单元测试桩
|
||||
*
|
||||
* 覆盖点:
|
||||
* 1. 入队逻辑:正常入队写库,返回 queue_no
|
||||
* 2. 退款触发:pending 单数 >= triggerMultiple 时触发退款 Job
|
||||
* 3. 分布式锁:并发入队时只有一个请求能获得锁
|
||||
* 4. 幂等性:同一订单不能重复入队
|
||||
*
|
||||
* Class QueueEngineTest
|
||||
* @package tests\hjf
|
||||
*/
|
||||
class QueueEngineTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// 入队逻辑
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 正常入队:should write a new record to queue_pool and return queue_no
|
||||
*/
|
||||
public function testEnqueueCreatesRecord(): void
|
||||
{
|
||||
// Arrange
|
||||
$uid = 1001;
|
||||
$orderId = 'ORDER_20240101_001';
|
||||
$amount = 3600.00;
|
||||
|
||||
// Mock QueuePoolDao: expects one save() call
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
$daoMock->expects($this->once())
|
||||
->method('save')
|
||||
->willReturn(true);
|
||||
$daoMock->method('nextQueueNo')->willReturn(42);
|
||||
|
||||
// Mock CacheService (Redis lock): SET NX succeeds
|
||||
$cacheMock = $this->createMock(\crmeb\services\CacheService::class);
|
||||
$cacheMock->method('handler')->willReturnSelf();
|
||||
$cacheMock->method('set')->willReturn(true); // lock acquired
|
||||
|
||||
// Act — 实际测试时替换为 DI 注入
|
||||
// $service = new QueuePoolServices($daoMock, $cacheMock);
|
||||
// $result = $service->enqueue($uid, $orderId, $amount);
|
||||
|
||||
// Assert
|
||||
// $this->assertArrayHasKey('queue_no', $result);
|
||||
// $this->assertEquals(42, $result['queue_no']);
|
||||
|
||||
// Stub assertion (placeholder until DI wiring is ready)
|
||||
$this->assertTrue(true, '入队正常流程桩测试通过');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 重复入队:same order_id should throw or return error
|
||||
*/
|
||||
public function testEnqueueDuplicateOrderIdIsRejected(): void
|
||||
{
|
||||
// Mock QueuePoolDao: getOne returns existing record
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
$daoMock->method('getOne')
|
||||
->willReturn(['id' => 99, 'order_id' => 'ORDER_20240101_001']);
|
||||
|
||||
// Assert that duplicate is rejected (exception or false return)
|
||||
// $this->expectException(\RuntimeException::class);
|
||||
// $service->enqueue(1001, 'ORDER_20240101_001', 3600);
|
||||
|
||||
$this->assertTrue(true, '幂等性保护桩测试通过');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 退款触发
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 触发阈值:当 pending >= triggerMultiple(4),应该派发 QueueRefundJob
|
||||
*/
|
||||
public function testRefundTriggeredWhenThresholdReached(): void
|
||||
{
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
$daoMock->method('countPending')->willReturn(4); // 4 >= 4, should trigger
|
||||
$daoMock->method('getEarliestPending')->willReturn([
|
||||
'id' => 1,
|
||||
'uid' => 1001,
|
||||
'amount' => '3600.00',
|
||||
]);
|
||||
|
||||
// Verify Job dispatch would be called
|
||||
// In real test: assert QueueRefundJob::dispatch() called once
|
||||
|
||||
$this->assertTrue(true, '退款触发桩测试通过(pending=4, multiple=4)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 未达阈值:当 pending < triggerMultiple,不触发退款
|
||||
*/
|
||||
public function testNoRefundWhenBelowThreshold(): void
|
||||
{
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
$daoMock->method('countPending')->willReturn(3); // 3 < 4, should NOT trigger
|
||||
|
||||
// Verify Job dispatch would NOT be called
|
||||
$this->assertTrue(true, '未触发退款桩测试通过(pending=3, multiple=4)');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 分布式锁
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 并发锁:第一个请求获得锁后,第二个并发请求应被拒绝
|
||||
*/
|
||||
public function testDistributedLockPreventsConcurrentEnqueue(): void
|
||||
{
|
||||
// Redis SET NX 模拟:第一次返回 true(获得锁),第二次返回 false(锁已占用)
|
||||
$responses = [true, false];
|
||||
$callCount = 0;
|
||||
|
||||
$cacheMock = $this->getMockBuilder(\stdClass::class)
|
||||
->addMethods(['set', 'del'])
|
||||
->getMock();
|
||||
|
||||
$cacheMock->method('set')->willReturnCallback(
|
||||
function () use (&$responses, &$callCount) {
|
||||
return $responses[$callCount++] ?? false;
|
||||
}
|
||||
);
|
||||
|
||||
// First request: lock acquired → proceed
|
||||
$lock1 = $responses[0]; // true
|
||||
$this->assertTrue($lock1, '第一个请求应获得分布式锁');
|
||||
|
||||
// Second request: lock not acquired → reject
|
||||
$lock2 = $responses[1]; // false
|
||||
$this->assertFalse($lock2, '第二个并发请求应被分布式锁拒绝');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 退款 Job 执行
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 退款 Job:status 已是 refunded 时不重复处理(幂等)
|
||||
*/
|
||||
public function testRefundJobIsIdempotent(): void
|
||||
{
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
// Record already refunded
|
||||
$daoMock->method('get')->willReturn([
|
||||
'id' => 1,
|
||||
'status' => 'refunded',
|
||||
]);
|
||||
|
||||
// doJob should return true without doing any DB writes
|
||||
// Assert no userDao->bcInc() called
|
||||
|
||||
$this->assertTrue(true, '退款 Job 幂等性桩测试通过');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* 退款 Job:正常执行 → markRefunded + balance increment
|
||||
*/
|
||||
public function testRefundJobExecutesSuccessfully(): void
|
||||
{
|
||||
$daoMock = $this->createMock(\app\dao\hjf\QueuePoolDao::class);
|
||||
$userMock = $this->createMock(\app\dao\user\UserDao::class);
|
||||
|
||||
$daoMock->method('get')->willReturn([
|
||||
'id' => 1,
|
||||
'uid' => 1001,
|
||||
'amount' => '3600.00',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
$daoMock->expects($this->once())->method('markRefunded')->willReturn(true);
|
||||
$userMock->expects($this->once())->method('bcInc')->willReturn(true);
|
||||
|
||||
// Real execution: $job->doJob(1, 1001, 3600.00, 'BATCH_001')
|
||||
// Assert both mock methods were called
|
||||
|
||||
$this->assertTrue(true, '退款 Job 执行流程桩测试通过');
|
||||
}
|
||||
}
|
||||
326
pro_v3.5.1/tests/hjf/SmokeTest.php
Normal file
326
pro_v3.5.1/tests/hjf/SmokeTest.php
Normal file
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\hjf;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* P4 联调冒烟测试(SmokeTest)
|
||||
*
|
||||
* 使用说明:
|
||||
* 1. 确保 Phase 2 数据库迁移已执行(eb_queue_pool / eb_points_release_log 等表已创建)
|
||||
* 2. 确保后端服务已启动(ThinkPHP 8 + Swoole 或 PHP-FPM)
|
||||
* 3. 设置环境变量:
|
||||
* HJF_API_BASE=http://127.0.0.1:8080/api
|
||||
* HJF_ADMIN_BASE=http://127.0.0.1:8080/adminapi
|
||||
* HJF_TOKEN=<有效用户Token>
|
||||
* HJF_ADMIN_TOKEN=<有效管理员Token>
|
||||
* 4. 运行:./vendor/bin/phpunit tests/hjf/SmokeTest.php
|
||||
*
|
||||
* 覆盖场景:
|
||||
* P4-02 UniApp 冒烟:登录后访问公排/资产/积分/会员接口
|
||||
* P4-03 Admin 冒烟:公排订单列表 / 配置保存 / 会员等级 / 积分日志
|
||||
* P4-05 定时任务验证:手动调用积分释放 command,验证 release_log 有新记录
|
||||
*
|
||||
* Class SmokeTest
|
||||
* @package tests\hjf
|
||||
*/
|
||||
class SmokeTest extends TestCase
|
||||
{
|
||||
private string $apiBase;
|
||||
private string $adminBase;
|
||||
private string $token;
|
||||
private string $adminToken;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiBase = rtrim(getenv('HJF_API_BASE') ?: 'http://127.0.0.1:8080/api', '/');
|
||||
$this->adminBase = rtrim(getenv('HJF_ADMIN_BASE') ?: 'http://127.0.0.1:8080/adminapi', '/');
|
||||
$this->token = getenv('HJF_TOKEN') ?: '';
|
||||
$this->adminToken = getenv('HJF_ADMIN_TOKEN') ?: '';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 辅助方法
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回解析后的响应体数组
|
||||
*/
|
||||
private function get(string $url, string $token = ''): array
|
||||
{
|
||||
$headers = ['Content-Type: application/json'];
|
||||
if ($token) {
|
||||
$headers[] = "Authorization: Bearer {$token}";
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$data = json_decode((string)$body, true) ?: [];
|
||||
$data['__http_code'] = $http;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回解析后的响应体数组
|
||||
*/
|
||||
private function post(string $url, array $payload, string $token = ''): array
|
||||
{
|
||||
$headers = ['Content-Type: application/json'];
|
||||
if ($token) {
|
||||
$headers[] = "Authorization: Bearer {$token}";
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$data = json_decode((string)$body, true) ?: [];
|
||||
$data['__http_code'] = $http;
|
||||
return $data;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P4-02 UniApp 冒烟测试
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-02-A: 公排状态接口可访问,返回 200 且包含 total_orders/my_orders/progress 字段
|
||||
*/
|
||||
public function testUniAppQueueStatus(): void
|
||||
{
|
||||
if (!$this->token) {
|
||||
$this->markTestSkipped('HJF_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->apiBase}/hjf/queue/status", $this->token);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], '公排状态接口 HTTP 200');
|
||||
$this->assertArrayHasKey('data', $res, '响应包含 data 字段');
|
||||
|
||||
$data = $res['data'];
|
||||
$this->assertArrayHasKey('total_orders', $data, 'data 包含 total_orders');
|
||||
$this->assertArrayHasKey('my_orders', $data, 'data 包含 my_orders');
|
||||
$this->assertArrayHasKey('progress', $data, 'data 包含 progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-02-B: 公排历史接口可访问,返回分页格式
|
||||
*/
|
||||
public function testUniAppQueueHistory(): void
|
||||
{
|
||||
if (!$this->token) {
|
||||
$this->markTestSkipped('HJF_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->apiBase}/hjf/queue/history?page=1&limit=10", $this->token);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], '公排历史接口 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('list', $data, 'data 包含 list');
|
||||
$this->assertArrayHasKey('count', $data, 'data 包含 count');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-02-C: 资产总览接口,返回余额+积分字段
|
||||
*/
|
||||
public function testUniAppAssetsOverview(): void
|
||||
{
|
||||
if (!$this->token) {
|
||||
$this->markTestSkipped('HJF_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->apiBase}/hjf/assets/overview", $this->token);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], '资产总览接口 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('now_money', $data, 'data 包含 now_money');
|
||||
$this->assertArrayHasKey('frozen_points', $data, 'data 包含 frozen_points');
|
||||
$this->assertArrayHasKey('available_points', $data, 'data 包含 available_points');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-02-D: 积分明细接口,返回分页列表
|
||||
*/
|
||||
public function testUniAppPointsDetail(): void
|
||||
{
|
||||
if (!$this->token) {
|
||||
$this->markTestSkipped('HJF_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->apiBase}/hjf/points/detail?page=1&limit=10", $this->token);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], '积分明细接口 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('list', $data, 'data 包含 list');
|
||||
$this->assertArrayHasKey('count', $data, 'data 包含 count');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-02-E: 会员信息接口,返回 member_level 字段
|
||||
*/
|
||||
public function testUniAppMemberInfo(): void
|
||||
{
|
||||
if (!$this->token) {
|
||||
$this->markTestSkipped('HJF_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->apiBase}/hjf/member/info", $this->token);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], '会员信息接口 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('member_level', $data, 'data 包含 member_level');
|
||||
$this->assertContains(
|
||||
(int)($data['member_level'] ?? -1),
|
||||
[0, 1, 2, 3, 4],
|
||||
'member_level 取值 0~4'
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P4-03 Admin 冒烟测试
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-03-A: Admin 公排订单列表接口,返回分页数据
|
||||
*/
|
||||
public function testAdminQueueOrderList(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->adminBase}/hjf/queue/order?page=1&limit=10", $this->adminToken);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], 'Admin 公排订单列表 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('list', $data, 'data 包含 list');
|
||||
$this->assertArrayHasKey('count', $data, 'data 包含 count');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-03-B: Admin 公排配置读取,返回 hjf_trigger_multiple 等关键字段
|
||||
*/
|
||||
public function testAdminQueueConfigGet(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->adminBase}/hjf/queue/config", $this->adminToken);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], 'Admin 公排配置 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('hjf_trigger_multiple', $data, 'data 包含 hjf_trigger_multiple');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-03-C: Admin 公排配置保存(写回原值,不改变业务数据)
|
||||
*/
|
||||
public function testAdminQueueConfigSave(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
// 先读取当前配置
|
||||
$getRes = $this->get("{$this->adminBase}/hjf/queue/config", $this->adminToken);
|
||||
$current = $getRes['data'] ?? ['hjf_trigger_multiple' => 4, 'hjf_release_rate' => 4];
|
||||
|
||||
// 写回原值
|
||||
$saveRes = $this->post("{$this->adminBase}/hjf/queue/config", $current, $this->adminToken);
|
||||
$this->assertEquals(200, $saveRes['__http_code'], 'Admin 公排配置保存 HTTP 200');
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-03-D: Admin 会员列表接口,返回分页数据含 member_level 列
|
||||
*/
|
||||
public function testAdminMemberList(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->adminBase}/hjf/member/list?page=1&limit=10", $this->adminToken);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], 'Admin 会员列表 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('list', $data, 'data 包含 list');
|
||||
$this->assertArrayHasKey('count', $data, 'data 包含 count');
|
||||
|
||||
// 若有记录,验证字段完整性
|
||||
if (!empty($data['list'])) {
|
||||
$first = $data['list'][0];
|
||||
$this->assertArrayHasKey('member_level', $first, 'list[0] 包含 member_level');
|
||||
$this->assertArrayHasKey('direct_order_count', $first, 'list[0] 包含 direct_order_count');
|
||||
$this->assertArrayHasKey('umbrella_order_count', $first, 'list[0] 包含 umbrella_order_count');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-03-E: Admin 积分释放日志接口,返回分页数据
|
||||
*/
|
||||
public function testAdminPointsReleaseLog(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
$res = $this->get("{$this->adminBase}/hjf/points/release-log?page=1&limit=10", $this->adminToken);
|
||||
|
||||
$this->assertEquals(200, $res['__http_code'], 'Admin 积分释放日志 HTTP 200');
|
||||
$data = $res['data'] ?? [];
|
||||
$this->assertArrayHasKey('list', $data, 'data 包含 list');
|
||||
$this->assertArrayHasKey('count', $data, 'data 包含 count');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P4-05 定时任务验证(积分每日释放)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @test
|
||||
* P4-05: 手动触发 Artisan/Think 积分释放命令后,release_log 有新记录
|
||||
*
|
||||
* 验证方式:
|
||||
* 1. 记录触发前的日志总数
|
||||
* 2. 调用 Admin 接口触发(或本地 CLI:php think hjf:release-points)
|
||||
* 3. 等待队列消费(最多10秒)
|
||||
* 4. 再次查询日志总数,新增量 >= 1
|
||||
*/
|
||||
public function testDailyPointsReleaseCreatesLogs(): void
|
||||
{
|
||||
if (!$this->adminToken) {
|
||||
$this->markTestSkipped('HJF_ADMIN_TOKEN 未设置,跳过');
|
||||
}
|
||||
|
||||
// 触发前的日志总数
|
||||
$before = $this->get("{$this->adminBase}/hjf/points/release-log?page=1&limit=1", $this->adminToken);
|
||||
$countBefore = (int)($before['data']['count'] ?? 0);
|
||||
|
||||
// 触发定时任务(通过 CLI,实际测试时需在后端服务器执行)
|
||||
// 此处记录预期行为:执行后 count 应增加
|
||||
// 若在 CI 环境中可替换为:shell_exec('php think hjf:release-points');
|
||||
|
||||
// 由于无法在 PHPUnit 内部确保队列消费完成,此测试标记为 skipped 并记录说明
|
||||
$this->markTestIncomplete(
|
||||
"P4-05 定时任务测试需在后端服务器手动执行:\n" .
|
||||
" php think hjf:release-points\n" .
|
||||
"执行后再运行本测试检验 release_log count 从 {$countBefore} 增加。\n" .
|
||||
"若 frozen_points 为0的用户较多,释放量可能为0(属正常行为)。"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user