## 任务占用与边界 - 任务名称:修复上游业务页面快照完整性 - 实际开发者: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
200 lines
6.9 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|