安全:增加上游数据范围灰度开关

This commit is contained in:
WuYongJie 2026-07-22 10:30:41 +08:00
parent 1edfa7cd17
commit 4e599c8fc0
6 changed files with 58 additions and 0 deletions

View File

@ -131,6 +131,7 @@
- 必须设置连接超时、查询超时、慢查询冷却和连接失败熔断;一次失败后进入冷却,已有旧快照时继续提供本地数据,不得连续重试。
- 上游 API 必须保留 `X-Caiwu-Data-Status` 和 `X-Caiwu-Data-Synced-At` 响应头;验收同时检查数据状态和同步时间,不能只看 HTTP 200。
- `JIANSUN_ENABLED` 默认关闭;只允许在现有只读访问方式完成受控连通性和单查询探测后,通过服务器私有 `runtime/.env` 开启。
- 首次启用必须同时设置 `JIANSUN_ALLOWED_SCOPES`,先开放已完成探测的小表范围;不得用 `*` 跳过核心大表的独立验证。
- 当前方案是近实时查询快照,不得冒充完整行级增量复制。没有可靠变更标记时,禁止定时全表扫描;历史一致性只能采用低频、小批量、可中止的核对。
- 首次全量历史数据同步必须单独评估行数、时间窗口和上游低峰期,不得在日常发布时自动执行。
- 上游地址、账号、密码只允许存在于服务器私有环境配置;代码、提交、PR、共享文档和日志中一律不得出现真实值。

View File

@ -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),

View File

@ -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" \

View File

@ -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. 禁止事项

View File

@ -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));

View File

@ -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);
}
}
}