## 任务占用与边界 - 任务名称:生产经营分析副本库隧道端点 - 实际开发者:WuYongJie - 任务来源/团队分工:生产快照恢复 - 目标结果:生产环境经受限隧道读取 operations_replica;账号、密码和业务库信息仍仅来自共享系统数据库。 - 预计修改路径:config/upstreams.php;modules/EcommerceFinance/Services/UpstreamSourceRegistry.php;tests/Feature/SystemSettings/UpstreamDatabasesTest.php - 预计页面或路由:公司、店铺、商标信息页的共享快照读取 - 预计 Controller / Repository:UpstreamSourceRegistry - 数据源、表/视图和字段:operations_replica 固定只读查询;不新增上游写入或全表同步。 - 上游 source_key / scope:operations_replica;company-information.read、store-information.read、trademark-information.read - 权限名、菜单键或共享组件:不变 - 明确不修改的范围:上游账号、密码、表结构、业务数据、开发机直连行为 ## 开工碰撞检查 - [x] Codex 已执行 scripts/codex-task-preflight.ps1 - [x] 已检查最新 main 和所有远程任务分支 - [x] 已搜索主线既有页面、接口、Repository 和数据查询 - [x] 未发现重叠 ## 实际改动 - 完成内容:为生产共享配置增加可选私有隧道端点;仅替换连接主机和端口。 - Codex 实际修改的文件:config/upstreams.php;modules/EcommerceFinance/Services/UpstreamSourceRegistry.php;tests/Feature/SystemSettings/UpstreamDatabasesTest.php - 新增或变更的公共契约:UPSTREAM_TUNNEL_OPERATIONS_REPLICA_HOST;UPSTREAM_TUNNEL_OPERATIONS_REPLICA_PORT - 与原计划的差异:无 ## 验证证据 - 正常流程:生产 Docker 网络已验证可达既有受限隧道。 - 空数据/错误输入/无权限场景:端点缺失或无效时拒绝使用不完整隧道配置。 - 受影响旧功能:本地共享模式与显式本机覆盖模式保持原行为。 - 自动测试及结果:UpstreamDatabasesTest 18 passed,102 assertions。 - 实际页面、接口或只读业务查询结果:待合并和生产部署后执行首份快照验证。 ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [x] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [x] 前端改动符合 DESIGN.md;无前端改动则写“不适用” 高风险影响、回滚和授权状态:生产私有运行配置会新增两项仅端点变量;已存在运行配置备份与共享系统数据库备份。部署和生成快照需在合并后单独复核。 ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 WIP:,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 origin/main...任务分支 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [x] 已授权当前任务的 Codex 执行 Squash merge、验证 main 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/8
161 lines
5.0 KiB
PHP
161 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Services;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
/**
|
|
* 统一解析上游数据源定义,避免业务代码直接拼接配置路径。
|
|
*/
|
|
class UpstreamSourceRegistry
|
|
{
|
|
public function __construct(
|
|
private readonly UpstreamStoredConfiguration $storedConfiguration,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string,label:string,config:string}
|
|
*/
|
|
public function definition(string $source): array
|
|
{
|
|
$source = strtolower(trim($source));
|
|
|
|
if ($source === '' || ! preg_match('/^[a-z0-9_-]+$/', $source)) {
|
|
throw new InvalidArgumentException('上游数据源标识不合法。');
|
|
}
|
|
|
|
$definition = config('upstreams.sources.' . $source);
|
|
|
|
if (! is_array($definition)) {
|
|
throw new InvalidArgumentException('未登记的上游数据源:' . $source);
|
|
}
|
|
|
|
$configKey = trim((string) ($definition['config'] ?? ''));
|
|
|
|
if ($configKey === '' || ! preg_match('/^[a-z0-9_.-]+$/i', $configKey)) {
|
|
throw new InvalidArgumentException('上游数据源配置路径不合法:' . $source);
|
|
}
|
|
|
|
return [
|
|
'key' => $source,
|
|
'label' => trim((string) ($definition['label'] ?? $source)) ?: $source,
|
|
'config' => $configKey,
|
|
];
|
|
}
|
|
|
|
public function value(string $source, string $path, mixed $default = null): mixed
|
|
{
|
|
$definition = $this->definition($source);
|
|
$suffix = trim($path, '.');
|
|
$key = $definition['config'] . ($suffix === '' ? '' : '.' . $suffix);
|
|
$configured = config($key, $default);
|
|
|
|
if ($this->usesLocalConfiguration()) {
|
|
return $configured;
|
|
}
|
|
|
|
if ($suffix === 'connection') {
|
|
return $this->connection($definition['key'], (array) $configured);
|
|
}
|
|
|
|
if (str_starts_with($suffix, 'connection.')) {
|
|
$field = substr($suffix, strlen('connection.'));
|
|
$connection = $this->connection(
|
|
$definition['key'],
|
|
(array) config($definition['config'] . '.connection', []),
|
|
);
|
|
|
|
return $connection[$field] ?? $default;
|
|
}
|
|
|
|
return $this->storedConfiguration->value($definition['key'], $suffix, $configured);
|
|
}
|
|
|
|
public function enabled(string $source): bool
|
|
{
|
|
return (bool) $this->value($source, 'enabled', false);
|
|
}
|
|
|
|
private function usesLocalConfiguration(): bool
|
|
{
|
|
return (bool) config('upstreams.local_configuration.enabled', false);
|
|
}
|
|
|
|
private function connection(string $source, array $configured): array
|
|
{
|
|
$fields = array_values(array_unique(array_merge(array_keys($configured), [
|
|
'host',
|
|
'port',
|
|
'database',
|
|
'username',
|
|
'password',
|
|
'odbc_driver',
|
|
'tds_version',
|
|
'connect_timeout',
|
|
'query_timeout',
|
|
])));
|
|
$resolved = $configured;
|
|
|
|
foreach ($fields as $field) {
|
|
// ODBC 驱动由运行应用的操作系统决定,不能因共享记录中的
|
|
// Windows 驱动名而覆盖 Linux 容器中的可用驱动。
|
|
if ($field === 'odbc_driver' && trim((string) ($configured[$field] ?? '')) !== '') {
|
|
$resolved[$field] = $configured[$field];
|
|
|
|
continue;
|
|
}
|
|
|
|
$resolved[$field] = $this->storedConfiguration->value(
|
|
$source,
|
|
'connection.' . $field,
|
|
$configured[$field] ?? null,
|
|
);
|
|
}
|
|
|
|
return $this->withProductionTunnelEndpoint($source, $resolved);
|
|
}
|
|
|
|
/**
|
|
* 生产服务器无法直连上游时,只覆盖到受限 SSH 隧道的私有端点。
|
|
* 共享系统数据库仍是账号、密码和业务库信息的唯一来源,开发机则
|
|
* 保持原始上游地址直连,避免把生产 Docker 网络地址传播给开发环境。
|
|
*/
|
|
private function withProductionTunnelEndpoint(string $source, array $connection): array
|
|
{
|
|
if (
|
|
! app()->environment('production')
|
|
|| $this->usesLocalConfiguration()
|
|
|| ! $this->storedConfiguration->enabled()
|
|
) {
|
|
return $connection;
|
|
}
|
|
|
|
$endpoint = config('upstreams.tunnel_endpoints.' . $source, []);
|
|
|
|
if (! is_array($endpoint)) {
|
|
return $connection;
|
|
}
|
|
|
|
$host = trim((string) ($endpoint['host'] ?? ''));
|
|
$port = $endpoint['port'] ?? null;
|
|
|
|
if ($host === '' && ($port === null || $port === '')) {
|
|
return $connection;
|
|
}
|
|
|
|
if (
|
|
$host === ''
|
|
|| preg_match('/^[A-Za-z0-9._:-]+$/', $host) !== 1
|
|
|| filter_var($port, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]) === false
|
|
) {
|
|
throw new \RuntimeException('生产上游隧道端点配置不完整。');
|
|
}
|
|
|
|
return array_replace($connection, [
|
|
'host' => $host,
|
|
'port' => (int) $port,
|
|
]);
|
|
}
|
|
}
|