diff --git a/AGENTS.md b/AGENTS.md index 146632c1..9ba9fec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,7 @@ - 必须设置连接超时、查询超时、慢查询冷却和连接失败熔断;一次失败后进入冷却,已有旧快照时继续提供本地数据,不得连续重试。 - 上游 API 必须保留 `X-Caiwu-Data-Status` 和 `X-Caiwu-Data-Synced-At` 响应头;验收同时检查数据状态和同步时间,不能只看 HTTP 200。 - `JIANSUN_ENABLED` 默认关闭;只允许在现有只读访问方式完成受控连通性和单查询探测后,通过服务器私有 `runtime/.env` 开启。 +- 首次启用必须同时设置 `JIANSUN_ALLOWED_SCOPES`,先开放已完成探测的小表范围;不得用 `*` 跳过核心大表的独立验证。 - 当前方案是近实时查询快照,不得冒充完整行级增量复制。没有可靠变更标记时,禁止定时全表扫描;历史一致性只能采用低频、小批量、可中止的核对。 - 首次全量历史数据同步必须单独评估行数、时间窗口和上游低峰期,不得在日常发布时自动执行。 - 上游地址、账号、密码只允许存在于服务器私有环境配置;代码、提交、PR、共享文档和日志中一律不得出现真实值。 diff --git a/config/jiansun.php b/config/jiansun.php index 8681a2cf..11278728 100644 --- a/config/jiansun.php +++ b/config/jiansun.php @@ -12,6 +12,12 @@ return [ */ 'enabled' => filter_var(env('JIANSUN_ENABLED', false), FILTER_VALIDATE_BOOL), + // 生产首次启用时从小表范围开始;支持精确 scope、prefix.* 和 *。 + 'allowed_scopes' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('JIANSUN_ALLOWED_SCOPES', '')) + ))), + 'connection' => [ 'host' => env('DB_JIANSUN_HOST'), 'port' => (int) env('DB_JIANSUN_PORT', 1433), diff --git a/deploy/bootstrap-server.sh b/deploy/bootstrap-server.sh index 3f399476..4c5580ae 100644 --- a/deploy/bootstrap-server.sh +++ b/deploy/bootstrap-server.sh @@ -59,6 +59,7 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then 'SESSION_DRIVER=file' \ 'QUEUE_CONNECTION=sync' \ 'JIANSUN_ENABLED=false' \ + 'JIANSUN_ALLOWED_SCOPES=' \ 'MARIADB_DATABASE=caiwu' \ 'MARIADB_USER=caiwu' \ "MARIADB_PASSWORD=$DB_PASSWORD" \ diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index 2441605e..869ce92d 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -156,6 +156,7 @@ git pull --ff-only ```dotenv JIANSUN_ENABLED=false +JIANSUN_ALLOWED_SCOPES= DB_JIANSUN_HOST= DB_JIANSUN_PORT=1433 DB_JIANSUN_DATABASE= @@ -177,6 +178,8 @@ JIANSUN_SLOW_QUERY_MS=5000 JIANSUN_SLOW_QUERY_COOLDOWN=300 ``` +`JIANSUN_ALLOWED_SCOPES` 支持逗号分隔的精确范围和 `prefix.*`。首次上线先开放完成探测的小表范围,例如 `invoice-deductions.*`;约 280 万行核心表对应范围必须单独验证,未验证前不得填写 `*`。 + 这套策略提供的是近实时查询快照,不等同于复制上游全库。对于没有可靠更新时间或删除标记的旧表,不能声称实现了完整行级增量;禁止为了追求秒级实时而周期性扫描数百万行。页面实时性依靠本地快照和一分钟后台刷新,历史一致性只能通过低频、小批量、可随时停止的核对补齐。 ## 9. 禁止事项 diff --git a/modules/EcommerceFinance/Services/JianSunQueryCache.php b/modules/EcommerceFinance/Services/JianSunQueryCache.php index 414339c3..fb1bc62f 100644 --- a/modules/EcommerceFinance/Services/JianSunQueryCache.php +++ b/modules/EcommerceFinance/Services/JianSunQueryCache.php @@ -52,6 +52,10 @@ class JianSunQueryCache return $stale['value']; } + if (! $this->isScopeAllowed($scope)) { + throw new ServiceUnavailableHttpException(30, '该数据范围仍在低压力验证中,请稍后再试。'); + } + // 首次访问没有任何本地数据时才允许同步获取一次;先登记,失败后后台 // 仍可在熔断结束后继续补齐快照。 $this->register($scope, $parameters, true); @@ -86,6 +90,10 @@ class JianSunQueryCache { $parameters = $this->normalize($parameters); + if (! $this->isScopeAllowed($scope)) { + return 'stale'; + } + return $this->resolve($scope, $parameters, $resolver, true)['status']; } @@ -105,6 +113,7 @@ class JianSunQueryCache $entries = array_filter($entries, function (mixed $entry) use ($now, $activeTtl): bool { return is_array($entry) && isset($entry['seen_at'], $entry['scope'], $entry['parameters']) + && $this->isScopeAllowed((string) $entry['scope']) && ($now - (int) $entry['seen_at']) <= $activeTtl && $this->isDue($entry, $now); }); @@ -380,6 +389,25 @@ class JianSunQueryCache return 'jiansun:query-cooldown:' . $queryId; } + private function isScopeAllowed(string $scope): bool + { + $allowed = (array) config('jiansun.allowed_scopes', []); + + foreach ($allowed as $pattern) { + $pattern = trim((string) $pattern); + + if ($pattern === '*' || $pattern === $scope) { + return true; + } + + if (str_ends_with($pattern, '.*') && str_starts_with($scope, substr($pattern, 0, -1))) { + return true; + } + } + + return false; + } + private function entryKey(string $scope, array $parameters): string { return hash('sha256', $scope . '|' . json_encode($parameters, JSON_UNESCAPED_UNICODE)); diff --git a/tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php b/tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php index 29a3daea..8f54bf95 100644 --- a/tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php +++ b/tests/Unit/EcommerceFinance/JianSunQueryCacheTest.php @@ -31,6 +31,7 @@ class JianSunQueryCacheTest extends TestCase 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.allowed_scopes', ['*']); } protected function tearDown(): void @@ -131,4 +132,22 @@ class JianSunQueryCacheTest extends TestCase $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); + } + } }