From 410e8bc04adb9a303312e7efbe1bc2a35e35b411 Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 31 Aug 2026 16:37:38 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E7=BB=8F=E8=90=A5?= =?UTF-8?q?=E6=8A=A5=E8=A1=A8=E5=A2=9E=E5=8A=A0=E5=AE=89=E8=A3=85=E6=98=8E?= =?UTF-8?q?=E7=BB=86=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根据安装费工作表增加安装明细数据表、模型和通用页面映射。 --- app/Business/TableRegistry.php | 2 + app/Models/Business/InstallationDetail.php | 78 +++++++++++++++++ ...te_business_installation_details_table.php | 87 +++++++++++++++++++ tests/Feature/Business/ManagementCrudTest.php | 29 +++++++ 4 files changed, 196 insertions(+) create mode 100644 app/Models/Business/InstallationDetail.php create mode 100644 database/migrations/2026_08_31_000000_create_business_installation_details_table.php diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index 13b61a68..6b7e0a75 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -12,6 +12,7 @@ use App\Models\Business\CompanyStoreData; use App\Models\Business\ExpressReceiving; use App\Models\Business\FundForecast; use App\Models\Business\InvoiceBasic; +use App\Models\Business\InstallationDetail; use App\Models\Business\StoreChange; use App\Models\Business\StoreChangeLog; use App\Models\Business\StoreClosing; @@ -166,6 +167,7 @@ class TableRegistry 'fund-management/bank-accounts' => BankAccount::class, 'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤 'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class, + 'financial-operations/installation-details' => InstallationDetail::class, ]; } diff --git a/app/Models/Business/InstallationDetail.php b/app/Models/Business/InstallationDetail.php new file mode 100644 index 00000000..b90f2c3f --- /dev/null +++ b/app/Models/Business/InstallationDetail.php @@ -0,0 +1,78 @@ + 'integer', + 'item_quantity' => 'integer', + 'actual_install_quantity' => 'integer', + 'detail_requested_quantity' => 'integer', + 'requested_amount' => 'decimal:2', + 'settled_amount' => 'decimal:2', + 'actual_install_fee' => 'decimal:2', + 'payment_amount' => 'decimal:2', + 'detail_requested_amount' => 'decimal:2', + 'detail_settled_amount' => 'decimal:2', + 'detail_received_amount' => 'decimal:2', + ]; + + public static function columnLabels(): array + { + return [ + 'installation_no' => '安装单号', 'delete_status' => '删除状态', 'quote_status' => '报价状态', + 'quote_deadline' => '报价截至时间', 'installation_company' => '安装公司', 'submitted_at' => '提交时间', + 'shipped_at' => '发货时间', 'buyer_nickname' => '买家昵称', 'store_name' => '店铺名称', + 'installation_type' => '类型', 'pre_push_verification' => '推送前验证', 'push_status' => '推送状态', + 'push_result' => '推送结果', 'arranged_status' => '是否安排', 'order_no' => '订单编号', + 'item_quantity' => '商品数量', 'buyer_name' => '买家姓名', 'buyer_phone' => '买家号码', 'notes' => '备注', + 'province' => '省', 'city' => '市', 'district' => '区', 'address' => '地址', + 'requested_amount' => '申请总金额', 'settled_amount' => '结算总金额', 'image_urls' => '图片地址', + 'completed_status' => '是否完工', 'customer_service_review' => '客服审核', + 'customer_service_reviewed_at' => '客服审核时间', 'finance_review' => '财务审核', + 'finance_reviewed_at' => '财务审核时间', 'actual_install_quantity' => '实际安装数量', + 'actual_install_fee' => '实际安装费用', 'actual_install_product_name' => '实际安装商品名称', + 'installer_phone' => '安装师傅电话', 'installation_company_notes' => '安装公司备注', + 'installation_fee_payer' => '安装费承担方', 'payment_channel' => '收款渠道', + 'payment_transaction_no' => '收款交易单号', 'payment_voucher' => '收款凭证', 'payment_amount' => '收款金额', + 'creator' => '创建人', 'related_guid' => '关联guid', 'product_id' => '商品id', 'id_owner' => 'ID负责人', + 'department_owner' => '部门负责人', 'auxiliary_spu' => '辅助码Spu', 'product_store_code' => '商品店铺编码', + 'item_no' => '货号', 'barcode' => '条形码', 'product_name' => '商品名称', 'supplier_name' => '供应商名称', + 'detail_requested_amount' => '申请明细金额', 'detail_requested_quantity' => '申请明细数量', + 'detail_settled_amount' => '结算明细金额', 'detail_received_amount' => '收款明细金额', + 'change_reason' => '变更原因', 'source_created_at' => '创建时间', 'source_modified_at' => '修改时间', + 'product_name_1' => '商品名称(1)', 'installation_company_1' => '安装公司', + ]; + } + + public static function fieldTypes(): array + { + return [ + 'item_quantity' => 'number', 'actual_install_quantity' => 'number', 'detail_requested_quantity' => 'number', + 'requested_amount' => 'currency', 'settled_amount' => 'currency', 'actual_install_fee' => 'currency', + 'payment_amount' => 'currency', 'detail_requested_amount' => 'currency', + 'detail_settled_amount' => 'currency', 'detail_received_amount' => 'currency', + ]; + } +} diff --git a/database/migrations/2026_08_31_000000_create_business_installation_details_table.php b/database/migrations/2026_08_31_000000_create_business_installation_details_table.php new file mode 100644 index 00000000..a0e6327d --- /dev/null +++ b/database/migrations/2026_08_31_000000_create_business_installation_details_table.php @@ -0,0 +1,87 @@ +id(); + $table->unsignedInteger('company_id')->index(); + $table->text('installation_no')->nullable(); + $table->text('delete_status')->nullable(); + $table->text('quote_status')->nullable(); + $table->text('quote_deadline')->nullable(); + $table->text('installation_company')->nullable(); + $table->text('submitted_at')->nullable(); + $table->text('shipped_at')->nullable(); + $table->text('buyer_nickname')->nullable(); + $table->text('store_name')->nullable(); + $table->text('installation_type')->nullable(); + $table->text('pre_push_verification')->nullable(); + $table->text('push_status')->nullable(); + $table->text('push_result')->nullable(); + $table->text('arranged_status')->nullable(); + $table->text('order_no')->nullable(); + $table->integer('item_quantity')->nullable(); + $table->text('buyer_name')->nullable(); + $table->text('buyer_phone')->nullable(); + $table->text('notes')->nullable(); + $table->text('province')->nullable(); + $table->text('city')->nullable(); + $table->text('district')->nullable(); + $table->text('address')->nullable(); + $table->decimal('requested_amount', 18, 2)->nullable(); + $table->decimal('settled_amount', 18, 2)->nullable(); + $table->text('image_urls')->nullable(); + $table->text('completed_status')->nullable(); + $table->text('customer_service_review')->nullable(); + $table->text('customer_service_reviewed_at')->nullable(); + $table->text('finance_review')->nullable(); + $table->text('finance_reviewed_at')->nullable(); + $table->integer('actual_install_quantity')->nullable(); + $table->decimal('actual_install_fee', 18, 2)->nullable(); + $table->text('actual_install_product_name')->nullable(); + $table->text('installer_phone')->nullable(); + $table->text('installation_company_notes')->nullable(); + $table->text('installation_fee_payer')->nullable(); + $table->text('payment_channel')->nullable(); + $table->text('payment_transaction_no')->nullable(); + $table->text('payment_voucher')->nullable(); + $table->decimal('payment_amount', 18, 2)->nullable(); + $table->text('creator')->nullable(); + $table->text('related_guid')->nullable(); + $table->text('product_id')->nullable(); + $table->text('id_owner')->nullable(); + $table->text('department_owner')->nullable(); + $table->text('auxiliary_spu')->nullable(); + $table->text('product_store_code')->nullable(); + $table->text('item_no')->nullable(); + $table->text('barcode')->nullable(); + $table->text('product_name')->nullable(); + $table->text('supplier_name')->nullable(); + $table->decimal('detail_requested_amount', 18, 2)->nullable(); + $table->integer('detail_requested_quantity')->nullable(); + $table->decimal('detail_settled_amount', 18, 2)->nullable(); + $table->decimal('detail_received_amount', 18, 2)->nullable(); + $table->text('change_reason')->nullable(); + $table->text('source_created_at')->nullable(); + $table->text('source_modified_at')->nullable(); + $table->text('product_name_1')->nullable(); + $table->text('installation_company_1')->nullable(); + $table->timestamps(); + $table->softDeletes(); + // installation_no is a TEXT field and cannot be included in a full + // composite index under MySQL's 3072-byte key limit; company_id + // already provides the tenant filter index above. + }); + } + + public function down(): void + { + Schema::dropIfExists('business_installation_details'); + } +}; diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 320525e8..86b362b7 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Business; use App\Business\UpstreamBusinessInformation; use App\Models\Auth\Permission; +use App\Models\Business\InstallationDetail; use App\Utilities\BusinessNavigation; use Illuminate\Pagination\LengthAwarePaginator; use Mockery; @@ -20,6 +21,34 @@ class ManagementCrudTest extends FeatureTestCase ); } + public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void + { + InstallationDetail::create([ + 'company_id' => $this->company->id, + 'installation_no' => 'INSTALL-TEST-001', + 'installation_company' => '测试安装公司', + 'store_name' => '测试店铺', + 'requested_amount' => 120.50, + 'settled_amount' => 100.00, + 'item_quantity' => 2, + ]); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'installation-details', + ])) + ->assertOk() + ->assertSeeText('安装明细 · 数据列表') + ->assertSeeText('安装单号') + ->assertSeeText('申请总金额') + ->assertSeeText('商品名称(1)') + ->assertSeeText('INSTALL-TEST-001') + ->assertSeeText('测试安装公司') + ->assertSeeText('120.50') + ->assertSeeText('共 1 条'); + } + public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void { $this->mockUpstreamPage((object) [ From b12749bf8d10918a644891d2062aa15da5469b3a Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 31 Aug 2026 17:05:12 +0800 Subject: [PATCH 02/25] feat: connect installation details to business mirror --- app/Business/TableRegistry.php | 1 + app/Business/UpstreamBusinessInformation.php | 13 ++++ config/upstream_mirrors.php | 15 ++++ .../InstallationDetailMirrorRepository.php | 68 +++++++++++++++++++ tests/Feature/Business/ManagementCrudTest.php | 16 ++--- 5 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php diff --git a/app/Business/TableRegistry.php b/app/Business/TableRegistry.php index 6b7e0a75..ede657b1 100644 --- a/app/Business/TableRegistry.php +++ b/app/Business/TableRegistry.php @@ -242,6 +242,7 @@ class TableRegistry 'company-management/basic-information', 'store-management/basic-information', 'trademark-management/information', + 'financial-operations/installation-details', ], true); } diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 9598170b..076c5b7b 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -7,6 +7,8 @@ use InvalidArgumentException; use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; class UpstreamBusinessInformation { @@ -15,6 +17,7 @@ class UpstreamBusinessInformation public function __construct( private readonly UpstreamQueryCacheFactory $caches, private readonly BusinessInformationMirrorRepository $mirror, + private readonly InstallationDetailMirrorRepository $installationMirror, ) {} public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator @@ -27,6 +30,7 @@ class UpstreamBusinessInformation 'company-management/basic-information' => $this->companyPage($currentPage, $perPage), 'store-management/basic-information' => $this->storePage($currentPage, $perPage), 'trademark-management/information' => $this->trademarkPage($currentPage, $perPage), + 'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage), default => ['data' => [], 'total' => 0], }; @@ -42,6 +46,15 @@ class UpstreamBusinessInformation ); } + private function installationPage(int $page, int $perPage): array + { + $payload = $this->installationMirror->paginate($page, $perPage); + if (! $payload['ready']) { + throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步'); + } + return ['data' => $payload['data'], 'total' => $payload['total']]; + } + /** * 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。 * diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 80b376ec..62c6e908 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -157,6 +157,21 @@ return [ 'jiansun.dbo.alibaba_bill' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'alibaba_bill', 'target' => 'js_alibaba_bill', 'keys' => ['id']], 'jiansun.dbo.jd_spreadBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_spreadBills', 'target' => 'js_jd_spread_bills', 'keys' => ['id'], 'watermark' => 'updateTime'], 'jiansun.dbo.jd_costBills' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jd_costBills', 'target' => 'js_jd_cost_bills', 'keys' => ['id'], 'watermark' => 'creatTime'], + // 安装明细页面使用已整理的只读视图,避免在 HTTP 请求中临时 JOIN + // 安装主表和明细表;视图中的安装字段与 Excel 模板字段保持稳定别名。 + 'jiansun.dbo.fys_Ticketing_install_view' => [ + 'source_key' => 'jiansun', + 'schema' => 'dbo', + 'object' => 'fys_Ticketing_install_view', + 'target' => 'js_fys_ticketing_install_view', + 'keys' => ['Id'], + 'watermark' => null, + 'indexes' => [ + ['shop_name', 'Id'], + ['SubmitTime', 'Id'], + ['ordernum_id', 'Id'], + ], + ], 'jiansun.dbo.finance_purchaseReturn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_purchaseReturn', 'target' => 'js_finance_purchase_return', 'keys' => ['id']], 'jiansun.dbo.finance_invoiceDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_invoiceDeduction', 'target' => 'js_finance_invoice_deduction', 'keys' => ['id'], 'watermark' => 'createTime'], 'jiansun.dbo.fys_supplierPaymentSummary' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierPaymentSummary', 'target' => 'js_supplier_payment_summary', 'keys' => ['id'], 'watermark' => 'modifiedTime'], diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php new file mode 100644 index 00000000..15df775d --- /dev/null +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -0,0 +1,68 @@ +>,total:int,ready:bool} */ + public function paginate(int $page, int $perPage): array + { + $connection = $this->mirror->connection(); + $dataset = $this->datasets->get(self::DATASET); + if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) { + return ['data' => [], 'total' => 0, 'ready' => false]; + } + + $perPage = min(max($perPage, 1), 100); + $query = $connection->table($dataset->targetTable()); + $total = (clone $query)->count(); + $rows = $query->orderByDesc('Id')->forPage(max($page, 1), $perPage)->get(); + + return ['data' => $rows->map(fn (object $row): array => $this->map((array) $row))->all(), 'total' => $total, 'ready' => true]; + } + + /** @param array $row */ + private function map(array $row): array + { + $value = static function (array $row, string ...$names): mixed { + foreach ($names as $name) { + foreach ($row as $key => $value) if (strcasecmp((string) $key, $name) === 0) return $value; + } + return null; + }; + + $mapped = [ + 'id' => $value($row, 'Id'), 'installation_no' => $value($row, 'ordernum_id', 'OrderNum'), + 'quote_status' => $value($row, 'BidStatus'), 'quote_deadline' => $value($row, 'BidEndTime'), + 'installation_company' => $value($row, 'installCompanyName'), 'submitted_at' => $value($row, 'SubmitTime'), + 'shipped_at' => $value($row, 'ShipTIme'), 'buyer_nickname' => $value($row, 'BuyersNick'), + 'store_name' => $value($row, 'shop_name', 'shopname'), 'order_no' => $value($row, 'OrderNum'), + 'item_quantity' => $value($row, 'Num'), 'buyer_name' => $value($row, 'BuyersName'), 'buyer_phone' => $value($row, 'Mobile'), + 'notes' => $value($row, 'Nots'), 'province' => $value($row, 'Province'), 'city' => $value($row, 'City'), + 'district' => $value($row, 'Area'), 'address' => $value($row, 'Address'), 'requested_amount' => $value($row, 'installcost'), + 'settled_amount' => $value($row, 'settleAmount'), 'image_urls' => $value($row, 'Picture'), 'completed_status' => $value($row, 'installComplete'), + 'customer_service_reviewed_at' => $value($row, 'kfshTime'), 'finance_reviewed_at' => $value($row, 'cwshTime'), + 'actual_install_quantity' => $value($row, 'installNum'), 'actual_install_fee' => $value($row, 'install_Cost'), + 'actual_install_product_name' => $value($row, 'installGoodsName'), 'installer_phone' => $value($row, 'installTel'), + 'installation_company_notes' => $value($row, 'installNote'), 'installation_fee_payer' => $value($row, 'costtaker'), + 'payment_channel' => $value($row, 'costWay'), 'payment_transaction_no' => $value($row, 'actporderNum'), + 'payment_voucher' => $value($row, 'costWayVoucher'), 'payment_amount' => $value($row, 'acceptAmount'), + ]; + // 页面按模板的完整 61 列渲染;视图暂未提供的字段必须显式为 null, + // 不能让 Blade 读取未定义属性。 + return array_replace(array_fill_keys(array_keys(\App\Models\Business\InstallationDetail::columnLabels()), null), $mapped); + } +} diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 86b362b7..09228e5e 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -8,6 +8,7 @@ use App\Models\Business\InstallationDetail; use App\Utilities\BusinessNavigation; use Illuminate\Pagination\LengthAwarePaginator; use Mockery; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Tests\Feature\FeatureTestCase; class ManagementCrudTest extends FeatureTestCase @@ -23,15 +24,14 @@ class ManagementCrudTest extends FeatureTestCase public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void { - InstallationDetail::create([ - 'company_id' => $this->company->id, - 'installation_no' => 'INSTALL-TEST-001', - 'installation_company' => '测试安装公司', - 'store_name' => '测试店铺', - 'requested_amount' => 120.50, - 'settled_amount' => 100.00, - 'item_quantity' => 2, + $mirror = Mockery::mock(InstallationDetailMirrorRepository::class); + $mirror->shouldReceive('paginate')->once()->andReturn([ + 'ready' => true, 'total' => 1, 'data' => [array_replace(array_fill_keys(array_keys(InstallationDetail::columnLabels()), null), [ + 'id' => 1, 'installation_no' => 'INSTALL-TEST-001', 'installation_company' => '测试安装公司', + 'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2, + ])], ]); + $this->app->instance(InstallationDetailMirrorRepository::class, $mirror); $this->loginAs() ->get(route('business.pages.show', [ From 76ea948970a1337b1e44f9f2798586b03332beca Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 31 Aug 2026 17:25:41 +0800 Subject: [PATCH 03/25] fix: allow installation mirror schema indexes --- config/upstream_mirrors.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 62c6e908..29d1a1de 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -167,9 +167,7 @@ return [ 'keys' => ['Id'], 'watermark' => null, 'indexes' => [ - ['shop_name', 'Id'], ['SubmitTime', 'Id'], - ['ordernum_id', 'Id'], ], ], 'jiansun.dbo.finance_purchaseReturn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_purchaseReturn', 'target' => 'js_finance_purchase_return', 'keys' => ['id']], From 11c79a21468acef6c37afbcb7eec401af644a58b Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 31 Aug 2026 17:26:25 +0800 Subject: [PATCH 04/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=91=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86=E4=B8=8D=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20PostgreSQL=20=E8=BF=9E=E6=8E=A5=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/SystemSettings/Databases.php | 45 ++++++++---- deploy/Dockerfile | 6 ++ deploy/Dockerfile.local | 5 ++ .../Services/UpstreamDatabaseProbe.php | 68 +++++++++++++++++++ .../system_settings/databases/form.blade.php | 68 ++++++++++++++----- .../system_settings/databases/index.blade.php | 2 +- .../SystemSettings/UpstreamDatabasesTest.php | 60 ++++++++++++++++ .../UpstreamDatabaseProbeTest.php | 65 ++++++++++++++++++ 8 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php create mode 100644 tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 0eb9b009..581ba4b8 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -15,6 +15,7 @@ use Illuminate\Routing\Controller; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\UpstreamOdbcConnection; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -258,24 +259,26 @@ class Databases extends Controller return redirect()->route('system-settings.databases.index'); } - public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration) + public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration, UpstreamDatabaseProbe $probe) { abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403); $connection = $this->connection($connection); try { - UpstreamOdbcConnection::probeConfiguration($this->odbcConfiguration($connection), $connection->name); + $probe->probe($connection->driver, $this->odbcConfiguration($connection), $connection->name); } catch (DecryptException $exception) { throw ValidationException::withMessages([ 'connection' => $exception->getMessage(), ]); - } catch (ServiceUnavailableHttpException) { + } catch (ServiceUnavailableHttpException $exception) { throw ValidationException::withMessages([ - 'connection' => '连接失败。请检查地址、端口、数据库、账号、网络白名单和本机 ODBC 驱动。', + 'connection' => $connection->driver === 'pgsql' + ? $exception->getMessage() + : 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。', ]); } - flash($connection->name . '连接成功,仅执行了 SELECT 1 只读测试。')->success(); + flash($connection->name . ($connection->driver === 'pgsql' ? ' PostgreSQL SSL 连接成功,仅执行了 SELECT 1 只读测试。' : '连接成功,仅执行了 SELECT 1 只读测试。'))->success(); return redirect()->route('system-settings.databases.index'); } @@ -283,8 +286,17 @@ class Databases extends Controller private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array { $sourceKeys = array_keys($this->sourceLabels()); + $request->merge(['driver' => $request->input('driver', $connection?->driver ?? 'sqlsrv')]); + $postgres = $request->input('driver') === 'pgsql'; + + if ($postgres && $request->input('source_key', $connection?->source_key)) { + throw ValidationException::withMessages([ + 'source_key' => '现有业务来源使用 SQL Server,不能改绑到 PostgreSQL。请为 PostgreSQL 单独保存连接。', + ]); + } return $request->validate([ + 'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])], 'name' => [ 'required', 'string', @@ -300,15 +312,20 @@ class Databases extends Controller ->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)) ->ignore($connection?->id), ], - 'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'host' => ['required', 'string', 'max:191', function ($attribute, $value, $fail) { + if (! filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + && ! preg_match('/^[a-z0-9_.-]+$/i', $value)) { + $fail('主机地址只填写域名或 IP,端口请单独填写。'); + } + }], 'port' => ['required', 'integer', 'between:1,65535'], 'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'], - 'odbc_driver' => ['required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], - 'tds_version' => ['required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], + 'odbc_driver' => [$postgres ? 'nullable' : 'required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], + 'tds_version' => [$postgres ? 'nullable' : 'required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], 'connect_timeout' => ['required', 'integer', 'between:1,30'], - 'query_timeout' => ['required', 'integer', 'between:1,120'], + 'query_timeout' => ['required', 'integer', $postgres ? 'between:1,15' : 'between:1,120'], 'allowed_scopes' => ['nullable', 'string', 'max:4000'], 'enabled' => ['nullable', 'boolean'], ]); @@ -318,15 +335,15 @@ class Databases extends Controller { $attributes = [ 'company_id' => UpstreamStoredConfiguration::SYSTEM_COMPANY_ID, - 'source_key' => $data['source_key'] ?? null, + 'source_key' => array_key_exists('source_key', $data) ? $data['source_key'] : $connection?->source_key, 'name' => trim($data['name']), - 'driver' => 'sqlsrv', + 'driver' => $data['driver'], 'host' => trim($data['host']), 'port' => (int) $data['port'], 'database_name' => trim($data['database_name']), 'username' => trim($data['username']), - 'odbc_driver' => trim($data['odbc_driver']), - 'tds_version' => trim($data['tds_version']), + 'odbc_driver' => $data['driver'] === 'pgsql' ? null : trim($data['odbc_driver']), + 'tds_version' => $data['driver'] === 'pgsql' ? null : trim($data['tds_version']), 'connect_timeout' => (int) $data['connect_timeout'], 'query_timeout' => (int) $data['query_timeout'], 'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')), @@ -374,6 +391,7 @@ class Databases extends Controller { return [ 'name' => '', + 'driver' => 'sqlsrv', 'source_key' => '', 'enabled' => false, 'host' => '', @@ -392,6 +410,7 @@ class Databases extends Controller { return [ 'name' => $connection->name, + 'driver' => $connection->driver, 'source_key' => $connection->source_key, 'enabled' => $connection->enabled, 'host' => $connection->host, diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2024a960..aba499ae 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -72,6 +72,12 @@ RUN --mount=type=cache,target=/tmp/composer-cache \ FROM php-base AS runtime +# Keep the existing PHP/Composer cache layers when adding PostgreSQL support. +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + ARG VCS_REF=unknown ARG BUILD_DATE=unknown diff --git a/deploy/Dockerfile.local b/deploy/Dockerfile.local index 2f1383b5..eb2307bc 100644 --- a/deploy/Dockerfile.local +++ b/deploy/Dockerfile.local @@ -83,6 +83,11 @@ RUN --mount=type=cache,target=/root/.npm \ npm ci --no-audit --no-fund --legacy-peer-deps # ---------------- 复制源码 ---------------- +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + COPY . /var/www/html COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini diff --git a/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php new file mode 100644 index 00000000..a41b9ff1 --- /dev/null +++ b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php @@ -0,0 +1,68 @@ +quote((string) $connection['host']) + . ';port=' . min(max((int) $connection['port'], 1), 65535) + . ';dbname=' . $this->quote((string) $connection['database']) + . ';sslmode=require;connect_timeout=' . $timeout + . ";options='-c default_transaction_read_only=on -c statement_timeout=" . $queryTimeout . "'"; + + try { + // One short-lived connection, TLS and read-only enforced at session startup. + $pdo = $this->connect($dsn, (string) $connection['username'], (string) $connection['password']); + $statement = $pdo->query('SELECT 1'); + if ($statement === false || (int) $statement->fetchColumn() !== 1) { + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 只读测试未返回预期结果。'); + } + $statement->closeCursor(); + } catch (PDOException) { + // Do not expose raw driver errors, DSNs or credentials in logs/session flashes. + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 连接失败。请检查账号、密码、数据库访问权限,以及服务器网络和 SSL 配置。'); + } + } + + protected function connect(string $dsn, string $username, #[SensitiveParameter] string $password): PDO + { + if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { + throw new ServiceUnavailableHttpException(30, '应用缺少 PostgreSQL 驱动,请部署包含 pdo_pgsql 的新版应用镜像。'); + } + + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_PERSISTENT => false, + ]); + } + + private function quote(string $value): string + { + // PDO replaces all DSN semicolons with spaces; reject rather than alter database names. + if (preg_match('/[;\x00-\x1F]/', $value)) { + throw new ServiceUnavailableHttpException(30, '主机或数据库名称包含不支持的字符。'); + } + + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; + } +} diff --git a/resources/views/system_settings/databases/form.blade.php b/resources/views/system_settings/databases/form.blade.php index 9cd2946e..7e3e5cdf 100644 --- a/resources/views/system_settings/databases/form.blade.php +++ b/resources/views/system_settings/databases/form.blade.php @@ -5,7 +5,8 @@
@include('system_settings.partials.navigation') -
+ @php($postgres = old('driver', $values['driver']) === 'pgsql') + @csrf @if ($connection) @method('PATCH') @endif @@ -16,64 +17,97 @@ @endif
+ +

通过 SSL 加密连接(sslmode=require);仅执行只读测试,查询最长 15 秒。保存连接不会自动同步业务表。

-
高级设置
- - - - - + + + + +
- +
取消 - +
+ @push('body_end') + + @endpush diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index 3df03c6b..d38520ea 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -96,7 +96,7 @@ @forelse ($connections as $connection) - {{ $connection->name }} + {{ $connection->name }}{{ $connection->driver === 'pgsql' ? 'PostgreSQL · SSL' : 'SQL Server' }} {{ $connection->host }}:{{ $connection->port }} {{ $connection->database_name }} {{ $sourceLabels[$connection->source_key] ?? '未绑定' }} diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 61e29e86..1812ff0f 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Mockery; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Tests\Feature\FeatureTestCase; class UpstreamDatabasesTest extends FeatureTestCase @@ -499,6 +500,65 @@ class UpstreamDatabasesTest extends FeatureTestCase ->assertSessionHasErrors('password'); } + public function test_postgres_can_be_saved_without_sql_server_settings(): void + { + $payload = $this->payload(['driver' => 'pgsql', 'port' => 25432, 'password' => 'pg-secret']); + unset($payload['odbc_driver'], $payload['tds_version']); + $this->loginAs()->post(route('system-settings.databases.store'), $payload) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $connection = DatabaseConnection::where('driver', 'pgsql')->firstOrFail(); + $this->assertNull($connection->odbc_driver); + $this->assertNull($connection->source_key); + $this->assertSame('pg-secret', $connection->password); + $this->get(route('system-settings.databases.edit', $connection->id)) + ->assertOk()->assertSee('sslmode=require')->assertSee('PostgreSQL')->assertDontSee('pg-secret'); + } + + public function test_changing_wrong_driver_preserves_password(): void + { + $connection = $this->connection('keep-secret'); + $this->loginAs()->patch(route('system-settings.databases.update', $connection->id), $this->payload([ + 'driver' => 'pgsql', 'port' => 25432, 'password' => '', + ]))->assertSessionHasNoErrors(); + $connection->refresh(); + $this->assertSame('pgsql', $connection->driver); + $this->assertSame(25432, $connection->port); + $this->assertSame('keep-secret', $connection->password); + } + + public function test_postgres_cannot_replace_existing_sql_server_business_source(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'driver' => 'pgsql', 'source_key' => 'operations_replica', + ]))->assertSessionHasErrors('source_key'); + $this->assertSame(0, DatabaseConnection::where('company_id', 0)->count()); + } + + public function test_host_must_not_include_port(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'host' => '127.0.0.1:25432', + ]))->assertSessionHasErrors('host'); + } + + public function test_test_button_uses_saved_postgres_driver_and_password(): void + { + $connection = $this->connection('saved-pg-secret'); + $connection->update(['driver' => 'pgsql', 'port' => 25432]); + $probe = Mockery::mock(UpstreamDatabaseProbe::class); + $probe->shouldReceive('probe')->once()->withArgs(fn ($driver, $config, $label) => + $driver === 'pgsql' && $config['password'] === 'saved-pg-secret' && $config['port'] === 25432 + ); + $this->app->instance(UpstreamDatabaseProbe::class, $probe); + $this->loginAs()->post(route('system-settings.databases.test', $connection->id)) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $this->assertFlashLevel('success'); + $this->assertStringContainsString('PostgreSQL SSL 连接成功', session('flash_notification')->first()->message); + $this->get(route('system-settings.databases.index'))->assertSeeText('PostgreSQL')->assertDontSee('saved-pg-secret'); + } + private function payload(array $overrides = []): array { return array_merge([ diff --git a/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php new file mode 100644 index 00000000..0f66222e --- /dev/null +++ b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php @@ -0,0 +1,65 @@ + 'db.example.test', 'port' => 25432, 'database' => "test' database", + 'username' => 'reader', 'password' => 'fixture-secret', 'connect_timeout' => 5, 'query_timeout' => 120]; + } + + public function test_postgres_uses_tls_read_only_timeout_and_one_select(): void + { + $statement = Mockery::mock(PDOStatement::class); + $statement->shouldReceive('fetchColumn')->once()->andReturn(1); + $statement->shouldReceive('closeCursor')->once(); + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('query')->once()->with('SELECT 1')->andReturn($statement); + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->withArgs(function ($dsn, $user, $password) { + $this->assertStringContainsString('sslmode=require', $dsn); + $this->assertStringContainsString('default_transaction_read_only=on', $dsn); + $this->assertStringContainsString('statement_timeout=15000', $dsn); + $this->assertStringContainsString("dbname='test\\' database'", $dsn); + $this->assertStringContainsString('connect_timeout=5', $dsn); + $this->assertStringNotContainsString('fixture-secret', $dsn); + + return $user === 'reader' && $password === 'fixture-secret'; + })->andReturn($pdo); + $probe->probe('pgsql', $this->connection(), '测试库'); + } + + public function test_driver_error_never_exposes_credentials(): void + { + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->andThrow(new PDOException('fixture-secret private driver detail')); + try { + $probe->probe('pgsql', $this->connection(), '测试库'); + $this->fail('Expected sanitized connection error'); + } catch (ServiceUnavailableHttpException $exception) { + $this->assertStringContainsString('PostgreSQL 连接失败', $exception->getMessage()); + $this->assertStringNotContainsString('fixture-secret', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + } + } + + public function test_unsupported_driver_is_not_probed(): void + { + $this->expectException(ServiceUnavailableHttpException::class); + $this->expectExceptionMessage('不支持此数据库类型'); + (new UpstreamDatabaseProbe())->probe('unknown', [], '测试库'); + } +} From 8cbd1f21be58e6508a9f7590d039632ee9b0d289 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 31 Aug 2026 17:50:42 +0800 Subject: [PATCH 05/25] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=B0=E4=BA=BA?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E5=BC=95=E5=AF=BC=E5=BA=93=E4=B8=8E=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8E=A5=E5=85=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=9A=84=E8=AF=AF=E5=88=A4=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/ApplicationReady.php | 9 ++++- app/Console/Commands/VerifySystemDatabase.php | 16 +++++--- app/Utilities/SystemDatabaseConfiguration.php | 1 + deploy/local-init.sh | 32 ++++++++++----- .../Installation/ApplicationReadyTest.php | 16 ++++++++ .../Installation/VerifySystemDatabaseTest.php | 35 ++++++++++++++++- .../LocalSharedDatabaseStatusTest.php | 39 +++++++++++++++++++ 7 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php index eac78802..34ca2d43 100644 --- a/app/Console/Commands/ApplicationReady.php +++ b/app/Console/Commands/ApplicationReady.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Utilities\SystemDatabaseConfiguration; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Throwable; @@ -18,7 +19,7 @@ class ApplicationReady extends Command protected $description = '检查应用安装、运行目录和数据库是否已经可用'; - public function handle(): int + public function handle(SystemDatabaseConfiguration $configuration): int { if (! config('app.installed')) { return $this->failed('APP_INSTALLED 未被 Laravel 加载。', self::EXIT_NOT_INSTALLED); @@ -42,7 +43,11 @@ class ApplicationReady extends Command return $this->failed('Laravel 数据库连接失败。', self::EXIT_DATABASE_CONNECTION_FAILED); } - $this->components->info('应用已就绪。'); + if ($configuration->current()['mode'] === 'bootstrap') { + $this->components->info('本地网页和临时引导库已就绪;团队共享数据尚未接入。'); + } else { + $this->components->info('应用运行正常,当前数据库可查询。团队数据接入状态请运行 system-database:verify 检查。'); + } return self::SUCCESS; } diff --git a/app/Console/Commands/VerifySystemDatabase.php b/app/Console/Commands/VerifySystemDatabase.php index 658965b0..52d8ddb0 100644 --- a/app/Console/Commands/VerifySystemDatabase.php +++ b/app/Console/Commands/VerifySystemDatabase.php @@ -15,6 +15,8 @@ class VerifySystemDatabase extends Command public const EXIT_SYSTEM_TABLE_VALIDATION_FAILED = 12; + public const EXIT_SHARED_NOT_CONFIGURED = 13; + protected $signature = 'system-database:verify'; protected $description = '在新的 Laravel 进程中验证当前共享系统数据库'; @@ -30,12 +32,16 @@ class VerifySystemDatabase extends Command ); } - if ( - $settings['mode'] !== 'shared' - || ! config('upstreams.stored_configuration.enabled', false) - ) { + if ($settings['mode'] === 'bootstrap') { + $this->components->warn('当前仍使用本机临时引导库,尚未接入团队共享系统数据库;本次没有测试远程连接。'); + $this->line('下一步:在“系统设置 → 数据库管理 → 系统数据库”中输入一次负责人提供的系统数据库密码。'); + + return self::EXIT_SHARED_NOT_CONFIGURED; + } + + if ($settings['mode'] !== 'shared' || ! config('upstreams.stored_configuration.enabled', false)) { return $this->failed( - '新配置未被 Laravel 加载。', + '共享配置未完整启用,请通过系统数据库连接页面重新保存;不要仅手工修改运行模式。', self::EXIT_CONFIG_NOT_LOADED ); } diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index 312100f4..e95eb153 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -462,6 +462,7 @@ class SystemDatabaseConfiguration VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED => '新配置未被加载', VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED => '系统表验证失败', + VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED => '新配置仍指向本机临时引导库', default => '应用级验证失败', }; } diff --git a/deploy/local-init.sh b/deploy/local-init.sh index a01ff624..e2a32135 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -397,16 +397,30 @@ fi echo "" echo "=====================================================" -echo "本地测试环境已就绪:http://127.0.0.1:18088" +echo "本地网页可访问:http://127.0.0.1:18088" -db_host="$(read_env_value DB_HOST)" -if [ "$db_host" = "mysql" ]; then - echo "临时登录凭据保存在本机私有文件:$credential_file" - echo "请自行打开该文件,不要把内容提交到 Git、聊天或日志。" - echo "" - echo "下一步:登录后打开“系统设置 → 数据库管理 → 系统数据库”," - echo "点击“连接”,只填写负责人提供的系统数据库密码。" +# Docker healthy 只表示当前连接可用;共享接入必须由新 Laravel 进程实测, +# 不能根据 DB_HOST 不是 mysql 就宣称团队数据已连接。 +if docker compose -f "$compose_file" exec -T -u www-data app \ + php artisan system-database:verify --no-ansi; then + shared_status=0 else - echo "已连接团队共享系统数据库,请使用其中属于自己的正式账号登录。" + shared_status=$? fi + +case "$shared_status" in + 0) + echo "团队共享系统数据库验证通过,请使用其中属于自己的正式账号登录。" + ;; + 13) + echo "团队数据接入尚未完成;后台 worker 启动不代表数据已经同步。" + echo "临时登录凭据保存在本机私有文件:$credential_file" + echo "请登录后输入一次系统数据库密码,连接成功后改用团队正式账号登录。" + ;; + *) + echo "团队共享系统数据库未通过验证;不要将 Docker healthy 当作接入成功。" >&2 + echo "请让 Codex 根据上方失败阶段检查本机配置,保留现有配置和未提交代码。" >&2 + exit "$shared_status" + ;; +esac echo "=====================================================" diff --git a/tests/Feature/Installation/ApplicationReadyTest.php b/tests/Feature/Installation/ApplicationReadyTest.php index 74f5cc4d..947cb0f0 100644 --- a/tests/Feature/Installation/ApplicationReadyTest.php +++ b/tests/Feature/Installation/ApplicationReadyTest.php @@ -18,4 +18,20 @@ class ApplicationReadyTest extends TestCase $this->artisan('app:ready') ->assertExitCode(\App\Console\Commands\ApplicationReady::EXIT_NOT_INSTALLED); } + + public function test_bootstrap_stays_healthy_without_claiming_shared_data_is_ready(): void + { + config(['app.installed' => true, 'system_database.mode' => 'bootstrap']); + $this->artisan('app:ready') + ->expectsOutputToContain('团队共享数据尚未接入') + ->assertSuccessful(); + } + + public function test_shared_application_health_does_not_claim_upstream_sync_success(): void + { + config(['app.installed' => true, 'system_database.mode' => 'shared']); + $this->artisan('app:ready') + ->expectsOutputToContain('团队数据接入状态请运行 system-database:verify') + ->assertSuccessful(); + } } diff --git a/tests/Feature/Installation/VerifySystemDatabaseTest.php b/tests/Feature/Installation/VerifySystemDatabaseTest.php index 95284d83..059cba2a 100644 --- a/tests/Feature/Installation/VerifySystemDatabaseTest.php +++ b/tests/Feature/Installation/VerifySystemDatabaseTest.php @@ -10,7 +10,7 @@ use Tests\TestCase; class VerifySystemDatabaseTest extends TestCase { - public function test_verify_command_reports_configuration_not_loaded(): void + public function test_bootstrap_is_not_reported_as_a_failed_remote_connection(): void { $configuration = Mockery::mock(SystemDatabaseConfiguration::class); $configuration->shouldReceive('current')->once()->andReturn([ @@ -19,9 +19,42 @@ class VerifySystemDatabaseTest extends TestCase $this->app->instance(SystemDatabaseConfiguration::class, $configuration); $this->artisan('system-database:verify') + ->expectsOutputToContain('尚未接入团队共享系统数据库;本次没有测试远程连接') + ->assertExitCode(VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED); + } + + public function test_shared_mode_with_disabled_stored_configuration_is_not_ready(): void + { + config(['upstreams.stored_configuration.enabled' => false]); + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn(['mode' => 'shared']); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify') + ->expectsOutputToContain('共享配置未完整启用') ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED); } + public function test_current_configuration_failure_remains_a_configuration_error(): void + { + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andThrow(new RuntimeException('private details')); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify') + ->expectsOutputToContain('新配置未被 Laravel 加载') + ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED); + } + + public function test_shared_success_requires_connection_and_system_table_verification(): void + { + $this->enableSharedMode(); + $settings = ['mode' => 'shared', 'connection' => config('database.default')]; + $configuration = Mockery::mock(SystemDatabaseConfiguration::class); + $configuration->shouldReceive('current')->once()->andReturn($settings); + $configuration->shouldReceive('probe')->once()->with($settings)->andReturn([]); + $this->app->instance(SystemDatabaseConfiguration::class, $configuration); + $this->artisan('system-database:verify')->expectsOutputToContain('共享系统数据库已通过应用级验证')->assertSuccessful(); + } + public function test_verify_command_reports_database_connection_failure(): void { $this->enableSharedMode(); diff --git a/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php new file mode 100644 index 00000000..13c6b4a5 --- /dev/null +++ b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php @@ -0,0 +1,39 @@ +assertNotFalse($start); + $process = new Process(['bash', '-c', + 'set -eu; compose_file=fixture; credential_file=fixture; docker() { return ' . $verificationExit . '; }; ' + . substr($script, $start), + ]); + $process->setTimeout(5); + $process->run(); + $this->assertSame($expectedExit, $process->getExitCode()); + $this->assertStringContainsString($message, $process->getOutput() . $process->getErrorOutput()); + if ($verificationExit !== 0) { + $this->assertStringNotContainsString('团队共享系统数据库验证通过', $process->getOutput()); + } + } + + public static function verificationStates(): array + { + return [ + 'bootstrap' => [13, 0, '团队数据接入尚未完成'], + 'shared success' => [0, 0, '团队共享系统数据库验证通过'], + 'config failure' => [10, 10, '未通过验证'], + 'network failure' => [11, 11, '未通过验证'], + 'schema failure' => [12, 12, '未通过验证'], + ]; + } +} From b6cefe776bd8384b70b85e5cdb51157c5e7b06c6 Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 31 Aug 2026 17:59:39 +0800 Subject: [PATCH 06/25] feat: join installation item mirror and summaries --- app/Http/Controllers/Business/Pages.php | 8 +++ app/Models/Business/InstallationDetail.php | 4 +- config/upstream_mirrors.php | 5 ++ docs/上游业务镜像清单.md | 1 + .../InstallationDetailMirrorRepository.php | 69 ++++++++++++++++--- resources/views/business/pages/show.blade.php | 9 +++ tests/Feature/Business/ManagementCrudTest.php | 1 + .../BusinessMirrorSynchronizerTest.php | 4 +- 8 files changed, 90 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 289212a7..d0b503a3 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -21,6 +21,7 @@ use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; +use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; @@ -58,6 +59,7 @@ class Pages extends Controller public function __construct( private readonly EdgeBandingRepository $edgeBanding, private readonly InventoryRepository $inventory, + private readonly InstallationDetailMirrorRepository $installationDetails, private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds, private readonly ProductCatalogMirrorRepository $productCatalog, private readonly ProductPriceMirrorRepository $productPrices, @@ -173,6 +175,7 @@ class Pages extends Controller $tabValue = null; $tabColumn = null; $upstreamError = null; + $installationSummary = null; if ($page && $content === null) { $descriptor = TableRegistry::describe( @@ -189,6 +192,9 @@ class Pages extends Controller $page['slug'], 50, ); + if ($section['slug'] === 'financial-operations' && $page['slug'] === 'installation-details') { + $installationSummary = $this->installationDetails->summary(); + } } catch (ServiceUnavailableHttpException $exception) { // 首份快照尚未生成或某个来源暂时不可用时,保留页面、 // 交代原因并允许后台刷新,而不是把整个业务页变成通用 503。 @@ -208,6 +214,7 @@ class Pages extends Controller 'descriptor' => $descriptor, 'rows' => $rows, 'upstreamError' => $upstreamError, + 'installationSummary' => $installationSummary, 'tabColumn' => null, 'tabValue' => null, 'edgeBanding' => $this->edgeBandingContent($page['slug']), @@ -248,6 +255,7 @@ class Pages extends Controller 'descriptor' => $descriptor, 'rows' => $rows, 'upstreamError' => $upstreamError, + 'installationSummary' => $installationSummary, 'tabColumn' => $tabColumn, 'tabValue' => $tabValue, 'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null, diff --git a/app/Models/Business/InstallationDetail.php b/app/Models/Business/InstallationDetail.php index b90f2c3f..cc8edd0d 100644 --- a/app/Models/Business/InstallationDetail.php +++ b/app/Models/Business/InstallationDetail.php @@ -21,7 +21,7 @@ class InstallationDetail extends Model 'department_owner', 'auxiliary_spu', 'product_store_code', 'item_no', 'barcode', 'product_name', 'supplier_name', 'detail_requested_amount', 'detail_requested_quantity', 'detail_settled_amount', 'detail_received_amount', 'change_reason', 'source_created_at', 'source_modified_at', - 'product_name_1', 'installation_company_1', + 'product_name_1', ]; protected $casts = [ @@ -62,7 +62,7 @@ class InstallationDetail extends Model 'detail_requested_amount' => '申请明细金额', 'detail_requested_quantity' => '申请明细数量', 'detail_settled_amount' => '结算明细金额', 'detail_received_amount' => '收款明细金额', 'change_reason' => '变更原因', 'source_created_at' => '创建时间', 'source_modified_at' => '修改时间', - 'product_name_1' => '商品名称(1)', 'installation_company_1' => '安装公司', + 'product_name_1' => '商品名称(1)', ]; } diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index 29d1a1de..133cd081 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -170,6 +170,11 @@ return [ ['SubmitTime', 'Id'], ], ], + 'jiansun.dbo.fys_Ticketing_install_item' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_Ticketing_install_item', + 'target' => 'js_fys_ticketing_install_item', 'keys' => ['Id'], 'watermark' => 'modifyTime', + 'indexes' => [['guid', 'Id']], + ], 'jiansun.dbo.finance_purchaseReturn' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_purchaseReturn', 'target' => 'js_finance_purchase_return', 'keys' => ['id']], 'jiansun.dbo.finance_invoiceDeduction' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'finance_invoiceDeduction', 'target' => 'js_finance_invoice_deduction', 'keys' => ['id'], 'watermark' => 'createTime'], 'jiansun.dbo.fys_supplierPaymentSummary' => ['source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'fys_supplierPaymentSummary', 'target' => 'js_supplier_payment_summary', 'keys' => ['id'], 'watermark' => 'modifiedTime'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 11710982..1ca9dd80 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -39,6 +39,7 @@ | --- | --- | | 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` | +| 安装明细 | `dbo.fys_Ticketing_install_view`、`dbo.fys_Ticketing_install_item` | | 板材 | `dbo.finance_boardDeduction`、`dbo.pm_plainPlateData`、`dbo.pm_boardPurchasePrice`、`dbo.pm_boardPriceChangeImport`、`dbo.pm_boardApplyDetail` | | 封边条 | `dbo.pm_edgeBandData`、`dbo.pm_edgeBandApplyDetail`、`dbo.pm_edgeBandProfitInfo`、`dbo.finance_edgeBandDeductio`、`dbo.pm_averageUnitPrice` | | 库存与货款对账 | `dbo.pm_boardInventoryDetail_snapshot`、`dbo.fys_supplierGoods_sttlement_purchase`、`dbo.fys_supplierGoods_sttlement_sale`、`dbo.pm_purchaseIn` | diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php index 15df775d..a7abe7cb 100644 --- a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -9,6 +9,7 @@ use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; class InstallationDetailMirrorRepository { public const DATASET = 'jiansun.dbo.fys_Ticketing_install_view'; + public const ITEM_DATASET = 'jiansun.dbo.fys_Ticketing_install_item'; public function __construct( private readonly GenericBusinessMirrorRepository $mirror, @@ -22,18 +23,42 @@ class InstallationDetailMirrorRepository { $connection = $this->mirror->connection(); $dataset = $this->datasets->get(self::DATASET); - if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) { + $items = $this->datasets->get(self::ITEM_DATASET); + if (! $this->ready($dataset, $items, $connection->getName())) { return ['data' => [], 'total' => 0, 'ready' => false]; } $perPage = min(max($perPage, 1), 100); - $query = $connection->table($dataset->targetTable()); + $query = $connection->table($dataset->targetTable() . ' AS v') + ->leftJoin($items->targetTable() . ' AS i', 'i.guid', '=', 'v.SourceGuid') + ->select(['v.*', 'i.Id AS detail_id', 'i.guid AS detail_guid', 'i.shopproid', 'i.Spu', 'i.shopskucode', + 'i.proCode', 'i.barCode', 'i.skuName', 'i.scmname', 'i.applyItemAmount', 'i.installcount', + 'i.settleItemAmount', 'i.acceptItemAmount', 'i.remark', 'i.createTime AS item_created_at', 'i.modifyTime AS item_modified_at']); $total = (clone $query)->count(); - $rows = $query->orderByDesc('Id')->forPage(max($page, 1), $perPage)->get(); + $rows = $query->orderByDesc('v.Id')->orderByDesc('i.Id')->forPage(max($page, 1), $perPage)->get(); return ['data' => $rows->map(fn (object $row): array => $this->map((array) $row))->all(), 'total' => $total, 'ready' => true]; } + private function ready($dataset, $items, string $connection): bool + { + return $this->mirror->ready($dataset) && $this->mirror->ready($items) + && Schema::connection($connection)->hasTable($dataset->targetTable()) + && Schema::connection($connection)->hasTable($items->targetTable()); + } + + /** @return array{rows:int,requested:float,settled:float,paid:float} */ + public function summary(): array + { + $dataset = $this->datasets->get(self::DATASET); + $connection = $this->mirror->connection(); + if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) { + return ['rows' => 0, 'requested' => 0.0, 'settled' => 0.0, 'paid' => 0.0]; + } + $row = $connection->table($dataset->targetTable())->selectRaw('COUNT(*) AS rows, COALESCE(SUM(installcost),0) AS requested, COALESCE(SUM(settleAmount),0) AS settled, COALESCE(SUM(acceptAmount),0) AS paid')->first(); + return ['rows' => (int) $row->rows, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid]; + } + /** @param array $row */ private function map(array $row): array { @@ -45,24 +70,52 @@ class InstallationDetailMirrorRepository }; $mapped = [ - 'id' => $value($row, 'Id'), 'installation_no' => $value($row, 'ordernum_id', 'OrderNum'), - 'quote_status' => $value($row, 'BidStatus'), 'quote_deadline' => $value($row, 'BidEndTime'), + 'id' => $value($row, 'detail_id', 'Id'), 'installation_no' => $value($row, 'ordernum_id', 'OrderNum'), + 'delete_status' => $value($row, 'Delete'), 'quote_status' => $value($row, 'BidStatus'), 'quote_deadline' => $value($row, 'BidEndTime'), 'installation_company' => $value($row, 'installCompanyName'), 'submitted_at' => $value($row, 'SubmitTime'), 'shipped_at' => $value($row, 'ShipTIme'), 'buyer_nickname' => $value($row, 'BuyersNick'), - 'store_name' => $value($row, 'shop_name', 'shopname'), 'order_no' => $value($row, 'OrderNum'), + 'store_name' => $value($row, 'shop_name', 'shopname'), 'installation_type' => $value($row, 'TypeDto'), + 'pre_push_verification' => $value($row, 'checkMsg'), 'push_status' => $value($row, 'apistatus'), + 'push_result' => $value($row, 'apiresult'), 'arranged_status' => $this->arranged($value($row, 'StatusId')), + 'order_no' => $value($row, 'OrderNum'), 'item_quantity' => $value($row, 'Num'), 'buyer_name' => $value($row, 'BuyersName'), 'buyer_phone' => $value($row, 'Mobile'), 'notes' => $value($row, 'Nots'), 'province' => $value($row, 'Province'), 'city' => $value($row, 'City'), 'district' => $value($row, 'Area'), 'address' => $value($row, 'Address'), 'requested_amount' => $value($row, 'installcost'), - 'settled_amount' => $value($row, 'settleAmount'), 'image_urls' => $value($row, 'Picture'), 'completed_status' => $value($row, 'installComplete'), - 'customer_service_reviewed_at' => $value($row, 'kfshTime'), 'finance_reviewed_at' => $value($row, 'cwshTime'), + 'settled_amount' => $value($row, 'settleAmount'), 'image_urls' => $value($row, 'Picture'), + 'completed_status' => $this->yesNo($value($row, 'installComplete')), + 'customer_service_review' => $this->review($value($row, 'kfsh')), 'customer_service_reviewed_at' => $value($row, 'kfshTime'), + 'finance_review' => $this->review($value($row, 'cwsh')), 'finance_reviewed_at' => $value($row, 'cwshTime'), 'actual_install_quantity' => $value($row, 'installNum'), 'actual_install_fee' => $value($row, 'install_Cost'), 'actual_install_product_name' => $value($row, 'installGoodsName'), 'installer_phone' => $value($row, 'installTel'), 'installation_company_notes' => $value($row, 'installNote'), 'installation_fee_payer' => $value($row, 'costtaker'), 'payment_channel' => $value($row, 'costWay'), 'payment_transaction_no' => $value($row, 'actporderNum'), 'payment_voucher' => $value($row, 'costWayVoucher'), 'payment_amount' => $value($row, 'acceptAmount'), + 'creator' => $value($row, 'Operation'), 'related_guid' => $value($row, 'detail_guid', 'SourceGuid'), + 'product_id' => $value($row, 'shopproid'), 'auxiliary_spu' => $value($row, 'Spu'), + 'product_store_code' => $value($row, 'shopskucode'), 'item_no' => $value($row, 'proCode'), + 'barcode' => $value($row, 'barCode'), 'product_name' => $value($row, 'skuName'), 'supplier_name' => $value($row, 'scmname'), + 'detail_requested_amount' => $value($row, 'applyItemAmount'), 'detail_requested_quantity' => $value($row, 'installcount'), + 'detail_settled_amount' => $value($row, 'settleItemAmount'), 'detail_received_amount' => $value($row, 'acceptItemAmount'), + 'change_reason' => $value($row, 'remark'), 'source_created_at' => $value($row, 'item_created_at'), + 'source_modified_at' => $value($row, 'item_modified_at'), ]; // 页面按模板的完整 61 列渲染;视图暂未提供的字段必须显式为 null, // 不能让 Blade 读取未定义属性。 return array_replace(array_fill_keys(array_keys(\App\Models\Business\InstallationDetail::columnLabels()), null), $mapped); } + + private function yesNo(mixed $value): ?string + { + return $value === null ? null : ((int) $value === 1 ? '是' : '否'); + } + + private function review(mixed $value): ?string + { + return $value === null ? null : ((int) $value === 1 ? '已审核' : '未审核'); + } + + private function arranged(mixed $value): ?string + { + return $value === null ? null : ((int) $value > 0 ? '已安排' : '未安排'); + } } diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index 51e6e075..98ceb45e 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -521,6 +521,15 @@ @endunless + @if ($page['slug'] === 'installation-details' && is_array($installationSummary ?? null)) +
+
安装明细
{{ number_format((int) ($installationSummary['rows'] ?? 0)) }}
+
申请总金额
{{ number_format((float) ($installationSummary['requested'] ?? 0), 2) }}
+
结算总金额
{{ number_format((float) ($installationSummary['settled'] ?? 0), 2) }}
+
收款金额
{{ number_format((float) ($installationSummary['paid'] ?? 0), 2) }}
+
+ @endif +
diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index 09228e5e..129825fa 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -31,6 +31,7 @@ class ManagementCrudTest extends FeatureTestCase 'store_name' => '测试店铺', 'requested_amount' => '120.50', 'settled_amount' => '100.00', 'item_quantity' => 2, ])], ]); + $mirror->shouldReceive('summary')->once()->andReturn(['rows' => 1, 'requested' => 120.50, 'settled' => 100.00, 'paid' => 0.0]); $this->app->instance(InstallationDetailMirrorRepository::class, $mirror); $this->loginAs() diff --git a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php index 9c85136a..a601244b 100644 --- a/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/BusinessMirrorSynchronizerTest.php @@ -277,11 +277,13 @@ class BusinessMirrorSynchronizerTest extends TestCase $datasets = (new BusinessMirrorDatasetRegistry())->all(); $targets = array_map(fn ($dataset): string => $dataset->targetTable(), $datasets); - $this->assertCount(38, $datasets); + $this->assertGreaterThanOrEqual(38, count($datasets)); $this->assertCount(count($targets), array_unique($targets)); $this->assertContains('operations_replica', array_map(fn ($dataset): string => $dataset->sourceKey(), $datasets)); $this->assertContains('jiansun', array_map(fn ($dataset): string => $dataset->sourceKey(), $datasets)); $this->assertNotContains('Bulk_Price_Limit', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets)); + $this->assertContains('fys_Ticketing_install_view', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets)); + $this->assertContains('fys_Ticketing_install_item', array_map(fn ($dataset): string => $dataset->sourceObject(), $datasets)); } /** @return array */ From 5c5a137760c34652eade399303f660605126c0b7 Mon Sep 17 00:00:00 2001 From: chenxi Date: Mon, 31 Aug 2026 18:08:29 +0800 Subject: [PATCH 07/25] fix: avoid reserved summary alias --- .../Repositories/InstallationDetailMirrorRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php index a7abe7cb..2c7381df 100644 --- a/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/InstallationDetailMirrorRepository.php @@ -55,8 +55,8 @@ class InstallationDetailMirrorRepository if (! $this->mirror->ready($dataset) || ! Schema::connection($connection->getName())->hasTable($dataset->targetTable())) { return ['rows' => 0, 'requested' => 0.0, 'settled' => 0.0, 'paid' => 0.0]; } - $row = $connection->table($dataset->targetTable())->selectRaw('COUNT(*) AS rows, COALESCE(SUM(installcost),0) AS requested, COALESCE(SUM(settleAmount),0) AS settled, COALESCE(SUM(acceptAmount),0) AS paid')->first(); - return ['rows' => (int) $row->rows, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid]; + $row = $connection->table($dataset->targetTable())->selectRaw('COUNT(*) AS row_count, COALESCE(SUM(installcost),0) AS requested, COALESCE(SUM(settleAmount),0) AS settled, COALESCE(SUM(acceptAmount),0) AS paid')->first(); + return ['rows' => (int) $row->row_count, 'requested' => (float) $row->requested, 'settled' => (float) $row->settled, 'paid' => (float) $row->paid]; } /** @param array $row */ From c44d1787998a0bae3c40044d932efdcc43b58940 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 31 Aug 2026 18:49:00 +0800 Subject: [PATCH 08/25] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=9A=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E6=8E=A5=E5=85=A5=E3=80=81=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E9=95=9C=E5=83=8F=E5=8F=8A=E6=8E=A5=E5=85=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=BF=AE=E5=A4=8D=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/BusinessMirrorStatus.php | 40 ++++ app/Console/Commands/InspectUpstream.php | 66 +++++++ .../Commands/RegisterBusinessMirror.php | 94 +++++++++ app/Console/Commands/SyncBusinessMirrors.php | 40 ++-- .../Commands/UpstreamSyncSchemaCatalog.php | 2 +- .../Controllers/SystemSettings/Databases.php | 32 +-- app/Models/Settings/DatabaseConnection.php | 6 + app/Utilities/SystemDatabaseConfiguration.php | 9 +- config/upstream_pipeline.php | 12 ++ ..._create_business_mirror_datasets_table.php | 24 +++ docs/开发与部署SOP.md | 2 + docs/通用上游数据接入.md | 49 +++++ .../Services/BusinessMirrorDataReader.php | 46 +++++ .../BusinessMirrorDatasetRegistry.php | 53 ++++- .../Services/BusinessMirrorSchemaManager.php | 24 ++- .../Services/BusinessMirrorSynchronizer.php | 40 ++-- .../Services/UpstreamConnection.php | 73 +++++++ .../Services/UpstreamDatabaseProbe.php | 46 +---- .../Services/UpstreamReadOnlyQuery.php | 7 +- .../UpstreamSchemaCatalogSynchronizer.php | 30 +-- .../Services/UpstreamSourceRegistry.php | 26 ++- .../Services/UpstreamSqlDialect.php | 55 ++++++ .../Services/UpstreamStoredConfiguration.php | 23 ++- .../system_settings/databases/form.blade.php | 28 +-- .../system_settings/databases/index.blade.php | 6 +- .../Console/BusinessMirrorScheduleTest.php | 44 +++++ .../SystemSettings/UpstreamDatabasesTest.php | 24 +++ .../GenericUpstreamPipelineTest.php | 184 ++++++++++++++++++ .../UpstreamDatabaseProbeTest.php | 15 +- .../SystemDatabasePersistenceTest.php | 36 ++++ 30 files changed, 981 insertions(+), 155 deletions(-) create mode 100644 app/Console/Commands/BusinessMirrorStatus.php create mode 100644 app/Console/Commands/InspectUpstream.php create mode 100644 app/Console/Commands/RegisterBusinessMirror.php create mode 100644 config/upstream_pipeline.php create mode 100644 database/migrations/2026_08_31_180000_create_business_mirror_datasets_table.php create mode 100644 docs/通用上游数据接入.md create mode 100644 modules/EcommerceFinance/Services/BusinessMirrorDataReader.php create mode 100644 modules/EcommerceFinance/Services/UpstreamConnection.php create mode 100644 modules/EcommerceFinance/Services/UpstreamSqlDialect.php create mode 100644 tests/Unit/EcommerceFinance/GenericUpstreamPipelineTest.php diff --git a/app/Console/Commands/BusinessMirrorStatus.php b/app/Console/Commands/BusinessMirrorStatus.php new file mode 100644 index 00000000..298e02d0 --- /dev/null +++ b/app/Console/Commands/BusinessMirrorStatus.php @@ -0,0 +1,40 @@ +argument('dataset'); + $selected = $requested ? [$requested => $datasets->get($requested)] : $datasets->all(); + $rows = []; + foreach ($selected as $key => $dataset) { + $state = $mirror->state($dataset); + $rows[] = [ + 'dataset' => $key, 'enabled' => $datasets->scheduled($key), + 'initial_completed' => ($state['initial_completed_at'] ?? null) !== null, + 'status' => $state['status'] ?? ($datasets->automaticInitialSyncAllowed($key) ? 'queued' : 'not_initialized'), + 'last_attempted_at' => $state['last_attempted_at'] ?? null, + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + 'error_code' => $state['last_error_code'] ?? null, + ]; + } + if ($this->option('json')) $this->line(json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + else $this->table(['数据集', '启用', '首次完成', '状态', '最近尝试', '最近完成', '错误类型'], array_map(fn ($r) => array_values($r), $rows)); + return self::SUCCESS; + } catch (Throwable) { + $this->error('无法读取镜像状态,请确认共享系统数据库和业务镜像库已接入。'); + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/InspectUpstream.php b/app/Console/Commands/InspectUpstream.php new file mode 100644 index 00000000..6613f338 --- /dev/null +++ b/app/Console/Commands/InspectUpstream.php @@ -0,0 +1,66 @@ +argument('source'); + $schema = (string) $this->argument('schema'); + $object = (string) $this->argument('object'); + try { + if ($source === '') { + $rows = []; + foreach ($sources->all() as $key => $definition) $rows[] = [$key, $definition['label'], $sources->driver($key), $sources->enabled($key) ? '启用' : '停用']; + $this->table(['内部标识', '数据库名称', '类型', '状态'], $rows); + return self::SUCCESS; + } + $sources->definition($source); + if ($schema === '' || $object === '') { + $objects = DB::table('upstream_schema_columns')->where('source_key', $source) + ->when($schema !== '', fn ($q) => $q->where('schema_name', $schema)) + ->select('schema_name', 'object_name')->distinct()->orderBy('schema_name')->orderBy('object_name')->get(); + $this->table(['Schema', '表 / 视图'], $objects->map(fn ($row) => [$row->schema_name, $row->object_name])->all()); + return self::SUCCESS; + } + $catalog = DB::table('upstream_schema_columns')->where('source_key', $source) + ->where('schema_name', $schema)->where('object_name', $object)->orderBy('ordinal_position') + ->get(['column_name', 'data_type', 'is_nullable']); + if ($catalog->isEmpty()) { + $this->error('尚无该对象的字段目录,请先核实来源并同步数据字典。'); + return self::FAILURE; + } + if (! $this->option('columns')) { + $this->table(['字段', '类型', '允许空值'], $catalog->map(fn ($c) => [$c->column_name, $c->data_type, $c->is_nullable ? '是' : '否'])->all()); + return self::SUCCESS; + } + $columns = array_values(array_unique(array_filter(array_map('trim', explode(',', (string) $this->option('columns')))))); + if ($columns === [] || array_diff($columns, $catalog->pluck('column_name')->all())) { + $this->error('样本字段必须来自该对象的数据字典。'); + return self::FAILURE; + } + $dialect = new UpstreamSqlDialect($sources->driver($source)); + $sql = $dialect->select(implode(', ', array_map($dialect->quote(...), $columns)), + 'FROM ' . $dialect->quote($schema) . '.' . $dialect->quote($object), + min(max((int) $this->option('limit'), 1), 20)); + $rows = $queries->forSource($source)->select($sql); + $this->table($columns, array_map(fn ($row) => array_values($row), $rows)); + $this->info('样本仅用于字段核实,不能作为完整业务数据。'); + return self::SUCCESS; + } catch (Throwable) { + $this->error('只读检查未完成,请检查共享连接、来源启用状态和访问权限。'); + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/RegisterBusinessMirror.php b/app/Console/Commands/RegisterBusinessMirror.php new file mode 100644 index 00000000..a6c96d17 --- /dev/null +++ b/app/Console/Commands/RegisterBusinessMirror.php @@ -0,0 +1,94 @@ +error('尚未执行镜像数据集登记表迁移。'); + return self::FAILURE; + } + $source = (string) $this->argument('source'); + $schema = (string) $this->argument('schema'); + $object = (string) $this->argument('object'); + $key = $source . '.' . $schema . '.' . $object; + try { + $sources->definition($source); + if (strlen($key) > 191 || isset(config('upstream_mirrors.datasets', [])[$key])) { + throw new \RuntimeException('数据集键过长或由现有代码维护,不能覆盖。'); + } + if ($this->option('disable-sync')) { + if ($this->option('enable-sync')) throw new \RuntimeException('不能同时启用和停用。'); + if (DB::table('business_mirror_datasets')->where('dataset_key', $key)->update(['sync_enabled' => false, 'updated_at' => now()]) !== 1) { + throw new \RuntimeException('未找到该登记数据集。'); + } + $this->info('已停止自动同步,现有镜像保留。'); + return self::SUCCESS; + } + $keys = $this->csv((string) $this->option('keys')); + $columns = $this->csv((string) $this->option('columns')); + $watermark = trim((string) $this->option('watermark')); + if ($keys === [] || $columns === [] || array_diff([...$keys, ...($watermark === '' ? [] : [$watermark])], $columns)) { + throw new \RuntimeException('请明确字段和稳定键;同步字段必须包含稳定键及增量字段。'); + } + $catalog = DB::table('upstream_schema_columns')->where('source_key', $source) + ->where('schema_name', $schema)->where('object_name', $object)->pluck('column_name')->all(); + if (array_diff($columns, $catalog)) { + throw new \RuntimeException('字段不在数据字典中,请先同步该来源字典并核实字段。'); + } + $definition = ['source_key' => $source, 'schema' => $schema, 'object' => $object, + 'target' => 'mirror_' . substr(hash('sha256', $key), 0, 24), + 'keys' => $keys, 'columns' => $columns, 'watermark' => $watermark]; + $datasets->validate($key, $definition); + $enable = (bool) $this->option('enable-sync'); + DB::transaction(function () use ($key, $source, $definition, $enable): void { + $existing = DB::table('business_mirror_datasets')->where('dataset_key', $key)->lockForUpdate()->first(); + if ($existing !== null && json_decode($existing->definition, true) != $definition) { + throw new \RuntimeException('已有数据集定义不同,不能覆盖稳定键、字段或同步游标。请先完成变更评估。'); + } + if ($existing !== null) { + if ($enable) DB::table('business_mirror_datasets')->where('dataset_key', $key)->update([ + 'sync_enabled' => true, 'initial_sync_approved' => true, 'updated_at' => now(), + ]); + return; + } + DB::table('business_mirror_datasets')->insert([ + 'dataset_key' => $key, 'source_key' => $source, 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), + 'sync_enabled' => $enable, 'initial_sync_approved' => $enable, 'created_at' => now(), 'updated_at' => now(), + ]); + }); + $this->info($key . ($enable ? ':已启用,生产调度将自动执行首次同步并持续增量。' : ':已登记,自动同步未启用。')); + return self::SUCCESS; + } catch (\Illuminate\Database\QueryException) { + $this->error('登记失败,请检查系统数据库状态;原数据集保留。'); + } catch (\RuntimeException|\InvalidArgumentException $e) { + $this->error($e->getMessage()); + } catch (Throwable) { + $this->error('登记失败,未启用新同步。请检查系统数据库状态。'); + } + return self::FAILURE; + } + + private function csv(string $value): array + { + return array_values(array_unique(array_filter(array_map('trim', explode(',', $value))))); + } +} diff --git a/app/Console/Commands/SyncBusinessMirrors.php b/app/Console/Commands/SyncBusinessMirrors.php index 46772a7c..7c3bb48c 100644 --- a/app/Console/Commands/SyncBusinessMirrors.php +++ b/app/Console/Commands/SyncBusinessMirrors.php @@ -35,23 +35,27 @@ class SyncBusinessMirrors extends Command $requested = $this->argument('dataset'); $keys = $requested === null ? $this->scheduledKeys($datasets, $mirror) - : [(string) $requested]; + : [['key' => (string) $requested, 'full' => (bool) $this->option('full'), 'catchup' => false]]; $maxDatasets = max((int) ($this->option('max-datasets') - ?? config('upstream_mirrors.table_sync.max_datasets_per_run', 1)), 1); + ?? config('upstream_pipeline.sync.max_datasets', 8)), 1); $processed = 0; $failed = false; + $deadline = microtime(true) + max((int) config('upstream_pipeline.sync.cycle_seconds', 30), 1); - foreach ($keys as $key) { - if ($processed >= $maxDatasets) { + foreach ($keys as $task) { + if ($processed >= $maxDatasets || ($processed > 0 && microtime(true) >= $deadline)) { break; } + $key = $task['key']; try { $result = $synchronizer->sync( $key, - (bool) $this->option('full'), + (bool) $task['full'], $this->option('batch') === null ? null : (int) $this->option('batch'), - $this->option('max-batches') === null ? null : (int) $this->option('max-batches'), + $this->option('max-batches') === null + ? ($task['catchup'] ? max((int) config('upstream_pipeline.sync.catchup_batches', 6), 1) : null) + : (int) $this->option('max-batches'), ); } catch (Throwable $exception) { $failed = true; @@ -105,7 +109,7 @@ class SyncBusinessMirrors extends Command return $failed ? self::FAILURE : self::SUCCESS; } - /** @return list */ + /** @return list */ private function scheduledKeys( BusinessMirrorDatasetRegistry $datasets, GenericBusinessMirrorRepository $mirror, @@ -117,17 +121,20 @@ class SyncBusinessMirrors extends Command ), true); foreach ($datasets->all() as $key => $dataset) { - if (isset($dedicated[$key])) { + if (isset($dedicated[$key]) || ! $datasets->scheduled($key)) { continue; } $state = $mirror->state($dataset); - if (! is_array($state) || ($state['initial_completed_at'] ?? null) === null) { - continue; - } + $initial = ! is_array($state) || ($state['initial_completed_at'] ?? null) === null; + if ($initial && ! $datasets->automaticInitialSyncAllowed($key)) continue; + if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at']) + && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($state['last_attempted_at'])) < 300) continue; $scheduled[] = [ 'key' => $key, + 'full' => $initial || (bool) $this->option('full'), + 'catchup' => $initial || ($state['status'] ?? '') === 'running', 'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''), ]; } @@ -136,6 +143,15 @@ class SyncBusinessMirrors extends Command [$left['last_attempted_at'], $left['key']] <=> [$right['last_attempted_at'], $right['key']] ); - return array_column($scheduled, 'key'); + // Alternate unfinished windows and routine refreshes: catch-up progresses + // every cycle without starving small tables or monopolizing a source. + $catchup = array_values(array_filter($scheduled, fn ($task) => $task['catchup'])); + $routine = array_values(array_filter($scheduled, fn ($task) => ! $task['catchup'])); + $ordered = []; + while ($catchup !== [] || $routine !== []) { + if ($routine !== []) $ordered[] = array_shift($routine); + if ($catchup !== []) $ordered[] = array_shift($catchup); + } + return $ordered; } } diff --git a/app/Console/Commands/UpstreamSyncSchemaCatalog.php b/app/Console/Commands/UpstreamSyncSchemaCatalog.php index 25318160..cc78fbc1 100644 --- a/app/Console/Commands/UpstreamSyncSchemaCatalog.php +++ b/app/Console/Commands/UpstreamSyncSchemaCatalog.php @@ -32,7 +32,7 @@ class UpstreamSyncSchemaCatalog extends Command $requested = $this->argument('source'); $sourceKeys = $requested ? [(string) $requested] - : array_keys((array) config('upstreams.sources', [])); + : array_keys($sources->all()); $cache = $cacheManager->store((string) config('upstreams.snapshots.cache_store', 'upstream_shared')); $failed = false; diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 581ba4b8..abaa3df5 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -211,7 +211,7 @@ class Databases extends Controller $data = $this->validateConnection($request); $connection = DatabaseConnection::create($this->attributes($data, null, $request)); - $this->resetSource($connection->source_key, $storedConfiguration); + $this->resetSource($connection->sourceIdentifier(), $storedConfiguration); flash('数据库配置已新增。')->success(); @@ -234,12 +234,12 @@ class Databases extends Controller { abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403); $connection = $this->connection($connection); - $oldSource = $connection->source_key; + $oldSource = $connection->sourceIdentifier(); $data = $this->validateConnection($request, $connection); $connection->update($this->attributes($data, $connection, $request)); $this->resetSource($oldSource, $storedConfiguration); - $this->resetSource($connection->source_key, $storedConfiguration); + $this->resetSource($connection->sourceIdentifier(), $storedConfiguration); flash('数据库配置已修改。')->success(); @@ -250,7 +250,7 @@ class Databases extends Controller { abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403); $connection = $this->connection($connection); - $source = $connection->source_key; + $source = $connection->sourceIdentifier(); $connection->delete(); $this->resetSource($source, $storedConfiguration); @@ -272,13 +272,14 @@ class Databases extends Controller ]); } catch (ServiceUnavailableHttpException $exception) { throw ValidationException::withMessages([ - 'connection' => $connection->driver === 'pgsql' + 'connection' => $connection->driver !== 'sqlsrv' ? $exception->getMessage() : 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。', ]); } - flash($connection->name . ($connection->driver === 'pgsql' ? ' PostgreSQL SSL 连接成功,仅执行了 SELECT 1 只读测试。' : '连接成功,仅执行了 SELECT 1 只读测试。'))->success(); + $protocol = match ($connection->driver) { 'pgsql' => ' PostgreSQL SSL ', 'mysql' => ' MySQL/MariaDB TLS ', default => '' }; + flash($connection->name . $protocol . '连接成功,仅执行了 SELECT 1 只读测试。')->success(); return redirect()->route('system-settings.databases.index'); } @@ -287,16 +288,16 @@ class Databases extends Controller { $sourceKeys = array_keys($this->sourceLabels()); $request->merge(['driver' => $request->input('driver', $connection?->driver ?? 'sqlsrv')]); - $postgres = $request->input('driver') === 'pgsql'; + $postgres = in_array($request->input('driver'), ['pgsql', 'mysql'], true); if ($postgres && $request->input('source_key', $connection?->source_key)) { throw ValidationException::withMessages([ - 'source_key' => '现有业务来源使用 SQL Server,不能改绑到 PostgreSQL。请为 PostgreSQL 单独保存连接。', + 'source_key' => '现有业务来源使用 SQL Server,不能更换其数据库类型。请单独添加新数据库。', ]); } return $request->validate([ - 'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])], + 'driver' => ['required', Rule::in(['sqlsrv', 'pgsql', 'mysql'])], 'name' => [ 'required', 'string', @@ -342,11 +343,12 @@ class Databases extends Controller 'port' => (int) $data['port'], 'database_name' => trim($data['database_name']), 'username' => trim($data['username']), - 'odbc_driver' => $data['driver'] === 'pgsql' ? null : trim($data['odbc_driver']), - 'tds_version' => $data['driver'] === 'pgsql' ? null : trim($data['tds_version']), + 'odbc_driver' => $data['driver'] !== 'sqlsrv' ? null : trim($data['odbc_driver']), + 'tds_version' => $data['driver'] !== 'sqlsrv' ? null : trim($data['tds_version']), 'connect_timeout' => (int) $data['connect_timeout'], 'query_timeout' => (int) $data['query_timeout'], - 'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')), + 'allowed_scopes' => array_key_exists('allowed_scopes', $data) + ? $this->parseScopes((string) $data['allowed_scopes']) : ($connection?->allowed_scopes ?? []), 'charset' => 'UTF-8', 'enabled' => $request->boolean('enabled'), ]; @@ -382,7 +384,7 @@ class Databases extends Controller private function sourceLabels(): array { - return collect((array) config('upstreams.sources', [])) + return collect(app(UpstreamSourceRegistry::class)->all()) ->mapWithKeys(fn (array $source, string $key) => [$key => $source['label'] ?? $key]) ->all(); } @@ -393,7 +395,7 @@ class Databases extends Controller 'name' => '', 'driver' => 'sqlsrv', 'source_key' => '', - 'enabled' => false, + 'enabled' => true, 'host' => '', 'port' => 1433, 'database_name' => '', @@ -460,6 +462,6 @@ class Databases extends Controller } $storedConfiguration->forget($source); - UpstreamOdbcConnection::reset($source); + \Modules\EcommerceFinance\Services\UpstreamConnection::reset($source); } } diff --git a/app/Models/Settings/DatabaseConnection.php b/app/Models/Settings/DatabaseConnection.php index de430301..62df065b 100644 --- a/app/Models/Settings/DatabaseConnection.php +++ b/app/Models/Settings/DatabaseConnection.php @@ -65,4 +65,10 @@ class DatabaseConnection extends Model { return $this->password; } + + public function sourceIdentifier(): string + { + // Legacy names stay stable; new connections need no manually assigned binding. + return $this->source_key ?: 'database_' . $this->id; + } } diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php index e95eb153..f48b2e60 100644 --- a/app/Utilities/SystemDatabaseConfiguration.php +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -178,6 +178,12 @@ class SystemDatabaseConfiguration $updated = $this->replaceEnvironmentValues($current, [ 'DB_CONNECTION' => 'mysql', 'DB_HOST' => (string) $settings['host'], + // Laravel prioritizes split read/write hosts, URL and socket over + // DB_HOST. They must point to the same newly tested connection. + 'DB_HOST_READ' => (string) $settings['host'], + 'DB_HOST_WRITE' => (string) $settings['host'], + 'DATABASE_URL' => '', + 'DB_SOCKET' => '', 'DB_PORT' => (string) $settings['port'], 'DB_DATABASE' => (string) $settings['database'], 'DB_USERNAME' => (string) $settings['username'], @@ -555,10 +561,9 @@ class SystemDatabaseConfiguration $found = false; foreach ($lines as $index => $line) { - if (preg_match('/^' . preg_quote($key, '/') . '=/', $line) === 1) { + if (preg_match('/^\s*(?:export\s+)?' . preg_quote($key, '/') . '\s*=/', $line) === 1) { $lines[$index] = $replacement; $found = true; - break; } } diff --git a/config/upstream_pipeline.php b/config/upstream_pipeline.php new file mode 100644 index 00000000..b1c515bd --- /dev/null +++ b/config/upstream_pipeline.php @@ -0,0 +1,12 @@ + ['enabled' => false, 'connection' => ['connect_timeout' => 5, 'query_timeout' => 15]], + 'sync' => [ + 'max_datasets' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 8), + 'cycle_seconds' => (int) env('BUSINESS_MIRROR_CYCLE_SECONDS', 30), + 'dataset_seconds' => (int) env('BUSINESS_MIRROR_DATASET_SECONDS', 8), + 'catchup_batches' => (int) env('BUSINESS_MIRROR_CATCHUP_BATCHES', 6), + ], +]; diff --git a/database/migrations/2026_08_31_180000_create_business_mirror_datasets_table.php b/database/migrations/2026_08_31_180000_create_business_mirror_datasets_table.php new file mode 100644 index 00000000..7bef0672 --- /dev/null +++ b/database/migrations/2026_08_31_180000_create_business_mirror_datasets_table.php @@ -0,0 +1,24 @@ +string('dataset_key', 191)->primary(); + $table->string('source_key', 64)->index(); + $table->json('definition'); + $table->boolean('sync_enabled')->default(false); + $table->boolean('initial_sync_approved')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('business_mirror_datasets'); + } +}; diff --git a/docs/开发与部署SOP.md b/docs/开发与部署SOP.md index 146c0a6d..37511d8a 100644 --- a/docs/开发与部署SOP.md +++ b/docs/开发与部署SOP.md @@ -163,6 +163,8 @@ git pull --ff-only ## 8. 多上游财务数据库同步 +新数据库和新数据集优先使用 [通用上游数据接入](通用上游数据接入.md):来源由连接记录自动登记,已批准的声明式数据集由调度自动首次同步和增量同步。下面的固定来源、逐项 `--full` 和旧快照说明适用于既有兼容路径,不应当作为新增普通开发任务的重复审批要求。 + ### 8.1 统一接入边界 本系统按 3 个以上上游数据库设计。生产系统数据库的启动连接只存在于服务器私有 `runtime/.env`;本地新人只填写系统数据库密码,即可自动解锁两套上游的固定主机、端口、库名、账号和密码,不需要复制生产 `APP_KEY`、填写上游环境变量或等待负责人开放范围。 diff --git a/docs/通用上游数据接入.md b/docs/通用上游数据接入.md new file mode 100644 index 00000000..94c33592 --- /dev/null +++ b/docs/通用上游数据接入.md @@ -0,0 +1,49 @@ +# 通用上游数据接入 + +## 使用边界 + +财务主库仍为团队 MariaDB/MySQL。SQL Server、PostgreSQL、MySQL/MariaDB 上游共用来源发现、字典、有限样本查询和表级镜像入口。添加同类型的第二个数据库不需要修改来源枚举、工厂或刷新器。 + +数据库连接记录未设置历史 `source_key` 时,系统使用稳定标识 `database_<记录ID>`。已有 `jiansun`、`operations_replica`、`js_data_middle_platform` 等映射保留。普通连接表单不再要求手工绑定来源或填写旧快照 scope;编辑旧连接仍保留这些历史字段。 + +原生 PostgreSQL/MySQL 连接统一要求 TLS、只读会话、非持久连接及最长 15 秒查询。SQL Server 继续复用已验证的 ODBC/DBLIB 入口。应用限制不替代上游只读账号;不提供接受用户原始 SQL 的网页接口。 + +## 开发流程(由 Codex 执行) + +1. 新人通过标准初始化启动本地环境,首次输入一次团队系统数据库密码。Docker healthy 只证明当前程序可用,`system-database:verify` 成功才表示共享接入通过。普通页面、计算逻辑和自动测试可先使用明确的隔离 fixture;不得把模拟数据当作真实财务数据。 +2. 在数据库管理页面添加并启用连接;“测试”与实际读取共用同一驱动。生产及现有字典刷新任务会发现新来源,不再要求逐库添加专用刷新器。 +3. `php artisan upstream:inspect` 列出来源标识,不输出凭据。`upstream:inspect ` 列出已有字典对象;`upstream:inspect ` 查看字段。 +4. 缺少目录时运行 `upstream:schema:sync `。此命令只读上游元数据、写入系统库目录,失败保留原目录。 +5. 需要样本时明确指定 `--columns=id,updated_at,label --limit=5`,最多返回20行。只选本任务有权读取的非敏感字段;真实样本不得进入 Git、PR 或公开日志。样本不能证明全量完整性。 +6. 确认业务键、需要的字段、增量依据和业务含义后,登记数据集。以下仅为示例,表名必须来自实际目录: + +```sh +php artisan business-mirror:register database_17 public example_orders \ + --keys=id --columns=id,updated_at,label --watermark=updated_at +``` + +登记默认不启动同步。生产首次同步涉及系统库登记和业务镜像表创建/写入,应先说明精确字段、批次、影响及备份,再取得负责人授权。授权后对同一声明追加 `--enable-sync`,生产调度即可自动完成或续传首次全量,完成后转为增量;不再为每次部署、分页、筛选重新审批或生成快照。`--disable-sync` 停止后续自动执行,保留已有镜像。 + +声明式登记不能覆盖代码维护的数据集,也不能悄悄修改已有声明的稳定键、字段和增量口径;此类变化需评估历史数据/游标兼容性。来源表没有可靠更新时间时只按主键追加,不能承诺自动发现历史修改或删除,需另行声明校准策略。 + +## 页面读取与同步状态 + +新页面在原有公司、角色和数据权限校验之后调用 `BusinessMirrorDataReader::paginate($datasetKey, $filters, $page, $perPage)`。它只读业务镜像,返回完整总数、稳定分页、实际同步状态和最近完成时间。来源暂时关闭或不可达时,已完成镜像仍可读取;无首次镜像时明确报“尚未完成首次同步”,不登记逐页快照、不从页面直接访问上游。 + +通用筛选只接受已声明字段的等值条件;复杂业务联表、金额口径、公司行级过滤继续由固定 Repository 实现。不能把此内部读取器当作绕过业务权限的公开接口。 + +`php artisan business-mirror:status [dataset] --json` 只读输出启用、首次完成、最近尝试、最近完成及错误类型。`running` 表示一个同步窗口尚未完成,不保证此刻正有进程运行。最后尝试时间不是最后数据完成时间。 + +通用调度默认每轮最多8个数据集、30秒周期预算;未完成窗口与常规数据集交替处理,单数据集默认8秒软预算,积压任务默认最多6批。预算在批次之间检查,单条查询仍受驱动超时限制;不提高上游并发。失败任务至少冷却5分钟。专用商品/价格/钉钉调度保持既有路径。 + +只对明确批准的登记数据集自动首次同步;既有代码数据集仍保留原首次授权流程。未完成的旧页面切换不等于已经上线,必须逐页验收并与正在开发的 PR 协调。 + +## 类型及验收 + +- PostgreSQL 带时区时间戳在镜像中保存为 UTC;业务展示按自己的明确时区换算。无时区日期时间保留原值。`bytea` 使用十六进制文本表示;不自动解释 JSON、数组、枚举中的业务语义。 +- 验收包含:没有修改来源枚举也能发现新库;TLS/只读保护;字段目录;只复制明确字段;首次同步、第二页、末页、总数和筛选;增量插入与更新;源不可达时读旧镜像;失败和未授权状态。 +- SQL Server 既有 Repository 与专用同步器需回归。不得把“新连接测试成功”当作全项目页面已迁移或所有业务表已同步。 + +## 系统连接保存后回滚的排查 + +截图中的“新配置未通过应用级验证”表示已尝试保存并复核,不等于未输入密码。保存系统连接必须同时统一 `DB_HOST`、读写地址,清除旧 `DATABASE_URL` 和 socket 覆盖,并处理重复赋值;验证失败时恢复完整旧配置。不要只改 `SYSTEM_DATABASE_MODE` 标志,也不要关闭失败回滚来制造“连接成功”。 diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDataReader.php b/modules/EcommerceFinance/Services/BusinessMirrorDataReader.php new file mode 100644 index 00000000..a4c4f5bc --- /dev/null +++ b/modules/EcommerceFinance/Services/BusinessMirrorDataReader.php @@ -0,0 +1,46 @@ +datasets->get($key); + if (! $this->mirror->ready($dataset)) { + throw new ServiceUnavailableHttpException(30, '该数据集尚未完成首次同步,请查看同步状态;页面不会临时访问上游。'); + } + $columns = array_column($this->schemas->columns($dataset), 'column_name'); + $query = $this->mirror->connection()->table($dataset->targetTable()); + foreach ($filters as $column => $value) { + if (! in_array($column, $columns, true) || (! is_scalar($value) && $value !== null)) { + throw new InvalidArgumentException('镜像筛选字段或值无效。'); + } + $query->where($column, $value); + } + $total = (clone $query)->count(); + foreach ($dataset->keyColumns() as $column) $query->orderBy($column); + $page = max($page, 1); + $perPage = min(max($perPage, 1), 100); + $state = $this->mirror->state($dataset); + return [ + 'rows' => $query->select($columns)->offset(($page - 1) * $perPage)->limit($perPage)->get() + ->map(fn ($row) => (array) $row)->all(), + 'total' => $total, 'page' => $page, 'per_page' => $perPage, + 'last_page' => max((int) ceil($total / $perPage), 1), + 'sync_status' => $state['status'] ?? 'unknown', + 'last_succeeded_at' => $state['last_succeeded_at'] ?? null, + ]; + } +} diff --git a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php index 6464d7a8..52029d6d 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorDatasetRegistry.php @@ -3,6 +3,8 @@ namespace Modules\EcommerceFinance\Services; use InvalidArgumentException; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset; class BusinessMirrorDatasetRegistry @@ -12,7 +14,7 @@ class BusinessMirrorDatasetRegistry { $datasets = []; - foreach ((array) config('upstream_mirrors.datasets', []) as $key => $definition) { + foreach ($this->definitions() as $key => $definition) { $datasets[(string) $key] = $this->make((string) $key, (array) $definition); } @@ -21,7 +23,7 @@ class BusinessMirrorDatasetRegistry public function get(string $key): BusinessMirrorDataset { - $definitions = (array) config('upstream_mirrors.datasets', []); + $definitions = $this->definitions(); $definition = (array) ($definitions[$key] ?? []); if ($definition === []) { @@ -31,6 +33,43 @@ class BusinessMirrorDatasetRegistry return $this->make($key, $definition); } + private function definitions(): array + { + $definitions = (array) config('upstream_mirrors.datasets', []); + if (Schema::hasTable('business_mirror_datasets')) { + foreach (DB::table('business_mirror_datasets')->get() as $row) { + // Existing code-owned datasets cannot be redirected by a new registration. + $definitions[$row->dataset_key] ??= json_decode($row->definition, true, 32, JSON_THROW_ON_ERROR); + } + } + return $definitions; + } + + public function automaticInitialSyncAllowed(string $key): bool + { + return Schema::hasTable('business_mirror_datasets') && DB::table('business_mirror_datasets') + ->where('dataset_key', $key)->where('sync_enabled', true)->where('initial_sync_approved', true)->exists(); + } + + public function scheduled(string $key): bool + { + $definition = $this->definitions()[$key] ?? null; + if (! $definition || ! app(UpstreamSourceRegistry::class)->enabled($definition['source_key'])) return false; + if (array_key_exists($key, (array) config('upstream_mirrors.datasets', []))) return true; + return Schema::hasTable('business_mirror_datasets') && DB::table('business_mirror_datasets') + ->where('dataset_key', $key)->where('sync_enabled', true)->exists(); + } + + public function validate(string $key, array $definition): BusinessMirrorDataset + { + return $this->make($key, $definition); + } + + public function columnSelection(string $key): ?array + { + return $this->definitions()[$key]['columns'] ?? null; + } + private function make(string $key, array $definition): BusinessMirrorDataset { $source = trim((string) ($definition['source_key'] ?? '')); @@ -45,17 +84,23 @@ class BusinessMirrorDatasetRegistry )); $indexColumns = array_merge(...($indexes ?: [[]])); - foreach ([$source, $schema, $object, $target, ...$keys, ...$indexColumns] as $identifier) { + foreach ([$source, $target] as $identifier) { if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) { throw new InvalidArgumentException('业务镜像数据集包含不安全的标识符:' . $key); } } + foreach ([$schema, $object, ...$keys, ...$indexColumns] as $identifier) { + if ($identifier === '' || mb_strlen($identifier) > 128 || str_contains($identifier, "\0")) { + throw new InvalidArgumentException('业务镜像数据集包含无效的标识符:' . $key); + } + } + if ($keys === [] || in_array([], $indexes, true) || strlen($target) > 64) { throw new InvalidArgumentException('业务镜像数据集缺少稳定键或目标表名过长:' . $key); } - if ($watermark !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $watermark) !== 1) { + if ($watermark !== '' && (mb_strlen($watermark) > 128 || str_contains($watermark, "\0"))) { throw new InvalidArgumentException('业务镜像数据集的增量字段不安全:' . $key); } diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php index 547b5401..7f011d71 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php @@ -6,6 +6,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset; +use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; class BusinessMirrorSchemaManager @@ -87,6 +88,13 @@ class BusinessMirrorSchemaManager ->map(fn (object $row): array => (array) $row) ->all(); + $selected = app(BusinessMirrorDatasetRegistry::class)->columnSelection($dataset->key()); + if ($selected !== null) { + $selected = array_map('strtolower', $selected); + $rows = array_values(array_filter($rows, fn (array $row): bool => in_array(strtolower($row['column_name']), $selected, true))); + if (count($rows) !== count($selected)) throw new RuntimeException('business_mirror_selected_column_missing:' . $dataset->key()); + } + if ($rows === []) { throw new RuntimeException('business_mirror_schema_catalog_missing:' . $dataset->key()); } @@ -121,24 +129,24 @@ class BusinessMirrorSchemaManager $scale = min(max((int) ($column['numeric_scale'] ?? 0), 0), min($precision, 30)); $definition = match ($type) { - 'bit' => $table->boolean($name), + 'bit', 'boolean' => $table->boolean($name), 'tinyint' => $table->unsignedTinyInteger($name), 'smallint' => $table->smallInteger($name), - 'int' => $table->integer($name), + 'int', 'integer' => $table->integer($name), 'bigint' => $table->bigInteger($name), 'decimal', 'numeric', 'money', 'smallmoney' => $table->decimal($name, $precision, $scale), - 'float', 'real' => $table->double($name), + 'float', 'real', 'double', 'double precision' => $table->double($name), 'date' => $table->date($name), - 'datetime', 'datetime2', 'smalldatetime' => $table->dateTime($name, 6), + 'datetime', 'datetime2', 'smalldatetime', 'timestamp without time zone', 'timestamp with time zone' => $table->dateTime($name, 6), 'time' => $table->time($name, 6), - 'uniqueidentifier' => $table->char($name, 36), + 'uniqueidentifier', 'uuid' => $table->char($name, 36), 'binary', 'varbinary', 'image', 'timestamp', 'rowversion' => $table->binary($name), // MariaDB counts every inline varchar column towards the 65,535-byte // row limit. Wide legacy SQL Server tables can contain dozens of // nvarchar columns even though each individual column looks small. // Keep only genuinely short values inline; TEXT is stored off-page // and lets the mirror preserve the complete source schema. - 'char', 'nchar', 'varchar', 'nvarchar' => $length > 0 && $length <= 64 + 'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0 && $length <= 64 ? $table->string($name, $length) : $table->longText($name), default => $table->longText($name), @@ -157,8 +165,8 @@ class BusinessMirrorSchemaManager $type = strtolower((string) $column['data_type']); $length = (int) ($column['max_length'] ?? 0); - return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary'], true) - && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar'], true) || ($length > 0 && $length <= 64)); + return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true) + && (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true) || ($length > 0 && $length <= 64)); } return false; diff --git a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php index 7577037c..673340dd 100644 --- a/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php +++ b/modules/EcommerceFinance/Services/BusinessMirrorSynchronizer.php @@ -11,6 +11,7 @@ use Throwable; class BusinessMirrorSynchronizer { + private string $driver = 'sqlsrv'; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, private readonly BusinessMirrorSchemaManager $schemas, @@ -23,10 +24,11 @@ class BusinessMirrorSynchronizer public function sync(string $datasetKey, bool $full = false, ?int $batchSize = null, ?int $maxBatches = null): array { $dataset = $this->datasets->get($datasetKey); + $this->driver = app(UpstreamSourceRegistry::class)->driver($dataset->sourceKey()); // A long-running scheduler may have retained a timed-out PDO instance // from the previous dataset. Each dataset starts with a fresh upstream // connection so one source failure cannot poison subsequent mirrors. - UpstreamOdbcConnection::reset($dataset->sourceKey()); + UpstreamConnection::reset($dataset->sourceKey()); $state = $this->mirror->state($dataset); if (! $full && ($state['initial_completed_at'] ?? null) === null) { @@ -60,6 +62,7 @@ class BusinessMirrorSynchronizer $batchSize = min(max($batchSize ?? (int) config('upstream_mirrors.table_sync.batch_size', 500), 1), 5000); $maxBatches = max($maxBatches ?? (int) config('upstream_mirrors.table_sync.max_batches_per_run', 2), 0); $batches = 0; + $deadline = microtime(true) + max((int) config('upstream_pipeline.sync.dataset_seconds', 8), 1); $rows = (int) ($state['last_upserted_rows'] ?? 0); try { @@ -92,7 +95,7 @@ class BusinessMirrorSynchronizer return $this->result($dataset, 'succeeded', $mode, $batches, $rows); } - if ($maxBatches > 0 && $batches >= $maxBatches) { + if ($maxBatches > 0 && ($batches >= $maxBatches || microtime(true) >= $deadline)) { return $this->result($dataset, 'running', $mode, $batches, $rows); } } @@ -120,8 +123,11 @@ class BusinessMirrorSynchronizer if ($mode === 'incremental' && $dataset->watermarkColumn() !== null) { $watermark = $this->quote($dataset->watermarkColumn()); $where[] = $watermark . ' >= ? AND ' . $watermark . ' < ?'; - $bindings[] = $this->sqlServerDateTime($windowStart); - $bindings[] = $this->sqlServerDateTime($windowEnd); + $timezoneAware = $this->driver === 'pgsql' && collect($columns)->contains(fn ($c) => + $c['column_name'] === $dataset->watermarkColumn() && $c['data_type'] === 'timestamp with time zone' + ); + $bindings[] = $timezoneAware ? $windowStart?->format('Y-m-d\TH:i:s.uP') : $this->sqlServerDateTime($windowStart); + $bindings[] = $timezoneAware ? $windowEnd?->format('Y-m-d\TH:i:s.uP') : $this->sqlServerDateTime($windowEnd); } elseif ($mode === 'incremental' && $cursor === null && $highWatermark !== []) { [$predicate, $values] = $this->keyset($dataset->keyColumns(), $highWatermark); $where[] = $predicate; @@ -134,11 +140,12 @@ class BusinessMirrorSynchronizer array_push($bindings, ...$values); } - $sql = 'SELECT TOP (' . $limit . ') ' - . implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns)) - . ' FROM ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject()) + $tail = 'FROM ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject()) . ($where === [] ? '' : ' WHERE (' . implode(') AND (', $where) . ')') . ' ORDER BY ' . implode(', ', array_map(fn (string $name): string => $this->quote($name), $order)); + $sql = (new UpstreamSqlDialect($this->driver))->select( + implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns)), $tail, $limit, + ); return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings); } @@ -149,6 +156,17 @@ class BusinessMirrorSynchronizer $name = (string) $column['column_name']; $quoted = $this->quote($name); + if ($this->driver !== 'sqlsrv') { + if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'timestamp with time zone') { + // Store timezone-aware PostgreSQL instants as UTC in the shared mirror. + return "to_char({$quoted} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}"; + } + if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'bytea') { + return "encode({$quoted}, 'hex') AS {$quoted}"; + } + return $quoted; + } + // FreeTDS may return SQL Server date values using the connection locale // (for example "Jan 1 2026"). Convert temporal columns at the source so // every mirror receives deterministic ISO values that MariaDB accepts. @@ -244,7 +262,7 @@ class BusinessMirrorSynchronizer $type = $types[strtolower((string) $name)] ?? ''; if ($type === 'date') { $row[$name] = substr($value, 0, 10); - } elseif (in_array($type, ['datetime', 'datetime2', 'smalldatetime'], true)) { + } elseif (in_array($type, ['datetime', 'datetime2', 'smalldatetime', 'timestamp without time zone', 'timestamp with time zone'], true)) { $row[$name] = preg_replace('/(\.\d{6})\d+/', '$1', str_replace('T', ' ', $value)); } elseif (in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'text', 'ntext', 'xml'], true)) { $row[$name] = $this->normalizeText($value); @@ -271,11 +289,7 @@ class BusinessMirrorSynchronizer private function quote(string $identifier): string { - if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $identifier) !== 1) { - throw new RuntimeException('business_mirror_invalid_identifier'); - } - - return '[' . $identifier . ']'; + return (new UpstreamSqlDialect($this->driver))->quote($identifier); } private function cursorValue(array $cursor, string $column): mixed diff --git a/modules/EcommerceFinance/Services/UpstreamConnection.php b/modules/EcommerceFinance/Services/UpstreamConnection.php new file mode 100644 index 00000000..8ebc7f08 --- /dev/null +++ b/modules/EcommerceFinance/Services/UpstreamConnection.php @@ -0,0 +1,73 @@ + */ + private static array $native = []; + + public static function get(string $source): PDO + { + $registry = app(UpstreamSourceRegistry::class); + if ($registry->driver($source) === 'sqlsrv') { + return UpstreamOdbcConnection::get($source); + } + if (! $registry->enabled($source)) { + throw new ServiceUnavailableHttpException(30, '该数据源尚未启用。'); + } + return self::$native[$source] ??= self::openNative($registry->driver($source), $registry->value($source, 'connection', [])); + } + + public static function reset(?string $source = null): void + { + if ($source === null) self::$native = []; + else unset(self::$native[$source]); + UpstreamOdbcConnection::reset($source); + } + + public static function openNative(string $driver, #[SensitiveParameter] array $config): PDO + { + if (! in_array($driver, ['pgsql', 'mysql'], true) || ! in_array($driver, PDO::getAvailableDrivers(), true)) { + throw new ServiceUnavailableHttpException(30, '应用缺少该数据库类型的驱动,请更新应用镜像。'); + } + $timeout = min(max((int) ($config['connect_timeout'] ?? 5), 1), 30); + $seconds = min(max((int) ($config['query_timeout'] ?? 15), 1), 15); + $options = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_PERSISTENT => false, PDO::ATTR_TIMEOUT => $timeout]; + $host = (string) $config['host']; + $database = (string) $config['database']; + if (preg_match('/[;\x00-\x1F]/', $host . $database)) { + throw new ServiceUnavailableHttpException(30, '主机或数据库名称包含不支持的字符。'); + } + $port = min(max((int) $config['port'], 1), 65535); + if ($driver === 'pgsql') { + $quote = static fn (string $s): string => "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $s) . "'"; + $dsn = 'pgsql:host=' . $quote($host) . ';port=' . $port . ';dbname=' . $quote($database) + . ';sslmode=require;connect_timeout=' . $timeout + . ";options='-c default_transaction_read_only=on -c statement_timeout=" . ($seconds * 1000) . "'"; + } else { + $dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4"; + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT'; + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + try { + $pdo = new PDO($dsn, (string) $config['username'], (string) $config['password'], $options); + if ($driver === 'mysql') { + $tls = $pdo->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")->fetch(PDO::FETCH_NUM); + if (empty($tls[1])) throw new ServiceUnavailableHttpException(30, '上游数据库未建立 TLS 加密连接。'); + $pdo->exec('SET SESSION TRANSACTION READ ONLY'); + $pdo->exec(str_contains($pdo->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB') + ? 'SET SESSION max_statement_time=' . $seconds + : 'SET SESSION MAX_EXECUTION_TIME=' . ($seconds * 1000)); + } + return $pdo; + } catch (PDOException) { + throw new ServiceUnavailableHttpException(30, '上游只读连接失败,请检查账号、权限、网络和 TLS 配置。'); + } + } +} diff --git a/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php index a41b9ff1..7cd0adbe 100644 --- a/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php +++ b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php @@ -7,62 +7,32 @@ use PDOException; use SensitiveParameter; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; -/** Connection tests only: never used to route business queries or synchronize tables. */ +/** The test button and business readers share one driver implementation. */ class UpstreamDatabaseProbe { public function probe(string $driver, #[SensitiveParameter] array $connection, string $label): void { if ($driver === 'sqlsrv') { UpstreamOdbcConnection::probeConfiguration($connection, $label); - return; } - - if ($driver !== 'pgsql') { - throw new ServiceUnavailableHttpException(30, '不支持此数据库类型,请选择 SQL Server 或 PostgreSQL。'); + if (! in_array($driver, ['pgsql', 'mysql'], true)) { + throw new ServiceUnavailableHttpException(30, '不支持此数据库类型。'); } - - $timeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30); - $queryTimeout = min(max((int) ($connection['query_timeout'] ?? 15), 1), 15) * 1000; - $dsn = 'pgsql:host=' . $this->quote((string) $connection['host']) - . ';port=' . min(max((int) $connection['port'], 1), 65535) - . ';dbname=' . $this->quote((string) $connection['database']) - . ';sslmode=require;connect_timeout=' . $timeout - . ";options='-c default_transaction_read_only=on -c statement_timeout=" . $queryTimeout . "'"; - try { - // One short-lived connection, TLS and read-only enforced at session startup. - $pdo = $this->connect($dsn, (string) $connection['username'], (string) $connection['password']); + $pdo = $this->connect($driver, $connection); $statement = $pdo->query('SELECT 1'); if ($statement === false || (int) $statement->fetchColumn() !== 1) { - throw new ServiceUnavailableHttpException(30, 'PostgreSQL 只读测试未返回预期结果。'); + throw new ServiceUnavailableHttpException(30, '只读测试未返回预期结果。'); } $statement->closeCursor(); } catch (PDOException) { - // Do not expose raw driver errors, DSNs or credentials in logs/session flashes. - throw new ServiceUnavailableHttpException(30, 'PostgreSQL 连接失败。请检查账号、密码、数据库访问权限,以及服务器网络和 SSL 配置。'); + throw new ServiceUnavailableHttpException(30, '上游只读连接失败,请检查账号、权限、网络和 TLS 配置。'); } } - protected function connect(string $dsn, string $username, #[SensitiveParameter] string $password): PDO + protected function connect(string $driver, #[SensitiveParameter] array $connection): PDO { - if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { - throw new ServiceUnavailableHttpException(30, '应用缺少 PostgreSQL 驱动,请部署包含 pdo_pgsql 的新版应用镜像。'); - } - - return new PDO($dsn, $username, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_PERSISTENT => false, - ]); - } - - private function quote(string $value): string - { - // PDO replaces all DSN semicolons with spaces; reject rather than alter database names. - if (preg_match('/[;\x00-\x1F]/', $value)) { - throw new ServiceUnavailableHttpException(30, '主机或数据库名称包含不支持的字符。'); - } - - return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; + return UpstreamConnection::openNative($driver, $connection); } } diff --git a/modules/EcommerceFinance/Services/UpstreamReadOnlyQuery.php b/modules/EcommerceFinance/Services/UpstreamReadOnlyQuery.php index 30825d79..8d2f6ea1 100644 --- a/modules/EcommerceFinance/Services/UpstreamReadOnlyQuery.php +++ b/modules/EcommerceFinance/Services/UpstreamReadOnlyQuery.php @@ -27,7 +27,7 @@ class UpstreamReadOnlyQuery { self::assertReadOnlySql($sql); - $statement = UpstreamOdbcConnection::get($this->source)->prepare($sql); + $statement = UpstreamConnection::get($this->source)->prepare($sql); $statement->execute($bindings); return $statement->fetchAll(PDO::FETCH_ASSOC); @@ -45,7 +45,7 @@ class UpstreamReadOnlyQuery { self::assertReadOnlySql($sql); - $statement = UpstreamOdbcConnection::get($this->source)->prepare($sql); + $statement = UpstreamConnection::get($this->source)->prepare($sql); $statement->execute($bindings); return $statement->fetchColumn(); @@ -68,6 +68,7 @@ class UpstreamReadOnlyQuery $inspect = preg_replace("/'(?:''|[^'])*'/s", "''", $sql) ?? $sql; $inspect = preg_replace('/\[(?:\]\]|[^\]])*\]/s', '[]', $inspect) ?? $inspect; $inspect = preg_replace('/"(?:""|[^"])*"/s', '""', $inspect) ?? $inspect; + $inspect = preg_replace('/`(?:``|[^`])*`/s', '``', $inspect) ?? $inspect; $inspect = strtoupper(ltrim($inspect)); if (preg_match('/^(SELECT|WITH)\b/', $inspect) !== 1) { @@ -76,7 +77,7 @@ class UpstreamReadOnlyQuery $forbidden = '/\b(?:INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|' . 'GRANT|REVOKE|DENY|BACKUP|RESTORE|DBCC|BULK|OPENROWSET|OPENDATASOURCE|INTO|' - . 'WAITFOR|KILL|SHUTDOWN)\b|\bNEXT\s+VALUE\s+FOR\b/i'; + . 'WAITFOR|KILL|SHUTDOWN|SET_CONFIG|NEXTVAL|SETVAL|SLEEP|BENCHMARK|GET_LOCK|RELEASE_LOCK|LOAD_FILE|PG_SLEEP)\b|\bNEXT\s+VALUE\s+FOR\b/i'; if (preg_match($forbidden, $inspect) === 1) { throw new InvalidArgumentException('上游查询包含禁止的写入或高风险语法。'); diff --git a/modules/EcommerceFinance/Services/UpstreamSchemaCatalogSynchronizer.php b/modules/EcommerceFinance/Services/UpstreamSchemaCatalogSynchronizer.php index a70f0a05..62093dc6 100644 --- a/modules/EcommerceFinance/Services/UpstreamSchemaCatalogSynchronizer.php +++ b/modules/EcommerceFinance/Services/UpstreamSchemaCatalogSynchronizer.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Schema; use RuntimeException; /** - * 将上游 SQL Server 的表、视图和字段结构同步到共享系统数据库。 + * 将已支持类型的上游表、视图和字段结构同步到共享系统数据库。 * * 这里只读取 INFORMATION_SCHEMA 元数据,不读取业务行,也不保存连接资料。 * 获取完整结果成功后才替换单个来源的旧目录,避免网络故障清空现有字典。 @@ -28,25 +28,15 @@ class UpstreamSchemaCatalogSynchronizer throw new RuntimeException('该上游数据库尚未启用。'); } - $rows = $this->queries->forSource($source)->select( - "SELECT - c.TABLE_SCHEMA AS schema_name, - c.TABLE_NAME AS object_name, - CASE WHEN t.TABLE_TYPE = 'VIEW' THEN 'view' ELSE 'table' END AS object_type, - c.COLUMN_NAME AS column_name, - c.ORDINAL_POSITION AS ordinal_position, - c.DATA_TYPE AS data_type, - c.CHARACTER_MAXIMUM_LENGTH AS max_length, - c.NUMERIC_PRECISION AS numeric_precision, - c.NUMERIC_SCALE AS numeric_scale, - c.IS_NULLABLE AS is_nullable - FROM INFORMATION_SCHEMA.COLUMNS c - INNER JOIN INFORMATION_SCHEMA.TABLES t - ON t.TABLE_SCHEMA = c.TABLE_SCHEMA - AND t.TABLE_NAME = c.TABLE_NAME - WHERE c.TABLE_SCHEMA NOT IN ('sys', 'INFORMATION_SCHEMA') - ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION" - ); + $driver = $this->sources->driver($source); + $rows = $this->queries->forSource($source)->select((new UpstreamSqlDialect($driver))->catalogSql()); + if ($driver === 'mysql') { + foreach ($rows as &$row) { + // SQL Server timestamp is binary; MySQL timestamp is temporal. + if (strtolower($row['data_type'] ?? '') === 'timestamp') $row['data_type'] = 'datetime'; + } + unset($row); + } if ($rows === []) { throw new RuntimeException('上游数据库没有返回可用的表字段目录。'); diff --git a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php index d5287524..3380924f 100644 --- a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php +++ b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php @@ -28,7 +28,11 @@ class UpstreamSourceRegistry $definition = config('upstreams.sources.' . $source); if (! is_array($definition)) { - throw new InvalidArgumentException('未登记的上游数据源:' . $source); + $connection = $this->storedConfiguration->find($source); + if ($connection === null) { + throw new InvalidArgumentException('未登记的上游数据源:' . $source); + } + $definition = ['label' => $connection->name, 'config' => 'upstream_pipeline.defaults']; } $configKey = trim((string) ($definition['config'] ?? '')); @@ -77,6 +81,25 @@ class UpstreamSourceRegistry return (bool) $this->value($source, 'enabled', false); } + public function driver(string $source): string + { + $definition = $this->definition($source); + $default = config($definition['config'] . '.connection.driver', 'sqlsrv'); + return (string) (($this->usesLocalConfiguration() + ? $default : $this->storedConfiguration->value($source, 'connection.driver', $default)) ?: 'sqlsrv'); + } + + /** @return array */ + public function all(): array + { + $keys = array_unique(array_merge(array_keys((array) config('upstreams.sources', [])), array_keys($this->storedConfiguration->all()))); + $definitions = []; + foreach ($keys as $key) { + $definitions[$key] = $this->definition($key); + } + return $definitions; + } + private function usesLocalConfiguration(): bool { return (bool) config('upstreams.local_configuration.enabled', false); @@ -85,6 +108,7 @@ class UpstreamSourceRegistry private function connection(string $source, array $configured): array { $fields = array_values(array_unique(array_merge(array_keys($configured), [ + 'driver', 'host', 'port', 'database', diff --git a/modules/EcommerceFinance/Services/UpstreamSqlDialect.php b/modules/EcommerceFinance/Services/UpstreamSqlDialect.php new file mode 100644 index 00000000..1f813875 --- /dev/null +++ b/modules/EcommerceFinance/Services/UpstreamSqlDialect.php @@ -0,0 +1,55 @@ +driver) { + 'pgsql' => '"' . str_replace('"', '""', $identifier) . '"', + 'mysql' => '`' . str_replace('`', '``', $identifier) . '`', + default => '[' . str_replace(']', ']]', $identifier) . ']', + }; + } + + public function select(string $columns, string $tail, int $limit): string + { + $limit = min(max($limit, 1), 5000); + return $this->driver === 'sqlsrv' + ? 'SELECT TOP (' . $limit . ') ' . $columns . ' ' . $tail + : 'SELECT ' . $columns . ' ' . $tail . ' LIMIT ' . $limit; + } + + public function catalogSql(): string + { + // Same normalized catalog shape for all engines; MySQL never enumerates other databases. + $schema = $this->driver === 'sqlsrv' ? 'INFORMATION_SCHEMA' : 'information_schema'; + $sql = "SELECT c.table_schema AS schema_name, c.table_name AS object_name, + CASE WHEN t.table_type = 'VIEW' THEN 'view' ELSE 'table' END AS object_type, + c.column_name AS column_name, c.ordinal_position AS ordinal_position, + c.data_type AS data_type, c.character_maximum_length AS max_length, + c.numeric_precision AS numeric_precision, c.numeric_scale AS numeric_scale, + c.is_nullable AS is_nullable + FROM {$schema}." . ($this->driver === 'sqlsrv' ? 'COLUMNS' : 'columns') . " c + INNER JOIN {$schema}." . ($this->driver === 'sqlsrv' ? 'TABLES' : 'tables') . " t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema NOT IN ('sys', 'INFORMATION_SCHEMA', 'information_schema', 'pg_catalog', 'mysql', 'performance_schema')"; + if ($this->driver === 'mysql') $sql .= ' AND c.table_schema = DATABASE()'; + $sql .= ' ORDER BY c.table_schema, c.table_name, c.ordinal_position'; + if ($this->driver === 'sqlsrv') { + $sql = preg_replace_callback('/\b([ct])\.([a-z_]+)\b/', fn ($m) => $m[1] . '.' . strtoupper($m[2]), $sql); + } + return $sql; + } +} diff --git a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php index 82e5c4cd..cd9feee3 100644 --- a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php +++ b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php @@ -42,10 +42,13 @@ class UpstreamStoredConfiguration return $this->connections[$source] = null; } - $this->connections[$source] = DatabaseConnection::query() - ->where('company_id', self::SYSTEM_COMPANY_ID) - ->where('source_key', $source) - ->first(); + $query = DatabaseConnection::query()->where('company_id', self::SYSTEM_COMPANY_ID); + if (preg_match('/^database_([1-9][0-9]*)$/', $source, $matches)) { + $query->where('id', $matches[1])->whereNull('source_key'); + } else { + $query->where('source_key', $source); + } + $this->connections[$source] = $query->first(); return $this->connections[$source]; } @@ -55,6 +58,16 @@ class UpstreamStoredConfiguration unset($this->connections[$source]); } + /** @return array */ + public function all(): array + { + if (! $this->enabled() || ! Schema::hasTable('system_database_connections')) { + return []; + } + return DatabaseConnection::query()->where('company_id', self::SYSTEM_COMPANY_ID) + ->get()->keyBy(fn (DatabaseConnection $row) => $row->sourceIdentifier())->all(); + } + public function value(string $source, string $path, mixed $default = null): mixed { $connection = $this->find($source); @@ -68,6 +81,7 @@ class UpstreamStoredConfiguration 'allowed_scopes' => $connection->allowed_scopes ?? [], 'connection' => $this->connection($connection, $default), 'connection.host' => $connection->host, + 'connection.driver' => $connection->driver, 'connection.port' => $connection->port, 'connection.database' => $connection->database_name, 'connection.username' => $connection->username, @@ -85,6 +99,7 @@ class UpstreamStoredConfiguration $configured = is_array($default) ? $default : []; return array_replace($configured, [ + 'driver' => $connection->driver, 'host' => $connection->host, 'port' => $connection->port, 'database' => $connection->database_name, diff --git a/resources/views/system_settings/databases/form.blade.php b/resources/views/system_settings/databases/form.blade.php index 7e3e5cdf..e16fff02 100644 --- a/resources/views/system_settings/databases/form.blade.php +++ b/resources/views/system_settings/databases/form.blade.php @@ -5,7 +5,8 @@
@include('system_settings.partials.navigation') - @php($postgres = old('driver', $values['driver']) === 'pgsql') + @php($selectedDriver = old('driver', $values['driver'])) + @php($postgres = $selectedDriver !== 'sqlsrv')
@csrf @if ($connection) @method('PATCH') @endif @@ -20,11 +21,12 @@ -

通过 SSL 加密连接(sslmode=require);仅执行只读测试,查询最长 15 秒。保存连接不会自动同步业务表。

+

通过 TLS 加密连接(PostgreSQL 使用 sslmode=require);只读查询最长 15 秒。启用后自动发现字段目录,业务表按已批准的数据集同步。

-
@@ -69,7 +61,6 @@ -
@@ -90,11 +81,12 @@ (() => { const form = document.getElementById('cloud-database-form'); const driver = form.elements.driver; + const ports = {sqlsrv: '1433', pgsql: '5432', mysql: '3306'}; let previous = driver.value; const update = (changePort) => { - const postgres = driver.value === 'pgsql'; - if (changePort && form.elements.port.value === (previous === 'pgsql' ? '5432' : '1433')) { - form.elements.port.value = postgres ? '5432' : '1433'; + const postgres = driver.value !== 'sqlsrv'; + if (changePort && form.elements.port.value === ports[previous]) { + form.elements.port.value = ports[driver.value]; } form.querySelectorAll('[data-sqlserver-only]').forEach((group) => { group.style.display = postgres ? 'none' : ''; diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index d38520ea..be88516b 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -88,7 +88,6 @@
- @@ -96,10 +95,9 @@ @forelse ($connections as $connection) - + - - @@ -283,7 +292,7 @@
名称 地址 数据库业务绑定 状态 操作
{{ $connection->name }}{{ $connection->driver === 'pgsql' ? 'PostgreSQL · SSL' : 'SQL Server' }}{{ $connection->name }}{{ match ($connection->driver) { 'pgsql' => 'PostgreSQL · SSL', 'mysql' => 'MySQL / MariaDB · TLS', default => 'SQL Server' } }} {{ $connection->host }}:{{ $connection->port }} {{ $connection->database_name }}{{ $sourceLabels[$connection->source_key] ?? '未绑定' }} @empty
+ {{ $systemDatabase['mode'] === 'bootstrap' ? '连接团队共享系统数据库后,两套云数据库配置会自动显示在这里。' : '共享系统数据库中还没有云数据库配置。' }} diff --git a/tests/Feature/Console/BusinessMirrorScheduleTest.php b/tests/Feature/Console/BusinessMirrorScheduleTest.php index bccd7d41..1b525ae5 100644 --- a/tests/Feature/Console/BusinessMirrorScheduleTest.php +++ b/tests/Feature/Console/BusinessMirrorScheduleTest.php @@ -12,6 +12,49 @@ use Tests\TestCase; class BusinessMirrorScheduleTest extends TestCase { + public function test_unapproved_first_sync_is_skipped(): void + { + config(['upstream_mirrors.refresh_enabled' => true]); + $dataset = Mockery::mock(BusinessMirrorDataset::class); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $registry->shouldReceive('all')->once()->andReturn(['pending' => $dataset]); + $registry->shouldReceive('scheduled')->with('pending')->andReturn(true); + $registry->shouldReceive('automaticInitialSyncAllowed')->with('pending')->andReturn(false); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $mirror->shouldReceive('state')->with($dataset)->andReturn(null); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + $sync->shouldNotReceive('sync'); + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + } + + public function test_catchup_and_routine_datasets_both_receive_a_turn(): void + { + config(['upstream_mirrors.refresh_enabled' => true, 'upstream_pipeline.sync.max_datasets' => 2]); + $registry = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $objects = []; + foreach (['routine-a', 'routine-b', 'catchup-z'] as $key) { + $objects[$key] = Mockery::mock(BusinessMirrorDataset::class); + $registry->shouldReceive('scheduled')->with($key)->andReturn(true); + $mirror->shouldReceive('state')->with($objects[$key])->andReturn([ + 'initial_completed_at' => '2026-08-01 00:00:00', 'last_attempted_at' => '2026-08-01 00:00:00', + 'status' => $key === 'catchup-z' ? 'running' : 'succeeded', + ]); + } + $registry->shouldReceive('all')->andReturn($objects); + $sync = Mockery::mock(BusinessMirrorSynchronizer::class); + $result = ['status' => 'succeeded', 'batches' => 1, 'rows' => 1]; + $sync->shouldReceive('sync')->once()->with('routine-a', false, null, null)->ordered()->andReturn($result); + $sync->shouldReceive('sync')->once()->with('catchup-z', false, null, 6)->ordered()->andReturn($result); + $this->app->instance(BusinessMirrorDatasetRegistry::class, $registry); + $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); + $this->app->instance(BusinessMirrorSynchronizer::class, $sync); + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + } + public function test_generic_rotation_excludes_datasets_with_a_dedicated_scheduler(): void { config([ @@ -28,6 +71,7 @@ class BusinessMirrorScheduleTest extends TestCase ProductCatalogMirrorRepository::DATASET_KEY => $catalog, 'test.dbo.other' => $other, ]); + $datasets->shouldReceive('scheduled')->once()->with('test.dbo.other')->andReturn(true); $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); $mirror->shouldReceive('state')->once()->with($other)->andReturn([ 'initial_completed_at' => '2026-08-01 02:00:00', diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 1812ff0f..254257fc 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -578,6 +578,30 @@ class UpstreamDatabasesTest extends FeatureTestCase ], $overrides); } + public function test_mysql_connection_appears_in_dictionary_sources_without_a_binding(): void + { + $payload = $this->payload(['driver' => 'mysql', 'port' => 3306]); + unset($payload['source_key'], $payload['allowed_scopes'], $payload['odbc_driver'], $payload['tds_version']); + $this->loginAs()->post(route('system-settings.databases.store'), $payload)->assertSessionHasNoErrors(); + $row = DatabaseConnection::where('driver', 'mysql')->firstOrFail(); + $this->assertArrayHasKey($row->sourceIdentifier(), app(UpstreamSourceRegistry::class)->all()); + $this->get(route('system-settings.databases.create'))->assertOk() + ->assertSee('MySQL / MariaDB')->assertDontSee('name="source_key"', false)->assertDontSee('name="allowed_scopes"', false); + } + + public function test_editing_simplified_form_preserves_legacy_binding_and_scopes(): void + { + $row = $this->connection('keep-secret'); + $row->update(['source_key' => 'jiansun']); + $payload = $this->payload(['name' => $row->name, 'password' => '']); + unset($payload['source_key'], $payload['allowed_scopes']); + $this->loginAs()->patch(route('system-settings.databases.update', $row->id), $payload)->assertSessionHasNoErrors(); + $row->refresh(); + $this->assertSame('jiansun', $row->source_key); + $this->assertSame(['supplier.options'], $row->allowed_scopes); + $this->assertSame('keep-secret', $row->password); + } + private function connection(string $password): DatabaseConnection { return DatabaseConnection::create([ diff --git a/tests/Unit/EcommerceFinance/GenericUpstreamPipelineTest.php b/tests/Unit/EcommerceFinance/GenericUpstreamPipelineTest.php new file mode 100644 index 00000000..01cbfde2 --- /dev/null +++ b/tests/Unit/EcommerceFinance/GenericUpstreamPipelineTest.php @@ -0,0 +1,184 @@ + true, + 'upstreams.local_configuration.enabled' => false, + 'database.connections.sqlite.password' => 'fixture-cipher-key', + 'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''], + 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.datasets' => [], + 'upstream_mirrors.refresh_enabled' => true, + ]); + DB::purge('business_mirror'); + Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $t): void { + $t->string('dataset_key')->primary(); + foreach (['source_key', 'source_object', 'target_table', 'status', 'run_mode', 'generation', 'last_error_code'] as $c) $t->string($c)->nullable(); + foreach (['cursor', 'high_watermark'] as $c) $t->json($c)->nullable(); + foreach (['window_started_at', 'window_ends_at', 'initial_completed_at', 'last_full_succeeded_at', 'last_attempted_at', 'last_succeeded_at'] as $c) $t->dateTime($c)->nullable(); + $t->unsignedBigInteger('last_upserted_rows')->default(0); + $t->unsignedBigInteger('mirror_row_count')->default(0); + $t->timestamps(); + }); + } + + protected function tearDown(): void + { + UpstreamConnection::reset(); + parent::tearDown(); + } + + private function connection(string $driver = 'pgsql'): DatabaseConnection + { + return DatabaseConnection::create([ + 'company_id' => 0, 'name' => 'Isolated fixture', 'driver' => $driver, 'host' => 'fixture.invalid', + 'port' => $driver === 'pgsql' ? 5432 : 3306, 'database_name' => 'fixture', + 'username' => 'fixture', 'password' => 'fixture-password', 'enabled' => true, + 'connect_timeout' => 5, 'query_timeout' => 15, + ]); + } + + public function test_new_connection_is_immediately_a_source_without_changing_config(): void + { + $row = $this->connection(); + $key = $row->sourceIdentifier(); + $sources = app(UpstreamSourceRegistry::class); + $this->assertNull($row->source_key); + $this->assertNull(config('upstreams.sources.' . $key)); + $this->assertSame('pgsql', $sources->driver($key)); + $this->assertTrue($sources->enabled($key)); + $this->assertSame('fixture-password', $sources->value($key, 'connection.password')); + $this->assertArrayHasKey($key, $sources->all()); + $this->assertSame('jiansun', $sources->definition('jiansun')['key']); + } + + public function test_dialects_use_engine_specific_limits_and_quotes(): void + { + $this->assertSame('SELECT TOP (5) [id] FROM [dbo].[records]', (new UpstreamSqlDialect('sqlsrv'))->select('[id]', 'FROM [dbo].[records]', 5)); + $this->assertSame('SELECT "id" FROM "public"."records" LIMIT 5', (new UpstreamSqlDialect('pgsql'))->select('"id"', 'FROM "public"."records"', 5)); + $this->assertSame('SELECT `id` FROM `records` LIMIT 5', (new UpstreamSqlDialect('mysql'))->select('`id`', 'FROM `records`', 5)); + $this->assertStringContainsString('c.table_schema = DATABASE()', (new UpstreamSqlDialect('mysql'))->catalogSql()); + } + + public function test_source_discovery_does_not_decrypt_credentials(): void + { + $row = $this->connection(); + DB::table('system_database_connections')->where('id', $row->id)->update(['shared_password' => 'invalid-fixture-envelope']); + $sources = app(UpstreamSourceRegistry::class); + $this->assertArrayHasKey($row->sourceIdentifier(), $sources->all()); + $this->assertSame('pgsql', $sources->driver($row->sourceIdentifier())); + } + + public function test_declarations_require_fields_and_explicit_initial_sync_approval(): void + { + $row = $this->connection(); + $source = $row->sourceIdentifier(); + DB::table('upstream_schema_columns')->insert([ + 'source_key' => $source, 'schema_name' => 'public', 'object_name' => 'records', + 'object_type' => 'table', 'column_name' => 'id', 'ordinal_position' => 1, 'data_type' => 'bigint', + 'is_nullable' => false, 'synchronized_at' => now(), 'created_at' => now(), 'updated_at' => now(), + ]); + $args = ['source' => $source, 'schema' => 'public', 'object' => 'records', '--keys' => 'id', '--columns' => 'id']; + $this->artisan('business-mirror:register', $args)->assertSuccessful(); + $key = $source . '.public.records'; + $registry = app(BusinessMirrorDatasetRegistry::class); + $this->assertFalse($registry->automaticInitialSyncAllowed($key)); + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + $this->assertSame(0, DB::connection('business_mirror')->table('business_mirror_table_states')->count()); + $this->artisan('business-mirror:register', $args + ['--enable-sync' => true])->assertSuccessful(); + $this->assertTrue($registry->automaticInitialSyncAllowed($key)); + $this->assertSame(['id'], $registry->columnSelection($key)); + $this->artisan('business-mirror:register', ['source' => $source, 'schema' => 'public', 'object' => 'records', '--disable-sync' => true])->assertSuccessful(); + $this->assertFalse($registry->scheduled($key)); + } + + /** @dataProvider nativeDrivers */ + public function test_native_tls_catalog_first_sync_pagination_and_incremental(string $driver, bool $timezoneAware = false, ?string $fixtureHost = null): void + { + if (getenv('CAIWU_PIPELINE_INTEGRATION') !== '1') $this->markTestSkipped('Requires isolated pipeline fixture containers.'); + $host = $fixtureHost ?? ($driver === 'pgsql' ? 'caiwu-pipeline-pg' : 'caiwu-pipeline-mysql'); + $database = $driver === 'pgsql' ? 'postgres' : 'pipeline_fixture'; + $user = $driver === 'pgsql' ? 'postgres' : 'root'; + $schema = $driver === 'pgsql' ? 'public' : $database; + $dsn = $driver === 'pgsql' ? "pgsql:host={$host};dbname={$database};sslmode=require" : "mysql:host={$host};dbname={$database}"; + $options = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]; + if ($driver === 'mysql') { + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT'; + $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + $admin = new PDO($dsn, $user, 'fixture-password', $options); + $admin->exec('DROP TABLE IF EXISTS pipeline_records'); + $type = $driver === 'pgsql' ? ($timezoneAware ? 'timestamp with time zone' : 'timestamp without time zone') : 'datetime(6)'; + $admin->exec('CREATE TABLE pipeline_records (id bigint primary key, modified_at ' . $type . ', amount decimal(18,2), label varchar(50), private_note text)'); + $date = now()->subMinute()->format($timezoneAware ? 'Y-m-d H:i:sP' : 'Y-m-d H:i:s'); + $insert = $admin->prepare('INSERT INTO pipeline_records (id, modified_at, amount, label) VALUES (?, ?, ?, ?)'); + $insert->execute([1, $date, '12.30', 'first']); + $insert->execute([2, $date, '45.60', 'second']); + $row = $this->connection($driver); + $row->update(['host' => $host, 'database_name' => $database, 'username' => $user]); + $source = $row->sourceIdentifier(); + $result = app(UpstreamSchemaCatalogSynchronizer::class)->sync($source); + $this->assertGreaterThanOrEqual(5, $result['columns']); + $this->artisan('business-mirror:register', [ + 'source' => $source, 'schema' => $schema, 'object' => 'pipeline_records', '--keys' => 'id', + '--columns' => 'id,modified_at,amount,label', '--watermark' => 'modified_at', '--enable-sync' => true, + ])->assertSuccessful(); + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + $dataset = app(BusinessMirrorDatasetRegistry::class)->get($source . '.' . $schema . '.pipeline_records'); + $mirror = DB::connection('business_mirror')->table($dataset->targetTable()); + $this->assertSame(2, $mirror->count()); + $this->assertSame(2, (int) $mirror->orderBy('id')->offset(1)->value('id')); + $this->assertFalse(Schema::connection('business_mirror')->hasColumn($dataset->targetTable(), 'private_note')); + $admin->exec("UPDATE pipeline_records SET amount=99.50 WHERE id=1"); + $insert->execute([3, $date, '6.70', 'third']); + $insert->execute([4, now()->addHour()->format($timezoneAware ? 'Y-m-d H:i:sP' : 'Y-m-d H:i:s'), '1.00', 'future']); + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + $this->assertSame(3, DB::connection('business_mirror')->table($dataset->targetTable())->count()); + $this->assertEquals(99.5, DB::connection('business_mirror')->table($dataset->targetTable())->where('id', 1)->value('amount')); + $pdo = UpstreamConnection::get($source); + $row->update(['enabled' => false]); + $page = app(BusinessMirrorDataReader::class)->paginate($dataset->key(), [], 2, 2); + $this->assertSame(3, $page['total']); + $this->assertSame(3, (int) $page['rows'][0]['id']); + $this->assertArrayNotHasKey('_mirror_key', $page['rows'][0]); + $filtered = app(BusinessMirrorDataReader::class)->paginate($dataset->key(), ['label' => 'first']); + $this->assertSame(1, $filtered['total']); + if ($driver === 'pgsql') { + $this->assertSame('on', $pdo->query('SHOW default_transaction_read_only')->fetchColumn()); + $this->assertTrue((bool) $pdo->query('SELECT ssl FROM pg_stat_ssl WHERE pid=pg_backend_pid()')->fetchColumn()); + } else { + $this->assertNotEmpty($pdo->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")->fetch(PDO::FETCH_NUM)[1]); + } + try { + $pdo->exec("INSERT INTO pipeline_records (id) VALUES (99)"); + $this->fail('The native upstream connection must reject writes.'); + } catch (PDOException $e) { + $this->assertStringContainsString('read', strtolower($e->getMessage())); + } + } + + public static function nativeDrivers(): array + { + return [['pgsql'], ['mysql'], ['pgsql', true], ['mysql', false, 'caiwu-pipeline-mariadb']]; + } +} diff --git a/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php index 0f66222e..3ff1304b 100644 --- a/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php +++ b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php @@ -21,7 +21,7 @@ class UpstreamDatabaseProbeTest extends TestCase 'username' => 'reader', 'password' => 'fixture-secret', 'connect_timeout' => 5, 'query_timeout' => 120]; } - public function test_postgres_uses_tls_read_only_timeout_and_one_select(): void + public function test_postgres_probe_uses_the_shared_connector_and_one_select(): void { $statement = Mockery::mock(PDOStatement::class); $statement->shouldReceive('fetchColumn')->once()->andReturn(1); @@ -29,16 +29,7 @@ class UpstreamDatabaseProbeTest extends TestCase $pdo = Mockery::mock(PDO::class); $pdo->shouldReceive('query')->once()->with('SELECT 1')->andReturn($statement); $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); - $probe->shouldReceive('connect')->once()->withArgs(function ($dsn, $user, $password) { - $this->assertStringContainsString('sslmode=require', $dsn); - $this->assertStringContainsString('default_transaction_read_only=on', $dsn); - $this->assertStringContainsString('statement_timeout=15000', $dsn); - $this->assertStringContainsString("dbname='test\\' database'", $dsn); - $this->assertStringContainsString('connect_timeout=5', $dsn); - $this->assertStringNotContainsString('fixture-secret', $dsn); - - return $user === 'reader' && $password === 'fixture-secret'; - })->andReturn($pdo); + $probe->shouldReceive('connect')->once()->with('pgsql', $this->connection())->andReturn($pdo); $probe->probe('pgsql', $this->connection(), '测试库'); } @@ -50,7 +41,7 @@ class UpstreamDatabaseProbeTest extends TestCase $probe->probe('pgsql', $this->connection(), '测试库'); $this->fail('Expected sanitized connection error'); } catch (ServiceUnavailableHttpException $exception) { - $this->assertStringContainsString('PostgreSQL 连接失败', $exception->getMessage()); + $this->assertStringContainsString('上游只读连接失败', $exception->getMessage()); $this->assertStringNotContainsString('fixture-secret', $exception->getMessage()); $this->assertNull($exception->getPrevious()); } diff --git a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php index f68754c5..7d90e8a6 100644 --- a/tests/Unit/Utilities/SystemDatabasePersistenceTest.php +++ b/tests/Unit/Utilities/SystemDatabasePersistenceTest.php @@ -9,6 +9,42 @@ use Tests\TestCase; class SystemDatabasePersistenceTest extends TestCase { + public function test_switch_replaces_all_legacy_connection_routes_and_duplicate_keys(): void + { + $environmentPath = storage_path('framework/testing-legacy-routes.env'); + $original = "DB_HOST=mysql\nDB_HOST_READ=old-read\nDB_HOST_WRITE=old-write\n" + . "DATABASE_URL=mysql://fixture:fixture@old-url/old_database\nDB_SOCKET=/tmp/old.sock\n" + . "DB_HOST=old-duplicate\nSYSTEM_DATABASE_MODE=bootstrap\nUNRELATED_OPTION=keep\n"; + file_put_contents($environmentPath, $original); + $configuration = new class($environmentPath) extends SystemDatabaseConfiguration { + public array $verified = []; + protected function runFreshVerification(string $command): void + { + $this->verified = $this->freshVerificationEnvironment(); + } + protected function clearConfigurationCache(): void {} + }; + try { + $backup = $configuration->persist([ + 'host' => 'shared.example.test', 'port' => 8443, 'database' => 'shared_system', + 'username' => 'fixture_user', 'password' => 'fixture_password', 'prefix' => '', + 'tls_required' => true, 'tls_verify_server_cert' => false, + ]); + $saved = \Dotenv\Dotenv::parse(file_get_contents($environmentPath)); + foreach (['DB_HOST', 'DB_HOST_READ', 'DB_HOST_WRITE'] as $key) { + $this->assertSame('shared.example.test', $saved[$key]); + $this->assertSame('shared.example.test', $configuration->verified[$key]); + } + $this->assertSame('', $saved['DATABASE_URL']); + $this->assertSame('', $saved['DB_SOCKET']); + $this->assertSame('keep', $saved['UNRELATED_OPTION']); + $this->assertSame($original, file_get_contents($backup)); + } finally { + @unlink($environmentPath); + if (isset($backup)) @unlink($backup); + } + } + public function test_failed_fresh_process_verification_restores_original_environment(): void { $environmentPath = storage_path('framework/testing-system-database.env'); From 604531adacc25535c7812c247fe7c315a79c4973 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Mon, 31 Aug 2026 19:20:15 +0800 Subject: [PATCH 09/25] =?UTF-8?q?=E9=9B=86=E6=88=90=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E6=94=AF=E7=BA=BF=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E5=AD=97=E6=AE=B5=E5=92=8C=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=9D=83=E9=99=90=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Business/UpstreamBusinessInformation.php | 17 ++++ app/Listeners/Menu/ShowInAdmin.php | 28 ++++++- config/business_navigation.php | 6 +- docs/通用上游数据接入.md | 6 ++ .../BusinessInformationMirrorRepository.php | 10 +++ .../company-information.blade.php | 1 + .../company-progress.blade.php | 1 + .../entity-report.blade.php | 1 + .../people-inventory.blade.php | 1 + .../pages/partials/directory-table.blade.php | 13 +++- .../pages/partials/prototype-page.blade.php | 71 +++++++++++++++++ .../pages/partials/prototype-table.blade.php | 34 ++++++++ .../asset-report.blade.php | 1 + .../profit-report.blade.php | 1 + .../store-change-progress.blade.php | 1 + .../store-information.blade.php | 1 + .../store-management/store-report.blade.php | 1 + .../trademark-information.blade.php | 1 + .../trademark-progress.blade.php | 1 + .../pages/workspace/overview.blade.php | 1 + .../business/pages/workspace/tasks.blade.php | 1 + .../pages/workspace/warnings.blade.php | 1 + .../components/layouts/admin/menu.blade.php | 78 ++++++++++++++++++- tests/Feature/Business/PrototypePageTest.php | 75 ++++++++++++++++++ ...usinessInformationMirrorRepositoryTest.php | 14 ++++ .../UpstreamBusinessInformationTest.php | 20 ++++- 26 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 resources/views/business/pages/company-management/company-information.blade.php create mode 100644 resources/views/business/pages/company-management/company-progress.blade.php create mode 100644 resources/views/business/pages/company-management/entity-report.blade.php create mode 100644 resources/views/business/pages/company-management/people-inventory.blade.php create mode 100644 resources/views/business/pages/partials/prototype-page.blade.php create mode 100644 resources/views/business/pages/partials/prototype-table.blade.php create mode 100644 resources/views/business/pages/prototype-finance-reports/asset-report.blade.php create mode 100644 resources/views/business/pages/prototype-finance-reports/profit-report.blade.php create mode 100644 resources/views/business/pages/store-management/store-change-progress.blade.php create mode 100644 resources/views/business/pages/store-management/store-information.blade.php create mode 100644 resources/views/business/pages/store-management/store-report.blade.php create mode 100644 resources/views/business/pages/trademark-management/trademark-information.blade.php create mode 100644 resources/views/business/pages/trademark-management/trademark-progress.blade.php create mode 100644 resources/views/business/pages/workspace/overview.blade.php create mode 100644 resources/views/business/pages/workspace/tasks.blade.php create mode 100644 resources/views/business/pages/workspace/warnings.blade.php create mode 100644 tests/Feature/Business/PrototypePageTest.php diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 076c5b7b..8062d660 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -105,6 +105,7 @@ SELECT registeredAddress AS registered_address, businessScope AS business_scope, accountSerialNumber AS basic_deposit_account_number, + registeredCapitalVaildTime AS subscribed_at, companyStatus AS company_status, financialManager AS financial_officer, supervisor AS supervisor, @@ -140,7 +141,9 @@ SQL); $rows = $query->select(<<select(<<normalize($scope, $this->mirror->paginate($scope, $page, $perPage)); } + if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap' + && ! config('upstreams.local_configuration.enabled', false)) { + throw new ServiceUnavailableHttpException(null, '当前仍使用本机临时引导库。请在“系统设置 → 数据库管理 → 系统数据库”连接团队共享数据库,然后重新登录;无需另配上游 SQL Server。'); + } + $cache = $this->caches->forSource(self::SOURCE); return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) { @@ -239,6 +254,7 @@ SQL); { $row = $this->toUtf8($row); $row['establishment_date'] = $this->date($row['establishment_date'] ?? null); + $row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null); $row['company_status'] = match ((string) ($row['company_status'] ?? '')) { '0' => '正常', '1' => '已注销', @@ -252,6 +268,7 @@ SQL); { $row = $this->toUtf8($row); $row['opened_at'] = $this->date($row['opened_at'] ?? null); + $row['renewed_at'] = $this->date($row['renewed_at'] ?? null); $row['store_status'] = match ((string) ($row['store_status'] ?? '')) { '0' => '正常', '3' => '关闭中', diff --git a/app/Listeners/Menu/ShowInAdmin.php b/app/Listeners/Menu/ShowInAdmin.php index 7e538ea0..38f9c8d9 100644 --- a/app/Listeners/Menu/ShowInAdmin.php +++ b/app/Listeners/Menu/ShowInAdmin.php @@ -7,6 +7,14 @@ use App\Utilities\SidebarPermissions; class ShowInAdmin { + private const PROTOTYPE_NAV_SECTIONS = [ + 'workspace', + 'company-management', + 'trademark-management', + 'store-management', + 'prototype-finance-reports', + ]; + /** * Build the business navigation defined by the imported directory workbook. */ @@ -15,7 +23,25 @@ class ShowInAdmin $menu = $event->menu; foreach (SidebarPermissions::navigationFor(user()) as $sectionIndex => $section) { - // 订单数据按配置顺序展示,让“原始数据”保持在新增子页面之前。 + if (in_array($section['slug'], self::PROTOTYPE_NAV_SECTIONS, true)) { + continue; + } + + if (! empty($section['external_url'])) { + $menu->url( + $section['external_url'], + $section['title'], + ($sectionIndex + 1) * 10, + [ + 'icon' => $section['icon'] ?? '', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ] + ); + + continue; + } + $pages = $section['slug'] === 'order-data' ? array_values($section['pages'] ?? []) : $this->sortByTitleLength($section['pages'] ?? []); diff --git a/config/business_navigation.php b/config/business_navigation.php index a4d6db99..72b9750f 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -39,7 +39,7 @@ return [ 'directory' => [ 'icon' => 'business', 'status' => '既有只读上游', - 'description' => '沿用现有公司基础资料只读链路;Excel 中暂未匹配到的城市、认缴时间等字段保留为空。', + 'description' => '优先读取共享业务镜像中的公司资料;未匹配的城市等字段保留为空。', 'empty' => '尚未读取到公司基础资料。请检查共享镜像或上游同步状态。', 'fields' => [ ['name' => 'unified_social_credit_code', 'label' => '统一社会信用代码'], @@ -112,7 +112,7 @@ return [ 'directory' => [ 'icon' => 'verified', 'status' => '既有只读上游', - 'description' => '沿用现有商标资料只读链路;当前已匹配名称、状态、注册时间和有效期,其余 Excel 字段待接入。', + 'description' => '优先读取共享业务镜像中的商标权属、有效期和保管资料。', 'empty' => '尚未读取到商标基础资料。请检查共享镜像或上游同步状态。', 'fields' => [ ['name' => 'trademark_name', 'label' => '商标名称'], @@ -190,7 +190,7 @@ return [ 'directory' => [ 'icon' => 'storefront', 'status' => '既有只读上游', - 'description' => '沿用现有店铺资料只读链路;Excel 中的店铺 ID、续签时间和提现账户待字段映射后补充。', + 'description' => '优先读取共享业务镜像中的店铺资料、店铺 ID 和续签时间;提现账户仅显示配置状态。', 'empty' => '尚未读取到店铺基础资料。请检查共享镜像或上游同步状态。', 'fields' => [ ['name' => 'platform', 'label' => '平台'], diff --git a/docs/通用上游数据接入.md b/docs/通用上游数据接入.md index 94c33592..3f16c069 100644 --- a/docs/通用上游数据接入.md +++ b/docs/通用上游数据接入.md @@ -28,6 +28,12 @@ php artisan business-mirror:register database_17 public example_orders \ ## 页面读取与同步状态 +### 已有公司、店铺、商标页在新人电脑没有数据 + +这些页面优先读取 `caiwu_business_mirror`。本机 Docker 正常、临时引导库有用户,并不代表已经连接团队共享库。Codex 应先在最新主线执行 `deploy/local-init.sh`,通过系统数据库入口完成一次共享接入和重新登录,再运行 `system-database:verify` 并检查相应 `business-mirror:status`。镜像连接默认复用系统数据库主机、端口、账号和 TLS,不要求新人另填 SQL Server 密码,也不要把 `furniture3d_prod` 填成 `operations_replica`。 + +已有镜像可读时,上游开关关闭或网络暂不可达不应阻断页面。读取失败不能解释为真实记录数为 0。只有探索尚未接入的新表时,才需要使用对应上游的只读连接;不要为了让已有资料页出数据,在每个开发者的引导库重复登记生产来源。 + 新页面在原有公司、角色和数据权限校验之后调用 `BusinessMirrorDataReader::paginate($datasetKey, $filters, $page, $perPage)`。它只读业务镜像,返回完整总数、稳定分页、实际同步状态和最近完成时间。来源暂时关闭或不可达时,已完成镜像仍可读取;无首次镜像时明确报“尚未完成首次同步”,不登记逐页快照、不从页面直接访问上游。 通用筛选只接受已声明字段的等值条件;复杂业务联表、金额口径、公司行级过滤继续由固定 Repository 实现。不能把此内部读取器当作绕过业务权限的公开接口。 diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php index 26bcbbda..6931455c 100644 --- a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -63,6 +63,7 @@ legalRepresentative AS legal_representative, registeredAddress AS registered_address, businessScope AS business_scope, accountSerialNumber AS basic_deposit_account_number, +registeredCapitalVaildTime AS subscribed_at, companyStatus AS company_status, financialManager AS financial_officer, supervisor AS supervisor, @@ -89,7 +90,9 @@ SQL); ->leftJoin('ops_fys_company_info AS c', 'c.Id', '=', 'company_keys.latest_id') ->selectRaw(<<<'SQL' s.Id AS id, +s.sotreId AS store_id, s.openTime AS opened_at, +s.renewalTime AS renewed_at, s.erpShopName AS erp_store_name, s.unifiedSocialCreditCode AS unified_social_credit_code, c.businessLicenseName AS company_name, @@ -98,7 +101,9 @@ NULL AS trademark_name, s.platform AS platform, s.responsibleDepartment AS profit_center, s.shopHead AS store_manager, +NULL AS department_manager, s.shopState AS store_status, +s.shopBankAccount AS withdrawal_account, s.shopType AS store_type, s.channelType AS channel_mode SQL); @@ -113,11 +118,16 @@ SQL); ->where('isDelete', 0) ->selectRaw(<<<'SQL' Id AS id, +trademarkNumber AS trademark_number, trademarkName AS trademark_name, internationalClassification AS trademark_type, trademarkStatus AS trademark_status, registrationDate AS registration_date, termOfValidity AS expiry_date, +approvedCommodities AS approved_goods, +transferenceUser AS final_holder, +registrationAddress AS registered_address, +custodyDepartment AS custody_department, remark AS notes SQL); diff --git a/resources/views/business/pages/company-management/company-information.blade.php b/resources/views/business/pages/company-management/company-information.blade.php new file mode 100644 index 00000000..8eab897a --- /dev/null +++ b/resources/views/business/pages/company-management/company-information.blade.php @@ -0,0 +1 @@ +@include('business.pages.partials.prototype-page') diff --git a/resources/views/business/pages/company-management/company-progress.blade.php b/resources/views/business/pages/company-management/company-progress.blade.php new file mode 100644 index 00000000..8eab897a --- /dev/null +++ b/resources/views/business/pages/company-management/company-progress.blade.php @@ -0,0 +1 @@ +@include('business.pages.partials.prototype-page') diff --git a/resources/views/business/pages/company-management/entity-report.blade.php b/resources/views/business/pages/company-management/entity-report.blade.php new file mode 100644 index 00000000..8eab897a --- /dev/null +++ b/resources/views/business/pages/company-management/entity-report.blade.php @@ -0,0 +1 @@ +@include('business.pages.partials.prototype-page') diff --git a/resources/views/business/pages/company-management/people-inventory.blade.php b/resources/views/business/pages/company-management/people-inventory.blade.php new file mode 100644 index 00000000..8eab897a --- /dev/null +++ b/resources/views/business/pages/company-management/people-inventory.blade.php @@ -0,0 +1 @@ +@include('business.pages.partials.prototype-page') diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 28bd2e97..cb65c5d2 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1,6 +1,15 @@ @php $directoryRows = $rows ?? collect(); $directoryFields = $directory['fields'] ?? []; + $directoryFields = collect($directoryFields)->flatMap(function (array $field): array { + if ($field['name'] !== 'recent_three_month_sales') { + return [$field]; + } + return array_map(static function (int $monthsAgo): array { + $month = now()->startOfMonth()->subMonthsNoOverflow($monthsAgo); + return ['name' => $month->format('sales_Y_m'), 'label' => $month->format('n月销量'), 'kind' => 'number']; + }, range(3, 1)); + })->all(); $directoryIsPaginated = is_object($directoryRows) && method_exists($directoryRows, 'total'); $directoryTotal = $directoryIsPaginated ? $directoryRows->total() : (is_countable($directoryRows) ? count($directoryRows) : 0); $directoryCurrentPage = $directoryIsPaginated ? $directoryRows->currentPage() : 1; @@ -274,7 +283,7 @@
- {{ $directory['empty'] ?? '尚未同步数据。' }} + {{ !empty($upstreamError) ? '资料暂时无法读取,请按上方提示检查连接;这不代表业务数据为 0。' : ($directory['empty'] ?? '尚未同步数据。') }}
- @if ($directoryIsPaginated) + @if ($directoryIsPaginated && empty($upstreamError))