CaiWuXiTong/tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php
WuYongJie e144a3562c 修复上游业务页面快照完整性 (#2)
## 任务占用与边界

- 任务名称:修复上游业务页面快照完整性
- 实际开发者:WuYongJie
- 任务来源/团队分工:项目负责人反馈生产业务页面空数据、503 与首份快照长期未生成
- 目标结果:所有已接入上游的页面能够公平生成列表、汇总和筛选项快照;未放行范围明确列出并走生产授权
- 预计修改路径:modules/EcommerceFinance/Services/UpstreamQueryCache.php;tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php
- 预计页面或路由:所有使用 upstream snapshots 的业务页面与 workspace-data 接口
- 预计 Controller / Repository:不修改业务 Repository;修复共享 UpstreamQueryCache 调度
- 数据源、表/视图和字段:jiansun 已有固定只读 Repository;不新增表、不改字段
- 上游 source_key / scope:jiansun;goods.*、invoice-deductions.*、taobao-alipay-bills.* 及既有快照范围
- 权限名、菜单键或共享组件:不修改
- 明确不修改的范围:不写生产数据库、不迁移、不部署、不修改上游 SQL 与业务金额逻辑

## 开工碰撞检查

- [x] Codex 已执行 scripts/codex-task-preflight.ps1
- [x] 已检查最新 main 和所有远程任务分支
- [x] 已搜索主线既有页面、接口、Repository 和数据查询
- [x] 未发现当前计划文件重叠

相关分支、重叠项与协调结论:两个历史本地共享数据库分支涉及 Pages.php 等文件;本任务缩小为 UpstreamQueryCache 与对应单测,不与其重叠。

## 实际改动

- 完成内容:WIP
- Codex 实际修改的文件:WIP
- 新增或变更的公共契约:WIP
- 与原计划的差异:WIP

## 验证证据

- 正常流程:WIP
- 空数据/错误输入/无权限场景:WIP
- 受影响旧功能:WIP
- 自动测试及结果:WIP
- 实际页面、接口或只读业务查询结果:WIP

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [x] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [x] 前端改动符合 DESIGN.md;无前端改动,不适用

高风险影响、回滚和授权状态:代码修复本身不写生产数据;生产 allowed_scopes 调整、快照生成和部署必须另行取得明确授权。

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 WIP:,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex执行 Squash merge、验证 main 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/2
2026-08-03 18:22:46 +08:00

200 lines
6.9 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Tests\TestCase;
class JianSunQueryCacheTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config()->set('upstreams.snapshots.allow_inline_refresh', true);
Cache::setDefaultDriver('array');
Cache::flush();
Carbon::setTestNow('2026-07-22 10:00:00');
config()->set('upstreams.snapshots.cache_store', 'array');
config()->set('jiansun.snapshots.ttl_seconds', 60);
config()->set('jiansun.snapshots.summary_ttl_seconds', 60);
config()->set('jiansun.snapshots.options_ttl_seconds', 3600);
config()->set('jiansun.snapshots.stale_ttl_seconds', 3600);
config()->set('jiansun.snapshots.active_ttl_seconds', 3600);
config()->set('jiansun.snapshots.registry_limit', 300);
config()->set('jiansun.snapshots.refresh_limit', 2);
config()->set('jiansun.snapshots.miss_budget_per_window', 1000);
config()->set('jiansun.snapshots.budget_window_seconds', 60);
config()->set('jiansun.snapshots.max_concurrency', 2);
config()->set('jiansun.snapshots.slot_wait_ms', 0);
config()->set('jiansun.snapshots.lock_wait_seconds', 1);
config()->set('jiansun.snapshots.slow_query_ms', 5000);
config()->set('jiansun.enabled', true);
config()->set('jiansun.allowed_scopes', ['*']);
}
protected function tearDown(): void
{
Carbon::setTestNow();
Cache::flush();
parent::tearDown();
}
public function test_identical_queries_are_shared_without_user_count_limit(): void
{
$cache = app(JianSunQueryCache::class);
$calls = 0;
for ($i = 0; $i < 100; $i++) {
$value = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['data' => [['id' => 1]], 'total' => 1];
});
$this->assertSame(1, $value['total']);
}
$this->assertSame(1, $calls);
$this->assertNotEmpty(Cache::get('jiansun:snapshots:active:v2'));
}
public function test_expired_snapshot_returns_immediately_and_is_refreshed_later(): void
{
$cache = app(JianSunQueryCache::class);
$calls = 0;
$first = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['data' => [['version' => 1]], 'total' => 1];
});
Carbon::setTestNow(now()->addSeconds(61));
$stale = $cache->remember('goods.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['data' => [['version' => 999]], 'total' => 1];
});
$this->assertSame($first, $stale);
$this->assertSame(1, $calls, '页面读取过期快照时不应同步访问上游。');
$this->assertSame('stale', $cache->responseHeaders()['X-Caiwu-Data-Status']);
$this->assertTrue((bool) ($cache->activeEntries(1)[0]['urgent'] ?? false));
$status = $cache->refresh('goods.list', ['page' => 1], function () use (&$calls): array {
$calls++;
return ['data' => [['version' => 2]], 'total' => 1];
});
$fresh = $cache->remember('goods.list', ['page' => 1], fn (): array => []);
$this->assertSame('fresh', $status);
$this->assertSame(2, $fresh['data'][0]['version']);
$this->assertSame('fresh', $cache->responseHeaders()['X-Caiwu-Data-Status']);
$this->assertSame(2, $calls);
}
public function test_unrelated_cold_queries_do_not_share_one_global_query_lock(): void
{
$cache = app(JianSunQueryCache::class);
$parametersA = [
'page' => 1,
'__caiwu_snapshot_context' => [
'company_id' => 0,
'access_hash' => 'system',
],
];
ksort($parametersA);
$queryA = hash('sha256', 'goods.list|' . json_encode($parametersA, JSON_UNESCAPED_UNICODE));
$lockA = Cache::lock('jiansun:query-lock:' . $queryA, 30);
$this->assertTrue($lockA->get());
try {
$value = $cache->remember('goods.summary', [], fn (): array => ['total' => 7]);
$this->assertSame(7, $value['total']);
} finally {
$lockA->release();
}
}
public function test_upstream_slots_bound_parallel_cold_queries(): void
{
$cache = app(JianSunQueryCache::class);
$slot0 = Cache::lock('jiansun:upstream-slot:0', 30);
$slot1 = Cache::lock('jiansun:upstream-slot:1', 30);
$this->assertTrue($slot0->get());
$this->assertTrue($slot1->get());
try {
$this->expectException(ServiceUnavailableHttpException::class);
$cache->remember('goods.list', ['page' => 2], fn (): array => ['data' => [], 'total' => 0]);
} finally {
$slot0->release();
$slot1->release();
}
}
public function test_disallowed_cold_scope_never_calls_upstream(): void
{
config()->set('jiansun.allowed_scopes', ['invoice-deductions.*']);
$cache = app(JianSunQueryCache::class);
$calls = 0;
try {
$cache->remember('goods.list', [], function () use (&$calls): array {
$calls++;
return [];
});
$this->fail('未开放的数据范围不应进入冷查询。');
} catch (ServiceUnavailableHttpException) {
$this->assertSame(0, $calls);
}
}
public function test_oldest_urgent_snapshot_is_not_starved_by_newer_list_queries(): void
{
config()->set('upstreams.snapshots.allow_inline_refresh', false);
$cache = app(JianSunQueryCache::class);
$this->registerColdQuery($cache, 'goods.options');
$registry = Cache::get('jiansun:snapshots:active:v2');
$oldestKey = array_key_first($registry);
$registry[$oldestKey]['seen_at'] = time() - 30;
Cache::forever('jiansun:snapshots:active:v2', $registry);
$this->registerColdQuery($cache, 'invoice-deductions.list');
$this->registerColdQuery($cache, 'invoice-deductions.summary');
$entries = $cache->activeEntries(2);
$this->assertSame('goods.options', $entries[0]['scope']);
$this->assertContains(
$entries[1]['scope'],
['invoice-deductions.list', 'invoice-deductions.summary']
);
}
private function registerColdQuery(JianSunQueryCache $cache, string $scope): void
{
try {
$cache->remember($scope, [], fn (): array => []);
$this->fail('关闭页面内刷新时,冷查询应登记后返回暂不可用。');
} catch (ServiceUnavailableHttpException) {
$this->assertTrue(true);
}
}
}