From 3c74ffc2d5e76c135c0bfedfe8048eabd589cabb Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 26 May 2026 13:53:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20PHPSpreadsheet=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=A5=E5=A2=9E=E5=BC=BA=E6=8A=A5=E8=A1=A8?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `composer.json` 中新增 `phpoffice/phpspreadsheet` 依赖。 - 更新 `ReportJobDownloadController` 以使用 `AdminReportSpreadsheetExporter` 进行 XLSX 格式的报表导出,简化导出逻辑并确保文件名包含动态生成的输出路径后缀。 - 更新 `AdminAuthorizationRegistry` 中的权限定义,扩展相关权限以支持新的设置管理功能。 --- AGENTS.md | 17 + .../Reports/ReportJobDownloadController.php | 15 +- .../Admin/AdminReportSpreadsheetExporter.php | 34 ++ app/Support/AdminAuthorizationRegistry.php | 4 +- composer.json | 3 +- composer.lock | 411 +++++++++++++++--- ...add_admin_dashboard_analytics_resource.php | 7 - ...min_api_resources_after_dashboard_view.php | 72 +++ .../AdminDashboardApiResourceBindingTest.php | 50 +++ 9 files changed, 538 insertions(+), 75 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/Services/Admin/AdminReportSpreadsheetExporter.php create mode 100644 database/migrations/2026_05_28_100000_resync_admin_api_resources_after_dashboard_view.php create mode 100644 tests/Feature/AdminDashboardApiResourceBindingTest.php diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e4bdd42 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# lotterLaravel — Agent 须知 + +## 数据库:禁止擅自清空 + +**未经用户明确同意,不得执行:** + +| 禁止 | 说明 | +|------|------| +| `php artisan migrate:fresh` | 删表重建,业务数据全失 | +| `php artisan db:wipe` | 清空所有表 | +| `php -r` / 脚本中的 `migrate:fresh`、`db:wipe` | 易误连 `.env` 开发库(如 `pgsql` / `lottery`) | + +**可以做的:** `php artisan migrate`(增量)、`php artisan test`(走 `phpunit.xml` 的 SQLite 内存库)。 + +用户明确要求 `migrate:fresh` 时:先说明目标库名与数据将全部丢失,待用户确认后再执行。 + +详见 `.cursor/rules/database-destructive-commands.mdc`。 diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php index cb7c9c4..b8a8cc0 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/ReportJobDownloadController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Reports; use App\Models\ReportJob; use App\Services\Admin\AdminReportJobService; use App\Services\Admin\AdminReportQueryService; +use App\Services\Admin\AdminReportSpreadsheetExporter; use Symfony\Component\HttpFoundation\StreamedResponse; /** GET /api/v1/admin/report-jobs/{report_job}/download */ @@ -14,20 +15,24 @@ final class ReportJobDownloadController ReportJob $report_job, AdminReportJobService $service, AdminReportQueryService $queryService, + AdminReportSpreadsheetExporter $spreadsheetExporter, ): StreamedResponse { $filterJson = is_array($report_job->filter_json) ? $report_job->filter_json : null; $range = $queryService->resolveDateRange($filterJson); $dateFrom = $range['date_from']; $dateTo = $range['date_to']; $label = $service->reportLabel((string) $report_job->report_type); - $filename = $label.'_'.$dateFrom.'_'.$dateTo.'.'.$report_job->export_format; + $pathSuffix = $queryService->resolveOutputPathSuffix( + (string) $report_job->report_type, + $filterJson, + $dateFrom, + $dateTo, + ); + $filename = $label.'_'.$pathSuffix.'.'.$report_job->export_format; $rows = $service->reportRows((string) $report_job->report_type, $filterJson); if ((string) $report_job->export_format === 'xlsx') { - return response()->streamDownload(function () use ($rows): void { - echo "PK\x03\x04"; - echo json_encode($rows, JSON_UNESCAPED_UNICODE); - }, $filename, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']); + return $spreadsheetExporter->streamDownload($rows, $filename); } return response()->streamDownload(function () use ($rows): void { diff --git a/app/Services/Admin/AdminReportSpreadsheetExporter.php b/app/Services/Admin/AdminReportSpreadsheetExporter.php new file mode 100644 index 0000000..1a5a1f4 --- /dev/null +++ b/app/Services/Admin/AdminReportSpreadsheetExporter.php @@ -0,0 +1,34 @@ +> $rows + */ + public function streamDownload(array $rows, string $filename): StreamedResponse + { + return response()->streamDownload(function () use ($rows): void { + $spreadsheet = new Spreadsheet; + $sheet = $spreadsheet->getActiveSheet(); + + foreach ($rows as $rowIndex => $row) { + foreach ($row as $colIndex => $cell) { + $sheet->setCellValue([$colIndex + 1, $rowIndex + 1], $cell ?? ''); + } + } + + $writer = new Xlsx($spreadsheet); + $writer->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, $filename, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 42d35f7..99c8963 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -380,8 +380,8 @@ final class AdminAuthorizationRegistry ['code' => 'admin.config.risk-cap-versions.items.replace', 'module_code' => 'config', 'name' => '替换封顶版本条目', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/items', 'route_name' => 'api.v1.admin.config.risk-cap-versions.items.replace', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], ['code' => 'admin.config.risk-cap-versions.publish', 'module_code' => 'config', 'name' => '发布封顶版本', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}/publish', 'route_name' => 'api.v1.admin.config.risk-cap-versions.publish', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], ['code' => 'admin.config.risk-cap-versions.destroy', 'module_code' => 'config', 'name' => '删除封顶版本', 'http_method' => 'DELETE', 'uri_pattern' => '/api/v1/admin/config/risk-cap-versions/{id}', 'route_name' => 'api.v1.admin.config.risk-cap-versions.destroy', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.risk_cap.manage']], - ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], - ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage']], + ['code' => 'admin.settings.index', 'module_code' => 'settings', 'name' => '系统设置列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settings', 'route_name' => 'api.v1.admin.settings.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], + ['code' => 'admin.settings.update', 'module_code' => 'settings', 'name' => '系统设置更新', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/settings/{key}', 'route_name' => 'api.v1.admin.settings.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.payout.manage']], ['code' => 'admin.currencies.index', 'module_code' => 'settings', 'name' => '币种列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.store', 'module_code' => 'settings', 'name' => '创建币种', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/currencies', 'route_name' => 'api.v1.admin.currencies.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], ['code' => 'admin.currencies.update', 'module_code' => 'settings', 'name' => '更新币种', 'http_method' => 'PUT', 'uri_pattern' => '/api/v1/admin/currencies/{currency}', 'route_name' => 'api.v1.admin.currencies.update', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'legacy_permission_slugs' => ['prd.currency.manage']], diff --git a/composer.json b/composer.json index 1112585..08a31c8 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "laravel/framework": "^13.7", "laravel/reverb": "^1.10", "laravel/sanctum": "^4.3", - "laravel/tinker": "^3.0" + "laravel/tinker": "^3.0", + "phpoffice/phpspreadsheet": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 139f364..d039665 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d8d9d456c5d062cfd256fdf4112a87c8", + "content-hash": "1574f70ce48caf9305956ad1513280ca", "packages": [ { "name": "brick/math", @@ -233,6 +233,65 @@ ], "time": "2025-01-03T16:18:33+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/composer/pcre/3.3.2/composer-pcre-3.3.2.zip", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2073,6 +2132,191 @@ }, "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2026-04-11T18:38:28+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -2606,6 +2850,112 @@ }, "time": "2025-12-30T16:12:18+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "2.4.5", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/ec7815be350e03df90f3e2ace92653fa6cb4327c", + "reference": "ec7815be350e03df90f3e2ace92653fa6cb4327c", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=8.1.0 <8.6.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.6 || ^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormatter Wizard", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.5" + }, + "time": "2026-04-19T05:48:49+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -5916,65 +6266,6 @@ }, "time": "2026-03-29T15:46:14+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "dist": { - "type": "zip", - "url": "https://mirrors.cloud.tencent.com/repository/composer/composer/pcre/3.3.2/composer-pcre-3.3.2.zip", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php b/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php index 4e45af8..1641cb0 100644 --- a/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php +++ b/database/migrations/2026_05_25_140000_add_admin_dashboard_analytics_resource.php @@ -10,13 +10,6 @@ return new class extends Migration public function up(): void { $now = Carbon::now(); - $menuActionId = DB::table('admin_menu_actions') - ->where('permission_code', 'dashboard.view') - ->value('id'); - - if ($menuActionId === null) { - return; - } $resource = collect(AdminAuthorizationRegistry::resources()) ->firstWhere('code', 'admin.dashboard.analytics'); diff --git a/database/migrations/2026_05_28_100000_resync_admin_api_resources_after_dashboard_view.php b/database/migrations/2026_05_28_100000_resync_admin_api_resources_after_dashboard_view.php new file mode 100644 index 0000000..08f2577 --- /dev/null +++ b/database/migrations/2026_05_28_100000_resync_admin_api_resources_after_dashboard_view.php @@ -0,0 +1,72 @@ +pluck('id', 'permission_code'); + + foreach (AdminAuthorizationRegistry::resources() as $resource) { + $resourceId = DB::table('admin_api_resources') + ->where('code', $resource['code']) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => $resource['code'], + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + // 数据修复迁移:不在 down 中回滚 bindings,避免误删线上授权关系。 + } +}; diff --git a/tests/Feature/AdminDashboardApiResourceBindingTest.php b/tests/Feature/AdminDashboardApiResourceBindingTest.php new file mode 100644 index 0000000..ef37262 --- /dev/null +++ b/tests/Feature/AdminDashboardApiResourceBindingTest.php @@ -0,0 +1,50 @@ +seed(AdminRbacAndUserSeeder::class); + + $resourceId = (int) DB::table('admin_api_resources') + ->where('code', 'admin.dashboard') + ->value('id'); + + expect($resourceId)->toBeGreaterThan(0); + + $bindingCount = DB::table('admin_api_resource_bindings') + ->where('api_resource_id', $resourceId) + ->count(); + + expect($bindingCount)->toBeGreaterThan(0); + + $admin = AdminUser::query()->where('username', 'admin')->firstOrFail(); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertOk(); +}); + +test('admin user without dashboard permission is forbidden on dashboard api', function (): void { + $this->seed(AdminRbacAndUserSeeder::class); + + $admin = AdminUser::query()->create([ + 'username' => 'no_dashboard', + 'name' => 'No Dashboard', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/dashboard') + ->assertForbidden(); +});