From bcf54c27273c4d675077f2f5cc653d9c8f428e2d Mon Sep 17 00:00:00 2001 From: LQ Date: Fri, 14 Aug 2026 23:21:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=EF=BC=9A=E4=B8=BB=E9=A2=98=E9=85=8D=E8=89=B2=E9=9C=80?= =?UTF-8?q?=E8=A6=81=E4=BC=98=E5=8C=96=E6=95=B4=E4=BD=93=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/Code-Standards.mdc | 9 + .cursor/rules/lgp-pitfalls.mdc | 40 + .cursor/rules/lgp-project-map.mdc | 46 ++ .env.example | 21 +- README.md | 19 + app/BaseApp/BaseBusinessModel.php | 15 + app/BaseApp/BaseController.php | 11 + app/BaseApp/BaseWxService.php | 48 ++ app/Console/Commands/EndpointSyncCommand.php | 238 ++++++ app/Console/Commands/MenuSyncCommand.php | 157 ++++ app/Console/Commands/RbacAuditCommand.php | 396 ++++++++++ app/Console/Commands/RbacMigrateCommand.php | 415 +++++++++++ app/Http/Controllers/Api/AdminController.php | 8 +- .../Controllers/Api/CardClassController.php | 35 + .../Controllers/Api/CarouselController.php | 35 + .../Controllers/Api/CatalogueController.php | 35 + .../Controllers/Api/CategoryController.php | 35 + .../Controllers/Api/ColorcardController.php | 35 + .../Controllers/Api/CompanyController.php | 35 + .../Controllers/Api/DepartmentController.php | 47 ++ .../Controllers/Api/EnterpriseController.php | 38 + .../Api/FactoryClassificationController.php | 35 + .../Api/FactoryImageController.php | 44 ++ .../Controllers/Api/FactoryInfoController.php | 35 + .../Controllers/Api/FileFolderController.php | 22 + app/Http/Controllers/Api/ImageController.php | 53 ++ app/Http/Controllers/Api/ListController.php | 61 ++ app/Http/Controllers/Api/LoginController.php | 30 +- .../Controllers/Api/MaterialController.php | 111 +++ app/Http/Controllers/Api/OrderController.php | 79 ++ .../Controllers/Api/PriceSheetController.php | 63 ++ app/Http/Controllers/Api/RoleController.php | 49 ++ app/Http/Controllers/Api/UploadController.php | 15 + app/Http/Controllers/Api/WxAppController.php | 46 ++ .../Controllers/Api/WxTemplateController.php | 91 +++ app/Http/Controllers/Api/WxUserController.php | 107 +++ app/Http/Controllers/Wx/AuthController.php | 36 + app/Http/Controllers/Wx/HomeController.php | 39 + app/Http/Controllers/Wx/ListController.php | 96 +++ app/Http/Controllers/Wx/OrderController.php | 84 +++ app/Http/Controllers/Wx/ProductController.php | 52 ++ app/Http/Controllers/Wx/ThemeController.php | 56 ++ app/Http/Controllers/Wx/UserController.php | 51 ++ .../Controllers/Wx/WxUploadController.php | 37 + app/Http/Middleware/ApiAuthMiddleware.php | 68 ++ app/Http/Middleware/ApiOpLogMiddleware.php | 2 + app/Http/Middleware/WxAuthMiddleware.php | 41 ++ app/Jobs/ScanMediaReferencesJob.php | 33 + app/Jobs/SyncOssObjectsJob.php | 50 ++ app/Models/AdminModel.php | 12 + app/Models/FileFolderModel.php | 24 + app/Models/FileModel.php | 67 +- app/Models/RoleEndpointRelationModel.php | 14 + app/Models/WxAppModel.php | 20 + app/Models/business/CardClassModel.php | 15 + app/Models/business/CarouselModel.php | 15 + app/Models/business/CatalogueModel.php | 35 + app/Models/business/CategoryModel.php | 26 + app/Models/business/ColorcardModel.php | 28 + app/Models/business/CompanyModel.php | 15 + app/Models/business/DepartmentModel.php | 18 + app/Models/business/EnterpriseModel.php | 23 + .../business/FactoryClassificationModel.php | 15 + app/Models/business/FactoryImageModel.php | 23 + app/Models/business/FactoryInfoModel.php | 29 + app/Models/business/ImageModel.php | 26 + app/Models/business/ListItemModel.php | 34 + app/Models/business/ListModel.php | 34 + app/Models/business/OrderDeliveryModel.php | 23 + app/Models/business/OrderItemModel.php | 24 + app/Models/business/OrderModel.php | 63 ++ app/Models/business/OrderPaymentModel.php | 27 + app/Models/business/PriceSheetModel.php | 38 + app/Models/business/WxTemplateModel.php | 24 + app/Models/business/WxUserModel.php | 41 ++ app/Service/AdminService.php | 65 +- app/Service/DepartmentService.php | 203 +++++ app/Service/FileFolderService.php | 135 ++++ app/Service/FileService.php | 3 - app/Service/LoginService.php | 148 +++- app/Service/MaterialService.php | 692 ++++++++++++++++++ app/Service/PermissionService.php | 226 ++++++ app/Service/RoleService.php | 69 +- app/Service/WxAppConfigService.php | 157 ++++ app/Service/business/CardClassService.php | 83 +++ app/Service/business/CarouselService.php | 124 ++++ app/Service/business/CatalogueService.php | 193 +++++ app/Service/business/CategoryService.php | 121 +++ app/Service/business/ColorcardService.php | 96 +++ app/Service/business/CompanyService.php | 82 +++ app/Service/business/EnterpriseService.php | 162 ++++ .../business/FactoryClassificationService.php | 83 +++ app/Service/business/FactoryImageService.php | 141 ++++ app/Service/business/FactoryInfoService.php | 103 +++ app/Service/business/ImageService.php | 151 ++++ app/Service/business/ListService.php | 198 +++++ app/Service/business/OrderCoreService.php | 383 ++++++++++ app/Service/business/OrderService.php | 193 +++++ app/Service/business/PriceService.php | 152 ++++ app/Service/business/PriceSheetService.php | 214 ++++++ app/Service/business/SerialNoService.php | 61 ++ .../business/WxTemplatePresetService.php | 199 +++++ .../business/WxTemplateSchemaService.php | 186 +++++ app/Service/business/WxTemplateService.php | 262 +++++++ app/Service/business/WxUserService.php | 257 +++++++ app/Service/common/JWTService.php | 38 + app/Service/common/MediaUrlService.php | 100 +++ app/Service/common/RedisService.php | 25 + app/Service/common/UploadService.php | 27 + app/Service/common/UtilsService.php | 10 +- .../common/oss/OssRuntimeConfigService.php | 27 + .../common/oss/OssStorageInterface.php | 20 + .../common/upload/AliyunStorageService.php | 109 ++- .../common/upload/LocalhostStorageService.php | 52 ++ .../common/upload/ObjectKeyNormalizeTrait.php | 66 ++ .../common/upload/ObjectListXmlTrait.php | 64 ++ .../common/upload/QcloudStorageService.php | 132 +++- .../common/upload/QiniuStorageService.php | 81 ++ .../upload/S3CompatibleStorageService.php | 145 +++- app/Service/wx/WxAppService.php | 130 ++++ app/Service/wx/WxAuthService.php | 71 ++ app/Service/wx/WxHomeService.php | 37 + app/Service/wx/WxListService.php | 215 ++++++ app/Service/wx/WxMiniService.php | 94 +++ app/Service/wx/WxOrderService.php | 112 +++ app/Service/wx/WxPayService.php | 221 ++++++ app/Service/wx/WxProductService.php | 125 ++++ app/Service/wx/WxThemeService.php | 109 +++ app/Service/wx/WxTokenService.php | 104 +++ app/Service/wx/WxUploadService.php | 70 ++ app/Service/wx/WxUserCenterService.php | 67 ++ bootstrap/app.php | 6 + composer.json | 6 +- composer.lock | 486 +++++++++++- config/database.php | 22 + config/lgp_menu.php | 123 ++++ config/media_refs.php | 48 ++ config/nl.php | 60 +- config/wx_templates.php | 234 ++++++ ...01_create_role_endpoint_relation_table.php | 34 + ...00002_extend_business_enterprise_table.php | 68 ++ ..._000003_extend_file_table_for_material.php | 137 ++++ ...13_000004_create_business_order_tables.php | 233 ++++++ .../2026_08_13_000005_create_wx_app_table.php | 48 ++ database/seeders/WxAppSeeder.php | 54 ++ database/sql/rbac_audit.sql | 112 +++ database/sql/rbac_migrate_ddl.sql | 124 ++++ public/nl_admin.sql | 354 ++++++++- routes/api.php | 55 +- scripts/db-check.php | 64 ++ scripts/lint-all.sh | 43 ++ scripts/lint.sh | 41 ++ 152 files changed, 13521 insertions(+), 141 deletions(-) create mode 100644 .cursor/rules/lgp-pitfalls.mdc create mode 100644 .cursor/rules/lgp-project-map.mdc create mode 100644 app/BaseApp/BaseBusinessModel.php create mode 100644 app/BaseApp/BaseWxService.php create mode 100644 app/Console/Commands/EndpointSyncCommand.php create mode 100644 app/Console/Commands/MenuSyncCommand.php create mode 100644 app/Console/Commands/RbacAuditCommand.php create mode 100644 app/Console/Commands/RbacMigrateCommand.php create mode 100644 app/Http/Controllers/Api/CardClassController.php create mode 100644 app/Http/Controllers/Api/CarouselController.php create mode 100644 app/Http/Controllers/Api/CatalogueController.php create mode 100644 app/Http/Controllers/Api/CategoryController.php create mode 100644 app/Http/Controllers/Api/ColorcardController.php create mode 100644 app/Http/Controllers/Api/CompanyController.php create mode 100644 app/Http/Controllers/Api/DepartmentController.php create mode 100644 app/Http/Controllers/Api/EnterpriseController.php create mode 100644 app/Http/Controllers/Api/FactoryClassificationController.php create mode 100644 app/Http/Controllers/Api/FactoryImageController.php create mode 100644 app/Http/Controllers/Api/FactoryInfoController.php create mode 100644 app/Http/Controllers/Api/FileFolderController.php create mode 100644 app/Http/Controllers/Api/ImageController.php create mode 100644 app/Http/Controllers/Api/ListController.php create mode 100644 app/Http/Controllers/Api/MaterialController.php create mode 100644 app/Http/Controllers/Api/OrderController.php create mode 100644 app/Http/Controllers/Api/PriceSheetController.php create mode 100644 app/Http/Controllers/Api/WxAppController.php create mode 100644 app/Http/Controllers/Api/WxTemplateController.php create mode 100644 app/Http/Controllers/Api/WxUserController.php create mode 100644 app/Http/Controllers/Wx/AuthController.php create mode 100644 app/Http/Controllers/Wx/HomeController.php create mode 100644 app/Http/Controllers/Wx/ListController.php create mode 100644 app/Http/Controllers/Wx/OrderController.php create mode 100644 app/Http/Controllers/Wx/ProductController.php create mode 100644 app/Http/Controllers/Wx/ThemeController.php create mode 100644 app/Http/Controllers/Wx/UserController.php create mode 100644 app/Http/Controllers/Wx/WxUploadController.php create mode 100644 app/Http/Middleware/ApiAuthMiddleware.php create mode 100644 app/Http/Middleware/WxAuthMiddleware.php create mode 100644 app/Jobs/ScanMediaReferencesJob.php create mode 100644 app/Jobs/SyncOssObjectsJob.php create mode 100644 app/Models/FileFolderModel.php create mode 100644 app/Models/RoleEndpointRelationModel.php create mode 100644 app/Models/WxAppModel.php create mode 100644 app/Models/business/CardClassModel.php create mode 100644 app/Models/business/CarouselModel.php create mode 100644 app/Models/business/CatalogueModel.php create mode 100644 app/Models/business/CategoryModel.php create mode 100644 app/Models/business/ColorcardModel.php create mode 100644 app/Models/business/CompanyModel.php create mode 100644 app/Models/business/DepartmentModel.php create mode 100644 app/Models/business/EnterpriseModel.php create mode 100644 app/Models/business/FactoryClassificationModel.php create mode 100644 app/Models/business/FactoryImageModel.php create mode 100644 app/Models/business/FactoryInfoModel.php create mode 100644 app/Models/business/ImageModel.php create mode 100644 app/Models/business/ListItemModel.php create mode 100644 app/Models/business/ListModel.php create mode 100644 app/Models/business/OrderDeliveryModel.php create mode 100644 app/Models/business/OrderItemModel.php create mode 100644 app/Models/business/OrderModel.php create mode 100644 app/Models/business/OrderPaymentModel.php create mode 100644 app/Models/business/PriceSheetModel.php create mode 100644 app/Models/business/WxTemplateModel.php create mode 100644 app/Models/business/WxUserModel.php create mode 100644 app/Service/DepartmentService.php create mode 100644 app/Service/FileFolderService.php create mode 100644 app/Service/MaterialService.php create mode 100644 app/Service/PermissionService.php create mode 100644 app/Service/WxAppConfigService.php create mode 100644 app/Service/business/CardClassService.php create mode 100644 app/Service/business/CarouselService.php create mode 100644 app/Service/business/CatalogueService.php create mode 100644 app/Service/business/CategoryService.php create mode 100644 app/Service/business/ColorcardService.php create mode 100644 app/Service/business/CompanyService.php create mode 100644 app/Service/business/EnterpriseService.php create mode 100644 app/Service/business/FactoryClassificationService.php create mode 100644 app/Service/business/FactoryImageService.php create mode 100644 app/Service/business/FactoryInfoService.php create mode 100644 app/Service/business/ImageService.php create mode 100644 app/Service/business/ListService.php create mode 100644 app/Service/business/OrderCoreService.php create mode 100644 app/Service/business/OrderService.php create mode 100644 app/Service/business/PriceService.php create mode 100644 app/Service/business/PriceSheetService.php create mode 100644 app/Service/business/SerialNoService.php create mode 100644 app/Service/business/WxTemplatePresetService.php create mode 100644 app/Service/business/WxTemplateSchemaService.php create mode 100644 app/Service/business/WxTemplateService.php create mode 100644 app/Service/business/WxUserService.php create mode 100644 app/Service/common/MediaUrlService.php create mode 100644 app/Service/common/upload/ObjectKeyNormalizeTrait.php create mode 100644 app/Service/common/upload/ObjectListXmlTrait.php create mode 100644 app/Service/wx/WxAppService.php create mode 100644 app/Service/wx/WxAuthService.php create mode 100644 app/Service/wx/WxHomeService.php create mode 100644 app/Service/wx/WxListService.php create mode 100644 app/Service/wx/WxMiniService.php create mode 100644 app/Service/wx/WxOrderService.php create mode 100644 app/Service/wx/WxPayService.php create mode 100644 app/Service/wx/WxProductService.php create mode 100644 app/Service/wx/WxThemeService.php create mode 100644 app/Service/wx/WxTokenService.php create mode 100644 app/Service/wx/WxUploadService.php create mode 100644 app/Service/wx/WxUserCenterService.php create mode 100644 config/lgp_menu.php create mode 100644 config/media_refs.php create mode 100644 config/wx_templates.php create mode 100644 database/migrations/2026_08_13_000001_create_role_endpoint_relation_table.php create mode 100644 database/migrations/2026_08_13_000002_extend_business_enterprise_table.php create mode 100644 database/migrations/2026_08_13_000003_extend_file_table_for_material.php create mode 100644 database/migrations/2026_08_13_000004_create_business_order_tables.php create mode 100644 database/migrations/2026_08_13_000005_create_wx_app_table.php create mode 100644 database/seeders/WxAppSeeder.php create mode 100644 database/sql/rbac_audit.sql create mode 100644 database/sql/rbac_migrate_ddl.sql create mode 100644 scripts/db-check.php create mode 100644 scripts/lint-all.sh create mode 100644 scripts/lint.sh diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index 303da0e3..fe248c95 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -19,6 +19,15 @@ alwaysApply: true 4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(本仓库为 API,此项约束 uniapp) 5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化 +# 数据库变更(强制 SQL,禁止新建 PHP Migration) + +- **禁止**为业务/系统表 DDL 新建 `database/migrations/*.php`(Laravel migrate 不作变更载体) +- **一律**写到 `database/sql//`,文件名:`01_简短英文名.sql`、`02_...`(当天序号递增) + - 例:`database/sql/2026-08-14/01_cc_carousel_add_status.sql` +- 脚本尽量幂等(`information_schema` + `PREPARE`,风格对齐已有 `rbac_migrate_ddl.sql`) +- 系统表结构若影响全新安装,同步回写 `public/nl_admin.sql`;业务表 `cc_*` 只放 `database/sql/`,不进 `nl_admin.sql` +- 运维在目标库手动执行对应日期目录下的 SQL;不要指望 `php artisan migrate` 补列/补表 + # 全局架构规范 ## 分层架构(Controller → Service → Model,禁止跨层) diff --git a/.cursor/rules/lgp-pitfalls.mdc b/.cursor/rules/lgp-pitfalls.mdc new file mode 100644 index 00000000..1530a70d --- /dev/null +++ b/.cursor/rules/lgp-pitfalls.mdc @@ -0,0 +1,40 @@ +--- +description: LGP 新后端开发约定与全项目群已知陷阱 +alwaysApply: true +--- + +# 开发约定与已知陷阱 + +## 新后端 lgp-admin-plus-api + +- 路由由 `UtilsService::autoRouteRegister()` **反射注册**,继承自 `BaseController` 的方法也会被注册成路由。不需要暴露的继承方法必须用 `@Method NO` 覆盖,否则每个控制器白送一套 `create/update/delete` +- 方法名 camelCase 会转成 kebab-case 路径:`myInfo` → `my-info` +- 缺 `@Method` 注解会注册成 `ANY`,务必显式写 `@Method GET` 或 `@Method POST` +- 响应统一走 `jok` / `jerr`,结构固定 `{code, message, result}`,成功 `code=0`。前端拦截器按 `result` 取数据,**不要用 `data`** +- 业务表 Model 覆盖 `$connection = 'business'`;系统表沿用默认 `mysql` +- 免鉴权 Service 继承 `BaseNotAuthService`,不要像老项目那样硬编码路径白名单 +- 鉴权真正发生在 `BaseService::__construct`,绕过 BaseService 的方法就没有鉴权。新代码一律走 BaseService +- `nl_menu` 的 `keep_alive` 与 `affix_tab` 输出到 meta 时是**取反**的,插数据别弄反 +- 第三方凭据用 `FieldEncryptService` 加密入库,不回显明文 +- **库表变更禁止新建 PHP Migration**:写 `database/sql//01_名字.sql`(当天序号递增、尽量幂等);系统表同步 `public/nl_admin.sql`,业务表 `cc_*` 只放 sql 目录 + +## 老项目只读 + +`lgp-api` / `lgp-wx-api` / `lgp-vben` / `lgp-vben-new` 处于迁移期,除安全修复外不要改动。 +查业务逻辑可以读,新功能一律写在 `lgp-admin-plus-api` + `lgp-admin-plus`。 + +## 已知陷阱 + +- 老 `lgp-wx-api` 把微信 `session_key` 当 token 直接返回客户端,且 `AuthMiddleware` 先执行控制器再检查 token、只判空不验签。归并时必须改成服务端自签 token +- 老 `lgp-api` 密码是**无盐 sha1**,新后端是 bcrypt,靠 `nl_admin.legacy_password` 惰性升级,遗留列有过期时间 +- 老 `cc_role.id=1` 不是超管,而新 `nl_role.id=1` 是超管且代码硬判断全量放行。角色迁移必须做 ID 偏移 +- 老前端 10 个模块的批量删除在静默 404(后端只有 `template` 注册了 `delete-batch`) +- `cc_price_sheet` 的 6 个材质列是死 schema,`create()` 从不赋值、前端已注释,实际只有 `routine` 在用 +- 图片水印不是后端接口,是前端拼 OSS URL 参数 `?watermark/2/text/...` +- 小程序 `baseURL` 硬编码在 `api/interceptor.js` 且靠注释切换,极易把测试地址发上线 +- 小程序无分包配置,uview-plus 全量进主包 + +## 安全红线 + +不要把任何密钥写进源码。老项目里七牛 AK/SK(`UploadService.php`)、微信 AppSecret(`WeChatService.php`)明文硬编码, +`.env.example` 里还提交了真实数据库密码——这些是待清理的历史欠账,不要照抄这个做法。 diff --git a/.cursor/rules/lgp-project-map.mdc b/.cursor/rules/lgp-project-map.mdc new file mode 100644 index 00000000..bd54f171 --- /dev/null +++ b/.cursor/rules/lgp-project-map.mdc @@ -0,0 +1,46 @@ +--- +description: LGP 项目群架构、各仓库角色与双品牌对应关系 +alwaysApply: true +--- + +# LGP 项目地图 + +两个家具品牌共用一套代码,正从老架构迁往新架构。 + +## 新架构(开发主战场) + +- `lgp-admin-plus` — 后台前端,vben **5.7.0** monorepo,主应用 `apps/web-antd`,上游是自研 `nl-admin-view` +- `lgp-admin-plus-api` — 后端,**Laravel 13**,位于 `sites/lgp-api/index/lgp-admin-plus-api` + +## 老架构(迁移期只读,迁完归档) + +- `lgp-vben` — 老后台前端,vben 2.11.5 +- `lgp-api` — 老后台后端,Laravel 10 +- `lgp-wx-api` — 老小程序后端,Laravel 12,本期归并进新后端的 `wx` 路由组 +- `lgp-vben-new` — **已弃用**,与 `lgp-vben` 同源克隆,不要在里面做任何改动 + +## 小程序(uni-app + Vue3 + uview-plus) + +- `lgp-wx` — 佛山铂尔曼,AppID `wx679d36842570cea7`,API `brm.wx.api.borman.top` +- `lgp-wx-new` — 维伦家具,AppID `wx57b54060a579c31a`,API `wx.api.borman.top` + +两者页面与业务代码基本一致,差异只有 AppID、`baseURL` 与首页视觉。改一边通常要同步另一边。 + +## 双品牌 + +同一套后台代码部署两份,靠构建模式区分:`build:brm`(佛山铂尔曼)/ `build:wl`(维伦家具)。 +两个品牌共用一套代码与数据库,任何业务改动会同时影响两家客户,上线前按品牌分别回归。 + +## 数据库 + +同一个库、两个连接(`config/database.php`): + +- `mysql`,前缀 `nl_` — 脚手架系统表(admin / role / menu / oss_config / file / api_endpoint 等) +- `business`,前缀 `cc_` — 业务表(catalogue / category / image / price_sheet / list / wx_user / enterprise 等) + +业务数据零迁移,只迁账号与角色。业务 Model 必须覆盖 `protected $connection = 'business'`。 + +## 迁移范围 + +老后端注册 171 条路由但前端只调用 **79** 条;老小程序后端注册 35 条但小程序只调用 **14** 条。 +迁移目标是这 93 条,其余是反射自动注册出来的死路由,不要跟着迁。 diff --git a/.env.example b/.env.example index 10d31a04..0916eaed 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,9 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -# 新的数据库 +# 数据库:同一个库两个连接 +# mysql 前缀 DB_PREFIX → 脚手架系统表 +# business 前缀 DB_BUSINESS_PREFIX → 业务表(Model 继承 BaseBusinessModel) DB_CONNECTION=mysql DB_HOST=mysql8 DB_PORT=3306 @@ -27,6 +29,7 @@ DB_DATABASE=cc_admin DB_USERNAME=root DB_PASSWORD=root DB_PREFIX=nl_ +DB_BUSINESS_PREFIX=cc_ SESSION_DRIVER=file SESSION_LIFETIME=120 @@ -88,3 +91,19 @@ JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b AI_DEFAULT_PROVIDER=deepseek SPARK_API_PASSWORD= DEEPSEEK_API_KEY= + +# 小程序品牌声明(单品牌部署兜底用) +# 双品牌共用一份部署时,仍以 nl_wx_app 表 + 请求头 X-App-Code 分流为主 +# AppSecret 不写这里,必须经后台「小程序应用配置」加密入库 +WX_DEFAULT_APP_CODE= +WX_APP_ID= +WX_APP_NAME= + + + +# weilun +# $this->appId = 'wx57b54060a579c31a'; +# $this->appSecret = 'b1b1852d6c438893bfd2cc1cdd5114dc'; +# boerman +# $this->appId = 'wx679d36842570cea7'; +# $this->appSecret = '4f2bcb8ca2ed1aaa29ae82ce270338e3'; diff --git a/README.md b/README.md index 89d89811..55933692 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,22 @@ php artisan sql:run-nl-admin --force # 创建新数据库 php artisan sql:run-nl-admin --database=my_new_db ``` + +### 合并数据使用示例 +```cmd +# 1. 补列(解除 my-info Unknown column department_id) +# 在 Navicat 选中库 cc_new_stash,执行 database/sql/rbac_migrate_ddl.sql +# 或:mysql ... cc_new_stash < database/sql/rbac_migrate_ddl.sql + +# 2. 迁移菜单 / 同步路由 +php artisan lgp:menu-sync +php artisan lgp:endpoint-sync + +# 3. 账号角色体检(只读) +php artisan lgp:rbac-audit +# 等价 SQL:database/sql/rbac_audit.sql + +# 4. 账号角色迁移(有事务/ID 偏移/幂等,仍走 artisan) +php artisan lgp:rbac-migrate --dry-run +php artisan lgp:rbac-migrate --force +``` diff --git a/app/BaseApp/BaseBusinessModel.php b/app/BaseApp/BaseBusinessModel.php new file mode 100644 index 00000000..4ad32bfc --- /dev/null +++ b/app/BaseApp/BaseBusinessModel.php @@ -0,0 +1,15 @@ + 不注册成路由的方法名 + * + * autoRouteRegister 是反射整个类,继承来的方法也会被注册, + * 于是每个控制器都白得一套 list/option/detail/create/update/delete。 + * 对不该有 CRUD 的控制器(比如登录),在子类里声明要排除的方法即可, + * 不必为了挂 @Method NO 去空覆写六个方法。 + */ + protected array $exceptRoute = []; + public function __construct() { } diff --git a/app/BaseApp/BaseWxService.php b/app/BaseApp/BaseWxService.php new file mode 100644 index 00000000..25384606 --- /dev/null +++ b/app/BaseApp/BaseWxService.php @@ -0,0 +1,48 @@ +utils = UtilsService::getInstance(); + $token = WxTokenService::getInstance(); + if ($this->needLogin) { + $this->userInfo = $token->requireUser(); + } else { + $this->userInfo = $token->resolveUser(request()->bearerToken()) ?? []; + } + $this->userId = (int) ($this->userInfo['id'] ?? 0); + } + + /** + * 当前用户是否可见价格 + */ + protected function canSeePrice(): bool + { + return (bool) ($this->userInfo['show_price'] ?? 0); + } + + /** + * 当前用户的价格倍率 + */ + protected function priceMultiplier(): mixed + { + return $this->userInfo['price_number'] ?? 1; + } +} diff --git a/app/Console/Commands/EndpointSyncCommand.php b/app/Console/Commands/EndpointSyncCommand.php new file mode 100644 index 00000000..332efe1a --- /dev/null +++ b/app/Console/Commands/EndpointSyncCommand.php @@ -0,0 +1,238 @@ +dryRun = (bool) $this->option('dry-run'); + if ($this->dryRun) { + $this->warn('预演模式:不会写入任何数据'); + } + + $noLog = (array) config('nl.log.no_insert', []); + $seen = []; + $created = 0; + $updated = 0; + + foreach (Route::getRoutes() as $route) { + $url = $this->normalizeUri($route->uri()); + if ($url === null) { + continue; + } + $method = $this->mapMethod($route); + $key = $url . '#' . $method; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $attributes = [ + 'name' => $this->resolveName($route, $url), + 'description' => $this->resolveDescription($route), + 'controller' => $this->resolveController($route), + 'is_log' => in_array($url, $noLog, true) ? 0 : 1, + 'status' => 1, + ]; + + $exists = ApiEndpointModel::where('url', $url)->where('method', $method)->first(); + if ($exists) { + // 名称与说明可能被人在后台改过,不覆盖;只补空值与纠正控制器归属 + $diff = []; + foreach (['controller', 'status'] as $field) { + if ((string) $exists->{$field} !== (string) $attributes[$field]) { + $diff[$field] = $attributes[$field]; + } + } + foreach (['name', 'description'] as $field) { + if ((string) $exists->{$field} === '' && $attributes[$field] !== '') { + $diff[$field] = $attributes[$field]; + } + } + if ((int) $exists->deleted_at !== 0) { + $diff['deleted_at'] = 0; + } + if (!empty($diff)) { + $updated++; + $this->line(" ~ {$url} [{$method}] " . implode(', ', array_keys($diff))); + if (!$this->dryRun) { + $diff['updated_at'] = time(); + ApiEndpointModel::where('id', $exists->id)->update($diff); + } + } + continue; + } + + $created++; + $this->line(" + {$url} [{$method}] {$attributes['name']}"); + if (!$this->dryRun) { + ApiEndpointModel::insert($attributes + [ + 'url' => $url, + 'method' => $method, + 'created_at' => time(), + 'deleted_at' => 0, + ]); + } + } + + $stale = $this->findStale(array_keys($seen)); + $this->newLine(); + $this->info("新增 {$created} 条,更新 {$updated} 条,路由已不存在 " . count($stale) . ' 条'); + + if (!empty($stale)) { + foreach ($stale as $row) { + $this->line(" ? {$row->url} [{$row->method}] {$row->name}"); + } + if ($this->option('prune') && !$this->dryRun) { + ApiEndpointModel::whereIn('id', array_column($stale, 'id'))->update([ + 'status' => 0, + 'updated_at' => time(), + ]); + $this->warn('以上接口已停用(未删除,避免历史操作日志失去关联)'); + } else { + $this->warn('加 --prune 可把它们停用'); + } + } + + if (!$this->dryRun) { + PermissionService::getInstance()->clear(); + $this->info('已清空角色权限缓存'); + } + + return self::SUCCESS; + } + + /** + * 只收 /api/ 下的业务接口;带路径参数的路由不进注册表(授权与日志都按固定路径匹配) + */ + private function normalizeUri(string $uri): ?string + { + $uri = trim($uri, '/'); + if (!str_starts_with($uri, 'api/')) { + return null; + } + $uri = trim(substr($uri, 4), '/'); + if ($uri === '' || str_contains($uri, '{')) { + return null; + } + return $uri; + } + + /** + * GET=1 POST=2 其他=0,与 ApiOpLogMiddleware 的映射保持一致 + */ + private function mapMethod(RoutingRoute $route): int + { + $methods = array_diff($route->methods(), ['HEAD']); + if ($methods === ['GET']) { + return 1; + } + if ($methods === ['POST']) { + return 2; + } + return 0; + } + + private function resolveController(RoutingRoute $route): string + { + $action = $route->getAction('controller'); + if (!is_string($action) || !str_contains($action, '@')) { + return ''; + } + return class_basename(explode('@', $action)[0]); + } + + /** + * 取控制器方法 PHPDoc 的首行作为操作名,没有注释时回落成路径 + */ + private function resolveName(RoutingRoute $route, string $url): string + { + $summary = $this->docSummary($route); + return $summary !== '' ? mb_substr($summary, 0, 64) : $url; + } + + private function resolveDescription(RoutingRoute $route): string + { + $lines = $this->docLines($route); + array_shift($lines); + return mb_substr(trim(implode(' ', $lines)), 0, 255); + } + + private function docSummary(RoutingRoute $route): string + { + return $this->docLines($route)[0] ?? ''; + } + + /** + * @return array 去掉 @tag 行后的注释正文 + */ + private function docLines(RoutingRoute $route): array + { + $action = $route->getAction('controller'); + if (!is_string($action) || !str_contains($action, '@')) { + return []; + } + [$class, $method] = explode('@', $action); + try { + $doc = (new ReflectionMethod($class, $method))->getDocComment(); + } catch (\Throwable $e) { + return []; + } + if (!is_string($doc)) { + return []; + } + $lines = []; + foreach (explode("\n", $doc) as $line) { + $line = trim(ltrim(trim($line), '/*')); + if ($line === '' || str_starts_with($line, '@')) { + continue; + } + $lines[] = $line; + } + return $lines; + } + + /** + * @param array $seenKeys url#method 组合 + * @return array + */ + private function findStale(array $seenKeys): array + { + $stale = []; + $rows = ApiEndpointModel::where('deleted_at', 0)->where('status', 1)->get(['id', 'url', 'method', 'name']); + foreach ($rows as $row) { + if (!in_array($row->url . '#' . (int) $row->method, $seenKeys, true)) { + $stale[] = (object) [ + 'id' => (int) $row->id, + 'url' => (string) $row->url, + 'method' => (int) $row->method, + 'name' => (string) $row->name, + ]; + } + } + return $stale; + } +} diff --git a/app/Console/Commands/MenuSyncCommand.php b/app/Console/Commands/MenuSyncCommand.php new file mode 100644 index 00000000..4d876d03 --- /dev/null +++ b/app/Console/Commands/MenuSyncCommand.php @@ -0,0 +1,157 @@ +dryRun = (bool) $this->option('dry-run'); + if ($this->dryRun) { + $this->warn('预演模式:不会写入任何数据'); + } + + $tree = config('lgp_menu', []); + if (empty($tree)) { + $this->error('config/lgp_menu.php 为空'); + return self::FAILURE; + } + + $this->walk($tree, 0); + + $this->newLine(); + $this->info("新增 {$this->created} 条,更新 {$this->updated} 条"); + + if (!$this->dryRun) { + // 菜单树按角色缓存在 Redis,不清的话前端看不到新菜单 + RedisService::getInstance()->init(config('nl.redis.menu_key'))->delAll(); + $this->info('已清空菜单缓存'); + $this->reportRoleBinding(); + } + + return self::SUCCESS; + } + + /** + * @param array> $nodes + */ + private function walk(array $nodes, int $pid): void + { + foreach ($nodes as $node) { + // parent 显式声明时优先(用于挂到脚手架已有菜单下),否则跟随递归层级 + $parentId = isset($node['parent']) + ? $this->resolveParentId((string) $node['parent']) + : $pid; + $id = $this->upsert($node, $parentId); + if (!empty($node['children'])) { + $this->walk($node['children'], $id); + } + } + } + + private function resolveParentId(string $name): int + { + $id = (int) MenuModel::where('name', $name)->where('deleted_at', 0)->value('id'); + if ($id === 0) { + $this->warn("父级菜单 {$name} 不存在,该节点挂到根目录"); + } + return $id; + } + + /** + * @param array $node + * @return int 菜单 id(预演模式下返回 0,子节点会挂到根,仅影响预演输出) + */ + private function upsert(array $node, int $pid): int + { + $isDirectory = ($node['component'] ?? '') === 'BasicLayout'; + $attributes = [ + 'title' => (string) $node['title'], + 'icon' => (string) ($node['icon'] ?? ''), + 'path' => (string) $node['path'], + 'component' => (string) $node['component'], + 'redirect' => (string) ($node['redirect'] ?? ''), + // 取反字段:0 开启缓存 / 0 固定标签 + 'keep_alive' => (int) ($node['keep_alive'] ?? ($isDirectory ? 0 : 1)), + 'hide_in_menu' => (int) ($node['hide_in_menu'] ?? 0), + 'affix_tab' => (int) ($node['affix_tab'] ?? 1), + 'badge' => (string) ($node['badge'] ?? ''), + 'badge_type' => (int) ($node['badge_type'] ?? 0), + 'badge_variants' => (int) ($node['badge_variants'] ?? 0), + 'iframe_src' => (string) ($node['iframe_src'] ?? ''), + 'query' => (string) ($node['query'] ?? ''), + 'pid' => $pid, + 'sort' => (int) ($node['sort'] ?? 0), + ]; + + $name = (string) $node['name']; + $exists = MenuModel::where('name', $name)->first(); + + if ($exists) { + $diff = array_filter( + $attributes, + fn ($value, $key) => (string) $exists->{$key} !== (string) $value, + ARRAY_FILTER_USE_BOTH + ); + // 已软删的菜单同步时一并复活,否则改配置也救不回来 + if ((int) $exists->deleted_at !== 0) { + $diff['deleted_at'] = 0; + } + if (empty($diff)) { + $this->line(" = {$attributes['title']} ({$name})"); + return (int) $exists->id; + } + $this->line(" ~ {$attributes['title']} ({$name}) " . implode(', ', array_keys($diff))); + $this->updated++; + if (!$this->dryRun) { + $diff['updated_at'] = time(); + MenuModel::where('id', $exists->id)->update($diff); + } + return (int) $exists->id; + } + + $this->line(" + {$attributes['title']} ({$name})"); + $this->created++; + if ($this->dryRun) { + return 0; + } + $attributes['created_at'] = time(); + return (int) MenuModel::insertGetId($attributes); + } + + /** + * 只提示不自动授权:给谁看哪些菜单是业务决策,命令替用户决定容易造成越权 + */ + private function reportRoleBinding(): void + { + if (!$this->option('grant-super')) { + $bound = RoleMenuRelationModel::distinct()->count('role_id'); + $this->newLine(); + $this->warn("超级管理员(role_id=1)自动可见全部菜单;其余 {$bound} 个已授权角色需到「角色管理」重新勾选新菜单。"); + } + } +} diff --git a/app/Console/Commands/RbacAuditCommand.php b/app/Console/Commands/RbacAuditCommand.php new file mode 100644 index 00000000..c0dc9f3c --- /dev/null +++ b/app/Console/Commands/RbacAuditCommand.php @@ -0,0 +1,396 @@ + 汇总结果,供 --json 落盘 */ + private array $report = []; + + public function handle(): int + { + $this->info('账号与角色迁移前数据体检'); + $this->line('老库连接:' . self::OLD . '(前缀 ' . DB::connection(self::OLD)->getTablePrefix() . ')'); + $this->line('新库连接:' . self::NEW . '(前缀 ' . DB::connection(self::NEW)->getTablePrefix() . ')'); + $this->newLine(); + + if (!$this->checkTables()) { + return self::FAILURE; + } + + $this->sectionScale(); + $this->sectionMultiRole(); + $this->sectionDuplicatePhone(); + $this->sectionInvalidPhone(); + $this->sectionRoles(); + $this->sectionRoleIdCollision(); + $this->sectionPasswordFormat(); + $this->sectionDepartment(); + $this->sectionAccountVsPhone(); + + $this->newLine(); + if ($this->blockerCount > 0) { + $this->error("发现 {$this->blockerCount} 项阻断级问题,请先处理再执行 lgp:rbac-migrate"); + } else { + $this->info('未发现阻断级问题,可以执行 lgp:rbac-migrate --dry-run 预演'); + } + + if ($path = $this->option('json')) { + $this->writeJson($path); + } + + return $this->blockerCount > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * 前置检查:两侧表都得在,否则后面每条查询都会抛异常; + * 同时检查 nl_admin / nl_role 是否已补齐 RBAC 迁移所需列(缺列时 my-info 等接口会直接 500) + */ + private function checkTables(): bool + { + $missing = []; + foreach (['user', 'role', 'user_role_relation', 'role_menu_relation', 'department'] as $table) { + if (!DB::connection(self::OLD)->getSchemaBuilder()->hasTable($table)) { + $missing[] = DB::connection(self::OLD)->getTablePrefix() . $table; + } + } + foreach (['admin', 'role'] as $table) { + if (!DB::connection(self::NEW)->getSchemaBuilder()->hasTable($table)) { + $missing[] = DB::connection(self::NEW)->getTablePrefix() . $table; + } + } + if (!empty($missing)) { + $this->error('缺少表:' . implode('、', $missing)); + $this->line('新库系统表请先执行 php artisan sql:run-nl-admin 安装'); + return false; + } + + // 代码已依赖这些列(AdminService::selectField / LoginService 惰性升级),迁移前就必须存在 + $missingColumns = []; + foreach ([ + 'admin' => ['department_id', 'legacy_password', 'legacy_password_expire_at'], + 'role' => ['status', 'color'], + ] as $table => $columns) { + foreach ($columns as $column) { + if (!DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column)) { + $missingColumns[] = DB::connection(self::NEW)->getTablePrefix() . $table . '.' . $column; + } + } + } + if (!empty($missingColumns)) { + $this->error('新库缺少列:' . implode('、', $missingColumns)); + $this->line('请先执行 SQL:database/sql/rbac_migrate_ddl.sql(幂等,可重复跑)'); + $this->line('或直接跑:php artisan lgp:rbac-migrate --force (会先幂等补列再迁数据)'); + return false; + } + return true; + } + + /** + * 迁移规模:先知道要搬多少行 + */ + private function sectionScale(): void + { + $rows = [ + ['cc_user 待迁账号', $this->oldCount('user')], + ['cc_role 待迁角色', $this->oldCount('role')], + ['cc_department 部门(复用不迁)', $this->oldCount('department')], + ['cc_user_role_relation 用户角色关系', DB::connection(self::OLD)->table('user_role_relation')->count()], + ['cc_role_menu_relation 角色菜单关系', DB::connection(self::OLD)->table('role_menu_relation')->count()], + ['nl_admin 现有账号', $this->newCount('admin')], + ['nl_role 现有角色', $this->newCount('role')], + ]; + $this->line('[1] 迁移规模'); + $this->table(['项目', '数量'], $rows); + $this->report['scale'] = collect($rows)->mapWithKeys(fn ($r) => [$r[0] => $r[1]])->all(); + } + + /** + * 多角色用户:老库是中间表多对多,新库是 nl_admin.role_id 单字段,多绑的必须人工决定取哪个 + * + * 注意:business 连接有前缀 cc_,Laravel 会把别名 `ur` 编译成 `cc_ur`, + * 但 selectRaw 不会自动改写,必须手写带前缀的别名,否则报 Unknown column 'ur.user_id' + */ + private function sectionMultiRole(): void + { + $p = $this->oldPrefix(); + $rows = DB::connection(self::OLD)->table('user_role_relation as ur') + ->join('user as u', 'u.id', '=', 'ur.user_id') + ->where('u.deleted_at', 0) + ->groupBy('ur.user_id', 'u.account', 'u.nick_name') + ->havingRaw('COUNT(*) > 1') + ->selectRaw("{$p}ur.user_id, {$p}u.account, {$p}u.nick_name, COUNT(*) AS role_count, GROUP_CONCAT({$p}ur.role_id ORDER BY {$p}ur.role_id) AS role_ids") + ->get(); + + $this->line('[2] 绑定了多个角色的用户(新库一个账号只有一个 role_id)'); + if ($rows->isEmpty()) { + $this->info(' 无,可安全一对一迁移'); + $this->report['multi_role'] = []; + return; + } + $this->blockerCount++; + $this->table( + ['user_id', 'account', 'nick_name', '角色数', 'role_ids'], + $rows->map(fn ($r) => [$r->user_id, $r->account, $r->nick_name, $r->role_count, $r->role_ids])->all() + ); + $this->warn(' 需决策:取 role_id 最小者、还是给新系统补多角色支持'); + $this->report['multi_role'] = $rows->toArray(); + + // 没有任何角色关系的账号同样要处理,否则迁过去 role_id=0 什么菜单都看不到 + $noRole = DB::connection(self::OLD)->table('user as u') + ->where('u.deleted_at', 0) + ->whereNotExists(function ($q) { + $q->select(DB::raw(1))->from('user_role_relation as ur')->whereColumn('ur.user_id', 'u.id'); + }) + ->get(['id', 'account', 'nick_name']); + if ($noRole->isNotEmpty()) { + $this->warn(' 另有 ' . $noRole->count() . ' 个账号没有任何角色关系,迁移后 role_id=0 将看不到任何菜单'); + $this->report['no_role'] = $noRole->toArray(); + } + } + + /** + * 重复手机号:登录是 where('phone', ...)->first(),重复会登录到错误的人 + */ + private function sectionDuplicatePhone(): void + { + $rows = DB::connection(self::OLD)->table('user') + ->where('deleted_at', 0) + ->groupBy('phone') + ->havingRaw('COUNT(*) > 1') + ->selectRaw('phone, COUNT(*) AS c, GROUP_CONCAT(id ORDER BY id) AS ids, GROUP_CONCAT(account ORDER BY id) AS accounts') + ->get(); + + $this->line('[3] 重复手机号(新库登录按 phone 查询,重复会登错人)'); + if ($rows->isEmpty()) { + $this->info(' 无重复'); + $this->report['duplicate_phone'] = []; + return; + } + $this->blockerCount++; + $this->table( + ['phone', '重复数', 'user_ids', 'accounts'], + $rows->map(fn ($r) => [$r->phone, $r->c, $r->ids, $r->accounts])->all() + ); + $this->warn(' 必须先在老库消重,迁移后还要给 nl_admin.phone 补唯一索引'); + $this->report['duplicate_phone'] = $rows->toArray(); + } + + /** + * 手机号格式:新库 nl_admin.phone 是 char(11),超长会被截断,空值无法登录 + */ + private function sectionInvalidPhone(): void + { + $rows = DB::connection(self::OLD)->table('user') + ->where('deleted_at', 0) + ->whereRaw("(phone = '' OR CHAR_LENGTH(phone) <> 11)") + ->selectRaw('id, account, nick_name, phone, CHAR_LENGTH(phone) AS len') + ->get(); + + $this->line('[4] 手机号长度异常(新库是 char(11),超长会截断)'); + if ($rows->isEmpty()) { + $this->info(' 全部为 11 位'); + $this->report['invalid_phone'] = []; + return; + } + $this->blockerCount++; + $this->table( + ['id', 'account', 'nick_name', 'phone', '长度'], + $rows->map(fn ($r) => [$r->id, $r->account, $r->nick_name, $r->phone, $r->len])->all() + ); + $this->warn(' 空手机号将完全无法登录,带区号/空格的要先清洗'); + $this->report['invalid_phone'] = $rows->toArray(); + } + + /** + * 老角色清单:新库 nl_role 需要 value(英文标识),老库没这个字段,逐个要指定 + */ + private function sectionRoles(): void + { + $rows = DB::connection(self::OLD)->table('role') + ->where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'name', 'desc', 'status', 'color']); + + $counts = DB::connection(self::OLD)->table('user_role_relation as ur') + ->join('user as u', 'u.id', '=', 'ur.user_id') + ->where('u.deleted_at', 0) + ->groupBy('ur.role_id') + ->selectRaw("{$this->oldPrefix()}ur.role_id, COUNT(*) AS c") + ->pluck('c', 'role_id'); + + $menuCounts = DB::connection(self::OLD)->table('role_menu_relation') + ->groupBy('role_id') + ->selectRaw('role_id, COUNT(*) AS c') + ->pluck('c', 'role_id'); + + $this->line('[5] 老角色清单(新库 nl_role.value 必填,需为每个角色指定英文标识)'); + $this->table( + ['老 id', 'name', 'desc', 'status(0正常1禁用)', 'color', '账号数', '菜单数'], + $rows->map(fn ($r) => [ + $r->id, $r->name, $r->desc, $r->status, $r->color, + $counts[$r->id] ?? 0, $menuCounts[$r->id] ?? 0, + ])->all() + ); + $this->report['old_roles'] = $rows->toArray(); + } + + /** + * 角色 ID 撞车:nl_role.id=1 是超级管理员且代码里硬判断 role_id===1 全量放行, + * 老库 id=1 未必是超管,按原 ID 迁会把普通角色提权成超管 + */ + private function sectionRoleIdCollision(): void + { + $this->line('[6] 角色 ID 撞车检查'); + $oldOne = DB::connection(self::OLD)->table('role')->where('id', 1)->first(); + $newOne = DB::connection(self::NEW)->table('role')->where('id', 1)->first(); + if ($oldOne && $newOne) { + $this->table( + ['库', 'id', 'name'], + [['老 cc_role', 1, $oldOne->name], ['新 nl_role', 1, $newOne->name]] + ); + $this->warn(' 老 id=1 是「' . $oldOne->name . '」,新 id=1 是「' . $newOne->name . '」(硬编码全量放行)'); + $this->warn(' 迁移必须做 ID 偏移,不能按原 ID 直搬'); + $this->blockerCount++; + } else { + $this->info(' 老库无 id=1 角色,仍建议偏移以留出系统角色区间'); + } + + $maxOld = (int) DB::connection(self::OLD)->table('role')->max('id'); + $this->line(' 老角色最大 id = ' . $maxOld . ',建议偏移量 100(迁后占用 101..' . (100 + $maxOld) . ')'); + $this->report['role_id_collision'] = [ + 'old_role_1' => $oldOne->name ?? null, + 'new_role_1' => $newOne->name ?? null, + 'old_max_id' => $maxOld, + ]; + } + + /** + * 密码格式:老库是无盐 sha1(40 位 hex),非此格式的迁过去无法惰性升级,只能走重置 + */ + private function sectionPasswordFormat(): void + { + $total = $this->oldCount('user'); + $sha1 = DB::connection(self::OLD)->table('user') + ->where('deleted_at', 0) + ->whereRaw("CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$'") + ->count(); + $empty = DB::connection(self::OLD)->table('user') + ->where('deleted_at', 0)->where('password', '')->count(); + $other = $total - $sha1 - $empty; + + $this->line('[7] 密码格式(惰性升级依赖无盐 sha1,40 位 hex)'); + $this->table(['类型', '数量'], [ + ['标准 sha1(可惰性升级)', $sha1], + ['空密码(必须重置)', $empty], + ['其它格式(必须重置)', max($other, 0)], + ]); + if ($empty > 0 || $other > 0) { + $this->warn(' 非 sha1 的账号迁移后需强制走密码重置流程'); + } + $this->report['password_format'] = ['sha1' => $sha1, 'empty' => $empty, 'other' => max($other, 0)]; + } + + /** + * 部门引用:cc_user.department 指向 cc_department.id,悬空引用迁过去会显示空部门 + */ + private function sectionDepartment(): void + { + $rows = DB::connection(self::OLD)->table('user as u') + ->where('u.deleted_at', 0) + ->where('u.department', '>', 0) + ->whereNotExists(function ($q) { + $q->select(DB::raw(1))->from('department as d') + ->whereColumn('d.id', 'u.department')->where('d.deleted_at', 0); + }) + ->get(['u.id', 'u.account', 'u.department']); + + $this->line('[8] 部门悬空引用'); + if ($rows->isEmpty()) { + $this->info(' 无悬空引用'); + $this->report['orphan_department'] = []; + return; + } + $this->table( + ['user_id', 'account', 'department(已不存在)'], + $rows->map(fn ($r) => [$r->id, $r->account, $r->department])->all() + ); + $this->warn(' 这些账号迁移后 department_id 建议置 0'); + $this->report['orphan_department'] = $rows->toArray(); + } + + /** + * account 与 phone 的关系:老前端表单字段叫 account,但后端一直是 where('phone', ...), + * 也就是用户实际输的是手机号。account 不参与登录,迁移时只留痕 + */ + private function sectionAccountVsPhone(): void + { + $diff = DB::connection(self::OLD)->table('user') + ->where('deleted_at', 0) + ->whereColumn('account', '<>', 'phone') + ->count(); + + $this->line('[9] account 与 phone 不一致的账号'); + $this->line(' 共 ' . $diff . ' 个。account 从不参与登录(老 UserService::login 查的是 phone),'); + $this->line(' 迁移时把原 account 写进 nl_admin.desc 留痕即可,不新增列'); + $this->report['account_phone_diff'] = $diff; + } + + private function oldCount(string $table): int + { + return DB::connection(self::OLD)->table($table)->where('deleted_at', 0)->count(); + } + + private function newCount(string $table): int + { + return DB::connection(self::NEW)->table($table)->where('deleted_at', 0)->count(); + } + + /** + * 老库(business)表前缀,selectRaw 里引用别名时必须手动拼上 + * Laravel 会把 `as ur` 编译成 `as cc_ur`,但 selectRaw 字符串不会自动改写 + */ + private function oldPrefix(): string + { + return DB::connection(self::OLD)->getTablePrefix(); + } + + private function writeJson(string $path): void + { + $full = str_starts_with($path, '/') ? $path : base_path($path); + @mkdir(dirname($full), 0755, true); + file_put_contents( + $full, + json_encode([ + 'generated_at' => date('Y-m-d H:i:s'), + 'blocker_count' => $this->blockerCount, + 'report' => $this->report, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + $this->info('体检结果已写入:' . $full); + } +} diff --git a/app/Console/Commands/RbacMigrateCommand.php b/app/Console/Commands/RbacMigrateCommand.php new file mode 100644 index 00000000..534e54a5 --- /dev/null +++ b/app/Console/Commands/RbacMigrateCommand.php @@ -0,0 +1,415 @@ + 老角色 id => 新角色 id */ + private array $roleMap = []; + + public function handle(): int + { + $this->dryRun = (bool) $this->option('dry-run'); + $this->offset = max(1, (int) $this->option('offset')); + + if ($this->dryRun) { + $this->warn('预演模式:不会写入任何数据'); + } + + $this->ensureColumns(); + + if (!$this->dryRun && !$this->option('force')) { + $this->warn('即将向 nl_role / nl_admin 写入数据。建议先跑 lgp:rbac-audit 与 --dry-run。'); + if (!$this->confirm('继续?', false)) { + $this->line('已取消'); + return self::SUCCESS; + } + } + + try { + if (!$this->dryRun) { + DB::connection(self::NEW)->beginTransaction(); + } + $this->migrateRoles(); + $this->migrateAdmins(); + if (!$this->dryRun) { + DB::connection(self::NEW)->commit(); + } + } catch (Throwable $e) { + if (!$this->dryRun && DB::connection(self::NEW)->transactionLevel() > 0) { + DB::connection(self::NEW)->rollBack(); + } + $this->error('迁移失败已回滚:' . $e->getMessage()); + return self::FAILURE; + } + + $this->reportRoleMenu(); + + $this->newLine(); + $this->info($this->dryRun ? '预演结束' : '迁移完成'); + $this->line('后续手动步骤:'); + $this->line(' 1. 按第十一节重建 nl_menu,再按功能重绑角色菜单(老 menu_id 不能直搬)'); + $this->line(' 2. 确认手机号无重复后执行 database/sql/rbac_migrate_ddl.sql 里的 uk_phone 唯一索引'); + return self::SUCCESS; + } + + /** + * 幂等补列:MySQL 不支持 ADD COLUMN IF NOT EXISTS,这里查 information_schema 再决定 + */ + private function ensureColumns(): void + { + $adds = [ + 'admin' => [ + 'department_id' => "ADD COLUMN `department_id` int NOT NULL DEFAULT 0 COMMENT '所属部门ID,对应 cc_department.id' AFTER `role_id`", + 'legacy_password' => "ADD COLUMN `legacy_password` varchar(64) NOT NULL DEFAULT '' COMMENT '老系统无盐sha1密码,登录成功后清空' AFTER `password`", + 'legacy_password_expire_at' => "ADD COLUMN `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT '遗留密码失效时间' AFTER `legacy_password`", + ], + 'role' => [ + 'status' => "ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态 0正常 1禁用' AFTER `desc`", + 'color' => "ADD COLUMN `color` varchar(32) NOT NULL DEFAULT '' COMMENT '角色标签颜色' AFTER `status`", + ], + ]; + + foreach ($adds as $table => $columns) { + $missing = []; + foreach ($columns as $column => $clause) { + if (!$this->hasColumn($table, $column)) { + $missing[] = $clause; + } + } + if (empty($missing)) { + continue; + } + $physical = DB::connection(self::NEW)->getTablePrefix() . $table; + $sql = "ALTER TABLE `{$physical}` " . implode(', ', $missing); + $this->line('补列:' . $physical . ' → ' . count($missing) . ' 列'); + if (!$this->dryRun) { + DB::connection(self::NEW)->statement($sql); + } + } + + // 安装包里超管 status=1,而列注释是「0正常 1禁用」,补上状态校验后会被锁死,先纠正 + if (!$this->dryRun) { + DB::connection(self::NEW)->table('admin')->where('id', 1)->where('status', 1)->update(['status' => 0]); + } + } + + private function hasColumn(string $table, string $column): bool + { + return DB::connection(self::NEW)->getSchemaBuilder()->hasColumn($table, $column); + } + + /** + * 角色迁移:新 id = 老 id + offset,value 缺省生成 role_{老id} + */ + private function migrateRoles(): void + { + $this->newLine(); + $this->line('迁移角色'); + + $valueMap = $this->loadValueMap(); + $roles = DB::connection(self::OLD)->table('role')->where('deleted_at', 0)->orderBy('id')->get(); + $rows = []; + $skipped = 0; + + foreach ($roles as $role) { + $newId = (int) $role->id + $this->offset; + $this->roleMap[(int) $role->id] = $newId; + + if (DB::connection(self::NEW)->table('role')->where('id', $newId)->exists()) { + $skipped++; + continue; + } + + $value = $valueMap[$role->name] ?? ('role_' . $role->id); + $data = [ + 'id' => $newId, + 'name' => mb_substr((string) $role->name, 0, 32), + 'value' => mb_substr($value, 0, 32), + 'pid' => 0, + 'desc' => $this->tagged(self::ROLE_TAG, (int) $role->id, (string) $role->desc), + 'status' => (int) $role->status, + 'color' => (string) $role->color, + 'created_at' => (int) $role->created_at, + 'updated_at' => (int) $role->updated_at, + 'deleted_at' => 0, + ]; + $rows[] = $data; + } + + $this->table( + ['老 id', '新 id', 'name', 'value', 'status'], + array_map( + fn ($r) => [$r['id'] - $this->offset, $r['id'], $r['name'], $r['value'], $r['status']], + $rows + ) + ); + if ($skipped > 0) { + $this->line('已存在跳过:' . $skipped . ' 个'); + } + + if (!$this->dryRun && !empty($rows)) { + DB::connection(self::NEW)->table('role')->insert($rows); + } + $this->info('角色新增 ' . count($rows) . ' 个'); + } + + /** + * 账号迁移:新 id = 老 id + offset,密码进 legacy_password 走惰性升级 + */ + private function migrateAdmins(): void + { + $this->newLine(); + $this->line('迁移账号'); + + // 多角色降级:取 role_id 最小者,与体检报告口径一致 + $userRole = DB::connection(self::OLD)->table('user_role_relation') + ->groupBy('user_id') + ->selectRaw('user_id, MIN(role_id) AS role_id') + ->pluck('role_id', 'user_id'); + + $validDept = DB::connection(self::OLD)->table('department') + ->where('deleted_at', 0)->pluck('id')->all(); + $validDept = array_flip(array_map('intval', $validDept)); + + $legacyExpire = time() + max(1, (int) $this->option('legacy-days')) * 86400; + + $users = DB::connection(self::OLD)->table('user')->where('deleted_at', 0)->orderBy('id')->get(); + $rows = []; + $skipped = 0; + $problems = []; + + foreach ($users as $user) { + $newId = (int) $user->id + $this->offset; + if (DB::connection(self::NEW)->table('admin')->where('id', $newId)->exists()) { + $skipped++; + continue; + } + + $phone = trim((string) $user->phone); + if ($phone === '' || mb_strlen($phone) !== 11) { + $problems[] = [$user->id, $user->account, $phone === '' ? '(空)' : $phone, '手机号非 11 位,已跳过']; + continue; + } + + $legacy = $this->normalizeLegacyPassword((string) $user->password); + if ($legacy === '') { + $problems[] = [$user->id, $user->account, $phone, '密码非标准 sha1,迁移后需走重置']; + } + + $oldRoleId = (int) ($userRole[$user->id] ?? 0); + $newRoleId = $oldRoleId > 0 ? ($this->roleMap[$oldRoleId] ?? 0) : 0; + if ($newRoleId === 0) { + $problems[] = [$user->id, $user->account, $phone, '无角色,迁移后看不到菜单']; + } + + $dept = (int) $user->department; + if ($dept > 0 && !isset($validDept[$dept])) { + $problems[] = [$user->id, $user->account, $phone, "部门 {$dept} 不存在,已置 0"]; + $dept = 0; + } + + $nickName = trim((string) $user->nick_name); + if ($nickName === '') { + $nickName = (string) $user->account; + } + + $rows[] = [ + 'id' => $newId, + // NOT NULL 无默认值,格式对齐安装包里超管那行 + 'open_id' => 'nl_' . bin2hex(random_bytes(15)), + 'avatar' => (string) $user->avatar, + 'nick_name' => mb_substr($nickName, 0, 32), + // 新库是 bcrypt,老 sha1 没有明文无法转换,先留空由 legacy_password 兜底 + 'password' => '', + 'legacy_password' => $legacy, + 'legacy_password_expire_at' => $legacy === '' ? 0 : $legacyExpire, + 'phone' => $phone, + 'email' => substr((string) $user->email, 0, 32), + 'code' => '', + 'role_id' => $newRoleId, + 'department_id' => $dept, + 'province_id' => 0, + 'city_id' => 0, + 'reg_ip' => 0, + 'last_login_time' => $this->parseLastLogin((string) $user->last_login_at), + 'ip' => (string) ($user->last_login_ip ?: '0'), + // NOT NULL 的 json 列,不给值会直接插入失败 + 'ip_table' => '[]', + 'operation_password' => '0', + 'desc' => $this->buildAdminDesc($user), + // 老库同样是 0正常 1禁用,可以直搬;但新表默认值是 1,必须显式写 + 'status' => (int) $user->status, + 'created_at' => (int) $user->created_at, + 'updated_at' => (int) $user->updated_at, + 'deleted_at' => 0, + ]; + } + + if (!empty($problems)) { + $this->newLine(); + $this->warn('需要关注的账号:'); + $this->table(['老 id', 'account', 'phone', '说明'], $problems); + } + + if (!$this->dryRun && !empty($rows)) { + foreach (array_chunk($rows, 200) as $chunk) { + DB::connection(self::NEW)->table('admin')->insert($chunk); + } + // 后续新建账号从迁移区间之后继续,避免自增撞上已占用的 id + $maxId = (int) DB::connection(self::NEW)->table('admin')->max('id'); + $physical = DB::connection(self::NEW)->getTablePrefix() . 'admin'; + DB::connection(self::NEW)->statement("ALTER TABLE `{$physical}` AUTO_INCREMENT = " . ($maxId + 1)); + } + + if ($skipped > 0) { + $this->line('已存在跳过:' . $skipped . ' 个'); + } + $this->info('账号新增 ' . count($rows) . ' 个'); + } + + /** + * 老 sha1 必须是 40 位纯 hex 才能参与惰性升级,其它格式一律留空走重置 + */ + private function normalizeLegacyPassword(string $password): string + { + $password = strtolower(trim($password)); + return preg_match('/^[0-9a-f]{40}$/', $password) === 1 ? $password : ''; + } + + /** + * 老 last_login_at 是 varchar,可能是时间戳字符串也可能是日期文本 + */ + private function parseLastLogin(string $value): int + { + $value = trim($value); + if ($value === '' || $value === '0') { + return 0; + } + if (ctype_digit($value)) { + return (int) $value; + } + $ts = strtotime($value); + return $ts === false ? 0 : $ts; + } + + /** + * 备注里留迁移痕迹:account 不参与登录(老 login 查的是 phone),但对不上账时要能追溯 + */ + private function buildAdminDesc(object $user): string + { + $parts = []; + $intro = trim((string) ($user->introduction ?? '')); + $address = trim((string) ($user->address ?? '')); + if ($intro !== '') { + $parts[] = $intro; + } + if ($address !== '') { + $parts[] = '所在地:' . $address; + } + $desc = implode(';', $parts); + return $this->tagged(self::TAG, (int) $user->id, $desc, (string) $user->account); + } + + /** + * 拼迁移留痕,同时作为幂等判断依据。超长时优先保留留痕 + */ + private function tagged(string $tag, int $oldId, string $desc, string $account = ''): string + { + $mark = $tag . $oldId . ($account !== '' ? ' account=' . $account : '') . ']'; + $full = $desc === '' ? $mark : $mark . ' ' . $desc; + return mb_substr($full, 0, 255); + } + + /** + * 角色菜单关系不能按 menu_id 直搬:业务页面在 vben5 里路径全变了,老 menu_id 指向的菜单不复存在。 + * 这里只导出「哪个角色能看哪些功能」的语义清单,供重建菜单后按功能重绑。 + */ + private function reportRoleMenu(): void + { + $this->newLine(); + $this->line('角色菜单语义参照(需人工重绑,不自动迁移)'); + + $rows = DB::connection(self::OLD)->table('role_menu_relation as rm') + ->join('role as r', 'r.id', '=', 'rm.role_id') + ->join('menu as m', 'm.id', '=', 'rm.menu_id') + ->where('r.deleted_at', 0) + ->where('m.deleted_at', 0) + ->orderBy('r.id') + ->orderBy('m.order_no') + ->get(['r.id as role_id', 'r.name as role_name', 'm.title', 'm.router']); + + if ($rows->isEmpty()) { + $this->line(' 老库无角色菜单关系'); + return; + } + + $grouped = []; + foreach ($rows as $row) { + $key = ($this->roleMap[(int) $row->role_id] ?? 0) . '|' . $row->role_name; + $grouped[$key][] = $row->title; + } + $table = []; + foreach ($grouped as $key => $titles) { + [$newRoleId, $roleName] = explode('|', $key, 2); + $table[] = [$newRoleId, $roleName, count($titles), implode('、', array_slice($titles, 0, 8)) . (count($titles) > 8 ? ' …' : '')]; + } + $this->table(['新角色 id', '角色名', '菜单数', '可见功能(截断)'], $table); + } + + /** + * 角色 value 映射:老 cc_role 没有 value 字段,缺省用 role_{老id}, + * 想要可读的英文标识就传 --value-map + */ + private function loadValueMap(): array + { + $path = (string) $this->option('value-map'); + if ($path === '') { + return []; + } + $full = str_starts_with($path, '/') ? $path : base_path($path); + if (!file_exists($full)) { + $this->warn('value-map 文件不存在,改用默认 role_{老id}:' . $full); + return []; + } + $map = json_decode((string) file_get_contents($full), true); + return is_array($map) ? $map : []; + } +} diff --git a/app/Http/Controllers/Api/AdminController.php b/app/Http/Controllers/Api/AdminController.php index 26f2016f..7b13cf21 100644 --- a/app/Http/Controllers/Api/AdminController.php +++ b/app/Http/Controllers/Api/AdminController.php @@ -17,7 +17,7 @@ class AdminController extends BaseController $this->service = AdminService::getInstance(); $this->insertField = [ 'phone', 'nick_name', 'password', 'role_id' ]; $this->updateField = [ 'id', 'phone', 'nick_name', 'role_id' ]; - $this->notRequest = ['qr_code', 'avatar', 'email', 'status']; + $this->notRequest = ['qr_code', 'avatar', 'email', 'status', 'department_id', 'desc']; } /** @@ -115,7 +115,9 @@ class AdminController extends BaseController } /** - * 获取菜单列表 + * 获取当前账号的权限码 + * + * 码由接口路径推导(admin/list → admin:list),前端 v-access 与 TableAction 的 auth 用的就是它。 * @Method GET * @return JsonResponse * @throws Exception @@ -123,7 +125,7 @@ class AdminController extends BaseController public function codes(): JsonResponse { return jok( - [], + $this->service->codes(), '获取成功' ); } diff --git a/app/Http/Controllers/Api/CardClassController.php b/app/Http/Controllers/Api/CardClassController.php new file mode 100644 index 00000000..dd516ac9 --- /dev/null +++ b/app/Http/Controllers/Api/CardClassController.php @@ -0,0 +1,35 @@ +service = CardClassService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/CarouselController.php b/app/Http/Controllers/Api/CarouselController.php new file mode 100644 index 00000000..05620505 --- /dev/null +++ b/app/Http/Controllers/Api/CarouselController.php @@ -0,0 +1,35 @@ +service = CarouselService::getInstance(); + $this->insertField = ['url']; + $this->updateField = ['id', 'url']; + $this->notRequest = ['to_path', 'sort', 'status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/CatalogueController.php b/app/Http/Controllers/Api/CatalogueController.php new file mode 100644 index 00000000..417f007d --- /dev/null +++ b/app/Http/Controllers/Api/CatalogueController.php @@ -0,0 +1,35 @@ +service = CatalogueService::getInstance(); + $this->insertField = ['title', 'category_id', 'cover', 'identifier']; + $this->updateField = ['id', 'title', 'category_id', 'cover', 'identifier']; + $this->notRequest = ['alias', 'pdf', 'price', 'status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/CategoryController.php b/app/Http/Controllers/Api/CategoryController.php new file mode 100644 index 00000000..3875e51a --- /dev/null +++ b/app/Http/Controllers/Api/CategoryController.php @@ -0,0 +1,35 @@ +service = CategoryService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['url', 'pid', 'status', 'sort']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/ColorcardController.php b/app/Http/Controllers/Api/ColorcardController.php new file mode 100644 index 00000000..067ad87c --- /dev/null +++ b/app/Http/Controllers/Api/ColorcardController.php @@ -0,0 +1,35 @@ +service = ColorcardService::getInstance(); + $this->insertField = ['card_class', 'company', 'cover']; + $this->updateField = ['id', 'card_class', 'company']; + $this->notRequest = ['price', 'description', 'cover', 'status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/CompanyController.php b/app/Http/Controllers/Api/CompanyController.php new file mode 100644 index 00000000..55bb7b81 --- /dev/null +++ b/app/Http/Controllers/Api/CompanyController.php @@ -0,0 +1,35 @@ +service = CompanyService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['phone', 'address', 'status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/DepartmentController.php b/app/Http/Controllers/Api/DepartmentController.php new file mode 100644 index 00000000..23d7276f --- /dev/null +++ b/app/Http/Controllers/Api/DepartmentController.php @@ -0,0 +1,47 @@ +service = DepartmentService::getInstance(); + // insertField / updateField 既是必填校验清单,也是入库字段白名单,漏写的字段不会被收进 params + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + // 这几个允许缺省:pid 缺省为顶级,status 缺省为正常 + $this->notRequest = ['pid', 'desc', 'status', 'color']; + } + + /** + * 部门树下拉(表单选上级部门用) + * @Method GET + */ + public function treeOption(): JsonResponse + { + $isSelect = filter_var(request()->get('is_select', false), FILTER_VALIDATE_BOOLEAN); + return jok($this->service->getTreeOption($isSelect), '列表获取成功'); + } + + /** + * 停用 / 启用部门 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $id = request()->post('id'); + $status = request()->post('status'); + if (empty($id) || !in_array((string) $status, ['0', '1'], true)) { + return jerr('参数错误'); + } + return jok($this->service->status($id, $status), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/EnterpriseController.php b/app/Http/Controllers/Api/EnterpriseController.php new file mode 100644 index 00000000..0eec9000 --- /dev/null +++ b/app/Http/Controllers/Api/EnterpriseController.php @@ -0,0 +1,38 @@ +service = EnterpriseService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = [ + 'logo', 'contact_name', 'phone', 'address', + 'tax_no', 'settle_type', 'price_number', 'status', 'remark', + ]; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/FactoryClassificationController.php b/app/Http/Controllers/Api/FactoryClassificationController.php new file mode 100644 index 00000000..6c7526d1 --- /dev/null +++ b/app/Http/Controllers/Api/FactoryClassificationController.php @@ -0,0 +1,35 @@ +service = FactoryClassificationService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/FactoryImageController.php b/app/Http/Controllers/Api/FactoryImageController.php new file mode 100644 index 00000000..d42d4282 --- /dev/null +++ b/app/Http/Controllers/Api/FactoryImageController.php @@ -0,0 +1,44 @@ +service = FactoryImageService::getInstance(); + $this->insertField = ['factory', 'url']; + $this->updateField = ['id', 'url']; + $this->notRequest = ['factory', 'status']; + } + + /** + * 某工厂的全部产品图(入参 factory) + * @Method GET + */ + public function imageList(): JsonResponse + { + return jok($this->service->imageList((int) request()->get('factory', 0)), '列表获取成功'); + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/FactoryInfoController.php b/app/Http/Controllers/Api/FactoryInfoController.php new file mode 100644 index 00000000..529d1209 --- /dev/null +++ b/app/Http/Controllers/Api/FactoryInfoController.php @@ -0,0 +1,35 @@ +service = FactoryInfoService::getInstance(); + $this->insertField = ['name', 'classification']; + $this->updateField = ['id', 'name', 'classification']; + $this->notRequest = ['phone', 'cover', 'address', 'status']; + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/FileFolderController.php b/app/Http/Controllers/Api/FileFolderController.php new file mode 100644 index 00000000..c8db336c --- /dev/null +++ b/app/Http/Controllers/Api/FileFolderController.php @@ -0,0 +1,22 @@ +service = FileFolderService::getInstance(); + $this->insertField = ['name']; + $this->updateField = ['id', 'name']; + // pid = 0 就是根目录,sort / status 也允许留空走默认值 + $this->notRequest = ['pid', 'sort', 'status']; + } +} diff --git a/app/Http/Controllers/Api/ImageController.php b/app/Http/Controllers/Api/ImageController.php new file mode 100644 index 00000000..85ff797a --- /dev/null +++ b/app/Http/Controllers/Api/ImageController.php @@ -0,0 +1,53 @@ +service = ImageService::getInstance(); + $this->insertField = ['catalogue_id', 'url', 'type']; + $this->updateField = ['id', 'url']; + $this->notRequest = ['catalogue_id', 'type', 'status']; + } + + /** + * 渲染图列表(入参 catalogue_id) + * @Method GET + */ + public function renderGraph(): JsonResponse + { + return jok($this->service->renderGraph((int) request()->get('catalogue_id', 0)), '列表获取成功'); + } + + /** + * 实物图列表(入参 catalogue_id) + * @Method GET + */ + public function physicalDrawing(): JsonResponse + { + return jok($this->service->physicalDrawing((int) request()->get('catalogue_id', 0)), '列表获取成功'); + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/ListController.php b/app/Http/Controllers/Api/ListController.php new file mode 100644 index 00000000..a5f5429c --- /dev/null +++ b/app/Http/Controllers/Api/ListController.php @@ -0,0 +1,61 @@ +service = ListService::getInstance(); + $this->insertField = ['name', 'user_id']; + $this->updateField = ['id', 'name']; + $this->notRequest = ['remark', 'enterprise_id']; + } + + /** + * 某用户的清单 + * @Method GET + */ + public function byUser(): JsonResponse + { + return jok($this->service->byUser((int) request()->get('user_id', 0))); + } + + /** + * 清单生成订单 + * @Method POST + */ + public function toOrder(): JsonResponse + { + return jok( + $this->service->toOrder((int) request()->post('id', 0), request()->post()), + '订单已生成' + ); + } + + /** + * 保存清单明细 + * @Method POST + */ + public function saveItem(): JsonResponse + { + return jok($this->service->saveItem(request()->post()), '保存成功'); + } + + /** + * 删除清单明细 + * @Method POST + */ + public function deleteItem(): JsonResponse + { + return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功'); + } +} diff --git a/app/Http/Controllers/Api/LoginController.php b/app/Http/Controllers/Api/LoginController.php index 5479b12e..e8273a85 100644 --- a/app/Http/Controllers/Api/LoginController.php +++ b/app/Http/Controllers/Api/LoginController.php @@ -8,7 +8,11 @@ use Illuminate\Http\JsonResponse; class LoginController extends BaseController { - // + /** + * 登录控制器注册在免登录组里,继承来的 CRUD 会变成 /api/list、/api/create 这种 + * 无需鉴权、指向 LoginService(根本没有这些方法)的路由,必须排除 + */ + protected array $exceptRoute = ['list', 'option', 'detail', 'create', 'update', 'delete']; public function __construct() { @@ -54,20 +58,32 @@ class LoginController extends BaseController ); } - public function logout() + /** + * 退出登录 + * @Method POST + * @throws \Exception + */ + public function logout(): JsonResponse { return jok( - [], + $this->service->logout(), '退出成功' ); } - public function codes() + /** + * 续签 token + * + * 前端拦截器在 401 时调用,此时 token 已过期,所以只能放在免登录组里, + * 由 LoginService::refresh 自己校验签名与 Redis 会话。 + * @Method POST + * @throws \Exception + */ + public function refresh(): JsonResponse { return jok( - [], - '获取成功' + $this->service->refresh(), + '续签成功' ); } - } diff --git a/app/Http/Controllers/Api/MaterialController.php b/app/Http/Controllers/Api/MaterialController.php new file mode 100644 index 00000000..7e7c8707 --- /dev/null +++ b/app/Http/Controllers/Api/MaterialController.php @@ -0,0 +1,111 @@ +service = MaterialService::getInstance(); + // 只放行素材名与文件夹:对象键、体积、哈希都是同步结果,不接受手工改 + $this->updateField = ['id', 'name', 'folder_id']; + $this->notRequest = ['name', 'folder_id']; + } + + /** + * 素材概览统计 + * + * @Method GET + * @throws Exception + */ + public function stat(): JsonResponse + { + return jok($this->service->stat(), '统计获取成功'); + } + + /** + * 从 OSS 增量同步对象 + * + * @Method POST + * @throws Exception + */ + public function syncFromOss(): JsonResponse + { + $this->insertField = ['oss_config_id']; + $this->notRequest = ['prefix', 'marker', 'limit']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->syncFromOss($params), '同步完成'); + } + + /** + * 全库引用扫描 + * + * @Method POST + * @throws Exception + */ + public function scanReferences(): JsonResponse + { + // 表多、正文长时会跑几十秒,先把执行时限和内存放开 + cc_set_time_limit(); + return jok($this->service->scanReferences(request()->post()), '扫描完成'); + } + + /** + * 无引用素材清单 + * + * @Method GET + * @throws Exception + */ + public function unusedList(): JsonResponse + { + return jok($this->service->unusedList(request()->query()), '列表获取成功'); + } + + /** + * 回收:删远端对象并软删记录 + * + * @Method POST + * @throws Exception + */ + public function reclaim(): JsonResponse + { + $ids = request()->post('ids'); + if (empty($ids) || !is_array($ids)) { + UtilsService::getInstance()->notFound('参数错误'); + } + return jok($this->service->reclaim($ids), '回收完成'); + } + + /** + * 批量移动到文件夹 + * + * @Method POST + * @throws Exception + */ + public function moveToFolder(): JsonResponse + { + $ids = request()->post('ids'); + if (empty($ids) || !is_array($ids)) { + UtilsService::getInstance()->notFound('参数错误'); + } + return jok( + $this->service->moveToFolder($ids, (int) request()->post('folder_id', 0)), + '移动成功' + ); + } +} diff --git a/app/Http/Controllers/Api/OrderController.php b/app/Http/Controllers/Api/OrderController.php new file mode 100644 index 00000000..f5a1c1a6 --- /dev/null +++ b/app/Http/Controllers/Api/OrderController.php @@ -0,0 +1,79 @@ +service = OrderService::getInstance(); + $this->insertField = ['list_id']; + $this->updateField = ['id']; + $this->notRequest = [ + 'receiver_name', 'receiver_phone', 'receiver_address', + 'remark', 'delivery_type', + ]; + } + + /** + * 概览统计 + * @Method GET + */ + public function stat(): JsonResponse + { + return jok($this->service->stat()); + } + + /** + * 某用户的订单 + * @Method GET + */ + public function byUser(): JsonResponse + { + return jok($this->service->byUser((int) request()->get('user_id', 0))); + } + + /** + * 审核转账凭证 + * @Method POST + */ + public function auditPayment(): JsonResponse + { + return jok($this->service->auditPayment(request()->post()), '处理成功'); + } + + /** + * 发货 + * @Method POST + */ + public function ship(): JsonResponse + { + return jok($this->service->ship(request()->post()), '已发货'); + } + + /** + * 取消订单 + * @Method POST + */ + public function cancel(): JsonResponse + { + return jok($this->service->cancel((int) request()->post('id', 0)), '已取消'); + } + + /** + * 完成订单 + * @Method POST + */ + public function complete(): JsonResponse + { + return jok($this->service->complete((int) request()->post('id', 0)), '已完成'); + } +} diff --git a/app/Http/Controllers/Api/PriceSheetController.php b/app/Http/Controllers/Api/PriceSheetController.php new file mode 100644 index 00000000..47b0c9d8 --- /dev/null +++ b/app/Http/Controllers/Api/PriceSheetController.php @@ -0,0 +1,63 @@ +service = PriceSheetService::getInstance(); + $this->insertField = ['catalogue_id']; + $this->updateField = ['id']; + $this->notRequest = ['specification', 'dimension', 'routine', 'rows', 'status']; + } + + /** + * 某商品的全部规格行(报价单抽屉回填用) + * @Method GET + */ + public function rows(): JsonResponse + { + return jok($this->service->rowsOf((int) request()->get('catalogue_id', 0)), '列表获取成功'); + } + + /** + * 覆盖式保存某商品的全部规格行 + * @Method POST + * @throws Exception + */ + public function saveRows(): JsonResponse + { + $this->insertField = ['catalogue_id']; + $this->notRequest = ['rows']; + $this->checkRequiredFields(request()->post()); + return jok( + $this->service->saveRows( + (int) request()->post('catalogue_id'), + (array) request()->post('rows', []) + ), + '保存成功' + ); + } + + /** + * 启用 / 禁用 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } +} diff --git a/app/Http/Controllers/Api/RoleController.php b/app/Http/Controllers/Api/RoleController.php index f1e9af1d..b06df76c 100755 --- a/app/Http/Controllers/Api/RoleController.php +++ b/app/Http/Controllers/Api/RoleController.php @@ -17,6 +17,7 @@ class RoleController extends BaseController parent::__construct(); $this->insertField = ['name', 'value', 'desc']; $this->updateField = ['id', 'name', 'value', 'desc']; + $this->notRequest = ['status', 'color', 'pid']; $this->service = RoleService::getInstance(); } @@ -51,4 +52,52 @@ class RoleController extends BaseController $this->service->saveRoleMenu($roleId, $menuIds) ); } + + /** + * 接口授权树:按控制器分组的全部可授权接口 + * @Method GET + */ + public function endpointTree(): JsonResponse + { + return jok($this->service->endpointTree(), '获取成功'); + } + + /** + * 角色已授权的接口 id + * @Method GET + * @throws Exception + */ + public function getEndpointIdsByRoleIds(): JsonResponse + { + return jok($this->service->getEndpointIdsByRoleId(request()->get('role_id'))); + } + + /** + * 保存角色接口授权 + * @Method POST + * @throws Exception + */ + public function saveRoleEndpoint(): JsonResponse + { + $this->insertField = ['role_id']; + $this->checkRequiredFields(request()->post()); + return jok( + $this->service->saveRoleEndpoint( + (int) request()->post('role_id'), + (array) request()->post('endpoint_id', []) + ) + ); + } + + /** + * 启用 / 停用角色 + * @Method POST + * @throws Exception + */ + public function status(): JsonResponse + { + $this->insertField = ['id', 'status']; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->status($params['id'], $params['status']), '操作成功'); + } } diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 19f92597..5403a347 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -44,4 +44,19 @@ class UploadController extends BaseController '上传成功' ); } + + /** + * 文档上传(PDF 等) + * @Method POST + * @return JsonResponse + * @throws \Exception + */ + public function file() + { + $file = request()->file('file'); + return jok( + $this->service->uploadDocument($file), + '上传成功' + ); + } } diff --git a/app/Http/Controllers/Api/WxAppController.php b/app/Http/Controllers/Api/WxAppController.php new file mode 100644 index 00000000..bcbf0738 --- /dev/null +++ b/app/Http/Controllers/Api/WxAppController.php @@ -0,0 +1,46 @@ +service = WxAppConfigService::getInstance(); + $this->insertField = ['code', 'name', 'app_id']; + $this->updateField = ['id']; + $this->notRequest = [ + 'app_secret', 'mch_id', 'mch_key', 'mch_serial_no', 'mch_private_key', + 'platform_public_key', 'notify_url', 'template_code', 'remark', 'name', 'app_id', + ]; + } + + /** + * 启用/停用 + * @Method POST + */ + public function status(): JsonResponse + { + return jok( + $this->service->status(request()->post('id'), request()->post('status')), + '操作成功' + ); + } + + /** + * 按 .env 引导创建/更新一条应用壳子(不含 AppSecret) + * @Method POST + */ + public function initFromEnv(): JsonResponse + { + return jok($this->service->initFromEnv(), '初始化成功'); + } +} diff --git a/app/Http/Controllers/Api/WxTemplateController.php b/app/Http/Controllers/Api/WxTemplateController.php new file mode 100644 index 00000000..0fcf1276 --- /dev/null +++ b/app/Http/Controllers/Api/WxTemplateController.php @@ -0,0 +1,91 @@ +service = WxTemplateService::getInstance(); + $this->insertField = ['name', 'code']; + $this->updateField = ['id']; + $this->notRequest = [ + 'preview', 'style_tag', 'tokens', 'layout', 'app_code', 'sort', 'name', 'code', + ]; + } + + /** + * 启用/停用 + * @Method POST + */ + public function status(): JsonResponse + { + return jok( + $this->service->status(request()->post('id'), request()->post('status')), + '操作成功' + ); + } + + /** + * 设为默认 + * @Method POST + */ + public function setDefault(): JsonResponse + { + return jok($this->service->setDefault((int) request()->post('id', 0)), '已设为默认'); + } + + /** + * 导出模板 JSON + * @Method POST + */ + public function export(): JsonResponse + { + return jok($this->service->export((array) request()->post('ids', []))); + } + + /** + * 导入模板 JSON + * @Method POST + */ + public function import(): JsonResponse + { + return jok( + $this->service->import((array) request()->post('payload', []), (bool) request()->post('overwrite', false)), + '导入完成' + ); + } + + /** + * 用内置 20 套预设初始化模板库 + * @Method POST + */ + public function initPresets(): JsonResponse + { + return jok( + $this->service->initPresets((bool) request()->post('overwrite', false)), + '初始化完成' + ); + } + + /** + * 令牌与布局的可选项,前端渲染编辑表单用 + * @Method GET + */ + public function schema(): JsonResponse + { + return jok([ + 'tokens' => WxTemplateSchemaService::TOKEN_SCHEMA, + 'layout' => WxTemplateSchemaService::LAYOUT_SCHEMA, + ]); + } +} diff --git a/app/Http/Controllers/Api/WxUserController.php b/app/Http/Controllers/Api/WxUserController.php new file mode 100644 index 00000000..075e8871 --- /dev/null +++ b/app/Http/Controllers/Api/WxUserController.php @@ -0,0 +1,107 @@ +service = WxUserService::getInstance(); + $this->updateField = ['id']; + $this->notRequest = ['nick_name', 'phone', 'enterprise_id', 'show_price', 'price_number', 'is_p']; + } + + /** + * 设置 / 取消代理商身份 + * @Method POST + * @throws Exception + */ + public function updateUserIsP(): JsonResponse + { + $this->insertField = ['id']; + $this->notRequest = ['is_p']; + $params = $this->checkRequiredFields(request()->post()); + return jok( + $this->service->updateUserIsP($params['id'], $params['is_p'] ?? null), + '操作成功' + ); + } + + /** + * 设置价格倍率 + * @Method POST + * @throws Exception + */ + public function updateShowPrice(): JsonResponse + { + $this->insertField = ['id', 'number']; + $this->notRequest = []; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->updateShowPrice($params['id'], $params['number']), '操作成功'); + } + + /** + * 单独开关价格可见性 + * @Method POST + * @throws Exception + */ + public function updateShowPriceStatus(): JsonResponse + { + $this->insertField = ['id', 'show_price']; + $this->notRequest = []; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->updateShowPriceStatus($params['id'], $params['show_price']), '操作成功'); + } + + /** + * 绑定所属企业 + * @Method POST + * @throws Exception + */ + public function bindUser(): JsonResponse + { + $this->insertField = ['id', 'enterprise_id']; + $this->notRequest = []; + $params = $this->checkRequiredFields(request()->post()); + return jok($this->service->bindUser($params['id'], $params['enterprise_id']), '绑定成功'); + } + + /** + * 经销商绑定专属装修模板(template_code 空=跟随品牌默认) + * @Method POST + * @throws Exception + */ + public function bindTemplate(): JsonResponse + { + $this->insertField = ['id']; + $this->notRequest = ['template_code']; + $params = $this->checkRequiredFields(request()->post()); + return jok( + $this->service->bindTemplate($params['id'], $params['template_code'] ?? ''), + '模板绑定成功' + ); + } + + /** + * 该代理商名下的下级用户 + * @Method GET + */ + public function children(): JsonResponse + { + return jok($this->service->children((int) request()->get('id', 0)), '列表获取成功'); + } +} diff --git a/app/Http/Controllers/Wx/AuthController.php b/app/Http/Controllers/Wx/AuthController.php new file mode 100644 index 00000000..b5f8c72b --- /dev/null +++ b/app/Http/Controllers/Wx/AuthController.php @@ -0,0 +1,36 @@ +service = WxAuthService::getInstance(); + } + + /** + * 微信 code 登录 + * @Method POST + */ + public function login(): JsonResponse + { + return jok( + $this->service->login(request()->post()), + '登录成功' + ); + } +} diff --git a/app/Http/Controllers/Wx/HomeController.php b/app/Http/Controllers/Wx/HomeController.php new file mode 100644 index 00000000..efb792ab --- /dev/null +++ b/app/Http/Controllers/Wx/HomeController.php @@ -0,0 +1,39 @@ +service = WxHomeService::getInstance(); + } + + /** + * 首页轮播 + * @Method GET + */ + public function carousel(): JsonResponse + { + return jok($this->service->carousel()); + } + + /** + * 一级分类 + * @Method GET + */ + public function categoryList(): JsonResponse + { + return jok($this->service->categoryList((int) request()->get('pid', 0))); + } +} diff --git a/app/Http/Controllers/Wx/ListController.php b/app/Http/Controllers/Wx/ListController.php new file mode 100644 index 00000000..ee02fd4f --- /dev/null +++ b/app/Http/Controllers/Wx/ListController.php @@ -0,0 +1,96 @@ +service = WxListService::getInstance(); + } + + /** + * 我的清单 + * @Method GET + */ + public function list(): JsonResponse + { + return jok($this->service->list()); + } + + /** + * 清单详情 + * @Method GET + */ + public function detail(): JsonResponse + { + return jok($this->service->detail((int) request()->get('id', 0))); + } + + /** + * 新建清单 + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->create(request()->post()), '创建成功'); + } + + /** + * 改清单名称与备注 + * @Method POST + */ + public function update(): JsonResponse + { + return jok( + $this->service->update((int) request()->post('id', 0), request()->post()), + '保存成功' + ); + } + + /** + * 删除清单 + * @Method POST + */ + public function delete(): JsonResponse + { + return jok($this->service->delete(request()->post('ids', [])), '删除成功'); + } + + /** + * 删除清单明细 + * @Method POST + */ + public function deleteItem(): JsonResponse + { + return jok($this->service->deleteItem(request()->post('ids', [])), '删除成功'); + } + + /** + * 加入清单 + * @Method POST + */ + public function toCart(): JsonResponse + { + return jok($this->service->toCart(request()->post()), '添加成功'); + } + + /** + * 改明细的规格与数量 + * @Method POST + */ + public function updateItem(): JsonResponse + { + return jok($this->service->updateItem(request()->post()), '保存成功'); + } +} diff --git a/app/Http/Controllers/Wx/OrderController.php b/app/Http/Controllers/Wx/OrderController.php new file mode 100644 index 00000000..c8e68674 --- /dev/null +++ b/app/Http/Controllers/Wx/OrderController.php @@ -0,0 +1,84 @@ +service = WxOrderService::getInstance(); + } + + /** + * 我的订单 + * @Method GET + */ + public function list(): JsonResponse + { + return jok($this->service->list()); + } + + /** + * 订单详情 + * @Method GET + */ + public function detail(): JsonResponse + { + return jok($this->service->detail((int) request()->get('id', 0))); + } + + /** + * 清单转订单 + * @Method POST + */ + public function create(): JsonResponse + { + return jok($this->service->createFromList(request()->post()), '下单成功'); + } + + /** + * 上传转账凭证 + * @Method POST + */ + public function voucher(): JsonResponse + { + return jok($this->service->submitVoucher(request()->post()), '已提交,等待审核'); + } + + /** + * 调起微信支付 + * @Method POST + */ + public function wechatPay(): JsonResponse + { + return jok($this->service->wechatPay(request()->post())); + } + + /** + * 取消订单 + * @Method POST + */ + public function cancel(): JsonResponse + { + return jok($this->service->cancel((int) request()->post('id', 0)), '已取消'); + } + + /** + * 确认收货 + * @Method POST + */ + public function complete(): JsonResponse + { + return jok($this->service->complete((int) request()->post('id', 0)), '已完成'); + } +} diff --git a/app/Http/Controllers/Wx/ProductController.php b/app/Http/Controllers/Wx/ProductController.php new file mode 100644 index 00000000..962d5b63 --- /dev/null +++ b/app/Http/Controllers/Wx/ProductController.php @@ -0,0 +1,52 @@ +service = WxProductService::getInstance(); + } + + /** + * 商品列表 + * @Method GET + */ + public function list(): JsonResponse + { + return jok($this->service->list()); + } + + /** + * 商品详情,p_user_id 为代理商分享来源 + * @Method GET + */ + public function detail(): JsonResponse + { + return jok($this->service->detail( + (int) request()->get('id', 0), + (int) request()->get('p_user_id', 0) + )); + } + + /** + * 按父级取分类 + * @Method GET + */ + public function categoryListByPid(): JsonResponse + { + return jok(WxHomeService::getInstance()->categoryList((int) request()->get('pid', 0))); + } +} diff --git a/app/Http/Controllers/Wx/ThemeController.php b/app/Http/Controllers/Wx/ThemeController.php new file mode 100644 index 00000000..327b7ae2 --- /dev/null +++ b/app/Http/Controllers/Wx/ThemeController.php @@ -0,0 +1,56 @@ +service = WxThemeService::getInstance(); + } + + /** + * 当前主题 + * @Method GET + */ + public function theme(): JsonResponse + { + return jok($this->service->current((string) request()->get('code', ''))); + } + + /** + * 可选风格 + * @Method GET + */ + public function themeGallery(): JsonResponse + { + return jok($this->service->gallery()); + } + + /** + * 微信支付回调 + * + * 必须用原始报文验签:先 json_decode 再 encode 回去,字节顺序变了签名就对不上。 + * @Method POST + */ + public function payNotify(): JsonResponse + { + $headers = []; + foreach (['wechatpay-timestamp', 'wechatpay-nonce', 'wechatpay-signature', 'wechatpay-serial'] as $key) { + $headers[$key] = (string) request()->header($key, ''); + } + $result = WxPayService::getInstance()->handleNotify($headers, request()->getContent()); + return response()->json($result); + } +} diff --git a/app/Http/Controllers/Wx/UserController.php b/app/Http/Controllers/Wx/UserController.php new file mode 100644 index 00000000..478f0360 --- /dev/null +++ b/app/Http/Controllers/Wx/UserController.php @@ -0,0 +1,51 @@ +service = WxUserCenterService::getInstance(); + } + + /** + * 我的信息 + * @Method GET + */ + public function myInfo(): JsonResponse + { + return jok($this->service->myInfo()); + } + + /** + * 绑定手机号 + * @Method POST + */ + public function bandPhone(): JsonResponse + { + return jok($this->service->bandPhone(request()->post()), '绑定成功'); + } + + /** + * 修改昵称 + * @Method POST + */ + public function updateNickName(): JsonResponse + { + return jok( + $this->service->updateNickName((string) request()->post('nick_name', '')), + '保存成功' + ); + } +} diff --git a/app/Http/Controllers/Wx/WxUploadController.php b/app/Http/Controllers/Wx/WxUploadController.php new file mode 100644 index 00000000..24f7b069 --- /dev/null +++ b/app/Http/Controllers/Wx/WxUploadController.php @@ -0,0 +1,37 @@ +service = WxUploadService::getInstance(); + } + + /** + * 图片上传(转账凭证) + * @Method POST + */ + public function image(): JsonResponse + { + $file = request()->file('file'); + return jok( + $this->service->uploadImage($file), + '上传成功' + ); + } +} diff --git a/app/Http/Middleware/ApiAuthMiddleware.php b/app/Http/Middleware/ApiAuthMiddleware.php new file mode 100644 index 00000000..569e6403 --- /dev/null +++ b/app/Http/Middleware/ApiAuthMiddleware.php @@ -0,0 +1,68 @@ +normalizePath($request->path()); + if (in_array($path, (array) config('nl.api.white_list', []), true)) { + return $next($request); + } + + try { + $userInfo = JWTService::getInstance()->getToken()->getUserInfo(); + } catch (\Throwable $e) { + return $this->deny($e->getMessage() ?: '请先登录', ErrorEnum::NOT_AUTH); + } + if (empty($userInfo['id'])) { + return $this->deny('请先登录', ErrorEnum::NOT_AUTH); + } + + $roleId = (int) ($userInfo['role_id'] ?? 0); + if (!PermissionService::getInstance()->allows($roleId, $path)) { + return $this->deny('没有该操作的权限,请联系管理员', ErrorEnum::NOT_PERMISSION); + } + + // 后续无需再解 token 的地方可以直接取 + $request->attributes->set('nl_user', $userInfo); + return $next($request); + } + + private function normalizePath(string $path): string + { + $path = trim($path, '/'); + if (str_starts_with($path, 'api/')) { + $path = substr($path, 4); + } + return trim($path, '/'); + } + + /** + * HTTP 恒 200、业务码表达失败,与前端 request.ts 拦截器的约定一致 + */ + private function deny(string $message, ErrorEnum $code): Response + { + return response()->json([ + 'code' => $code->value, + 'message' => $message, + 'result' => [], + 'type' => 'error', + ]); + } +} diff --git a/app/Http/Middleware/ApiOpLogMiddleware.php b/app/Http/Middleware/ApiOpLogMiddleware.php index bca83ecd..d543f88d 100644 --- a/app/Http/Middleware/ApiOpLogMiddleware.php +++ b/app/Http/Middleware/ApiOpLogMiddleware.php @@ -145,6 +145,8 @@ class ApiOpLogMiddleware $maskKeys = [ 'password', 'old_password', 'new_password', 'confirm_password', 'access_key', 'secret_key', 'api_key', 'token', + // 小程序登录凭证与商户密钥同样不能落进日志表 + 'code', 'phone_code', 'session_key', 'app_secret', 'mch_key', 'mch_private_key', ]; foreach ($maskKeys as $k) { if (array_key_exists($k, $all) && $all[$k] !== '' && $all[$k] !== null) { diff --git a/app/Http/Middleware/WxAuthMiddleware.php b/app/Http/Middleware/WxAuthMiddleware.php new file mode 100644 index 00000000..2a222e0e --- /dev/null +++ b/app/Http/Middleware/WxAuthMiddleware.php @@ -0,0 +1,41 @@ +resolveUser($request->bearerToken()); + if (empty($user)) { + return response()->json([ + 'code' => ErrorEnum::NOT_AUTH->value, + 'message' => '请先登录', + 'result' => [], + 'type' => 'error', + ]); + } + if ((int) ($user['status'] ?? 0) === 1) { + return response()->json([ + 'code' => ErrorEnum::NOT_AUTH->value, + 'message' => '账号已被停用,请联系客服', + 'result' => [], + 'type' => 'error', + ]); + } + $request->attributes->set('wx_user', $user); + return $next($request); + } +} diff --git a/app/Jobs/ScanMediaReferencesJob.php b/app/Jobs/ScanMediaReferencesJob.php new file mode 100644 index 00000000..2c5a8fd5 --- /dev/null +++ b/app/Jobs/ScanMediaReferencesJob.php @@ -0,0 +1,33 @@ +scanReferences($this->params); + } +} diff --git a/app/Jobs/SyncOssObjectsJob.php b/app/Jobs/SyncOssObjectsJob.php new file mode 100644 index 00000000..e0999c7b --- /dev/null +++ b/app/Jobs/SyncOssObjectsJob.php @@ -0,0 +1,50 @@ +params; + $marker = (string) ($params['marker'] ?? ''); + while (true) { + $params['marker'] = $marker; + $result = $service->syncFromOss($params); + $next = (string) ($result['next_marker'] ?? ''); + if ((bool) ($result['finished'] ?? true) || $next === '' || $next === $marker) { + break; + } + $marker = $next; + } + } +} diff --git a/app/Models/AdminModel.php b/app/Models/AdminModel.php index 8ffcfe57..901ece9d 100644 --- a/app/Models/AdminModel.php +++ b/app/Models/AdminModel.php @@ -3,6 +3,7 @@ namespace App\Models; use App\BaseApp\BaseModel; +use App\Models\business\DepartmentModel; use Illuminate\Database\Eloquent\Relations\HasOne; class AdminModel extends BaseModel @@ -20,4 +21,15 @@ class AdminModel extends BaseModel { return $this->hasOne(RoleModel::class, 'id', 'role_id'); } + + /** + * 所属部门 + * + * 部门表在 business 连接(cc_ 前缀),Eloquent 关联可以跨连接, + * 但不能和本表做 SQL join,需要 join 时只能分两次查再在 PHP 里拼。 + */ + public function department(): HasOne + { + return $this->hasOne(DepartmentModel::class, 'id', 'department_id'); + } } diff --git a/app/Models/FileFolderModel.php b/app/Models/FileFolderModel.php new file mode 100644 index 00000000..8380f1de --- /dev/null +++ b/app/Models/FileFolderModel.php @@ -0,0 +1,24 @@ + 'integer', + 'sort' => 'integer', + 'status' => 'integer', + ]; +} diff --git a/app/Models/FileModel.php b/app/Models/FileModel.php index f265d26e..cadb51ed 100644 --- a/app/Models/FileModel.php +++ b/app/Models/FileModel.php @@ -4,14 +4,71 @@ namespace App\Models; use App\BaseApp\BaseModel; +/** + * 文件 / 素材表(nl_file) + * + * 原表只是上传流水(user_id + url)。素材库扩了对象键、体积、哈希与引用计数之后, + * 它同时充当素材主表,type / source 的取值被同步、回收、前端筛选三处共用, + * 所以固化成常量,免得三边各写一套魔法数字、还各写错一个。 + */ class FileModel extends BaseModel { + /* + * type 取值。0~4 是老表原有语义,5、6 是素材库补的: + * 图册 PDF、报价 Excel 这类文件原先全落在「其他」里没法筛。 + */ + public const TYPE_IMAGE = 0; + public const TYPE_VIDEO = 1; + public const TYPE_AUDIO = 2; + public const TYPE_EXCEL = 3; + public const TYPE_ARCHIVE = 4; + public const TYPE_DOCUMENT = 5; + public const TYPE_OTHER = 6; + + /** source:经上传接口进来的 */ + public const SOURCE_UPLOAD = 0; + /** source:从 OSS 反向列举补录的历史文件 */ + public const SOURCE_OSS = 1; protected $table = 'file'; - /** - * The attributes that are mass assignable. - * - * @var list - */ + protected $guarded = []; + + /** + * 数值列显式转型:MySQL 驱动会把 bigint / int 读成字符串, + * 前端拿 size 做体积换算、拿 ref_count 判断能否回收时会被字符串坑到 + * + * created_at / updated_at 不在此列:BaseModel 已经用访问器格式化成了日期串, + * 再加 cast 只会让两套逻辑互相打架 + */ + protected $casts = [ + 'user_id' => 'integer', + 'oss_config_id' => 'integer', + 'folder_id' => 'integer', + 'type' => 'integer', + 'size' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'ref_count' => 'integer', + 'last_scan_at' => 'integer', + 'source' => 'integer', + ]; + + /** + * 扩展名归类到 type + * + * 从 OSS 反向同步时手上只有对象键,没有上传时的 MIME,只能按扩展名判断 + */ + public static function typeOfExt(string $ext): int + { + return match (strtolower(trim($ext, " \t\n\r\0\x0B."))) { + 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'avif', 'heic' => self::TYPE_IMAGE, + 'mp4', 'mov', 'avi', 'mkv', 'flv', 'wmv', 'webm', 'm3u8', 'ts' => self::TYPE_VIDEO, + 'mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'amr' => self::TYPE_AUDIO, + 'xls', 'xlsx', 'csv' => self::TYPE_EXCEL, + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2' => self::TYPE_ARCHIVE, + 'pdf', 'doc', 'docx', 'ppt', 'pptx', 'txt', 'md' => self::TYPE_DOCUMENT, + default => self::TYPE_OTHER, + }; + } } diff --git a/app/Models/RoleEndpointRelationModel.php b/app/Models/RoleEndpointRelationModel.php new file mode 100644 index 00000000..fc19e3ff --- /dev/null +++ b/app/Models/RoleEndpointRelationModel.php @@ -0,0 +1,14 @@ +belongsTo(CategoryModel::class, 'category_id', 'id'); + } + + public function priceSheet(): HasMany + { + return $this->hasMany(PriceSheetModel::class, 'catalogue_id', 'id'); + } + + public function images(): HasMany + { + return $this->hasMany(ImageModel::class, 'catalogue_id', 'id'); + } +} diff --git a/app/Models/business/CategoryModel.php b/app/Models/business/CategoryModel.php new file mode 100644 index 00000000..e8f6d127 --- /dev/null +++ b/app/Models/business/CategoryModel.php @@ -0,0 +1,26 @@ +hasMany(self::class, 'pid', 'id'); + } + + public function catalogues(): HasMany + { + return $this->hasMany(CatalogueModel::class, 'category_id', 'id'); + } +} diff --git a/app/Models/business/ColorcardModel.php b/app/Models/business/ColorcardModel.php new file mode 100644 index 00000000..70dbc529 --- /dev/null +++ b/app/Models/business/ColorcardModel.php @@ -0,0 +1,28 @@ +belongsTo(CardClassModel::class, 'card_class', 'id'); + } + + public function companyInfo(): BelongsTo + { + return $this->belongsTo(CompanyModel::class, 'company', 'id'); + } +} diff --git a/app/Models/business/CompanyModel.php b/app/Models/business/CompanyModel.php new file mode 100644 index 00000000..5d08530f --- /dev/null +++ b/app/Models/business/CompanyModel.php @@ -0,0 +1,15 @@ +hasMany(WxUserModel::class, 'enterprise_id', 'id'); + } +} diff --git a/app/Models/business/FactoryClassificationModel.php b/app/Models/business/FactoryClassificationModel.php new file mode 100644 index 00000000..d5550385 --- /dev/null +++ b/app/Models/business/FactoryClassificationModel.php @@ -0,0 +1,15 @@ +belongsTo(FactoryInfoModel::class, 'factory', 'id'); + } +} diff --git a/app/Models/business/FactoryInfoModel.php b/app/Models/business/FactoryInfoModel.php new file mode 100644 index 00000000..31866a8a --- /dev/null +++ b/app/Models/business/FactoryInfoModel.php @@ -0,0 +1,29 @@ +belongsTo(FactoryClassificationModel::class, 'classification', 'id'); + } + + public function images(): HasMany + { + return $this->hasMany(FactoryImageModel::class, 'factory', 'id'); + } +} diff --git a/app/Models/business/ImageModel.php b/app/Models/business/ImageModel.php new file mode 100644 index 00000000..41937ea4 --- /dev/null +++ b/app/Models/business/ImageModel.php @@ -0,0 +1,26 @@ +belongsTo(CatalogueModel::class, 'catalogue_id', 'id'); + } +} diff --git a/app/Models/business/ListItemModel.php b/app/Models/business/ListItemModel.php new file mode 100644 index 00000000..a8cc2599 --- /dev/null +++ b/app/Models/business/ListItemModel.php @@ -0,0 +1,34 @@ +belongsTo(CatalogueModel::class, 'catalogue_id', 'id'); + } + + public function priceSheet(): BelongsTo + { + return $this->belongsTo(PriceSheetModel::class, 'price_sheet_id', 'id'); + } + + public function list(): BelongsTo + { + return $this->belongsTo(ListModel::class, 'list_id', 'id'); + } +} diff --git a/app/Models/business/ListModel.php b/app/Models/business/ListModel.php new file mode 100644 index 00000000..373e1312 --- /dev/null +++ b/app/Models/business/ListModel.php @@ -0,0 +1,34 @@ +hasMany(ListItemModel::class, 'list_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(WxUserModel::class, 'user_id', 'id'); + } + + public function enterprise(): BelongsTo + { + return $this->belongsTo(EnterpriseModel::class, 'enterprise_id', 'id'); + } +} diff --git a/app/Models/business/OrderDeliveryModel.php b/app/Models/business/OrderDeliveryModel.php new file mode 100644 index 00000000..35da7c51 --- /dev/null +++ b/app/Models/business/OrderDeliveryModel.php @@ -0,0 +1,23 @@ +belongsTo(OrderModel::class, 'order_id', 'id'); + } +} diff --git a/app/Models/business/OrderItemModel.php b/app/Models/business/OrderItemModel.php new file mode 100644 index 00000000..523388f8 --- /dev/null +++ b/app/Models/business/OrderItemModel.php @@ -0,0 +1,24 @@ +belongsTo(OrderModel::class, 'order_id', 'id'); + } +} diff --git a/app/Models/business/OrderModel.php b/app/Models/business/OrderModel.php new file mode 100644 index 00000000..3456d23c --- /dev/null +++ b/app/Models/business/OrderModel.php @@ -0,0 +1,63 @@ +hasMany(OrderItemModel::class, 'order_id', 'id'); + } + + public function payments(): HasMany + { + return $this->hasMany(OrderPaymentModel::class, 'order_id', 'id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(OrderDeliveryModel::class, 'order_id', 'id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(WxUserModel::class, 'user_id', 'id'); + } + + public function enterprise(): BelongsTo + { + return $this->belongsTo(EnterpriseModel::class, 'enterprise_id', 'id'); + } +} diff --git a/app/Models/business/OrderPaymentModel.php b/app/Models/business/OrderPaymentModel.php new file mode 100644 index 00000000..f4da78d4 --- /dev/null +++ b/app/Models/business/OrderPaymentModel.php @@ -0,0 +1,27 @@ +belongsTo(OrderModel::class, 'order_id', 'id'); + } +} diff --git a/app/Models/business/PriceSheetModel.php b/app/Models/business/PriceSheetModel.php new file mode 100644 index 00000000..7c59a9b3 --- /dev/null +++ b/app/Models/business/PriceSheetModel.php @@ -0,0 +1,38 @@ +belongsTo(CatalogueModel::class, 'catalogue_id', 'id'); + } +} diff --git a/app/Models/business/WxTemplateModel.php b/app/Models/business/WxTemplateModel.php new file mode 100644 index 00000000..109305fc --- /dev/null +++ b/app/Models/business/WxTemplateModel.php @@ -0,0 +1,24 @@ +, + * 所以令牌最终是注入到根节点的 CSS 变量,不能塞任意 CSS 文本进来。 + */ +class WxTemplateModel extends BaseBusinessModel +{ + protected $table = 'wx_template'; + + protected $guarded = []; + + protected $casts = [ + 'tokens' => 'array', + 'layout' => 'array', + ]; +} diff --git a/app/Models/business/WxUserModel.php b/app/Models/business/WxUserModel.php new file mode 100644 index 00000000..db390ceb --- /dev/null +++ b/app/Models/business/WxUserModel.php @@ -0,0 +1,41 @@ +belongsTo(EnterpriseModel::class, 'enterprise_id', 'id'); + } + + /** + * 该代理商名下的下级用户 + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'pid', 'id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'pid', 'id'); + } +} diff --git a/app/Service/AdminService.php b/app/Service/AdminService.php index 6cf45d4b..6f13d3cd 100644 --- a/app/Service/AdminService.php +++ b/app/Service/AdminService.php @@ -23,11 +23,12 @@ class AdminService extends BaseService 'open_id', 'avatar', 'nick_name', - 'password', + // 不查 password / legacy_password:列表与详情都会直接回给前端,密码哈希不该出网 'phone', 'email', 'code', 'role_id', + 'department_id', 'province_id', 'city_id', 'reg_ip', @@ -47,6 +48,7 @@ class AdminService extends BaseService 'nick_name' => 'like', 'email' => 'like', 'role_id' => '=', + 'department_id' => '=', 'status' => '=', ]; } @@ -60,14 +62,17 @@ class AdminService extends BaseService public function list(): array { $this->with = [ - 'role' + 'role', + 'department', ]; $result = $this->getPageList(); foreach ($result['items'] as &$v) { $v['ip_table'] = json_decode($v['ip_table'], true); $v['status_text'] = UserStatusEnum::from($v['status'])->description(); + $v['department_name'] = $v['department']['name'] ?? ''; } + unset($v); return $result; } @@ -95,6 +100,10 @@ class AdminService extends BaseService public function create($params): mixed { $params['ip_table'] = json_encode([]); + // open_id 是 NOT NULL 且无默认值,不显式赋值会插入失败 + $params['open_id'] = 'nl_' . bin2hex(random_bytes(15)); + // status 列默认值是 1(禁用),不显式写成正常态新账号一登录就被状态校验挡下 + $params['status'] = (int) ($params['status'] ?? UserStatusEnum::NORMAL->value); $params['password'] = password_hash($params['password'], PASSWORD_DEFAULT); // 手机号或邮箱任一重复都拒绝,且必须排除软删记录(deleted_at=0)。 // 原写法 whereOr('email',...) 是 Laravel 动态 where 的空操作(等于只按 phone 判断且不排软删), @@ -363,7 +372,13 @@ class AdminService extends BaseService 'affixTab' => !$menu->affix_tab, 'order' => $menu->sort, 'iframeSrc' => $menu->iframe_src, - ] + // 库里有这几列但之前没往 meta 输出,等于菜单管理里配了也不生效 + 'badge' => (string) $menu->badge, + 'badgeType' => $this->badgeType((int) $menu->badge_type), + 'badgeVariants' => $this->badgeVariants((int) $menu->badge_variants), + ], + // vben5 路由的 query 是路由级字段而不是 meta,且必须是对象 + 'query' => $this->decodeQuery((string) $menu->query), ]; } $result = $this->utils->tree($resultMenus); @@ -375,4 +390,48 @@ class AdminService extends BaseService RedisService::getInstance()->init(config('nl.redis.menu_key'))->set($this->roleId, json_encode($result), 3600); return $result; } + + /** + * 当前账号的权限码 + * @return array + */ + public function codes(): array + { + return PermissionService::getInstance()->codes($this->roleId); + } + + /** + * nl_menu.badge_type:0 dot 小红点 / 1 normal 文本 + */ + private function badgeType(int $type): string + { + return $type === 1 ? 'normal' : 'dot'; + } + + /** + * nl_menu.badge_variants:0 default 1 destructive 2 primary 3 success 4 warning + */ + private function badgeVariants(int $variants): string + { + return match ($variants) { + 1 => 'destructive', + 2 => 'primary', + 3 => 'success', + 4 => 'warning', + default => 'default', + }; + } + + /** + * 菜单默认查询参数:库里存的是 JSON 字符串,非法值按空对象处理,别让一行坏数据把整棵菜单打挂 + * @return array + */ + private function decodeQuery(string $query): array + { + if (trim($query) === '') { + return []; + } + $decoded = json_decode($query, true); + return is_array($decoded) ? $decoded : []; + } } diff --git a/app/Service/DepartmentService.php b/app/Service/DepartmentService.php new file mode 100644 index 00000000..abfaf0e2 --- /dev/null +++ b/app/Service/DepartmentService.php @@ -0,0 +1,203 @@ +model = DepartmentModel::class; + $this->selectField = ['id', 'name', 'desc', 'status', 'pid', 'color', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'status' => '=', 'pid' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + } + + /** + * 分页列表,附带每个部门的账号数 + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $ids = array_column($result['items'] ?? [], 'id'); + $counts = $this->countAdminByDepartment($ids); + foreach ($result['items'] as &$item) { + $item['admin_count'] = $counts[$item['id']] ?? 0; + } + unset($item); + return $result; + } + + /** + * 部门树(账号列表左侧筛选树、部门表单的上级选择都用这个) + */ + public function option(): array + { + $rows = DepartmentModel::where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'name', 'pid', 'status', 'color']) + ->toArray(); + return $this->utils->tree($rows); + } + + /** + * 部门树下拉(部门表单选上级用)。带 $isSelect 时首位补「顶级部门」 + */ + public function getTreeOption(bool $isSelect = false): array + { + $rows = DepartmentModel::where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'name', 'pid']) + ->toArray(); + $result = $this->utils->tree($rows); + if ($isSelect) { + array_unshift($result, ['id' => 0, 'name' => '顶级部门', 'pid' => 0]); + } + return $result; + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + /** + * 新增部门 + */ + public function create($params): mixed + { + $this->assertNameUnique((string) ($params['name'] ?? ''), (int) ($params['pid'] ?? 0)); + return $this->insert($params); + } + + /** + * 编辑部门 + * @throws Exception + */ + public function update($id, $params): mixed + { + $id = (int) $id; + if (array_key_exists('name', $params)) { + $this->assertNameUnique((string) $params['name'], (int) ($params['pid'] ?? 0), $id); + } + // 上级不能指向自己或自己的子孙,否则树会成环、递归建树直接栈溢出 + if (array_key_exists('pid', $params)) { + $pid = (int) $params['pid']; + if ($pid === $id) { + $this->utils->errorThrow('上级部门不能是自己'); + } + if ($pid > 0 && in_array($pid, $this->descendantIds($id), true)) { + $this->utils->errorThrow('上级部门不能是自己的下级'); + } + } + return $this->save($id, $params); + } + + /** + * 删除部门:有子部门或仍有账号在用时拒绝,避免留下悬空引用 + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + $hasChild = DepartmentModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists(); + if ($hasChild) { + $this->utils->errorThrow('存在下级部门,请先删除下级'); + } + $inUse = AdminModel::whereIn('department_id', $ids)->where('deleted_at', 0)->exists(); + if ($inUse) { + $this->utils->errorThrow('仍有账号属于该部门,请先调整账号所属部门'); + } + return $this->del($ids); + } + + /** + * 停用/启用部门 + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save((int) $id, ['status' => (int) $status]); + } + + /** + * 同一父级下部门名不允许重复,否则树上出现两个同名节点没法分辨 + */ + private function assertNameUnique(string $name, int $pid, int $exceptId = 0): void + { + $name = trim($name); + if ($name === '') { + $this->utils->errorThrow('部门名称不能为空'); + } + $exists = DepartmentModel::where('name', $name) + ->where('pid', $pid) + ->where('deleted_at', 0) + ->when($exceptId > 0, fn ($q) => $q->where('id', '<>', $exceptId)) + ->exists(); + if ($exists) { + $this->utils->errorThrow('同级下已存在同名部门'); + } + } + + /** + * 取某部门的全部子孙 id,用于成环校验 + */ + private function descendantIds(int $id): array + { + $all = DepartmentModel::where('deleted_at', 0)->get(['id', 'pid']); + $childMap = []; + foreach ($all as $row) { + $childMap[(int) $row->pid][] = (int) $row->id; + } + $result = []; + $stack = $childMap[$id] ?? []; + while (!empty($stack)) { + $current = array_pop($stack); + if (in_array($current, $result, true)) { + continue; + } + $result[] = $current; + foreach ($childMap[$current] ?? [] as $child) { + $stack[] = $child; + } + } + return $result; + } + + /** + * 部门账号数:admin 在 mysql 连接、department 在 business 连接,跨连接不能 join,分两次查 + */ + private function countAdminByDepartment(array $departmentIds): array + { + if (empty($departmentIds)) { + return []; + } + return AdminModel::whereIn('department_id', $departmentIds) + ->where('deleted_at', 0) + ->groupBy('department_id') + ->selectRaw('department_id, COUNT(*) AS c') + ->pluck('c', 'department_id') + ->all(); + } +} diff --git a/app/Service/FileFolderService.php b/app/Service/FileFolderService.php new file mode 100644 index 00000000..0bcc327c --- /dev/null +++ b/app/Service/FileFolderService.php @@ -0,0 +1,135 @@ +model = FileFolderModel::class; + $this->selectField = ['id', 'pid', 'name', 'sort', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'pid' => '=', 'status' => '=']; + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + } + + /** + * 列表,带每个文件夹下的素材数 + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $ids = array_column($result['items'], 'id'); + $counts = empty($ids) + ? [] + : FileModel::whereIn('folder_id', $ids) + ->where('deleted_at', 0) + ->selectRaw('folder_id, COUNT(*) as total') + ->groupBy('folder_id') + ->pluck('total', 'folder_id') + ->all(); + + foreach ($result['items'] as &$item) { + $item['file_count'] = (int) ($counts[$item['id']] ?? 0); + } + unset($item); + return $result; + } + + /** + * 目录树,素材库左侧栏直接用它渲染 + */ + public function option(): array + { + $rows = FileFolderModel::where('deleted_at', 0) + ->orderBy('sort') + ->orderBy('id') + ->get(['id', 'pid', 'name', 'sort']) + ->toArray(); + return $this->utils->tree($rows); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + /** + * @throws Exception + */ + public function create($params): mixed + { + $params['pid'] = $this->assertParent((int) ($params['pid'] ?? 0)); + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('pid', $params)) { + $pid = (int) $params['pid']; + if ($pid === (int) $id) { + $this->utils->errorThrow('上级文件夹不能是自己'); + } + $params['pid'] = $this->assertParent($pid); + } + return $this->save($id, $params); + } + + /** + * 删除文件夹:有子目录或仍有素材时拒绝 + * + * 直接删会让里面的素材挂在一个查不到的 folder_id 上, + * 按文件夹筛选时那批文件就再也点不出来 —— 文件还在,人找不到。 + * + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (FileFolderModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('存在子文件夹,请先删除子文件夹'); + } + if (FileModel::whereIn('folder_id', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该文件夹下仍有素材,请先移动素材'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + private function assertParent(int $pid): int + { + if ($pid <= 0) { + return 0; + } + if (!FileFolderModel::where('id', $pid)->where('deleted_at', 0)->exists()) { + $this->utils->notFound('上级文件夹不存在'); + } + return $pid; + } +} diff --git a/app/Service/FileService.php b/app/Service/FileService.php index 282ee607..16694689 100644 --- a/app/Service/FileService.php +++ b/app/Service/FileService.php @@ -4,9 +4,6 @@ namespace App\Service; use App\BaseApp\BaseService; use App\Models\FileModel; -use App\Models\ProjectModel; -use App\Models\RoleModel; -use App\Models\UserModel; use Exception; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; diff --git a/app/Service/LoginService.php b/app/Service/LoginService.php index ec7074b7..ff492786 100644 --- a/app/Service/LoginService.php +++ b/app/Service/LoginService.php @@ -36,22 +36,46 @@ class LoginService extends BaseNotAuthService $this->writeLoginLog(0, (string) $phone, '', 1, '用户或密码错误', $equipment, $browser); UtilsService::getInstance()->errorThrow('用户或密码错误!'); } - if (!password_verify($password, $userModel->password)) { + // 老后台是无盐 sha1,没有明文无法预先转 bcrypt,所以 bcrypt 校验失败时再回落比对遗留密码 + $legacyHit = false; + if (!password_verify($password, (string) $userModel->password)) { + $legacyHit = $this->verifyLegacyPassword($userModel, (string) $password); + if (!$legacyHit) { + $this->writeLoginLog( + (int) $userModel->id, + (string) $phone, + (string) $userModel->nick_name, + 1, + '用户或密码错误', + $equipment, + $browser + ); + UtilsService::getInstance()->errorThrow('用户或密码错误!'); + } + } + // 状态校验放在密码校验之后,避免未通过认证就泄露账号是否存在或被禁用 + if ((int) $userModel->status === 1) { $this->writeLoginLog( (int) $userModel->id, (string) $phone, (string) $userModel->nick_name, 1, - '用户或密码错误', + '账号已被禁用', $equipment, $browser ); - UtilsService::getInstance()->errorThrow('用户或密码错误!'); + UtilsService::getInstance()->errorThrow('账号已被禁用,请联系管理员!'); } $updateData = [ 'last_login_time' => get_time(), 'updated_at' => get_time(), ]; + if ($legacyHit) { + // 命中遗留密码即刻升级成 bcrypt 并清空遗留列,下次登录走正常校验 + $updateData['password'] = password_hash($password, PASSWORD_DEFAULT); + $updateData['legacy_password'] = ''; + $updateData['legacy_password_expire_at'] = 0; + } if ($userModel->ip !== get_ip()) { $ipTable = json_decode($userModel->ip_table, true); if (!in_array(get_ip(), $ipTable ?? [])) { @@ -60,26 +84,14 @@ class LoginService extends BaseNotAuthService $updateData['ip'] = get_ip(); $updateData['ip_table'] = json_encode($ipTable); } - $update = AdminModel::where('id', $userModel->id)->update($updateData); - if (!$update) { - $this->writeLoginLog( - (int) $userModel->id, - (string) $phone, - (string) $userModel->nick_name, - 1, - '更新登录信息失败', - $equipment, - $browser - ); - UtilsService::getInstance()->errorThrow('更新失败!'); - } + AdminModel::where('id', $userModel->id)->update($updateData); $userModel = AdminModel::with(['role:id,name,value'])->where('id', $userModel->id)->first(); $this->writeLoginLog( (int) $userModel->id, (string) $userModel->phone, (string) $userModel->nick_name, 0, - '登录成功', + $legacyHit ? '登录成功(遗留密码已升级)' : '登录成功', $equipment, $browser ); @@ -90,16 +102,99 @@ class LoginService extends BaseNotAuthService 'avatar' => $userModel->avatar, 'email' => $userModel->email, 'role_id' => $userModel->role_id, - 'role_name' => $userModel->role->name, - 'role_value' => $userModel->role->value, + // 迁移过来的账号可能 role_id=0,关联为空时不能直接取属性 + 'role_name' => $userModel->role->name ?? '', + 'role_value' => $userModel->role->value ?? '', 'ip' => $userModel->ip, 'ip_table' => $userModel->ip_table, ]; $token = JWTService::getInstance()->generateToken($result); $result['token'] = $token; + // 前端据此提示尽快改密:无盐 sha1 可被彩虹表秒破,升级后也建议换新密码 + $result['legacy_password_upgraded'] = $legacyHit; return $result; } + /** + * 退出登录:删掉 Redis 会话,手里那张 token 立刻作废 + * + * 注册在免登录组,token 缺失或已过期都不报错——前端登出时本地 token 常常已经清掉了, + * 这时报错只会让用户卡在退出流程里。 + */ + public function logout(): array + { + $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); + $userId = (int) ($decoded->data->id ?? 0); + if ($userId > 0) { + JWTService::getInstance()->revoke($userId); + } + return ['logout' => true]; + } + + /** + * 续签 token + * + * 前端拦截器在 401 时调这里。签名 + Redis 会话都要通过,只是放宽 exp, + * 所以「被登出」和「过期太久」两种情况仍然要求重新登录。 + * @throws Exception + */ + public function refresh(): array + { + $decoded = JWTService::getInstance()->parseExpiringToken($this->refreshTtl()); + $data = isset($decoded->data) ? (array) $decoded->data : []; + $userId = (int) ($data['id'] ?? 0); + if ($userId <= 0) { + UtilsService::getInstance()->notAuth('登录状态已失效,请重新登录'); + } + // 会话还在才允许续签;顺带用库里的最新角色刷新 payload,改了角色不用等 token 过期 + $session = JWTService::getInstance()->getToken()->getUserInfo(); + $userModel = AdminModel::with(['role:id,name,value']) + ->where('id', $userId) + ->where('deleted_at', 0) + ->first(); + if (empty($userModel) || (int) $userModel->status === 1) { + JWTService::getInstance()->revoke($userId); + UtilsService::getInstance()->notAuth('账号不可用,请重新登录'); + } + $payload = array_merge($session, [ + 'id' => (int) $userModel->id, + 'phone' => $userModel->phone, + 'nick_name' => $userModel->nick_name, + 'avatar' => $userModel->avatar, + 'role_id' => (int) $userModel->role_id, + 'role_name' => $userModel->role->name ?? '', + 'role_value' => $userModel->role->value ?? '', + ]); + return ['token' => JWTService::getInstance()->generateToken($payload)]; + } + + /** + * 续签宽限期:token 过期后仍可续签的时长,超过就必须重新登录 + */ + private function refreshTtl(): int + { + return (int) config('nl.jwt.refresh_ttl', 7 * 24 * 3600); + } + + /** + * 比对老系统的无盐 sha1 密码 + * + * 只在 bcrypt 校验失败时调用。遗留列有失效时间,过期后一律走重置流程, + * 因为无盐 sha1 可被彩虹表直接反查,不能无限期留着。 + */ + private function verifyLegacyPassword(AdminModel $userModel, string $password): bool + { + $legacy = strtolower(trim((string) ($userModel->legacy_password ?? ''))); + if ($legacy === '') { + return false; + } + $expireAt = (int) ($userModel->legacy_password_expire_at ?? 0); + if ($expireAt > 0 && $expireAt < get_time()) { + return false; + } + return hash_equals($legacy, sha1($password)); + } + /** * 注册管理员账号 * @@ -112,6 +207,10 @@ class LoginService extends BaseNotAuthService */ public function register($phone, $password, $email, $code): array { + // 这是后台管理端,自助注册默认关闭:开着等于任何人都能给自己开一个管理员账号 + if (!config('nl.register.enabled', false)) { + UtilsService::getInstance()->errorThrow('后台不开放自助注册,请联系管理员创建账号'); + } $userModel = AdminModel::where('phone', $phone)->where('deleted_at', 0)->first(); if ($userModel) { UtilsService::getInstance()->errorThrow('账号已被占用!'); @@ -122,9 +221,14 @@ class LoginService extends BaseNotAuthService 'password' => password_hash($password, PASSWORD_DEFAULT), 'nick_name' => '新用户' . Str::random(), 'avatar' => 'https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280', - 'role_id' => 2, + // 迁移的老角色统一偏移到 100 起,脚手架预留的 2(默认角色)不受影响,仍可作为默认值 + 'role_id' => (int) config('nl.register.default_role_id', 2), + // open_id 是 NOT NULL 且无默认值,不显式赋值会直接插入失败 + 'open_id' => 'nl_' . bin2hex(random_bytes(15)), 'ip' => get_ip(), 'ip_table' => json_encode([get_ip()]), + // status 列默认值是 1(禁用),不显式写成 0 新账号登录会被状态校验挡下 + 'status' => 0, 'created_at' => get_time(), ]); if (!$createModel) { @@ -137,8 +241,8 @@ class LoginService extends BaseNotAuthService 'nick_name' => $userInfo->nick_name, 'avatar' => $userInfo->avatar, 'email' => $userInfo->email ?? '', - 'role_name' => $userInfo->role->name, - 'role_value' => $userInfo->role->value, + 'role_name' => $userInfo->role->name ?? '', + 'role_value' => $userInfo->role->value ?? '', 'ip' => $userInfo->ip, 'ip_table' => $userInfo->ip_table, ]; diff --git a/app/Service/MaterialService.php b/app/Service/MaterialService.php new file mode 100644 index 00000000..c1e39c27 --- /dev/null +++ b/app/Service/MaterialService.php @@ -0,0 +1,692 @@ +runningInConsole()) { + $this->isAuth = false; + } + parent::__construct(); + $this->model = FileModel::class; + $this->selectField = [ + 'id', 'user_id', 'oss_config_id', 'folder_id', 'name', 'url', 'path', 'ext', + 'type', 'size', 'width', 'height', 'hash', 'ref_count', 'last_scan_at', + 'source', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'folder_id' => '=', + 'type' => '=', + 'ext' => '=', + 'oss_config_id' => '=', + 'source' => '=', + ]; + $this->media = MediaUrlService::getInstance(); + } + + /** + * 素材列表 + * + * keyword 要同时命中 name / path / url,而基类的 queryField 只会按列 AND, + * 拼不出这个 OR 组,所以这里自己组查询,返回结构与 getPageList 保持一致。 + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->getWhere(); + $keyword = trim((string) request()->get('keyword', '')); + $unused = (int) request()->get('unused', 0); + $searchTime = request()->get('search_time'); + + $query = FileModel::where($this->where) + ->when($keyword !== '', function ($q) use ($keyword) { + $q->where(function ($sub) use ($keyword) { + $sub->where('name', 'like', '%' . $keyword . '%') + ->orWhere('path', 'like', '%' . $keyword . '%') + ->orWhere('url', 'like', '%' . $keyword . '%'); + }); + }) + ->when($unused === 1, fn ($q) => $q->where('ref_count', 0)) + ->when(!empty($searchTime), function ($q) use ($searchTime) { + $this->getWhereBetween($searchTime); + $q->whereBetween($this->whereBetween[0], $this->whereBetween[1]); + }); + + return $this->toPage($query->select($this->selectField)->orderByDesc('id')); + } + + /** + * 详情,带上文件夹名,免得前端为了显示一个名字再请求一次目录树 + * + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->url = $this->media->toPublic($info->url); + $info->folder_name = (string) (FileFolderModel::where('id', (int) $info->folder_id)->value('name') ?? ''); + return $info; + } + + /** + * 只允许改素材名与所属文件夹 + * + * path / hash / size 是同步结果,手工改会让引用扫描直接失准: + * path 一旦被改歪,原本在用的素材就匹配不上任何引用,转头出现在可回收列表里。 + * + * @throws Exception + */ + public function update($id, $params): mixed + { + $data = []; + if (array_key_exists('name', $params)) { + $data['name'] = mb_substr(trim((string) $params['name']), 0, 255); + } + if (array_key_exists('folder_id', $params)) { + $data['folder_id'] = $this->assertFolder((int) $params['folder_id']); + } + if (empty($data)) { + $this->utils->errorThrow('没有可更新的内容'); + } + return $this->save($id, $data); + } + + /** + * 只软删素材记录,不动远端对象 + * + * 远端删除必须走 reclaim 的二次校验;这里如果顺手删了远端, + * 误删的文件就再也找不回来了,而软删记录随时可以恢复。 + * + * @throws Exception + */ + public function delete($ids): mixed + { + return $this->del(is_array($ids) ? $ids : [$ids]); + } + + /** + * 素材概览:总量、占用空间与可回收部分 + */ + public function stat(): array + { + $base = static fn () => FileModel::where('deleted_at', 0); + $types = $base()->selectRaw('type, COUNT(*) as count') + ->groupBy('type') + ->orderBy('type') + ->get() + ->map(static fn ($row) => ['type' => (int) $row->type, 'count' => (int) $row->count]) + ->all(); + + return [ + 'total' => $base()->count(), + 'total_size' => (int) $base()->sum('size'), + 'unused' => $base()->where('ref_count', 0)->count(), + 'unused_size' => (int) $base()->where('ref_count', 0)->sum('size'), + 'types' => $types, + ]; + } + + /** + * 从 OSS 增量拉对象进素材表 + * + * 一次只处理一页:返回的 next_marker 非空就带着它再调一次,前端点几下即可拉完。 + * 幂等靠三级匹配 —— 先按对象键,再按完整地址(换过域名的老记录), + * 最后才按哈希兜住「地址早就变了、内容没变」的历史上传记录。 + * 反复调用只会更新 size / hash,不会重复插入。 + * + * @param array $params oss_config_id / prefix / marker / limit + * @throws Exception + */ + public function syncFromOss(array $params): array + { + $configId = (int) ($params['oss_config_id'] ?? 0); + if ($configId <= 0) { + $this->utils->errorThrow('请选择要同步的存储配置'); + } + $limit = (int) ($params['limit'] ?? self::SYNC_DEFAULT_LIMIT); + $limit = $limit > 0 ? min($limit, self::SYNC_MAX_LIMIT) : self::SYNC_DEFAULT_LIMIT; + + $page = $this->driverOf($configId)->listObjects( + (string) ($params['prefix'] ?? ''), + (string) ($params['marker'] ?? ''), + $limit + ); + $items = array_values(array_filter( + (array) ($page['items'] ?? []), + static fn ($item) => !empty($item['key']) && !str_ends_with((string) $item['key'], '/') + )); + $result = [ + 'inserted' => 0, + 'updated' => 0, + 'next_marker' => (string) ($page['next_marker'] ?? ''), + 'finished' => (bool) ($page['finished'] ?? true), + ]; + if (empty($items)) { + return $result; + } + + [$byPath, $byUrl, $byHash] = $this->existingIndexOf($items); + $now = time(); + $pending = []; + $seen = []; + foreach ($items as $item) { + $key = (string) $item['key']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $url = (string) ($item['url'] ?? ''); + $hash = (string) ($item['hash'] ?? ''); + $ext = strtolower((string) pathinfo($key, PATHINFO_EXTENSION)); + $row = $byPath[$key] ?? $byUrl[$url] ?? ($hash !== '' ? ($byHash[$hash] ?? null) : null); + + if ($row !== null) { + FileModel::where('id', $row->id)->update( + $this->backfillOf($row, $key, $url, $hash, $ext, (int) ($item['size'] ?? 0), $configId, $now) + ); + $result['updated']++; + continue; + } + $pending[] = [ + 'user_id' => $this->userId, + 'oss_config_id' => $configId, + 'folder_id' => 0, + 'name' => basename($key), + 'url' => $url, + 'path' => $key, + 'ext' => $ext, + 'type' => FileModel::typeOfExt($ext), + 'size' => (int) ($item['size'] ?? 0), + 'width' => 0, + 'height' => 0, + 'hash' => $hash, + 'ref_count' => 0, + // 新补录的素材必须是 0:非 0 会被 reclaim 当成「扫过且没人用」直接删掉 + 'last_scan_at' => 0, + 'source' => FileModel::SOURCE_OSS, + // 用对象的实际修改时间当上传时间,否则批量补录出来的素材 + // 创建时间全挤在同一秒,「N 天前的无引用文件」这个筛选就没意义了 + 'created_at' => (int) ($item['last_modified'] ?? 0) ?: $now, + 'updated_at' => 0, + 'deleted_at' => 0, + ]; + } + if (!empty($pending)) { + FileModel::insert($pending); + $result['inserted'] = count($pending); + } + return $result; + } + + /** + * 全库引用扫描,回填 ref_count 与 last_scan_at + * + * 匹配策略:先按完整对象键,再退回文件名兜底。上传时的文件名是随机串, + * 现实中不会撞车;真撞车(同名多条)就放弃文件名兜底,宁可少算一次引用, + * 也不能把引用记到错的素材头上 —— 记错的那一头会被判成可回收。 + * + * 软删的业务行也照样算引用:那些记录随时可能被恢复, + * 把它们的封面提前删掉等于让恢复出来的数据全是裂图。 + */ + public function scanReferences(array $params = []): array + { + [$byPath, $byName] = $this->buildMaterialIndex(); + $counts = []; + $scanned = 0; + + foreach ((array) config('media_refs', []) as $ref) { + $conn = (string) ($ref['connection'] ?? 'mysql'); + $table = (string) ($ref['table'] ?? ''); + $field = (string) ($ref['field'] ?? ''); + $kind = (string) ($ref['kind'] ?? 'single'); + if ($table === '' || $field === '') { + continue; + } + try { + $schema = Schema::connection($conn); + if (!$schema->hasTable($table) || !$schema->hasColumn($table, $field)) { + // 登记了但库里还没这列(比如 catalogue.video 尚未上线):跳过而不是报错, + // 否则一个规划中的字段就能让整次扫描前功尽弃 + continue; + } + } catch (Throwable $e) { + continue; + } + + DB::connection($conn)->table($table) + ->select(['id', $field]) + ->orderBy('id') + ->chunk(self::CHUNK_SIZE, function ($rows) use ($field, $kind, $byPath, $byName, &$counts, &$scanned) { + foreach ($rows as $row) { + $raw = $row->{$field} ?? null; + if ($raw === null || trim((string) $raw) === '') { + continue; + } + foreach ($this->extractUrls((string) $raw, $kind) as $url) { + $key = $this->normalizeRefKey($url); + if ($key === '') { + continue; + } + $scanned++; + $id = $byPath[$key] ?? ($byName[basename($key)] ?? null); + if ($id !== null) { + $counts[$id] = ($counts[$id] ?? 0) + 1; + } + } + } + }); + } + + $now = time(); + // 先整表归零并盖上扫描时间戳,再把有引用的批量改回去: + // 逐条 update 在几万条素材上是几万次往返 + FileModel::where('deleted_at', 0)->update(['ref_count' => 0, 'last_scan_at' => $now]); + $grouped = []; + foreach ($counts as $id => $count) { + $grouped[(int) $count][] = (int) $id; + } + foreach ($grouped as $count => $ids) { + foreach (array_chunk($ids, self::CHUNK_SIZE) as $slice) { + FileModel::whereIn('id', $slice)->update(['ref_count' => $count]); + } + } + + return [ + 'scanned' => $scanned, + 'referenced' => count($counts), + 'unused' => FileModel::where('deleted_at', 0)->where('ref_count', 0)->count(), + ]; + } + + /** + * 无引用素材清单 + * + * 除了 ref_count = 0 还要卡「上传超过 N 天」:用户上传完图片、表单还没提交时, + * 这张图确实没人引用,但它马上就要被用上,不能出现在回收列表里。 + */ + public function unusedList(array $params = []): array + { + $days = (int) ($params['days'] ?? 30); + $days = $days > 0 ? $days : 30; + $query = FileModel::where('deleted_at', 0) + ->where('ref_count', 0) + ->where('created_at', '<', time() - $days * 86400) + ->select($this->selectField) + // 先给大文件,回收一页就能腾出可观的空间 + ->orderByDesc('size') + ->orderByDesc('id'); + return $this->toPage($query, (int) ($params['pageSize'] ?? 20)); + } + + /** + * 回收:删远端对象,成功后软删记录 + * + * 两道闸门都不能省 —— ref_count = 0 说明扫描时没人用,last_scan_at > 0 说明真的扫过。 + * 单条失败不中断整批:一批几百个对象,前面已经删掉的必须留下软删记录, + * 否则库里还挂着记录、远端对象已经没了,素材库里全是点开 404 的幽灵。 + * + * @param array $ids 素材 ID + * @throws Exception + */ + public function reclaim(array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if (empty($ids)) { + $this->utils->errorThrow('请选择要回收的素材'); + } + + $rows = FileModel::whereIn('id', $ids)->where('deleted_at', 0)->get(); + $deleted = 0; + $failed = []; + $drivers = []; + foreach ($rows as $row) { + $id = (int) $row->id; + if ((int) $row->last_scan_at <= 0) { + $failed[] = ['id' => $id, 'reason' => '尚未扫描过引用,请先执行引用扫描']; + continue; + } + if ((int) $row->ref_count !== 0) { + $failed[] = ['id' => $id, 'reason' => '仍被引用 ' . (int) $row->ref_count . ' 处']; + continue; + } + $key = trim((string) $row->path) !== '' + ? (string) $row->path + : $this->normalizeRefKey((string) $row->url); + if ($key === '') { + $failed[] = ['id' => $id, 'reason' => '没有可定位的对象键,请先同步一次']; + continue; + } + try { + $configId = (int) $row->oss_config_id; + if (!isset($drivers[$configId])) { + $drivers[$configId] = $this->driverOf($configId); + } + if (!$drivers[$configId]->deleteObject($key)) { + $failed[] = ['id' => $id, 'reason' => '远端删除失败']; + continue; + } + } catch (Throwable $e) { + $failed[] = ['id' => $id, 'reason' => $e->getMessage()]; + continue; + } + $now = time(); + FileModel::where('id', $id)->update(['deleted_at' => $now, 'updated_at' => $now]); + $deleted++; + } + + $found = $rows->pluck('id')->map(static fn ($value) => (int) $value)->all(); + foreach (array_diff($ids, $found) as $missing) { + $failed[] = ['id' => (int) $missing, 'reason' => '素材不存在或已删除']; + } + + return ['deleted' => $deleted, 'failed' => array_values($failed)]; + } + + /** + * 批量移动到文件夹(folder_id = 0 表示移回根目录) + * + * @throws Exception + */ + public function moveToFolder(array $ids, int $folderId): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + if (empty($ids)) { + $this->utils->errorThrow('请选择要移动的素材'); + } + $folderId = $this->assertFolder($folderId); + $moved = FileModel::whereIn('id', $ids)->where('deleted_at', 0)->update([ + 'folder_id' => $folderId, + 'updated_at' => time(), + ]); + return ['moved' => (int) $moved, 'folder_id' => $folderId]; + } + + /** + * 分页结果统一成前端约定的结构 + */ + private function toPage($query, int $pageSize = 0): array + { + $pageSize = $pageSize > 0 ? $pageSize : (int) request()->get('pageSize', 20); + $result = $query->paginate($pageSize > 0 ? $pageSize : 20)->toArray(); + foreach ($result['data'] as &$item) { + $item['url'] = $this->media->toPublic($item['url'] ?? ''); + } + unset($item); + return [ + 'page' => $result['current_page'], + 'size' => $result['per_page'], + 'page_count' => $result['last_page'], + 'total' => $result['total'], + 'items' => $result['data'], + ]; + } + + /** + * 一次把本批可能命中的已有记录捞出来,避免逐条 select 打 N 次库 + * + * byHash 只收 path still 为空的行:按内容哈希去认亲很容易把「两个键、同一份内容」 + * 的对象合成一条,只用来给还没记过对象键的历史上传记录补档。 + * + * @param array $items + * @return array{0: array, 1: array, 2: array} + */ + private function existingIndexOf(array $items): array + { + $paths = array_values(array_unique(array_column($items, 'key'))); + $urls = array_values(array_unique(array_filter(array_column($items, 'url')))); + $hashes = array_values(array_unique(array_filter(array_column($items, 'hash')))); + + $rows = FileModel::where('deleted_at', 0) + ->where(function ($q) use ($paths, $urls, $hashes) { + $q->whereIn('path', $paths); + if (!empty($urls)) { + $q->orWhereIn('url', $urls); + } + if (!empty($hashes)) { + $q->orWhere(function ($sub) use ($hashes) { + $sub->where('path', '')->whereIn('hash', $hashes); + }); + } + }) + ->get(['id', 'name', 'url', 'path', 'ext', 'type', 'hash', 'oss_config_id']); + + $byPath = []; + $byUrl = []; + $byHash = []; + foreach ($rows as $row) { + $path = (string) $row->path; + $url = (string) $row->url; + $hash = (string) $row->hash; + if ($path !== '') { + $byPath[$path] = $row; + } + if ($url !== '' && !isset($byUrl[$url])) { + $byUrl[$url] = $row; + } + if ($path === '' && $hash !== '' && !isset($byHash[$hash])) { + $byHash[$hash] = $row; + } + } + return [$byPath, $byUrl, $byHash]; + } + + /** + * 已有记录的更新字段 + * + * size / hash 每次都覆盖(对象可能被同名替换过),其余列只在原值为空时补, + * 免得把用户在素材库里改过的名字、归过的文件夹又冲回默认值。 + */ + private function backfillOf( + mixed $row, + string $key, + string $url, + string $hash, + string $ext, + int $size, + int $configId, + int $now + ): array { + $update = ['size' => $size, 'updated_at' => $now]; + if ($hash !== '') { + $update['hash'] = $hash; + } + if (trim((string) $row->path) === '') { + $update['path'] = $key; + } + if (trim((string) $row->url) === '' && $url !== '') { + $update['url'] = $url; + } + if (trim((string) $row->ext) === '' && $ext !== '') { + $update['ext'] = $ext; + $update['type'] = FileModel::typeOfExt($ext); + } + if (trim((string) $row->name) === '') { + $update['name'] = basename($key); + } + if ((int) $row->oss_config_id === 0) { + $update['oss_config_id'] = $configId; + } + return $update; + } + + /** + * 素材索引:对象键 → id,文件名 → id + * + * 文件名索引里同名多条时置 null(放弃兜底),见 scanReferences 的说明。 + * + * @return array{0: array, 1: array} + */ + private function buildMaterialIndex(): array + { + $byPath = []; + $byName = []; + FileModel::where('deleted_at', 0) + ->select(['id', 'path', 'url']) + ->orderBy('id') + ->chunk(self::CHUNK_SIZE, function ($rows) use (&$byPath, &$byName) { + foreach ($rows as $row) { + $id = (int) $row->id; + // path 与 url 都进索引:老记录只有 url,新记录两者都有 + foreach ([(string) $row->path, (string) $row->url] as $candidate) { + $key = $this->normalizeRefKey($candidate); + if ($key === '') { + continue; + } + $byPath[$key] = $id; + $name = basename($key); + if ($name === '') { + continue; + } + $byName[$name] = array_key_exists($name, $byName) && $byName[$name] !== $id + ? null + : $id; + } + } + }); + return [$byPath, $byName]; + } + + /** + * 把引用地址收敛成能与素材 path 比对的对象键 + * + * 同一个文件在库里可能是绝对地址、/storage 相对地址、带 ?imageView2 处理参数 + * 三种写法,不先归一化就只能匹配上碰巧写法一致的那批。 + */ + private function normalizeRefKey(string $value): string + { + $value = $this->media->stripProcessParams($value); + if ($value === '') { + return ''; + } + $path = (string) (parse_url($value, PHP_URL_PATH) ?: $value); + $path = ltrim((string) preg_replace('#/{2,}#', '/', rawurldecode($path)), '/'); + // 本地存储出库地址带 /storage 前缀,对象键里没有 + if (str_starts_with($path, 'storage/')) { + $path = substr($path, 8); + } + return $path; + } + + /** + * 按登记的 kind 从字段值里取出地址 + * + * @return array + */ + private function extractUrls(string $value, string $kind): array + { + $value = trim($value); + if ($value === '') { + return []; + } + return match ($kind) { + 'multi' => array_values(array_filter( + array_map('trim', explode(',', $value)), + static fn ($item) => $item !== '' + )), + 'rich' => $this->extractFromRichText($value), + default => [$value], + }; + } + + /** + * 富文本 / Markdown 里的地址 + * + * 三种写法都要抽:HTML 属性(src/href/poster)、内联样式的 url(…)、 + * Markdown 的 ![](…)。只抽 src 会漏掉正文里手写的 Markdown 图片。 + * + * @return array + */ + private function extractFromRichText(string $content): array + { + $found = []; + $patterns = [ + '#(?:src|href|poster|data-src)\s*=\s*[\'"]([^\'"]+)[\'"]#i', + '#url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)#i', + '#!\[[^\]]*\]\(\s*([^\s)]+)#', + ]; + foreach ($patterns as $pattern) { + if (preg_match_all($pattern, $content, $matches)) { + foreach ($matches[1] as $hit) { + $hit = trim((string) $hit); + if ($hit !== '') { + $found[] = $hit; + } + } + } + } + return array_values(array_unique($found)); + } + + /** + * 取指定配置的存储驱动 + * + * oss_config_id 为 0 的是扩表之前的老记录,只能按当前启用配置去删; + * 换过存储的站点这类记录得先同步一次把 config_id 补上,否则会删错 bucket。 + * + * @throws Exception + */ + private function driverOf(int $configId): OssStorageInterface + { + $runtime = OssRuntimeConfigService::getInstance(); + $config = $configId > 0 ? $runtime->getConfigById($configId) : $runtime->getActiveConfig(); + return OssStorageFactory::getInstance()->make($config); + } + + /** + * 0 表示根目录,其余必须是存在的文件夹 + * + * 不校验就会把素材移进一个查不到的 folder_id,那批文件在素材库里再也点不出来 + * + * @throws Exception + */ + private function assertFolder(int $folderId): int + { + if ($folderId <= 0) { + return 0; + } + if (!FileFolderModel::where('id', $folderId)->where('deleted_at', 0)->exists()) { + $this->utils->notFound('文件夹不存在'); + } + return $folderId; + } +} diff --git a/app/Service/PermissionService.php b/app/Service/PermissionService.php new file mode 100644 index 00000000..b2ccb631 --- /dev/null +++ b/app/Service/PermissionService.php @@ -0,0 +1,226 @@ +, paths: array}> 进程内缓存,一次请求里中间件与 codes 接口各要用一次 */ + private array $roleMemo = []; + + /** @var null|array 接口注册表 path => id */ + private ?array $pathMemo = null; + + public static function getInstance(): static + { + $name = get_called_class(); + if (!isset(self::$_instance[$name])) { + self::$_instance[$name] = new static(); + } + return self::$_instance[$name]; + } + + /** + * 超级管理员:代码里多处硬判断 role_id === 1 全量放行,这里保持一致 + */ + public function isSuper(int $roleId): bool + { + return $roleId === 1; + } + + /** + * 角色可用的权限码 + * @return array + */ + public function codes(int $roleId): array + { + return $this->load($roleId)['codes']; + } + + /** + * 判断角色能否调用某接口 + * + * @param string $path 已去掉 /api/ 前缀的路径,如 admin/list + */ + public function allows(int $roleId, string $path): bool + { + if ($this->isSuper($roleId)) { + return true; + } + $path = trim($path, '/'); + if ($path === '' || in_array($path, (array) config('nl.api.permission.always_allow', []), true)) { + return true; + } + $registered = $this->registeredPaths(); + if (!isset($registered[$path])) { + // 没登记进接口注册表的接口:迁移期放行,strict 模式拒绝 + return !config('nl.api.permission.strict', false); + } + return in_array($path, $this->load($roleId)['paths'], true); + } + + /** + * 接口授权树:按控制器分组,供角色授权抽屉勾选 + * @return array> + */ + public function endpointTree(): array + { + $rows = ApiEndpointModel::where('deleted_at', 0) + ->where('status', 1) + ->orderBy('controller') + ->orderBy('url') + ->get(['id', 'url', 'name', 'method', 'controller']) + ->toArray(); + + $groups = []; + foreach ($rows as $row) { + $controller = $row['controller'] !== '' ? $row['controller'] : '未归类'; + if (!isset($groups[$controller])) { + $groups[$controller] = [ + // 分组节点的 id 取负值,避免和真实 endpoint_id 混淆 + 'id' => -1 * (count($groups) + 1), + 'title' => $controller, + 'code' => '', + 'children' => [], + ]; + } + $groups[$controller]['children'][] = [ + 'id' => (int) $row['id'], + 'title' => $row['name'] !== '' ? $row['name'] : $row['url'], + 'code' => $this->pathToCode($row['url']), + 'url' => $row['url'], + ]; + } + return array_values($groups); + } + + /** + * 角色已授权的接口 id + * @return array + */ + public function grantedIds(int $roleId): array + { + return RoleEndpointRelationModel::where('role_id', $roleId) + ->pluck('endpoint_id') + ->map(fn ($v) => (int) $v) + ->all(); + } + + /** + * 保存角色的接口授权(全量覆盖) + */ + public function grant(int $roleId, array $endpointIds): bool + { + // 分组节点用的是负 id,落库前剔掉 + $ids = array_values(array_unique(array_filter(array_map('intval', $endpointIds), fn ($id) => $id > 0))); + $exists = $this->grantedIds($roleId); + + $toDelete = array_diff($exists, $ids); + if (!empty($toDelete)) { + RoleEndpointRelationModel::where('role_id', $roleId) + ->whereIn('endpoint_id', array_values($toDelete)) + ->delete(); + } + $toAdd = array_diff($ids, $exists); + if (!empty($toAdd)) { + $now = time(); + RoleEndpointRelationModel::insert(array_map( + fn ($id) => ['role_id' => $roleId, 'endpoint_id' => $id, 'created_at' => $now], + array_values($toAdd) + )); + } + $this->clear($roleId); + return true; + } + + /** + * 清角色权限缓存;不传角色则全清 + */ + public function clear(?int $roleId = null): void + { + $this->pathMemo = null; + $redis = RedisService::getInstance()->init(config('nl.redis.permission_key')); + if ($roleId === null) { + $this->roleMemo = []; + $redis->delAll(); + return; + } + unset($this->roleMemo[$roleId]); + $redis->del($roleId); + } + + /** + * admin/list → admin:list + */ + public function pathToCode(string $path): string + { + return str_replace('/', ':', trim($path, '/')); + } + + /** + * @return array{codes: array, paths: array} + */ + private function load(int $roleId): array + { + if (isset($this->roleMemo[$roleId])) { + return $this->roleMemo[$roleId]; + } + $redis = RedisService::getInstance()->init(config('nl.redis.permission_key')); + $cached = $redis->get($roleId); + if (!empty($cached)) { + $decoded = json_decode($cached, true); + if (is_array($decoded) && isset($decoded['codes'], $decoded['paths'])) { + return $this->roleMemo[$roleId] = $decoded; + } + } + + if ($this->isSuper($roleId)) { + // 超管拿全量码:前端按码匹配,给 ['*'] 反而什么都匹配不上 + $paths = array_keys($this->registeredPaths()); + } else { + $paths = ApiEndpointModel::where('deleted_at', 0) + ->where('status', 1) + ->whereIn('id', $this->grantedIds($roleId)) + ->pluck('url') + ->all(); + } + $paths = array_values(array_unique(array_map(fn ($p) => trim((string) $p, '/'), $paths))); + $data = [ + 'paths' => $paths, + 'codes' => array_map(fn ($p) => $this->pathToCode($p), $paths), + ]; + $redis->set($roleId, json_encode($data, JSON_UNESCAPED_UNICODE), 3600); + return $this->roleMemo[$roleId] = $data; + } + + /** + * 已登记的接口路径表(path => id) + * @return array + */ + private function registeredPaths(): array + { + if ($this->pathMemo !== null) { + return $this->pathMemo; + } + $rows = ApiEndpointModel::where('deleted_at', 0) + ->where('status', 1) + ->pluck('id', 'url') + ->all(); + $normalized = []; + foreach ($rows as $url => $id) { + $normalized[trim((string) $url, '/')] = (int) $id; + } + return $this->pathMemo = $normalized; + } +} diff --git a/app/Service/RoleService.php b/app/Service/RoleService.php index a9a8201d..951554b2 100755 --- a/app/Service/RoleService.php +++ b/app/Service/RoleService.php @@ -20,8 +20,8 @@ class RoleService extends BaseService { parent::__construct(); $this->model = RoleModel::class; - $this->selectField = ['id', 'name', 'value', 'pid', 'desc', 'created_at']; - $this->queryField = ['name' => 'like', 'value' => 'like', 'pid' => '=']; + $this->selectField = ['id', 'name', 'value', 'pid', 'desc', 'status', 'color', 'created_at']; + $this->queryField = ['name' => 'like', 'value' => 'like', 'pid' => '=', 'status' => '=']; $this->orderBy = ['name' => 'id', 'sort' => 'asc']; } @@ -97,13 +97,13 @@ class RoleService extends BaseService 'created_at' => time() ]; } - $userRoleBinding = RoleMenuRelationModel::insert($insertData); - if (!$userRoleBinding) { + // 只取消勾选、没有新增时 $insertData 为空,insert([]) 返回 false 会误判成失败 + if (!empty($insertData) && !RoleMenuRelationModel::insert($insertData)) { $this->utils->errorThrow('更新失败!'); } DB::commit(); - RedisService::getInstance()->init(config('nl.redis.menu_key'))->del($roleId); + $this->flushRoleCache((int) $roleId); } catch (\Exception $e) { DB::rollBack(); $this->utils->errorThrow($e->getMessage()); @@ -112,6 +112,50 @@ class RoleService extends BaseService return true; } + /** + * 接口授权树 + */ + public function endpointTree(): array + { + return PermissionService::getInstance()->endpointTree(); + } + + /** + * 角色已授权的接口 id + * @throws Exception + */ + public function getEndpointIdsByRoleId($roleId): array + { + if (empty($roleId)) $this->utils->errorThrow('请选择角色'); + return PermissionService::getInstance()->grantedIds((int) $roleId); + } + + /** + * 保存角色接口授权 + * @throws Exception + */ + public function saveRoleEndpoint(int $roleId, array $endpointIds): bool + { + if ($roleId <= 0) $this->utils->errorThrow('请选择角色'); + if ($roleId === 1) $this->utils->errorThrow('超级管理员默认拥有全部接口权限,无需授权'); + return PermissionService::getInstance()->grant($roleId, $endpointIds); + } + + /** + * 启用 / 停用角色 + * + * 停用后该角色下的账号仍能登录但权限为空,所以要顺手清掉权限与菜单缓存 + * @throws Exception + */ + public function status($id, $status): mixed + { + $id = (int) $id; + if ($id === 1) $this->utils->errorThrow('超级管理员角色禁止停用'); + $result = $this->save($id, ['status' => (int) $status]); + $this->flushRoleCache($id); + return $result; + } + /** * 获取数据详情 * @param $id @@ -159,6 +203,19 @@ class RoleService extends BaseService if (array_intersect(array_map('intval', $ids), [1, 2])) { $this->utils->errorThrow('管理员角色禁止删除'); } - return $this->del($id); + $result = $this->del($id); + foreach ($ids as $roleId) { + $this->flushRoleCache((int) $roleId); + } + return $result; + } + + /** + * 角色的菜单与权限都按角色缓存,改动后必须一起清,否则要等 1 小时才生效 + */ + private function flushRoleCache(int $roleId): void + { + RedisService::getInstance()->init(config('nl.redis.menu_key'))->del($roleId); + PermissionService::getInstance()->clear($roleId); } } diff --git a/app/Service/WxAppConfigService.php b/app/Service/WxAppConfigService.php new file mode 100644 index 00000000..e37555a7 --- /dev/null +++ b/app/Service/WxAppConfigService.php @@ -0,0 +1,157 @@ + 密文列 + */ + private const SECRET_FIELDS = ['app_secret', 'mch_key', 'mch_private_key']; + + public function __construct() + { + parent::__construct(); + $this->model = WxAppModel::class; + $this->selectField = [ + 'id', 'code', 'name', 'app_id', 'mch_id', 'mch_serial_no', 'notify_url', + 'template_code', 'status', 'remark', 'created_at', 'updated_at', + ]; + $this->queryField = ['code' => '=', 'name' => 'like', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + } + + public function list(): array + { + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item = $this->withSecretFlags($item); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'name', 'code']; + return $this->getOption(); + } + + public function detail($id): mixed + { + $info = $this->getDetail($id); + return $this->withSecretFlags(is_array($info) ? $info : $info->toArray()); + } + + public function create($params): mixed + { + return $this->insert($this->encryptSecrets($params)); + } + + public function update($id, $params): mixed + { + return $this->save($id, $this->encryptSecrets($params)); + } + + public function delete($ids): mixed + { + return $this->del($ids); + } + + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + /** + * 按 .env 的 WX_DEFAULT_APP_CODE / WX_APP_ID 引导落库一条空密钥记录 + * 方便首次部署:先建壳子,再进后台填 AppSecret(密钥仍不进 .env) + */ + public function initFromEnv(): array + { + $code = trim((string) config('nl.wx.default_app_code', '')); + $appId = trim((string) config('nl.wx.env_app_id', '')); + $name = trim((string) config('nl.wx.env_app_name', '')); + if ($code === '' || $appId === '') { + $this->utils->errorThrow('请先在 .env 配置 WX_DEFAULT_APP_CODE 与 WX_APP_ID,再点初始化'); + } + $row = WxAppModel::where('code', $code)->where('deleted_at', 0)->first(); + if (!empty($row)) { + $row->update([ + 'app_id' => $appId, + 'name' => $name !== '' ? $name : $row->name, + 'updated_at' => time(), + ]); + return [ + 'created' => false, + 'id' => (int) $row->id, + 'code' => $code, + 'app_id' => $appId, + 'app_secret_set' => trim((string) ($row->app_secret ?? '')) !== '', + 'message' => '已更新 AppID/名称,请编辑该行填写 AppSecret', + ]; + } + $id = WxAppModel::insertGetId([ + 'code' => $code, + 'name' => $name !== '' ? $name : $code, + 'app_id' => $appId, + 'status' => 0, + 'created_at' => time(), + 'updated_at' => time(), + ]); + return [ + 'created' => true, + 'id' => (int) $id, + 'code' => $code, + 'app_id' => $appId, + 'app_secret_set' => false, + 'message' => '已创建应用记录,请编辑填写 AppSecret', + ]; + } + + /** + * 加密要落库的密钥;留空表示不改动 + */ + private function encryptSecrets(array $params): array + { + $encrypt = FieldEncryptService::getInstance(); + foreach (self::SECRET_FIELDS as $field) { + if (!array_key_exists($field, $params)) { + continue; + } + $value = trim((string) $params[$field]); + if ($value === '') { + unset($params[$field]); + continue; + } + if ($encrypt->isEncrypted($value)) { + continue; + } + $params[$field] = $encrypt->encryptForStorage($value); + } + return $params; + } + + /** + * 只告诉前端「配没配」,不回显密文也不回显明文 + */ + private function withSecretFlags(array $row): array + { + $raw = WxAppModel::where('id', $row['id'] ?? 0)->first(self::SECRET_FIELDS); + foreach (self::SECRET_FIELDS as $field) { + $row[$field . '_set'] = !empty($raw[$field] ?? ''); + unset($row[$field]); + } + return $row; + } +} diff --git a/app/Service/business/CardClassService.php b/app/Service/business/CardClassService.php new file mode 100644 index 00000000..9fb88000 --- /dev/null +++ b/app/Service/business/CardClassService.php @@ -0,0 +1,83 @@ +model = CardClassModel::class; + $this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + return $this->getPageList(); + } + + public function option(): mixed + { + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + public function create($params): mixed + { + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (ColorcardModel::whereIn('card_class', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该分类下仍有色卡,请先调整色卡分类'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/CarouselService.php b/app/Service/business/CarouselService.php new file mode 100644 index 00000000..535bd8e5 --- /dev/null +++ b/app/Service/business/CarouselService.php @@ -0,0 +1,124 @@ +model = CarouselModel::class; + $this->selectField = ['id', 'url', 'to_path', 'sort', 'status', 'created_at', 'updated_at']; + $this->queryField = ['to_path' => 'like', 'status' => '=']; + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $this->media->publicEach($result['items'], ['url']); + return $result; + } + + public function option(): mixed + { + $rows = CarouselModel::where('deleted_at', 0) + ->orderBy('sort') + ->get(['id', 'url', 'to_path', 'sort']) + ->toArray(); + $this->media->publicEach($rows, ['url']); + return $rows; + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->url = $this->media->toPublic($info->url); + return $info; + } + + /** + * 新增:支持一次选多张图批量建轮播 + * @throws Exception + */ + public function create($params): mixed + { + $urls = $params['url'] ?? ''; + $urls = is_array($urls) ? $urls : [$urls]; + $now = time(); + $sort = (int) ($params['sort'] ?? 0); + + $rows = []; + foreach ($urls as $index => $url) { + $url = $this->media->toStorage(is_string($url) ? $url : ''); + if ($url === '') { + continue; + } + $rows[] = [ + 'url' => $url, + 'to_path' => (string) ($params['to_path'] ?? ''), + 'sort' => $sort + $index, + // 缺省显示;前端可传 status,批量建时统一用同一状态 + 'status' => (int) ($params['status'] ?? 0), + 'created_at' => $now, + ]; + } + if (empty($rows)) { + $this->utils->errorThrow('请上传轮播图'); + } + if (count($rows) === 1) { + return $this->insert($rows[0]); + } + return CarouselModel::insert($rows); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('url', $params)) { + $params['url'] = $this->media->firstOf($params['url']); + } + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/CatalogueService.php b/app/Service/business/CatalogueService.php new file mode 100644 index 00000000..e9036ff6 --- /dev/null +++ b/app/Service/business/CatalogueService.php @@ -0,0 +1,193 @@ +model = CatalogueModel::class; + $this->selectField = ['id', 'title', 'category_id', 'cover', 'pdf', 'price', 'alias', 'identifier', 'status', 'created_at', 'updated_at']; + $this->queryField = ['title' => 'like', 'alias' => 'like', 'identifier' => 'like', 'status' => '=']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * 列表 + * + * 两处沿用老行为: + * 1. 按分类筛选时连同该分类的子分类一起查,否则选了父分类会一条都搜不到 + * 2. 报价单里 6 个历史材质列绝大多数为空,只把有值的列名回给前端(show_field), + * 前端据此决定表格显示哪几列 + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->with = ['category', 'priceSheet']; + + $categoryId = (int) request()->get('category_id', 0); + if ($categoryId > 0) { + $ids = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all(); + $ids[] = $categoryId; + $this->whereIn = ['category_id', $ids]; + } + + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['category_name'] = $item['category']['name'] ?? ''; + unset($item['category']); + $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); + $item['pdf'] = $this->media->toPublic($item['pdf'] ?? ''); + $item['show_field'] = $this->pickUsedMaterialFields($item['price_sheet'] ?? []); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'title as name', 'identifier']; + return $this->getOption(); + } + + /** + * 详情,带规格与两类相册 + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->cover = $this->media->toPublic($info->cover); + $info->pdf = $this->media->toPublic($info->pdf); + + $priceSheet = PriceSheetModel::where('catalogue_id', $id) + ->where('deleted_at', 0) + ->orderBy('id') + ->get() + ->toArray(); + $info->show_field = $this->pickUsedMaterialFields($priceSheet); + $info->price_sheet = $priceSheet; + $info->render_images = $this->imagesOf($id, ImageModel::TYPE_RENDER); + $info->physical_images = $this->imagesOf($id, ImageModel::TYPE_PHYSICAL); + return $info; + } + + public function create($params): mixed + { + $params = $this->normalize($params); + $this->assertIdentifierUnique((string) ($params['identifier'] ?? '')); + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + $params = $this->normalize($params); + if (array_key_exists('identifier', $params)) { + $this->assertIdentifierUnique((string) $params['identifier'], (int) $id); + } + return $this->save($id, $params); + } + + /** + * 删除商品:连带软删它的规格与相册,否则会留下一堆查不到主体的孤儿数据 + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + $now = time(); + PriceSheetModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0) + ->update(['deleted_at' => $now, 'updated_at' => $now]); + ImageModel::whereIn('catalogue_id', $ids)->where('deleted_at', 0) + ->update(['deleted_at' => $now, 'updated_at' => $now]); + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + /** + * 报价单里真正有值的材质列 + */ + private function pickUsedMaterialFields(array $priceSheetRows): array + { + $used = []; + foreach ($priceSheetRows as $row) { + foreach (PriceSheetModel::MATERIAL_FIELDS as $field) { + if (!empty($row[$field] ?? '')) { + $used[$field] = true; + } + } + } + return array_keys($used); + } + + private function imagesOf(int|string $catalogueId, int $type): array + { + $rows = ImageModel::where('catalogue_id', $catalogueId) + ->where('type', $type) + ->where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'url', 'type']) + ->toArray(); + $this->media->publicEach($rows, ['url']); + return $rows; + } + + private function normalize(array $params): array + { + foreach (['cover', 'pdf'] as $field) { + if (array_key_exists($field, $params)) { + $params[$field] = $this->media->firstOf($params[$field]); + } + } + return $params; + } + + /** + * 商品编号是小程序搜索和线下对单的依据,重复了就没法定位货品 + * @throws Exception + */ + private function assertIdentifierUnique(string $identifier, int $exceptId = 0): void + { + $identifier = trim($identifier); + if ($identifier === '') { + return; + } + $exists = CatalogueModel::where('identifier', $identifier) + ->where('deleted_at', 0) + ->when($exceptId > 0, fn ($q) => $q->where('id', '<>', $exceptId)) + ->exists(); + if ($exists) { + $this->utils->errorThrow('商品编号已存在:' . $identifier); + } + } +} diff --git a/app/Service/business/CategoryService.php b/app/Service/business/CategoryService.php new file mode 100644 index 00000000..87c70a74 --- /dev/null +++ b/app/Service/business/CategoryService.php @@ -0,0 +1,121 @@ +model = CategoryModel::class; + $this->selectField = ['id', 'name', 'url', 'pid', 'sort', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'pid' => '=', 'status' => '=']; + // 有 sort 列后按排序值,同值再按 id,保证稳定 + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $this->media->publicEach($result['items'], ['url']); + return $result; + } + + /** + * 分类树。老接口在首位塞了一个 {id:0,name:'全部'} 供小程序做「全部」标签用, + * 这里保留该行为,但只在显式要求时加,后台表单选上级时不需要它。 + */ + public function option(): array + { + $withAll = filter_var(request()->get('with_all', false), FILTER_VALIDATE_BOOLEAN); + $rows = CategoryModel::where('deleted_at', 0) + ->orderBy('sort', 'asc') + ->orderBy('id', 'asc') + ->get(['id', 'name', 'url', 'pid', 'sort']) + ->toArray(); + $this->media->publicEach($rows, ['url']); + $tree = $this->utils->tree($rows); + if ($withAll) { + array_unshift($tree, ['id' => 0, 'name' => '全部', 'url' => '', 'pid' => 0]); + } + return $tree; + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->url = $this->media->toPublic($info->url); + return $info; + } + + public function create($params): mixed + { + $params['url'] = $this->media->firstOf($params['url'] ?? ''); + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('url', $params)) { + $params['url'] = $this->media->firstOf($params['url']); + } + if (array_key_exists('pid', $params)) { + $pid = (int) $params['pid']; + if ($pid === (int) $id) { + $this->utils->errorThrow('上级分类不能是自己'); + } + } + return $this->save($id, $params); + } + + /** + * 删除分类:有子分类或仍挂着商品时拒绝,避免商品失去归属后在小程序里查不到 + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (CategoryModel::whereIn('pid', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('存在子分类,请先删除子分类'); + } + if (CatalogueModel::whereIn('category_id', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该分类下仍有商品,请先调整商品分类'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/ColorcardService.php b/app/Service/business/ColorcardService.php new file mode 100644 index 00000000..4017de63 --- /dev/null +++ b/app/Service/business/ColorcardService.php @@ -0,0 +1,96 @@ +model = ColorcardModel::class; + $this->selectField = ['id', 'card_class', 'company', 'price', 'description', 'cover', 'status', 'created_at', 'updated_at']; + $this->queryField = ['card_class' => '=', 'company' => '=', 'description' => 'like', 'status' => '=']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->with = ['cardClassInfo', 'companyInfo']; + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['card_class_name'] = $item['card_class_info']['name'] ?? ''; + $item['company_name'] = $item['company_info']['name'] ?? ''; + unset($item['card_class_info'], $item['company_info']); + $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'description as name']; + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->cover = $this->media->toPublic($info->cover); + return $info; + } + + public function create($params): mixed + { + if (array_key_exists('cover', $params)) { + $params['cover'] = $this->media->firstOf($params['cover']); + } + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('cover', $params)) { + $params['cover'] = $this->media->firstOf($params['cover']); + } + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/CompanyService.php b/app/Service/business/CompanyService.php new file mode 100644 index 00000000..9306e91f --- /dev/null +++ b/app/Service/business/CompanyService.php @@ -0,0 +1,82 @@ +model = CompanyModel::class; + $this->selectField = ['id', 'name', 'phone', 'address', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'phone' => 'like', 'address' => 'like', 'status' => '=']; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + return $this->getPageList(); + } + + public function option(): mixed + { + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + public function create($params): mixed + { + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (ColorcardModel::whereIn('company', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该公司下仍有色卡,请先调整色卡所属公司'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/EnterpriseService.php b/app/Service/business/EnterpriseService.php new file mode 100644 index 00000000..96f11186 --- /dev/null +++ b/app/Service/business/EnterpriseService.php @@ -0,0 +1,162 @@ +model = EnterpriseModel::class; + $this->selectField = [ + 'id', 'name', 'logo', 'contact_name', 'phone', 'address', + 'tax_no', 'settle_type', 'price_number', 'status', 'remark', + 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'name' => 'like', + 'contact_name' => 'like', + 'phone' => 'like', + 'status' => '=', + ]; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $ids = array_column($result['items'], 'id'); + $counts = empty($ids) + ? [] + : WxUserModel::whereIn('enterprise_id', $ids) + ->where('deleted_at', 0) + ->selectRaw('enterprise_id, COUNT(*) as total') + ->groupBy('enterprise_id') + ->pluck('total', 'enterprise_id') + ->all(); + + foreach ($result['items'] as &$item) { + $item['logo'] = $this->media->toPublic($item['logo'] ?? ''); + $item['user_count'] = (int) ($counts[$item['id']] ?? 0); + } + unset($item); + return $result; + } + + /** + * 下拉:SearchSelect 组件按关键词模糊搜索,所以支持 keyword 入参 + */ + public function option(): mixed + { + $keyword = trim((string) request()->get('keyword', '')); + $limit = (int) request()->get('limit', 30); + $limit = $limit > 0 && $limit <= 100 ? $limit : 30; + + return EnterpriseModel::where('deleted_at', 0) + ->where('status', 0) + ->when($keyword !== '', function ($q) use ($keyword) { + $q->where(function ($sub) use ($keyword) { + $sub->where('name', 'like', "%{$keyword}%") + ->orWhere('contact_name', 'like', "%{$keyword}%") + ->orWhere('phone', 'like', "%{$keyword}%"); + }); + }) + ->orderBy('id', 'desc') + ->limit($limit) + ->get(['id', 'name', 'logo', 'contact_name', 'phone', 'price_number']) + ->map(function ($row) { + $row->logo = $this->media->toPublic($row->logo); + return $row; + }); + } + + /** + * 详情:带企业下的微信用户 + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->logo = $this->media->toPublic($info->logo); + $info->users = WxUserModel::where('enterprise_id', $id) + ->where('deleted_at', 0) + ->orderBy('id', 'desc') + ->get(['id', 'nick_name', 'phone', 'is_p', 'show_price', 'price_number', 'created_at']) + ->toArray(); + $info->user_count = count($info->users); + return $info; + } + + public function create($params): mixed + { + $params = $this->normalize($params); + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + $params = $this->normalize($params); + return $this->save($id, $params); + } + + /** + * 删除企业前先解绑用户,否则用户会挂在一个查不到的企业上 + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (WxUserModel::whereIn('enterprise_id', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该企业下仍有微信用户,请先解绑用户'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + private function normalize(array $params): array + { + if (array_key_exists('logo', $params)) { + $params['logo'] = $this->media->firstOf($params['logo']); + } + if (array_key_exists('price_number', $params)) { + $number = $params['price_number']; + $params['price_number'] = !is_numeric($number) || (float) $number <= 0 + ? '1' + : (string) $number; + } + return $params; + } +} diff --git a/app/Service/business/FactoryClassificationService.php b/app/Service/business/FactoryClassificationService.php new file mode 100644 index 00000000..ab382877 --- /dev/null +++ b/app/Service/business/FactoryClassificationService.php @@ -0,0 +1,83 @@ +model = FactoryClassificationModel::class; + $this->selectField = ['id', 'name', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + return $this->getPageList(); + } + + public function option(): mixed + { + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + public function create($params): mixed + { + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + if (FactoryInfoModel::whereIn('classification', $ids)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('该分类下仍有工厂,请先调整工厂分类'); + } + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/FactoryImageService.php b/app/Service/business/FactoryImageService.php new file mode 100644 index 00000000..910553b9 --- /dev/null +++ b/app/Service/business/FactoryImageService.php @@ -0,0 +1,141 @@ +model = FactoryImageModel::class; + $this->selectField = ['id', 'factory', 'url', 'status', 'created_at', 'updated_at']; + $this->queryField = ['factory' => '=', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'desc']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->with = ['factoryInfo']; + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['factory_name'] = $item['factory_info']['name'] ?? ''; + unset($item['factory_info']); + $item['url'] = $this->media->toPublic($item['url'] ?? ''); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'url as name']; + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->url = $this->media->toPublic($info->url); + return $info; + } + + /** + * 某工厂的全部产品图(老接口 factory-image/image-list) + */ + public function imageList(int $factoryId): array + { + if ($factoryId <= 0) { + return []; + } + $rows = FactoryImageModel::where('factory', $factoryId) + ->where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'factory', 'url', 'status']) + ->toArray(); + $this->media->publicEach($rows, ['url']); + return $rows; + } + + /** + * 新增:一次可上传多张 + * @throws Exception + */ + public function create($params): mixed + { + $factoryId = (int) ($params['factory'] ?? 0); + if (!FactoryInfoModel::where('id', $factoryId)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('工厂不存在'); + } + $urls = $params['url'] ?? ''; + $urls = is_array($urls) ? $urls : [$urls]; + + $now = time(); + $rows = []; + foreach ($urls as $url) { + $url = $this->media->toStorage(is_string($url) ? $url : ''); + if ($url === '') { + continue; + } + $rows[] = [ + 'factory' => $factoryId, + 'url' => $url, + 'created_at' => $now, + ]; + } + if (empty($rows)) { + $this->utils->errorThrow('请上传图片'); + } + if (count($rows) === 1) { + return $this->insert($rows[0]); + } + return FactoryImageModel::insert($rows); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('url', $params)) { + $params['url'] = $this->media->firstOf($params['url']); + } + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/FactoryInfoService.php b/app/Service/business/FactoryInfoService.php new file mode 100644 index 00000000..7afb8f17 --- /dev/null +++ b/app/Service/business/FactoryInfoService.php @@ -0,0 +1,103 @@ +model = FactoryInfoModel::class; + $this->selectField = ['id', 'name', 'phone', 'classification', 'cover', 'address', 'status', 'created_at', 'updated_at']; + $this->queryField = ['name' => 'like', 'phone' => 'like', 'classification' => '=', 'status' => '=']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->with = ['classificationInfo']; + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['classification_name'] = $item['classification_info']['name'] ?? ''; + unset($item['classification_info']); + $item['cover'] = $this->media->toPublic($item['cover'] ?? ''); + } + unset($item); + return $result; + } + + public function option(): mixed + { + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->cover = $this->media->toPublic($info->cover); + return $info; + } + + public function create($params): mixed + { + if (array_key_exists('cover', $params)) { + $params['cover'] = $this->media->firstOf($params['cover']); + } + return $this->insert($params); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('cover', $params)) { + $params['cover'] = $this->media->firstOf($params['cover']); + } + return $this->save($id, $params); + } + + /** + * 删除工厂时连带软删它的产品图,避免留下查不到工厂的图片 + * @throws Exception + */ + public function delete($id): mixed + { + $ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id]))); + if (empty($ids)) { + $this->utils->errorThrow('参数错误'); + } + $now = time(); + FactoryImageModel::whereIn('factory', $ids)->where('deleted_at', 0) + ->update(['deleted_at' => $now, 'updated_at' => $now]); + return $this->del($ids); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } +} diff --git a/app/Service/business/ImageService.php b/app/Service/business/ImageService.php new file mode 100644 index 00000000..3c47382f --- /dev/null +++ b/app/Service/business/ImageService.php @@ -0,0 +1,151 @@ +model = ImageModel::class; + $this->selectField = ['id', 'catalogue_id', 'url', 'type', 'status', 'created_at', 'updated_at']; + $this->queryField = ['catalogue_id' => '=', 'type' => '=', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $result = $this->getPageList(); + $this->media->publicEach($result['items'], ['url']); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'url as name']; + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + $info = $this->getDetail($id); + $info->url = $this->media->toPublic($info->url); + return $info; + } + + /** + * 渲染图列表(老接口 image/get-render-graph,入参是 catalogue_id) + */ + public function renderGraph(int $catalogueId): array + { + return $this->listByCatalogue($catalogueId, ImageModel::TYPE_RENDER); + } + + /** + * 实物图列表(老接口 image/get-physical-drawing,入参是 catalogue_id) + */ + public function physicalDrawing(int $catalogueId): array + { + return $this->listByCatalogue($catalogueId, ImageModel::TYPE_PHYSICAL); + } + + /** + * 新增:前端图片组件一次能选多张,url 传数组时逐张入库 + * @throws Exception + */ + public function create($params): mixed + { + $catalogueId = (int) ($params['catalogue_id'] ?? 0); + if (!CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('商品不存在'); + } + $type = (int) ($params['type'] ?? ImageModel::TYPE_RENDER); + $urls = $params['url'] ?? ''; + $urls = is_array($urls) ? $urls : [$urls]; + + $now = time(); + $rows = []; + foreach ($urls as $url) { + $url = $this->media->toStorage(is_string($url) ? $url : ''); + if ($url === '') { + continue; + } + $rows[] = [ + 'catalogue_id' => $catalogueId, + 'url' => $url, + 'type' => $type, + 'created_at' => $now, + ]; + } + if (empty($rows)) { + $this->utils->errorThrow('请上传图片'); + } + if (count($rows) === 1) { + return $this->insert($rows[0]); + } + return ImageModel::insert($rows); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + if (array_key_exists('url', $params)) { + $params['url'] = $this->media->firstOf($params['url']); + } + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + private function listByCatalogue(int $catalogueId, int $type): array + { + if ($catalogueId <= 0) { + return []; + } + $rows = ImageModel::where('catalogue_id', $catalogueId) + ->where('type', $type) + ->where('deleted_at', 0) + ->orderBy('id') + ->get(['id', 'catalogue_id', 'url', 'type', 'status']) + ->toArray(); + $this->media->publicEach($rows, ['url']); + return $rows; + } +} diff --git a/app/Service/business/ListService.php b/app/Service/business/ListService.php new file mode 100644 index 00000000..8f605d28 --- /dev/null +++ b/app/Service/business/ListService.php @@ -0,0 +1,198 @@ +model = ListModel::class; + $this->selectField = [ + 'id', 'list_no', 'name', 'user_id', 'enterprise_id', 'remark', + 'status', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'list_no' => 'like', + 'name' => 'like', + 'user_id' => '=', + 'enterprise_id' => '=', + 'status' => '=', + ]; + $this->with = ['user', 'enterprise']; + } + + public function list(): array + { + $keyword = trim((string) request()->get('user_keyword', '')); + if ($keyword !== '') { + // 前端只给一个「客户」输入框,昵称与手机号都要能搜到 + $userIds = WxUserModel::where('deleted_at', 0) + ->where(function ($query) use ($keyword) { + $query->where('nick_name', 'like', '%' . $keyword . '%') + ->orWhere('phone', 'like', '%' . $keyword . '%'); + })->pluck('id')->all(); + $this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds]; + } + + $result = $this->getPageList(); + $listIds = array_column($result['items'], 'id'); + $counts = ListItemModel::whereIn('list_id', $listIds) + ->where('deleted_at', 0) + ->selectRaw('list_id, count(*) as total, sum(quantity) as quantity') + ->groupBy('list_id') + ->get() + ->keyBy('list_id'); + $orderCounts = OrderModel::whereIn('list_id', $listIds) + ->where('deleted_at', 0) + ->selectRaw('list_id, count(*) as total') + ->groupBy('list_id') + ->get() + ->keyBy('list_id'); + foreach ($result['items'] as &$item) { + $item['item_count'] = (int) ($counts[$item['id']]['total'] ?? 0); + $item['quantity'] = (int) ($counts[$item['id']]['quantity'] ?? 0); + $item['order_count'] = (int) ($orderCounts[$item['id']]['total'] ?? 0); + $item['user_name'] = $item['user']['nick_name'] ?? ''; + $item['user_phone'] = $item['user']['phone'] ?? ''; + $item['enterprise_name'] = $item['enterprise']['name'] ?? ''; + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'name']; + return $this->getOption(); + } + + /** + * 详情:带明细与算过倍率的价格 + */ + public function detail($id): mixed + { + $info = ListModel::with([ + 'user', + 'enterprise', + 'items' => fn ($query) => $query->where('deleted_at', 0), + 'items.catalogue', + 'items.priceSheet', + ])->where('id', $id)->where('deleted_at', 0)->first(); + if (empty($info)) { + return $this->utils->notFound('清单不存在'); + } + $info = $info->toArray(); + $price = PriceService::getInstance(); + $multiplier = $info['user']['price_number'] ?? 1; + $total = 0; + foreach ($info['items'] as &$item) { + $routine = $item['price_sheet']['routine'] ?? ''; + $unit = (int) $item['unit_price']; + if ($unit <= 0) { + $unit = $price->resolveUnitPrice($routine, (string) $item['material_key'], $multiplier); + } + $item['unit_price'] = $unit; + $item['unit_price_text'] = $price->centsToYuan($unit); + $item['total_price'] = $unit * max(1, (int) $item['quantity']); + $item['routine_list'] = $price->formatRoutine($routine, true, $multiplier); + $total += $item['total_price']; + } + unset($item); + $info['total_amount'] = $total; + $info['total_amount_text'] = $price->centsToYuan($total); + return $info; + } + + public function create($params): mixed + { + $params['list_no'] = SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'); + return $this->insert($params); + } + + public function update($id, $params): mixed + { + unset($params['list_no'], $params['user_id']); + return $this->save($id, $params); + } + + public function delete($ids): mixed + { + return $this->del($ids); + } + + /** + * 某个用户的全部清单,用户详情模态框里用 + */ + public function byUser(int $userId): array + { + $rows = ListModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([ + 'id', 'list_no', 'name', 'status', 'created_at', + ])->toArray(); + $counts = ListItemModel::whereIn('list_id', array_column($rows, 'id')) + ->where('deleted_at', 0) + ->selectRaw('list_id, count(*) as total') + ->groupBy('list_id') + ->get() + ->keyBy('list_id'); + foreach ($rows as &$row) { + $row['item_count'] = (int) ($counts[$row['id']]['total'] ?? 0); + } + unset($row); + return $rows; + } + + /** + * 后台代客下单 + */ + public function toOrder(int $listId, array $params): array + { + return OrderCoreService::getInstance()->createFromList($listId, 0, $params); + } + + /** + * 改明细(后台帮客户补规格与数量) + */ + public function saveItem(array $params): mixed + { + $itemId = (int) ($params['id'] ?? 0); + if ($itemId <= 0) { + $this->utils->errorThrow('参数错误'); + } + $update = ['updated_at' => time()]; + foreach (['price_sheet_id', 'quantity', 'unit_price'] as $field) { + if (array_key_exists($field, $params)) { + $update[$field] = (int) $params[$field]; + } + } + foreach (['material_key', 'remark'] as $field) { + if (array_key_exists($field, $params)) { + $update[$field] = (string) $params[$field]; + } + } + return ListItemModel::where('id', $itemId)->update($update); + } + + /** + * 删明细 + */ + public function deleteItem(array|int $ids): mixed + { + return ListItemModel::whereIn('id', (array) $ids)->update([ + 'deleted_at' => time(), + 'updated_at' => time(), + ]); + } +} diff --git a/app/Service/business/OrderCoreService.php b/app/Service/business/OrderCoreService.php new file mode 100644 index 00000000..f2949a07 --- /dev/null +++ b/app/Service/business/OrderCoreService.php @@ -0,0 +1,383 @@ + [OrderModel::STATUS_PAID, OrderModel::STATUS_CANCELLED], + OrderModel::STATUS_PAID => [OrderModel::STATUS_SHIPPED, OrderModel::STATUS_CANCELLED], + OrderModel::STATUS_SHIPPED => [OrderModel::STATUS_DONE], + OrderModel::STATUS_DONE => [], + OrderModel::STATUS_CANCELLED => [], + ]; + + public static function getInstance(): null|static + { + $name = get_called_class(); + if (!isset(self::$_instance[$name])) { + self::$_instance[$name] = new static(); + } + return self::$_instance[$name]; + } + + /** + * 由清单生成订单 + * + * @param int $listId 清单 ID + * @param int $userId 下单用户(cc_wx_user.id) + * @param array $params receiver_name/receiver_phone/receiver_address/delivery_type/remark + * @return array 新订单详情 + */ + public function createFromList(int $listId, int $userId, array $params = []): array + { + $list = ListModel::where('id', $listId)->where('deleted_at', 0)->first(); + if (empty($list)) { + UtilsService::getInstance()->errorThrow('清单不存在'); + } + if ($userId > 0 && (int) $list['user_id'] !== $userId) { + UtilsService::getInstance()->errorThrow('不能对他人的清单下单'); + } + $userId = (int) $list['user_id']; + + $items = ListItemModel::with([ + 'catalogue', + 'priceSheet', + ])->where('list_id', $listId)->where('deleted_at', 0)->get(); + if ($items->isEmpty()) { + UtilsService::getInstance()->errorThrow('清单里还没有商品'); + } + + $user = WxUserModel::where('id', $userId)->first(); + $multiplier = $user['price_number'] ?? 1; + $price = PriceService::getInstance(); + + $rows = []; + $missing = []; + $total = 0; + foreach ($items as $item) { + $catalogue = $item->catalogue; + if (empty($catalogue)) { + continue; + } + $sheet = $item->priceSheet; + if (empty($sheet)) { + // 老清单没有 price_sheet_id,缺规格的行必须让用户回清单补选,不能瞎猜一个价格 + $missing[] = $catalogue['title'] ?? ('#' . $item['catalogue_id']); + continue; + } + $quantity = max(1, (int) $item['quantity']); + $unitPrice = (int) $item['unit_price']; + if ($unitPrice <= 0) { + $unitPrice = $price->resolveUnitPrice($sheet['routine'] ?? '', (string) $item['material_key'], $multiplier); + } + $lineTotal = $unitPrice * $quantity; + $total += $lineTotal; + $rows[] = [ + 'catalogue_id' => (int) $item['catalogue_id'], + 'price_sheet_id' => (int) $item['price_sheet_id'], + 'title' => (string) ($catalogue['title'] ?? ''), + 'cover' => (string) ($catalogue['cover'] ?? ''), + 'alias' => (string) ($catalogue['alias'] ?? ''), + 'specification' => (string) ($sheet['specification'] ?? ''), + 'dimension' => (string) ($sheet['dimension'] ?? ''), + 'material_key' => (string) ($item['material_key'] ?? 'routine'), + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $lineTotal, + 'remark' => (string) ($item['remark'] ?? ''), + 'created_at' => time(), + ]; + } + if (!empty($missing)) { + UtilsService::getInstance()->errorThrow('以下商品还没有选规格:' . implode('、', array_slice($missing, 0, 5))); + } + if (empty($rows)) { + UtilsService::getInstance()->errorThrow('清单里没有可下单的商品'); + } + + $orderId = 0; + DB::connection('business')->transaction(function () use (&$orderId, $list, $userId, $user, $params, $rows, $total) { + $now = time(); + $orderId = OrderModel::insertGetId([ + 'order_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_ORDER, 'order', 'order_no'), + 'list_id' => (int) $list['id'], + 'user_id' => $userId, + 'enterprise_id' => (int) ($list['enterprise_id'] ?: ($user['enterprise_id'] ?? 0)), + 'total_amount' => $total, + 'delivery_type' => (int) ($params['delivery_type'] ?? 0), + 'receiver_name' => (string) ($params['receiver_name'] ?? ($user['nick_name'] ?? '')), + 'receiver_phone' => (string) ($params['receiver_phone'] ?? ($user['phone'] ?? '')), + 'receiver_address' => (string) ($params['receiver_address'] ?? ''), + 'remark' => (string) ($params['remark'] ?? ''), + 'status' => OrderModel::STATUS_UNPAID, + 'created_at' => $now, + ]); + foreach ($rows as &$row) { + $row['order_id'] = $orderId; + } + unset($row); + OrderItemModel::insert($rows); + ListModel::where('id', $list['id'])->update(['status' => 1, 'updated_at' => $now]); + }); + + return $this->detail($orderId); + } + + /** + * 订单详情(含明细、支付记录、发货记录) + */ + public function detail(int $orderId): array + { + $order = OrderModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0), + 'payments' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'), + 'deliveries' => fn ($query) => $query->where('deleted_at', 0)->orderBy('id', 'desc'), + 'user', + 'enterprise', + ])->where('id', $orderId)->where('deleted_at', 0)->first(); + if (empty($order)) { + UtilsService::getInstance()->errorThrow('订单不存在'); + } + $order = $order->toArray(); + $order['voucher_list'] = []; + foreach ($order['payments'] ?? [] as $payment) { + foreach (array_filter(explode(',', (string) $payment['voucher'])) as $image) { + $order['voucher_list'][] = $image; + } + } + return $order; + } + + /** + * 提交转账凭证,进入待审核 + */ + public function submitVoucher(int $orderId, array $params, int $userId = 0): array + { + $order = $this->lockOrder($orderId, $userId); + if ((int) $order['pay_status'] === OrderModel::PAY_STATUS_PAID) { + UtilsService::getInstance()->errorThrow('订单已付款'); + } + $voucher = $params['voucher'] ?? ''; + $voucher = is_array($voucher) ? implode(',', array_filter($voucher)) : (string) $voucher; + if ($voucher === '') { + UtilsService::getInstance()->errorThrow('请上传转账凭证'); + } + $amount = (int) ($params['amount'] ?? 0); + if ($amount <= 0) { + $amount = (int) $order['total_amount'] - (int) $order['paid_amount']; + } + $now = time(); + DB::connection('business')->transaction(function () use ($orderId, $voucher, $amount, $now) { + OrderPaymentModel::insert([ + 'order_id' => $orderId, + 'pay_type' => OrderModel::PAY_TYPE_VOUCHER, + 'amount' => $amount, + 'voucher' => $voucher, + // 转账没有微信单号,用订单 + 时间占位,仍受唯一索引约束防重复提交 + 'out_trade_no' => 'TR' . $orderId . '_' . $now, + 'status' => OrderPaymentModel::STATUS_AUDITING, + 'created_at' => $now, + ]); + OrderModel::where('id', $orderId)->update([ + 'pay_type' => OrderModel::PAY_TYPE_VOUCHER, + 'pay_status' => OrderModel::PAY_STATUS_AUDITING, + 'updated_at' => $now, + ]); + }); + return $this->detail($orderId); + } + + /** + * 审核转账凭证 + * + * @param int $status OrderPaymentModel::STATUS_CONFIRMED|STATUS_REJECTED + */ + public function auditPayment(int $paymentId, int $status, int $adminId, string $remark = ''): array + { + $orderId = 0; + DB::connection('business')->transaction(function () use ($paymentId, $status, $adminId, $remark, &$orderId) { + $payment = OrderPaymentModel::where('id', $paymentId)->lockForUpdate()->first(); + if (empty($payment)) { + UtilsService::getInstance()->errorThrow('支付记录不存在'); + } + if ((int) $payment['status'] !== OrderPaymentModel::STATUS_AUDITING) { + UtilsService::getInstance()->errorThrow('该支付记录已处理'); + } + $orderId = (int) $payment['order_id']; + $now = time(); + OrderPaymentModel::where('id', $paymentId)->update([ + 'status' => $status, + 'auditor_id' => $adminId, + 'audited_at' => $now, + 'audit_remark' => $remark, + 'updated_at' => $now, + ]); + if ($status !== OrderPaymentModel::STATUS_CONFIRMED) { + OrderModel::where('id', $orderId)->update([ + 'pay_status' => OrderModel::PAY_STATUS_REJECTED, + 'updated_at' => $now, + ]); + return; + } + $this->applyPaid($orderId, (int) $payment['amount'], $now); + }); + return $this->detail($orderId); + } + + /** + * 记一笔收款并推进订单状态 + * + * 收款可能分多笔,只有累计金额够了才算付清,否则停在部分收款。 + */ + public function applyPaid(int $orderId, int $amount, int $now = 0): void + { + $now = $now ?: time(); + $order = OrderModel::where('id', $orderId)->lockForUpdate()->first(); + if (empty($order)) { + UtilsService::getInstance()->errorThrow('订单不存在'); + } + $paid = (int) $order['paid_amount'] + $amount; + $update = [ + 'paid_amount' => $paid, + 'updated_at' => $now, + ]; + if ($paid >= (int) $order['total_amount']) { + $update['pay_status'] = OrderModel::PAY_STATUS_PAID; + $update['paid_at'] = $now; + if ($this->canTransition((int) $order['status'], OrderModel::STATUS_PAID)) { + $update['status'] = OrderModel::STATUS_PAID; + } + } + OrderModel::where('id', $orderId)->update($update); + } + + /** + * 微信支付回调落账(幂等) + * + * 微信会重复推送同一笔,靠 out_trade_no 唯一索引 + 状态判断挡住重复入账。 + */ + public function confirmWechatPay(string $outTradeNo, string $transactionId, int $amount): bool + { + $done = false; + DB::connection('business')->transaction(function () use ($outTradeNo, $transactionId, $amount, &$done) { + $payment = OrderPaymentModel::where('out_trade_no', $outTradeNo)->lockForUpdate()->first(); + if (empty($payment)) { + return; + } + if ((int) $payment['status'] === OrderPaymentModel::STATUS_CONFIRMED) { + $done = true; + return; + } + $now = time(); + OrderPaymentModel::where('id', $payment['id'])->update([ + 'status' => OrderPaymentModel::STATUS_CONFIRMED, + 'transaction_id' => $transactionId, + 'amount' => $amount > 0 ? $amount : (int) $payment['amount'], + 'audited_at' => $now, + 'updated_at' => $now, + ]); + $this->applyPaid((int) $payment['order_id'], $amount > 0 ? $amount : (int) $payment['amount'], $now); + $done = true; + }); + return $done; + } + + /** + * 发货:物流 / 自提 / 公司配送 + */ + public function ship(int $orderId, array $params, int $adminId): array + { + $order = $this->lockOrder($orderId); + $type = (int) ($params['delivery_type'] ?? OrderModel::DELIVERY_EXPRESS); + if ($type === OrderModel::DELIVERY_EXPRESS && trim((string) ($params['tracking_no'] ?? '')) === '') { + UtilsService::getInstance()->errorThrow('请填写运单号'); + } + if ($type === OrderModel::DELIVERY_PICKUP && trim((string) ($params['pickup_point'] ?? '')) === '') { + UtilsService::getInstance()->errorThrow('请填写自提点'); + } + if (!$this->canTransition((int) $order['status'], OrderModel::STATUS_SHIPPED)) { + UtilsService::getInstance()->errorThrow('当前订单状态不允许发货'); + } + $now = time(); + DB::connection('business')->transaction(function () use ($orderId, $params, $type, $adminId, $now) { + OrderDeliveryModel::insert([ + 'order_id' => $orderId, + 'delivery_type' => $type, + 'company' => (string) ($params['company'] ?? ''), + 'tracking_no' => (string) ($params['tracking_no'] ?? ''), + 'pickup_point' => (string) ($params['pickup_point'] ?? ''), + 'driver_info' => (string) ($params['driver_info'] ?? ''), + 'shipped_at' => $now, + 'remark' => (string) ($params['remark'] ?? ''), + 'operator_id' => $adminId, + 'created_at' => $now, + ]); + OrderModel::where('id', $orderId)->update([ + 'delivery_type' => $type, + 'status' => OrderModel::STATUS_SHIPPED, + 'updated_at' => $now, + ]); + }); + return $this->detail($orderId); + } + + /** + * 状态迁移(取消、完成等) + */ + public function transition(int $orderId, int $target, int $userId = 0): array + { + $order = $this->lockOrder($orderId, $userId); + if (!$this->canTransition((int) $order['status'], $target)) { + UtilsService::getInstance()->errorThrow('当前状态不允许该操作'); + } + OrderModel::where('id', $orderId)->update([ + 'status' => $target, + 'updated_at' => time(), + ]); + return $this->detail($orderId); + } + + public function canTransition(int $from, int $to): bool + { + return in_array($to, self::TRANSITIONS[$from] ?? [], true); + } + + /** + * 取订单并做归属校验($userId > 0 时限定本人) + */ + private function lockOrder(int $orderId, int $userId = 0): array + { + $order = OrderModel::where('id', $orderId)->where('deleted_at', 0)->first(); + if (empty($order)) { + UtilsService::getInstance()->errorThrow('订单不存在'); + } + if ($userId > 0 && (int) $order['user_id'] !== $userId) { + UtilsService::getInstance()->errorThrow('无权操作该订单'); + } + return $order->toArray(); + } +} diff --git a/app/Service/business/OrderService.php b/app/Service/business/OrderService.php new file mode 100644 index 00000000..b52b647e --- /dev/null +++ b/app/Service/business/OrderService.php @@ -0,0 +1,193 @@ +model = OrderModel::class; + $this->selectField = [ + 'id', 'order_no', 'list_id', 'user_id', 'enterprise_id', 'total_amount', 'paid_amount', + 'pay_type', 'pay_status', 'paid_at', 'delivery_type', 'receiver_name', 'receiver_phone', + 'receiver_address', 'status', 'remark', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'order_no' => 'like', + 'user_id' => '=', + 'enterprise_id' => '=', + 'status' => '=', + 'pay_status' => '=', + 'pay_type' => '=', + 'delivery_type' => '=', + 'receiver_phone' => 'like', + ]; + $this->with = ['user', 'enterprise']; + } + + public function list(): array + { + $keyword = trim((string) request()->get('user_keyword', '')); + if ($keyword !== '') { + $userIds = WxUserModel::where('deleted_at', 0) + ->where(function ($query) use ($keyword) { + $query->where('nick_name', 'like', '%' . $keyword . '%') + ->orWhere('phone', 'like', '%' . $keyword . '%'); + })->pluck('id')->all(); + $this->whereIn = ['user_id', empty($userIds) ? [0] : $userIds]; + } + $result = $this->getPageList(); + $price = PriceService::getInstance(); + foreach ($result['items'] as &$item) { + $item['user_name'] = $item['user']['nick_name'] ?? ''; + $item['user_phone'] = $item['user']['phone'] ?? ''; + $item['enterprise_name'] = $item['enterprise']['name'] ?? ''; + $item['total_amount_text'] = $price->centsToYuan((int) $item['total_amount']); + $item['paid_amount_text'] = $price->centsToYuan((int) $item['paid_amount']); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'order_no']; + return $this->getOption(); + } + + public function detail($id): mixed + { + $order = OrderCoreService::getInstance()->detail((int) $id); + $price = PriceService::getInstance(); + $order['total_amount_text'] = $price->centsToYuan((int) $order['total_amount']); + $order['paid_amount_text'] = $price->centsToYuan((int) $order['paid_amount']); + foreach ($order['items'] as &$item) { + $item['unit_price_text'] = $price->centsToYuan((int) $item['unit_price']); + $item['total_price_text'] = $price->centsToYuan((int) $item['total_price']); + } + unset($item); + return $order; + } + + /** + * 后台代客建单 + */ + public function create($params): mixed + { + $listId = (int) ($params['list_id'] ?? 0); + if ($listId <= 0) { + $this->utils->errorThrow('请选择清单'); + } + return OrderCoreService::getInstance()->createFromList($listId, 0, $params); + } + + /** + * 只允许改收件信息与备注:金额与状态必须走各自的业务入口 + */ + public function update($id, $params): mixed + { + $allowed = array_intersect_key($params, array_flip([ + 'receiver_name', 'receiver_phone', 'receiver_address', 'remark', 'delivery_type', + ])); + if (empty($allowed)) { + $this->utils->errorThrow('没有可修改的字段'); + } + return $this->save($id, $allowed); + } + + public function delete($ids): mixed + { + return $this->del($ids); + } + + /** + * 审核转账凭证 + */ + public function auditPayment(array $params): array + { + $paymentId = (int) ($params['payment_id'] ?? 0); + $pass = (int) ($params['status'] ?? 1) === 1; + return OrderCoreService::getInstance()->auditPayment( + $paymentId, + $pass ? OrderPaymentModel::STATUS_CONFIRMED : OrderPaymentModel::STATUS_REJECTED, + $this->userId, + (string) ($params['remark'] ?? '') + ); + } + + /** + * 发货 + */ + public function ship(array $params): array + { + return OrderCoreService::getInstance()->ship( + (int) ($params['id'] ?? 0), + $params, + $this->userId + ); + } + + public function cancel(int $id): array + { + return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED); + } + + public function complete(int $id): array + { + return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE); + } + + /** + * 某个用户的订单,用户详情模态框里用 + */ + public function byUser(int $userId): array + { + $price = PriceService::getInstance(); + $rows = OrderModel::where('user_id', $userId)->where('deleted_at', 0)->orderBy('id', 'desc')->get([ + 'id', 'order_no', 'total_amount', 'paid_amount', 'pay_status', 'status', 'created_at', + ])->toArray(); + foreach ($rows as &$row) { + $row['total_amount_text'] = $price->centsToYuan((int) $row['total_amount']); + } + unset($row); + return $rows; + } + + /** + * 概览:各状态数量与金额,给列表页顶部的统计条 + */ + public function stat(): array + { + $price = PriceService::getInstance(); + $rows = OrderModel::where('deleted_at', 0) + ->selectRaw('status, count(*) as total, sum(total_amount) as amount') + ->groupBy('status') + ->get(); + $stat = ['total' => 0, 'amount' => 0, 'status' => []]; + foreach ($rows as $row) { + $stat['total'] += (int) $row['total']; + $stat['amount'] += (int) $row['amount']; + $stat['status'][] = [ + 'status' => (int) $row['status'], + 'count' => (int) $row['total'], + 'amount' => (int) $row['amount'], + ]; + } + $stat['amount_text'] = $price->centsToYuan((int) $stat['amount']); + $stat['auditing'] = OrderPaymentModel::where('deleted_at', 0) + ->where('status', OrderPaymentModel::STATUS_AUDITING) + ->count(); + return $stat; + } +} diff --git a/app/Service/business/PriceService.php b/app/Service/business/PriceService.php new file mode 100644 index 00000000..081a7dbd --- /dev/null +++ b/app/Service/business/PriceService.php @@ -0,0 +1,152 @@ + $v !== '')); + } + + /** + * 按用户可见性与倍率格式化 routine + * + * @param bool $showPrice 是否可见价格 + * @param int|float|string $multiplier 价格倍率 + * @return array + */ + public function formatRoutine(?string $routine, bool $showPrice, mixed $multiplier = 1): array + { + if (!$showPrice) { + return [self::MASK]; + } + $items = $this->split($routine); + foreach ($items as &$item) { + $item = $this->formatPrice($item, $multiplier); + } + unset($item); + return $items; + } + + /** + * 单段价格乘倍率。「名称:价格」只乘价格部分,兼容半角冒号与空格 + */ + public function formatPrice(string $value, mixed $multiplier = 1): string + { + $multiplier = $this->normalizeMultiplier($multiplier); + try { + if (preg_match('/^[0-9.]+$/', $value)) { + return bcmul($value, $multiplier, 0); + } + $normalized = str_replace([':', ' '], [':', ''], $value); + $parts = explode(':', $normalized); + if (array_key_exists(1, $parts) && preg_match('/^[0-9.]+$/', $parts[1])) { + $parts[1] = bcmul($parts[1], $multiplier, 0); + } + return implode(':', $parts); + } catch (\Throwable) { + return $value; + } + } + + /** + * 给一组报价单行套上价格规则,返回值里 routine 变成数组 + * + * @param array $rows price_sheet 行 + * @return array{rows: array, is_show_price: bool} + */ + public function applyToRows(array $rows, bool $showPrice, mixed $multiplier = 1): array + { + foreach ($rows as &$row) { + $row['routine'] = $this->formatRoutine($row['routine'] ?? '', $showPrice, $multiplier); + } + unset($row); + return ['rows' => $rows, 'is_show_price' => $showPrice]; + } + + /** + * 取某个材质的单价,返回「分」 + * + * 下单要的是一个确定的数字,而 routine 是给人看的字符串(可能是 "1200", + * 也可能是 "布艺:1200@皮艺:1800")。这里按 materialKey 找对应段, + * 找不到就退回第一个能解析出数字的段;一个都没有返回 0,由调用方决定报错还是放过。 + * + * 金额一律整数分:老库价格是整数元,乘完倍率再 ×100,不引入浮点。 + */ + public function resolveUnitPrice(?string $routine, string $materialKey = '', mixed $multiplier = 1): int + { + $items = $this->split($routine); + if (empty($items)) { + return 0; + } + $materialKey = trim($materialKey); + $fallback = 0; + foreach ($items as $item) { + $normalized = str_replace([':', ' '], [':', ''], $item); + $parts = explode(':', $normalized); + $name = count($parts) > 1 ? $parts[0] : ''; + $value = count($parts) > 1 ? $parts[1] : $parts[0]; + if (!preg_match('/^[0-9.]+$/', $value)) { + continue; + } + $yuan = (int) bcmul($value, $this->normalizeMultiplier($multiplier), 0); + if ($materialKey !== '' && $materialKey !== 'routine' && $name === $materialKey) { + return $yuan * 100; + } + if ($fallback === 0) { + $fallback = $yuan * 100; + } + } + return $fallback; + } + + /** + * 分转元字符串,仅用于展示与导出 + */ + public function centsToYuan(int $cents): string + { + return number_format($cents / 100, 2, '.', ''); + } + + /** + * 倍率兜底:库里可能是空串、0 或负数,直接拿去 bcmul 会把价格清零 + */ + private function normalizeMultiplier(mixed $multiplier): string + { + if (!is_numeric($multiplier) || (float) $multiplier <= 0) { + return '1'; + } + return (string) $multiplier; + } +} diff --git a/app/Service/business/PriceSheetService.php b/app/Service/business/PriceSheetService.php new file mode 100644 index 00000000..ee1cdae1 --- /dev/null +++ b/app/Service/business/PriceSheetService.php @@ -0,0 +1,214 @@ +model = PriceSheetModel::class; + $this->selectField = array_merge( + ['id', 'catalogue_id', 'specification', 'dimension'], + PriceSheetModel::MATERIAL_FIELDS, + ['status', 'created_at', 'updated_at'] + ); + $this->queryField = ['catalogue_id' => '=', 'specification' => 'like', 'status' => '=']; + $this->orderBy = ['name' => 'id', 'sort' => 'asc']; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + return $this->getPageList(); + } + + public function option(): mixed + { + $this->optionField = ['id', 'specification as name']; + return $this->getOption(); + } + + /** + * @throws Exception + */ + public function detail($id): mixed + { + return $this->getDetail($id); + } + + /** + * 新增:单行或多行(rows) + * @throws Exception + */ + public function create($params): mixed + { + $catalogueId = (int) ($params['catalogue_id'] ?? 0); + $this->assertCatalogue($catalogueId); + + $rows = $this->normalizeRows($catalogueId, $params); + if (empty($rows)) { + $this->utils->errorThrow('请至少填写一行规格'); + } + if (count($rows) === 1) { + return $this->insert($rows[0]); + } + return PriceSheetModel::insert($rows); + } + + /** + * @throws Exception + */ + public function update($id, $params): mixed + { + unset($params['rows']); + return $this->save($id, $params); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * @throws Exception + */ + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + /** + * 覆盖式保存某商品的全部规格行:带 id 的更新,不带的新增,界面上被删掉的软删。 + * 一次事务完成,避免中途失败留下半套规格。 + * @throws Exception + */ + public function saveRows(int $catalogueId, array $rows): bool + { + $this->assertCatalogue($catalogueId); + + $now = time(); + $keepIds = []; + DB::connection('business')->beginTransaction(); + try { + foreach ($rows as $row) { + $payload = $this->pickRowFields($row); + if (trim((string) ($payload['specification'] ?? '')) === '') { + continue; + } + $payload['catalogue_id'] = $catalogueId; + $id = (int) ($row['id'] ?? 0); + if ($id > 0) { + $payload['updated_at'] = $now; + PriceSheetModel::where('id', $id)->where('catalogue_id', $catalogueId)->update($payload); + $keepIds[] = $id; + } else { + $payload['created_at'] = $now; + $keepIds[] = (int) PriceSheetModel::insertGetId($payload); + } + } + + PriceSheetModel::where('catalogue_id', $catalogueId) + ->where('deleted_at', 0) + ->when(!empty($keepIds), fn ($q) => $q->whereNotIn('id', $keepIds)) + ->update(['deleted_at' => $now, 'updated_at' => $now]); + + DB::connection('business')->commit(); + } catch (Exception $e) { + DB::connection('business')->rollBack(); + $this->utils->errorThrow($e->getMessage()); + } + return true; + } + + /** + * 某商品的全部规格行,供报价单抽屉回填 + */ + public function rowsOf(int $catalogueId): array + { + return PriceSheetModel::where('catalogue_id', $catalogueId) + ->where('deleted_at', 0) + ->orderBy('id') + ->get($this->selectField) + ->toArray(); + } + + /** + * 兼容单行字段与 rows 数组两种入参 + */ + private function normalizeRows(int $catalogueId, array $params): array + { + $now = time(); + $source = []; + if (!empty($params['rows']) && is_array($params['rows'])) { + $source = $params['rows']; + } elseif (trim((string) ($params['specification'] ?? '')) !== '') { + $source = [$params]; + } + + $rows = []; + foreach ($source as $row) { + if (!is_array($row)) { + continue; + } + $payload = $this->pickRowFields($row); + if (trim((string) ($payload['specification'] ?? '')) === '') { + continue; + } + $payload['catalogue_id'] = $catalogueId; + $payload['created_at'] = $now; + $rows[] = $payload; + } + return $rows; + } + + /** + * 只取表里真实存在的列,把前端多传的字段挡在外面 + */ + private function pickRowFields(array $row): array + { + $allowed = array_merge(['specification', 'dimension'], PriceSheetModel::MATERIAL_FIELDS); + $payload = []; + foreach ($allowed as $field) { + if (array_key_exists($field, $row)) { + $payload[$field] = is_scalar($row[$field]) ? (string) $row[$field] : ''; + } + } + return $payload; + } + + /** + * @throws Exception + */ + private function assertCatalogue(int $catalogueId): void + { + if ($catalogueId <= 0) { + $this->utils->errorThrow('请选择商品'); + } + $exists = CatalogueModel::where('id', $catalogueId)->where('deleted_at', 0)->exists(); + if (!$exists) { + $this->utils->errorThrow('商品不存在'); + } + } +} diff --git a/app/Service/business/SerialNoService.php b/app/Service/business/SerialNoService.php new file mode 100644 index 00000000..efc264e8 --- /dev/null +++ b/app/Service/business/SerialNoService.php @@ -0,0 +1,61 @@ +randomPart(6); + $exists = DB::connection('business')->table($table)->where($column, $no)->exists(); + if (!$exists) { + return $no; + } + } + // 连续撞 10 次说明随机源或并发量出了问题,静默返回可能重复的号比抛错危险得多 + throw new \RuntimeException('单号生成失败,请重试'); + } + + private function randomPart(int $length): string + { + $max = strlen(self::ALPHABET) - 1; + $out = ''; + for ($i = 0; $i < $length; $i++) { + $out .= self::ALPHABET[random_int(0, $max)]; + } + return $out; + } +} diff --git a/app/Service/business/WxTemplatePresetService.php b/app/Service/business/WxTemplatePresetService.php new file mode 100644 index 00000000..06ec5e27 --- /dev/null +++ b/app/Service/business/WxTemplatePresetService.php @@ -0,0 +1,199 @@ + [ + 'family' => 'Songti SC, Noto Serif SC, serif', + 'family_title' => 'Songti SC, Noto Serif SC, serif', + 'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx', + 'size_lg' => '34rpx', 'size_xl' => '42rpx', 'size_title' => '52rpx', + 'weight_normal' => '400', 'weight_bold' => '600', + 'line_height' => '1.7', 'letter_spacing' => '1rpx', + ], + 'serif-book' => [ + 'family' => 'Noto Serif SC, Georgia, serif', + 'family_title' => 'Noto Serif SC, Georgia, serif', + 'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx', + 'size_lg' => '34rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx', + 'weight_normal' => '400', 'weight_bold' => '700', + 'line_height' => '1.8', 'letter_spacing' => '0', + ], + 'sans-refined' => [ + 'family' => 'PingFang SC, HarmonyOS Sans, sans-serif', + 'family_title' => 'PingFang SC, HarmonyOS Sans, sans-serif', + 'size_xs' => '20rpx', 'size_sm' => '24rpx', 'size_md' => '28rpx', + 'size_lg' => '32rpx', 'size_xl' => '40rpx', 'size_title' => '48rpx', + 'weight_normal' => '400', 'weight_bold' => '600', + 'line_height' => '1.6', 'letter_spacing' => '0', + ], + 'sans-compact' => [ + 'family' => 'PingFang SC, Roboto, sans-serif', + 'family_title' => 'PingFang SC, Roboto, sans-serif', + 'size_xs' => '18rpx', 'size_sm' => '22rpx', 'size_md' => '26rpx', + 'size_lg' => '30rpx', 'size_xl' => '36rpx', 'size_title' => '42rpx', + 'weight_normal' => '400', 'weight_bold' => '700', + 'line_height' => '1.5', 'letter_spacing' => '0', + ], + 'sans-wide' => [ + 'family' => 'PingFang SC, Inter, sans-serif', + 'family_title' => 'PingFang SC, Inter, sans-serif', + 'size_xs' => '22rpx', 'size_sm' => '26rpx', 'size_md' => '30rpx', + 'size_lg' => '36rpx', 'size_xl' => '44rpx', 'size_title' => '56rpx', + 'weight_normal' => '400', 'weight_bold' => '700', + 'line_height' => '1.6', 'letter_spacing' => '2rpx', + ], + ]; + + private const RADIUS = [ + 'none' => ['none' => '0', 'sm' => '0', 'md' => '0', 'lg' => '0', 'xl' => '0', 'pill' => '0'], + 'sharp' => ['none' => '0', 'sm' => '2rpx', 'md' => '4rpx', 'lg' => '8rpx', 'xl' => '12rpx', 'pill' => '999rpx'], + 'soft' => ['none' => '0', 'sm' => '8rpx', 'md' => '12rpx', 'lg' => '20rpx', 'xl' => '28rpx', 'pill' => '999rpx'], + 'round' => ['none' => '0', 'sm' => '12rpx', 'md' => '20rpx', 'lg' => '32rpx', 'xl' => '44rpx', 'pill' => '999rpx'], + 'pill' => ['none' => '0', 'sm' => '20rpx', 'md' => '32rpx', 'lg' => '48rpx', 'xl' => '64rpx', 'pill' => '999rpx'], + ]; + + private const SHADOW = [ + 'flat' => ['none' => 'none', 'sm' => 'none', 'md' => 'none', 'lg' => 'none'], + 'airy' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.04)', 'md' => '0 8rpx 24rpx rgba(0,0,0,0.06)', 'lg' => '0 16rpx 48rpx rgba(0,0,0,0.08)'], + 'soft' => ['none' => 'none', 'sm' => '0 2rpx 8rpx rgba(0,0,0,0.06)', 'md' => '0 8rpx 20rpx rgba(0,0,0,0.10)', 'lg' => '0 16rpx 40rpx rgba(0,0,0,0.14)'], + 'deep' => ['none' => 'none', 'sm' => '0 4rpx 12rpx rgba(0,0,0,0.16)', 'md' => '0 12rpx 32rpx rgba(0,0,0,0.24)', 'lg' => '0 24rpx 64rpx rgba(0,0,0,0.32)'], + ]; + + private const MOTION = [ + 'snappy' => ['fast' => '120ms', 'base' => '180ms', 'slow' => '260ms', 'easing' => 'cubic-bezier(0.4,0,0.2,1)', 'easing_in' => 'cubic-bezier(0.4,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.2,1)'], + 'gentle' => ['fast' => '180ms', 'base' => '260ms', 'slow' => '400ms', 'easing' => 'cubic-bezier(0.25,0.1,0.25,1)', 'easing_in' => 'cubic-bezier(0.42,0,1,1)', 'easing_out' => 'cubic-bezier(0,0,0.58,1)'], + 'silk' => ['fast' => '220ms', 'base' => '320ms', 'slow' => '520ms', 'easing' => 'cubic-bezier(0.22,1,0.36,1)', 'easing_in' => 'cubic-bezier(0.55,0,1,0.45)', 'easing_out' => 'cubic-bezier(0.16,1,0.3,1)'], + 'bouncy' => ['fast' => '160ms', 'base' => '280ms', 'slow' => '460ms', 'easing' => 'cubic-bezier(0.34,1.56,0.64,1)', 'easing_in' => 'cubic-bezier(0.36,0,0.66,-0.56)', 'easing_out' => 'cubic-bezier(0.34,1.56,0.64,1)'], + ]; + + private const SPACE = [ + 'xxs' => '4rpx', 'xs' => '8rpx', 'sm' => '16rpx', + 'md' => '24rpx', 'lg' => '32rpx', 'xl' => '48rpx', 'page' => '32rpx', + ]; + + public static function getInstance(): null|static + { + $name = get_called_class(); + if (!isset(self::$_instance[$name])) { + self::$_instance[$name] = new static(); + } + return self::$_instance[$name]; + } + + /** + * 全部预设,已展开成入库可用的行 + * + * @return array + */ + public function all(): array + { + $rows = []; + foreach ((array) config('wx_templates', []) as $index => $preset) { + $rows[] = [ + 'code' => (string) $preset['code'], + 'name' => (string) $preset['name'], + 'style_tag' => (string) ($preset['style_tag'] ?? ''), + 'sort' => $index, + 'tokens' => $this->expandTokens($preset), + 'layout' => (array) ($preset['layout'] ?? []), + ]; + } + return $rows; + } + + /** + * 展开单个预设的令牌 + */ + public function expandTokens(array $preset): array + { + $palette = (array) ($preset['palette'] ?? []); + $primary = (string) ($palette['primary'] ?? '#B08D57'); + $isDark = $this->isDark((string) ($palette['bg'] ?? '#FFFFFF')); + return [ + 'color' => [ + 'primary' => $primary, + 'primary_soft' => $this->mix($primary, $isDark ? '#000000' : '#FFFFFF', 0.7), + 'primary_strong' => $this->mix($primary, '#000000', 0.2), + 'accent' => (string) ($palette['accent'] ?? $primary), + 'bg' => (string) ($palette['bg'] ?? '#FFFFFF'), + 'bg_soft' => $this->mix((string) ($palette['bg'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.04), + 'surface' => (string) ($palette['surface'] ?? '#FFFFFF'), + 'surface_soft' => $this->mix((string) ($palette['surface'] ?? '#FFFFFF'), $isDark ? '#FFFFFF' : '#000000', 0.03), + 'text' => (string) ($palette['text'] ?? '#18181B'), + 'text_soft' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.25), + 'text_muted' => $this->mix((string) ($palette['text'] ?? '#18181B'), (string) ($palette['bg'] ?? '#FFFFFF'), 0.5), + 'border' => (string) ($palette['border'] ?? '#E4E4E7'), + // 价格用主色的强化版,保证在浅色与深色底上都够醒目 + 'price' => $this->mix($primary, '#000000', $isDark ? 0 : 0.12), + 'success' => '#16A34A', + 'warning' => '#D97706', + 'danger' => '#DC2626', + 'mask' => $isDark ? 'rgba(0,0,0,0.72)' : 'rgba(0,0,0,0.45)', + ], + 'font' => self::SCALES[$preset['scale'] ?? 'sans-refined'] ?? self::SCALES['sans-refined'], + 'radius' => self::RADIUS[$preset['radius'] ?? 'soft'] ?? self::RADIUS['soft'], + 'shadow' => self::SHADOW[$preset['shadow'] ?? 'soft'] ?? self::SHADOW['soft'], + 'space' => self::SPACE, + 'motion' => self::MOTION[$preset['motion'] ?? 'gentle'] ?? self::MOTION['gentle'], + ]; + } + + /** + * 两色按比例混合,用来派生 soft / strong 变体 + * + * @param float $ratio target 的占比 + */ + private function mix(string $color, string $target, float $ratio): string + { + [$r1, $g1, $b1] = $this->toRgb($color); + [$r2, $g2, $b2] = $this->toRgb($target); + $ratio = max(0, min(1, $ratio)); + return sprintf( + '#%02X%02X%02X', + (int) round($r1 + ($r2 - $r1) * $ratio), + (int) round($g1 + ($g2 - $g1) * $ratio), + (int) round($b1 + ($b2 - $b1) * $ratio) + ); + } + + private function isDark(string $color): bool + { + [$r, $g, $b] = $this->toRgb($color); + // 感知亮度,低于 128 视作深色底 + return (0.299 * $r + 0.587 * $g + 0.114 * $b) < 128; + } + + /** + * @return array{0:int,1:int,2:int} + */ + private function toRgb(string $color): array + { + $hex = ltrim(trim($color), '#'); + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + if (strlen($hex) !== 6 || !ctype_xdigit($hex)) { + return [255, 255, 255]; + } + return [ + (int) hexdec(substr($hex, 0, 2)), + (int) hexdec(substr($hex, 2, 2)), + (int) hexdec(substr($hex, 4, 2)), + ]; + } +} diff --git a/app/Service/business/WxTemplateSchemaService.php b/app/Service/business/WxTemplateSchemaService.php new file mode 100644 index 00000000..f6811105 --- /dev/null +++ b/app/Service/business/WxTemplateSchemaService.php @@ -0,0 +1,186 @@ + 键名清单 + */ + public const TOKEN_SCHEMA = [ + 'color' => [ + 'primary', 'primary_soft', 'primary_strong', 'accent', 'bg', 'bg_soft', + 'surface', 'surface_soft', 'text', 'text_soft', 'text_muted', 'border', + 'price', 'success', 'warning', 'danger', 'mask', + ], + 'font' => [ + 'family', 'family_title', 'size_xs', 'size_sm', 'size_md', 'size_lg', + 'size_xl', 'size_title', 'weight_normal', 'weight_bold', 'line_height', 'letter_spacing', + ], + 'radius' => ['none', 'sm', 'md', 'lg', 'xl', 'pill'], + 'shadow' => ['none', 'sm', 'md', 'lg'], + 'space' => ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'page'], + 'motion' => ['fast', 'base', 'slow', 'easing', 'easing_in', 'easing_out'], + ]; + + /** + * 允许的布局键与可选值 + */ + public const LAYOUT_SCHEMA = [ + 'home' => [ + 'hero' => ['banner', 'carousel', 'split', 'fullscreen'], + 'category' => ['grid', 'scroll', 'card', 'sidebar'], + 'product' => ['waterfall', 'list', 'grid', 'magazine'], + ], + 'product' => [ + 'gallery' => ['swiper', 'stack', 'fullbleed'], + 'price' => ['inline', 'card', 'sticky'], + 'action' => ['fixed', 'inline'], + ], + 'list' => [ + 'style' => ['card', 'table', 'timeline'], + ], + 'mine' => [ + 'header' => ['gradient', 'image', 'plain'], + 'menu' => ['grid', 'list'], + ], + 'effect' => [ + 'transition' => ['fade', 'slide', 'zoom', 'none'], + 'skeleton' => ['shimmer', 'pulse', 'none'], + ], + ]; + + /** + * 危险片段:出现即拒绝整个值 + */ + private const FORBIDDEN = ['<', '>', ';', '{', '}', 'url(', 'expression', 'javascript:', 'import']; + + public static function getInstance(): null|static + { + $name = get_called_class(); + if (!isset(self::$_instance[$name])) { + self::$_instance[$name] = new static(); + } + return self::$_instance[$name]; + } + + /** + * 清洗令牌:只保留白名单键,值必须安全 + * + * @param mixed $tokens 数组或 JSON 字符串 + * @param bool $strict true 时遇到非法值直接报错(导入场景),false 时静默丢弃 + */ + public function sanitizeTokens(mixed $tokens, bool $strict = false): array + { + $tokens = $this->toArray($tokens); + $clean = []; + foreach (self::TOKEN_SCHEMA as $group => $keys) { + $source = $tokens[$group] ?? []; + if (!is_array($source)) { + continue; + } + foreach ($keys as $key) { + if (!array_key_exists($key, $source)) { + continue; + } + $value = $source[$key]; + if (!$this->isSafeValue($value)) { + if ($strict) { + UtilsService::getInstance()->errorThrow("模板令牌 {$group}.{$key} 的值不合法"); + } + continue; + } + $clean[$group][$key] = (string) $value; + } + } + return $clean; + } + + /** + * 清洗布局:值必须是枚举里的选项,非法值退回该项的第一个选项 + */ + public function sanitizeLayout(mixed $layout, bool $strict = false): array + { + $layout = $this->toArray($layout); + $clean = []; + foreach (self::LAYOUT_SCHEMA as $page => $options) { + $source = $layout[$page] ?? []; + if (!is_array($source)) { + continue; + } + foreach ($options as $key => $allowed) { + if (!array_key_exists($key, $source)) { + continue; + } + $value = (string) $source[$key]; + if (!in_array($value, $allowed, true)) { + if ($strict) { + UtilsService::getInstance()->errorThrow("模板布局 {$page}.{$key} 只能是:" . implode('/', $allowed)); + } + $value = $allowed[0]; + } + $clean[$page][$key] = $value; + } + } + return $clean; + } + + /** + * 小程序侧要的扁平 CSS 变量表:--color-primary 这种 + */ + public function toCssVariables(array $tokens): array + { + $vars = []; + foreach ($tokens as $group => $items) { + if (!is_array($items)) { + continue; + } + foreach ($items as $key => $value) { + $vars['--' . str_replace('_', '-', $group . '-' . $key)] = $value; + } + } + return $vars; + } + + private function isSafeValue(mixed $value): bool + { + if (is_int($value) || is_float($value)) { + return true; + } + if (!is_string($value)) { + return false; + } + $value = trim($value); + if ($value === '' || mb_strlen($value) > 64) { + return false; + } + foreach (self::FORBIDDEN as $needle) { + if (stripos($value, $needle) !== false) { + return false; + } + } + return true; + } + + private function toArray(mixed $value): array + { + if (is_string($value)) { + $value = json_decode($value, true); + if (!is_array($value)) { + UtilsService::getInstance()->errorThrow('模板 JSON 解析失败'); + } + } + return is_array($value) ? $value : []; + } +} diff --git a/app/Service/business/WxTemplateService.php b/app/Service/business/WxTemplateService.php new file mode 100644 index 00000000..9ff20bdd --- /dev/null +++ b/app/Service/business/WxTemplateService.php @@ -0,0 +1,262 @@ +model = WxTemplateModel::class; + $this->selectField = [ + 'id', 'name', 'code', 'preview', 'style_tag', 'is_default', 'status', + 'app_code', 'version', 'sort', 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'name' => 'like', + 'code' => 'like', + 'style_tag' => '=', + 'app_code' => '=', + 'status' => '=', + ]; + $this->orderBy = ['name' => 'sort', 'sort' => 'asc']; + } + + public function list(): array + { + $result = $this->getPageList(); + // 列表不回完整 tokens(体积大),但卡片预览需要色板与布局摘要 + $ids = array_values(array_filter(array_map( + static fn ($row) => (int) ($row['id'] ?? 0), + $result['items'] ?? [] + ))); + if ($ids === []) { + return $result; + } + $extras = WxTemplateModel::whereIn('id', $ids) + ->get(['id', 'tokens', 'layout']) + ->keyBy('id'); + foreach ($result['items'] as &$item) { + $extra = $extras[(int) $item['id']] ?? null; + $tokens = is_array($extra?->tokens) ? $extra->tokens : []; + $layout = is_array($extra?->layout) ? $extra->layout : []; + $color = is_array($tokens['color'] ?? null) ? $tokens['color'] : []; + $home = is_array($layout['home'] ?? null) ? $layout['home'] : []; + $item['swatch'] = [ + 'primary' => (string) ($color['primary'] ?? '#B08D57'), + 'accent' => (string) ($color['accent'] ?? ($color['primary'] ?? '#B08D57')), + 'bg' => (string) ($color['bg'] ?? '#FAFAF9'), + 'surface' => (string) ($color['surface'] ?? '#FFFFFF'), + 'text' => (string) ($color['text'] ?? '#18181B'), + 'border' => (string) ($color['border'] ?? '#E4E4E7'), + ]; + $item['layout_hint'] = [ + 'hero' => (string) ($home['hero'] ?? 'banner'), + 'category' => (string) ($home['category'] ?? 'grid'), + 'product' => (string) ($home['product'] ?? 'grid'), + ]; + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'name', 'code', 'style_tag']; + return $this->getOption(); + } + + public function detail($id): mixed + { + // 详情是单条记录,没有 list 的字段裁剪压力; + // tokens/layout 必须回显(编辑弹窗回填要用),不能用默认 selectField(它把这两个字段排除了)。 + $saved = $this->selectField; + $this->selectField = ['*']; + try { + return $this->getDetail($id); + } finally { + $this->selectField = $saved; + } + } + + public function create($params): mixed + { + $params = $this->normalize($params); + return $this->insert($params); + } + + public function update($id, $params): mixed + { + $params = $this->normalize($params, (int) $id); + return $this->save($id, $params); + } + + public function delete($ids): mixed + { + // 默认模板被删掉小程序就没样式可用了,必须先改默认再删 + $hasDefault = WxTemplateModel::whereIn('id', (array) $ids)->where('is_default', 1)->exists(); + if ($hasDefault) { + $this->utils->errorThrow('默认模板不能删除,请先把其他模板设为默认'); + } + return $this->del($ids); + } + + public function status($id, $status): mixed + { + return $this->save($id, ['status' => (int) $status]); + } + + /** + * 设为默认(同一 app_code 下只能有一个默认) + * 同时把 code 写入 nl_wx_app.template_code,保证小程序 current() 优先命中 + */ + public function setDefault(int $id): bool + { + $template = WxTemplateModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($template)) { + $this->utils->errorThrow('模板不存在'); + } + DB::connection('business')->transaction(function () use ($template, $id) { + WxTemplateModel::where('app_code', $template['app_code']) + ->where('id', '!=', $id) + ->update(['is_default' => 0, 'updated_at' => time()]); + WxTemplateModel::where('id', $id)->update([ + 'is_default' => 1, + 'status' => 0, + 'updated_at' => time(), + ]); + }); + // 系统库 nl_wx_app:空 app_code 的全局模板同步到全部启用应用;有品牌则只同步该品牌 + $appQuery = \App\Models\WxAppModel::where('deleted_at', 0)->where('status', 0); + $appCode = trim((string) ($template['app_code'] ?? '')); + if ($appCode !== '') { + $appQuery->where('code', $appCode); + } + $appQuery->update([ + 'template_code' => (string) $template['code'], + 'updated_at' => time(), + ]); + return true; + } + + /** + * 导出:给出可直接再导入的 JSON + */ + public function export(array $ids): array + { + $rows = WxTemplateModel::whereIn('id', $ids)->where('deleted_at', 0)->get([ + 'name', 'code', 'preview', 'style_tag', 'tokens', 'layout', 'app_code', 'version', + ]); + return [ + 'version' => 1, + 'exported_at' => date('Y-m-d H:i:s'), + 'templates' => $rows->toArray(), + ]; + } + + /** + * 导入:整包校验通过才落库 + * + * 单条不合法就整包拒绝,不做「部分成功」——一半新一半旧的模板库更难排查。 + */ + public function import(array $payload, bool $overwrite = false): array + { + $templates = $payload['templates'] ?? $payload; + if (!is_array($templates) || empty($templates)) { + $this->utils->errorThrow('导入内容为空'); + } + $schema = WxTemplateSchemaService::getInstance(); + $rows = []; + foreach ($templates as $index => $item) { + $code = trim((string) ($item['code'] ?? '')); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + $this->utils->errorThrow('第 ' . ($index + 1) . ' 个模板缺少 code 或 name'); + } + $rows[] = [ + 'code' => $code, + 'name' => $name, + 'preview' => (string) ($item['preview'] ?? ''), + 'style_tag' => (string) ($item['style_tag'] ?? ''), + 'app_code' => (string) ($item['app_code'] ?? ''), + 'tokens' => json_encode($schema->sanitizeTokens($item['tokens'] ?? [], true), JSON_UNESCAPED_UNICODE), + 'layout' => json_encode($schema->sanitizeLayout($item['layout'] ?? [], true), JSON_UNESCAPED_UNICODE), + 'version' => max(1, (int) ($item['version'] ?? 1)), + ]; + } + + $inserted = 0; + $updated = 0; + $skipped = 0; + DB::connection('business')->transaction(function () use ($rows, $overwrite, &$inserted, &$updated, &$skipped) { + foreach ($rows as $row) { + $exists = WxTemplateModel::where('code', $row['code']) + ->where('app_code', $row['app_code']) + ->first(); + if (!empty($exists)) { + if (!$overwrite) { + $skipped++; + continue; + } + $row['version'] = (int) $exists['version'] + 1; + $row['updated_at'] = time(); + WxTemplateModel::where('id', $exists['id'])->update($row); + $updated++; + continue; + } + $row['created_at'] = time(); + WxTemplateModel::insert($row); + $inserted++; + } + }); + return ['inserted' => $inserted, 'updated' => $updated, 'skipped' => $skipped]; + } + + /** + * 用内置预设初始化模板库(20 套) + */ + public function initPresets(bool $overwrite = false): array + { + $presets = WxTemplatePresetService::getInstance()->all(); + $result = $this->import(['templates' => $presets], $overwrite); + // 一套默认都没有的话,把第一套轻奢设为默认 + if (!WxTemplateModel::where('deleted_at', 0)->where('is_default', 1)->exists()) { + $first = WxTemplateModel::where('deleted_at', 0)->orderBy('sort', 'asc')->first(); + if (!empty($first)) { + $this->setDefault((int) $first['id']); + } + } + return $result; + } + + /** + * 入库前清洗;code 在同一品牌下唯一 + */ + private function normalize(array $params, int $excludeId = 0): array + { + $schema = WxTemplateSchemaService::getInstance(); + if (array_key_exists('tokens', $params)) { + $params['tokens'] = json_encode($schema->sanitizeTokens($params['tokens'], true), JSON_UNESCAPED_UNICODE); + } + if (array_key_exists('layout', $params)) { + $params['layout'] = json_encode($schema->sanitizeLayout($params['layout'], true), JSON_UNESCAPED_UNICODE); + } + if (!empty($params['code'])) { + $duplicate = WxTemplateModel::where('code', $params['code']) + ->where('app_code', (string) ($params['app_code'] ?? '')) + ->when($excludeId > 0, fn ($query) => $query->where('id', '!=', $excludeId)) + ->exists(); + if ($duplicate) { + $this->utils->errorThrow('模板标识已存在'); + } + } + return $params; + } +} diff --git a/app/Service/business/WxUserService.php b/app/Service/business/WxUserService.php new file mode 100644 index 00000000..2b2eef40 --- /dev/null +++ b/app/Service/business/WxUserService.php @@ -0,0 +1,257 @@ +model = WxUserModel::class; + $this->selectField = [ + 'id', 'open_id', 'phone', 'nick_name', 'avatar', + 'show_price', 'is_p', 'pid', 'enterprise_id', 'price_number', 'template_code', + 'created_at', 'updated_at', + ]; + $this->queryField = [ + 'nick_name' => 'like', + 'phone' => 'like', + 'is_p' => '=', + 'show_price' => '=', + 'enterprise_id' => '=', + ]; + $this->media = MediaUrlService::getInstance(); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function list(): array + { + $this->with = ['enterprise']; + $result = $this->getPageList(); + foreach ($result['items'] as &$item) { + $item['enterprise_name'] = $item['enterprise']['name'] ?? ''; + unset($item['enterprise']); + $item['avatar'] = $this->media->toPublic($item['avatar'] ?? ''); + } + unset($item); + return $result; + } + + public function option(): mixed + { + $this->optionField = ['id', 'nick_name as name', 'phone']; + return $this->getOption(); + } + + /** + * 详情:带所属企业、上级代理商与下级数量 + * @throws Exception + */ + public function detail($id): mixed + { + $this->with = ['enterprise']; + $info = $this->getDetail($id); + $info->avatar = $this->media->toPublic($info->avatar); + $info->enterprise_name = $info->enterprise->name ?? ''; + $info->parent_name = $info->pid > 0 + ? (string) (WxUserModel::where('id', $info->pid)->value('nick_name') ?? '') + : ''; + $info->child_count = WxUserModel::where('pid', $info->id)->where('deleted_at', 0)->count(); + return $info; + } + + /** + * 后台不创建微信用户(用户只能由小程序授权登录产生) + * @throws Exception + */ + public function create($params): mixed + { + return $this->utils->errorThrow('微信用户由小程序授权登录产生,后台不支持新建'); + } + + /** + * 后台只允许改这几项,避免把 open_id 之类的身份字段改花 + * @throws Exception + */ + public function update($id, $params): mixed + { + $allowed = ['nick_name', 'phone', 'enterprise_id', 'show_price', 'price_number', 'is_p', 'template_code']; + $payload = array_intersect_key($params, array_flip($allowed)); + if (empty($payload)) { + $this->utils->errorThrow('没有可更新的字段'); + } + if (array_key_exists('price_number', $payload)) { + $payload['price_number'] = $this->normalizeMultiplier($payload['price_number']); + } + if (array_key_exists('template_code', $payload)) { + $payload['template_code'] = trim((string) $payload['template_code']); + } + return $this->save($id, $payload); + } + + /** + * @throws Exception + */ + public function delete($id): mixed + { + return $this->del(is_array($id) ? $id : [$id]); + } + + /** + * 切换代理商身份 + * + * 老接口是「翻转」语义(不传目标值,后端读当前值取反),前端的开关点快了就会和后端打反。 + * 这里改成显式传 is_p,同时保留不传时的翻转以兼容老调用。 + * @throws Exception + */ + public function updateUserIsP($id, mixed $isP = null): bool + { + $user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($user)) { + $this->utils->errorThrow('用户不存在'); + } + + $target = $isP === null || $isP === '' + ? ((int) $user->is_p === 1 ? 0 : 1) + : (int) $isP; + + // 认成代理商就默认可见价格、倍率归 1;取消代理商则收回价格可见性,并清空专属模板 + $data = $target === 1 + ? ['is_p' => 1, 'show_price' => 1, 'price_number' => 1] + : ['is_p' => 0, 'show_price' => 0, 'price_number' => 1, 'template_code' => '']; + $data['updated_at'] = time(); + + WxUserModel::where('id', $id)->update($data); + return true; + } + + /** + * 给经销商绑定专属装修模板(空字符串=跟随品牌默认) + * @throws Exception + */ + public function bindTemplate($id, mixed $templateCode): bool + { + $user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($user)) { + $this->utils->errorThrow('用户不存在'); + } + if ((int) $user->is_p !== 1) { + $this->utils->errorThrow('仅经销商可绑定专属模板'); + } + $code = trim((string) $templateCode); + if ($code !== '') { + $exists = WxTemplateModel::where('code', $code) + ->where('deleted_at', 0) + ->where('status', 0) + ->exists(); + if (!$exists) { + $this->utils->errorThrow('模板不存在或已停用'); + } + } + WxUserModel::where('id', $id)->update([ + 'template_code' => $code, + 'updated_at' => time(), + ]); + return true; + } + + /** + * 设置价格倍率(同时打开价格可见) + * @throws Exception + */ + public function updateShowPrice($id, mixed $number): bool + { + $user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($user)) { + $this->utils->errorThrow('用户不存在'); + } + $multiplier = $this->normalizeMultiplier($number); + WxUserModel::where('id', $id)->update([ + 'show_price' => 1, + 'price_number' => $multiplier, + 'updated_at' => time(), + ]); + return true; + } + + /** + * 单独控制价格可见性(不动倍率) + * @throws Exception + */ + public function updateShowPriceStatus($id, mixed $showPrice): bool + { + $user = WxUserModel::where('id', $id)->where('deleted_at', 0)->first(); + if (empty($user)) { + $this->utils->errorThrow('用户不存在'); + } + WxUserModel::where('id', $id)->update([ + 'show_price' => (int) $showPrice === 1 ? 1 : 0, + 'updated_at' => time(), + ]); + return true; + } + + /** + * 绑定企业 + * @throws Exception + */ + public function bindUser($id, $enterpriseId): bool + { + $enterpriseId = (int) $enterpriseId; + if (!WxUserModel::where('id', $id)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('用户不存在'); + } + if ($enterpriseId > 0 && !EnterpriseModel::where('id', $enterpriseId)->where('deleted_at', 0)->exists()) { + $this->utils->errorThrow('企业不存在'); + } + WxUserModel::where('id', $id)->update([ + 'enterprise_id' => $enterpriseId, + 'updated_at' => time(), + ]); + return true; + } + + /** + * 该代理商名下的下级用户 + */ + public function children(int $id): array + { + return WxUserModel::where('pid', $id) + ->where('deleted_at', 0) + ->orderBy('id', 'desc') + ->get(['id', 'nick_name', 'phone', 'price_number', 'show_price', 'created_at']) + ->toArray(); + } + + /** + * 倍率兜底,0 或负数会把价格直接乘成 0 + */ + private function normalizeMultiplier(mixed $number): string + { + if (!is_numeric($number) || (float) $number <= 0) { + return '1'; + } + return (string) $number; + } +} diff --git a/app/Service/common/JWTService.php b/app/Service/common/JWTService.php index db29bc40..eb5ea787 100755 --- a/app/Service/common/JWTService.php +++ b/app/Service/common/JWTService.php @@ -122,6 +122,44 @@ class JWTService return $this->generateToken((array)$decoded->data); } + + /** + * 解析已过期但签名有效的 token + * + * 续签场景下 token 必然已经过期,正常 decode 会直接抛 ExpiredException。 + * 这里临时放宽 leeway 让 exp 校验通过,签名与 Redis 会话仍然照常校验, + * 所以过期的 token 依旧不能凭空续签——会话被登出或超过宽限期就必须重新登录。 + * + * @param int $leeway 允许的过期宽限秒数 + */ + public function parseExpiringToken(int $leeway): ?object + { + $token = request()->bearerToken(); + if (empty($token)) { + return null; + } + $origin = JWT::$leeway; + JWT::$leeway = max(0, $leeway); + try { + return JWT::decode($token, new Key($this->secretKey, 'HS256')); + } catch (Exception $e) { + return null; + } finally { + JWT::$leeway = $origin; + } + } + + /** + * 作废某个用户的登录态:删掉 Redis 会话,手里的 token 立即失效 + * (getUserInfo 拿不到会话就会 notAuth,所以不需要维护黑名单) + */ + public function revoke(int $userId): bool + { + if ($userId <= 0) { + return false; + } + return RedisService::getInstance()->init(config('nl.redis.jwt'))->del($userId); + } } diff --git a/app/Service/common/MediaUrlService.php b/app/Service/common/MediaUrlService.php new file mode 100644 index 00000000..2202829b --- /dev/null +++ b/app/Service/common/MediaUrlService.php @@ -0,0 +1,100 @@ +isAbsolute($path)) { + return $path; + } + return rtrim((string) config('app.url'), '/') . '/' . ltrim($path, '/'); + } + + /** + * 入库:本站域名下的地址存成相对路径,避免换域名后历史数据全指向旧域名; + * 第三方 OSS 地址保持原样 + */ + public function toStorage(?string $url): string + { + $url = trim((string) $url); + if ($url === '') { + return ''; + } + $appUrl = rtrim((string) config('app.url'), '/'); + if ($appUrl !== '' && str_starts_with($url, $appUrl)) { + return substr($url, strlen($appUrl)) ?: ''; + } + return $url; + } + + /** + * 批量出库,给 list 用 + */ + public function publicEach(array &$rows, array $fields): void + { + foreach ($rows as &$row) { + foreach ($fields as $field) { + if (array_key_exists($field, $row)) { + $row[$field] = $this->toPublic($row[$field]); + } + } + } + unset($row); + } + + /** + * 前端图片上传组件回传的是数组,取第一个;已是字符串则原样 + */ + public function firstOf(mixed $value): string + { + if (is_array($value)) { + $value = $value[0] ?? ''; + } + return $this->toStorage(is_string($value) ? $value : ''); + } + + /** + * 去掉 OSS 处理参数(?imageView2/... 或 ?watermark/...),用于素材库按 path 做引用匹配 + */ + public function stripProcessParams(?string $url): string + { + $url = trim((string) $url); + if ($url === '') { + return ''; + } + $pos = strpos($url, '?'); + return $pos === false ? $url : substr($url, 0, $pos); + } + + private function isAbsolute(string $path): bool + { + return (bool) preg_match('#^(https?:)?//#i', $path); + } +} diff --git a/app/Service/common/RedisService.php b/app/Service/common/RedisService.php index 29fa0fa6..bca32ef8 100755 --- a/app/Service/common/RedisService.php +++ b/app/Service/common/RedisService.php @@ -91,4 +91,29 @@ class RedisService { return $this->redis::del($this->prefix . $key); } + + /** + * 清空当前前缀下的全部键 + * + * 菜单这类按角色分片缓存的数据,改了菜单树要一次性失效所有角色的副本。 + * KEYS 的返回值带着 redis 客户端自身的 prefix,直接回传给 del 会二次拼前缀,所以要先剥掉。 + * @return int 删除的键数量 + */ + public function delAll(): int + { + $keys = $this->redis::keys($this->prefix . '*'); + if (empty($keys)) { + return 0; + } + $clientPrefix = (string) config('database.redis.options.prefix'); + $count = 0; + foreach ($keys as $key) { + if ($clientPrefix !== '' && str_starts_with($key, $clientPrefix)) { + $key = substr($key, strlen($clientPrefix)); + } + $this->redis::del($key); + $count++; + } + return $count; + } } diff --git a/app/Service/common/UploadService.php b/app/Service/common/UploadService.php index f00cc502..2574b648 100644 --- a/app/Service/common/UploadService.php +++ b/app/Service/common/UploadService.php @@ -71,4 +71,31 @@ class UploadService extends BaseService ]); return $result; } + + /** + * 上传文档(商品图册的 PDF、订单转账凭证的 PDF 等) + * + * 驱动层的 uploadVideo 就是通用的 put,图册 PDF 之前只能借 video 通道上传, + * 结果 key 落在 spa/video 下,素材库按目录归类时全错位,故单独开一路。 + * + * @param mixed $file 上传文件对象 + */ + public function uploadDocument($file): array|bool + { + $ext = strtolower($file->getClientOriginalExtension()); + $allowed = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'zip']; + if (!in_array($ext, $allowed, true)) { + $this->utils->errorThrow('不支持的文件类型:' . $ext); + } + $key = 'spa/file/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext; + $result = $this->uploadService->uploadVideo($file, $key); + if (!$result) { + $this->utils->errorThrow('文件上传失败'); + } + FileService::getInstance()->create([ + 'user_id' => $this->userId, + 'url' => $result['url'], + ]); + return $result; + } } diff --git a/app/Service/common/UtilsService.php b/app/Service/common/UtilsService.php index 7e9402ad..e026f060 100755 --- a/app/Service/common/UtilsService.php +++ b/app/Service/common/UtilsService.php @@ -243,14 +243,20 @@ class UtilsService { foreach ($class as $key => $value) { + $reflection = new ReflectionClass($value); // 获取控制器$value的所有方法 - $methods = (new ReflectionClass($value))->getMethods(); + $methods = $reflection->getMethods(); + // 子类声明的排除清单:继承来的 CRUD 也会被反射到,不该暴露的在这里拦掉 + $exceptRoute = (array) ($reflection->getDefaultProperties()['exceptRoute'] ?? []); // 注册路由 foreach ($methods as $method) { // 获取方法注释 @Method $docComment = $method->getDocComment(); - if ($method->name === '__construct' || preg_match('/@Method\s+(NO)\b/', $docComment, $matches)) { + if ($method->name === '__construct' + || in_array($method->name, $exceptRoute, true) + || preg_match('/@Method\s+(NO)\b/', (string) $docComment, $matches) + ) { continue; } diff --git a/app/Service/common/oss/OssRuntimeConfigService.php b/app/Service/common/oss/OssRuntimeConfigService.php index 26492f75..ea2995a1 100644 --- a/app/Service/common/oss/OssRuntimeConfigService.php +++ b/app/Service/common/oss/OssRuntimeConfigService.php @@ -6,6 +6,7 @@ use App\BaseApp\BaseService; use App\Models\oss\OssConfigModel; use App\Service\common\FieldEncryptService; use App\Service\SystemConfigService; +use Exception; /** * OSS 运行时配置:解析当前启用的存储配置并解密密钥,供上传工厂使用 @@ -74,6 +75,32 @@ class OssRuntimeConfigService extends BaseService 'extra_json' => null, ]; } + return $this->formatConfig($row); + } + + /** + * 按 ID 获取明文配置 + * + * 素材库要对指定 bucket 做列举与删除,不能只认「当前启用」那一份: + * 换过存储之后老素材仍然躺在旧配置的 bucket 里,回收时必须拿旧配置去删。 + * 也因此这里不校验 status —— 配置被禁用不代表里面的对象不用管了。 + * + * @throws Exception + */ + public function getConfigById(int $id): array + { + $row = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first(); + if (!$row) { + $this->utils->notFound('存储配置不存在'); + } + return $this->formatConfig($row); + } + + /** + * 库行转明文配置数组 + */ + private function formatConfig(OssConfigModel $row): array + { $enc = FieldEncryptService::getInstance(); $extra = $row->extra_json; if (is_string($extra) && $extra !== '') { diff --git a/app/Service/common/oss/OssStorageInterface.php b/app/Service/common/oss/OssStorageInterface.php index 8be3acaf..d8dd070a 100644 --- a/app/Service/common/oss/OssStorageInterface.php +++ b/app/Service/common/oss/OssStorageInterface.php @@ -31,4 +31,24 @@ interface OssStorageInterface * @return array{key:string,url:string}|false */ public function uploadVideo($filePath, string $key): bool|array; + + /** + * 分页列举对象 + * + * 素材库靠 marker 一页一页往回补:bucket 上万对象时一次拉全量必然打穿 + * PHP 的执行时限,所以约定「调用方拿着 next_marker 继续要下一页」。 + * + * @param string $prefix 只列举该前缀,留空时退回配置里的 path_prefix + * @param string $marker 上一页返回的 next_marker,首页传空串 + * @param int $limit 单页条数 + * @return array{items: array, next_marker: string, finished: bool} + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array; + + /** + * 删除对象 + * + * @param string $key 对象键(缺 path_prefix 时由实现补齐) + */ + public function deleteObject(string $key): bool; } diff --git a/app/Service/common/upload/AliyunStorageService.php b/app/Service/common/upload/AliyunStorageService.php index 25ddb116..bb72f2c1 100644 --- a/app/Service/common/upload/AliyunStorageService.php +++ b/app/Service/common/upload/AliyunStorageService.php @@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http; */ class AliyunStorageService extends BaseNotAuthService implements OssStorageInterface { + use ObjectKeyNormalizeTrait; + use ObjectListXmlTrait; + protected array $config = []; public function withConfig(array $config): static @@ -30,19 +33,65 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter return $this->uploadFile($filePath, $key); } + /** + * 列举 bucket 对象(GET Bucket,V1 签名) + * + * prefix / marker / max-keys 都不是 OSS V1 的 sub-resource,不参与签名, + * 所以 CanonicalizedResource 仍然只有 /bucket/,别照着 V4 的写法往里塞查询串。 + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array + { + $this->assertConfig(); + $bucket = (string) $this->config['bucket']; + $host = $this->host(); + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $query = ['max-keys' => $this->boundedLimit($limit)]; + $listPrefix = $this->scopedListPrefix($prefix); + if ($listPrefix !== '') { + $query['prefix'] = $listPrefix; + } + if ($marker !== '') { + $query['marker'] = $marker; + } + $response = Http::withHeaders([ + 'Date' => $date, + 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' + . $this->signature('GET', '', $date, '/' . $bucket . '/'), + ])->get('https://' . $host . '/', $query); + if (!$response->successful()) { + UtilsService::getInstance()->errorThrow('阿里云列举对象失败:' . $response->body()); + } + return $this->parseObjectListXml($response->body(), 'https://' . $host); + } + + /** + * 删除对象;OSS 对不存在的键也回 204,这里把它当成功处理 + */ + public function deleteObject(string $key): bool + { + $this->assertConfig(); + $key = $this->normalizeObjectKey($key); + if ($key === '') { + return false; + } + $date = gmdate('D, d M Y H:i:s \G\M\T'); + $resource = '/' . $this->config['bucket'] . '/' . $key; + $response = Http::withHeaders([ + 'Date' => $date, + 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' + . $this->signature('DELETE', '', $date, $resource), + ])->delete('https://' . $this->host() . '/' . $key); + return $response->successful(); + } + /** * 使用 OSS V1 签名上传对象 */ private function uploadFile($filePath, string $key): bool|array { - $accessKey = (string) ($this->config['access_key'] ?? ''); - $secretKey = (string) ($this->config['secret_key'] ?? ''); - $bucket = (string) ($this->config['bucket'] ?? ''); - $endpoint = (string) ($this->config['endpoint'] ?? ''); + $this->assertConfig(); $domain = rtrim((string) ($this->config['domain'] ?? ''), '/'); - if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') { - UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'); - } + $bucket = (string) $this->config['bucket']; $prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/'); if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) { $key = $prefix . '/' . ltrim($key, '/'); @@ -53,19 +102,12 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter $content = file_get_contents($path); $contentType = 'application/octet-stream'; $date = gmdate('D, d M Y H:i:s \G\M\T'); - $resource = '/' . $bucket . '/' . $key; - $stringToSign = "PUT\n\n{$contentType}\n{$date}\n{$resource}"; - $signature = base64_encode(hash_hmac('sha1', $stringToSign, $secretKey, true)); - $host = preg_replace('#^https?://#', '', rtrim($endpoint, '/')); - // 支持传入 oss-cn-xxx.aliyuncs.com 或带 bucket 的域名 - if (!str_starts_with($host, $bucket . '.')) { - $host = $bucket . '.' . $host; - } - $url = 'https://' . $host . '/' . $key; + $signature = $this->signature('PUT', $contentType, $date, '/' . $bucket . '/' . $key); + $url = 'https://' . $this->host() . '/' . $key; $response = Http::withHeaders([ 'Date' => $date, 'Content-Type' => $contentType, - 'Authorization' => 'OSS ' . $accessKey . ':' . $signature, + 'Authorization' => 'OSS ' . $this->config['access_key'] . ':' . $signature, ])->withBody($content, $contentType)->put($url); if (!$response->successful()) { UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body()); @@ -73,4 +115,37 @@ class AliyunStorageService extends BaseNotAuthService implements OssStorageInter $publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url; return ['key' => $key, 'url' => $publicUrl]; } + + /** + * OSS V1 签名:上传、列举、删除共用同一套 StringToSign 拼法 + */ + private function signature(string $method, string $contentType, string $date, string $resource): string + { + $stringToSign = "{$method}\n\n{$contentType}\n{$date}\n{$resource}"; + return base64_encode(hash_hmac('sha1', $stringToSign, (string) $this->config['secret_key'], true)); + } + + /** + * 请求主机名;支持配置里填 oss-cn-xxx.aliyuncs.com 或已带 bucket 的域名 + */ + private function host(): string + { + $bucket = (string) ($this->config['bucket'] ?? ''); + $host = (string) preg_replace('#^https?://#', '', rtrim((string) ($this->config['endpoint'] ?? ''), '/')); + if (!str_starts_with($host, $bucket . '.')) { + $host = $bucket . '.' . $host; + } + return $host; + } + + private function assertConfig(): void + { + if (trim((string) ($this->config['access_key'] ?? '')) === '' + || trim((string) ($this->config['secret_key'] ?? '')) === '' + || trim((string) ($this->config['bucket'] ?? '')) === '' + || trim((string) ($this->config['endpoint'] ?? '')) === '' + ) { + UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'); + } + } } diff --git a/app/Service/common/upload/LocalhostStorageService.php b/app/Service/common/upload/LocalhostStorageService.php index ada89ec3..2f059e19 100644 --- a/app/Service/common/upload/LocalhostStorageService.php +++ b/app/Service/common/upload/LocalhostStorageService.php @@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Storage; */ class LocalhostStorageService extends BaseNotAuthService implements OssStorageInterface { + use ObjectKeyNormalizeTrait; + protected array $config = [ 'domain' => '', 'path_prefix' => '', @@ -45,6 +47,56 @@ class LocalhostStorageService extends BaseNotAuthService implements OssStorageIn return $this->deleteFile($key); } + /** + * 扫本地磁盘目录 + * + * 本地盘没有 marker 这种服务端游标,用「已列举条数」当偏移量模拟: + * 先把文件名排序固定顺序,再按偏移切页,这样反复调用能稳定推进。 + * 代价是同步期间新增文件会让偏移错位,但素材同步是幂等的,下一轮就自愈。 + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array + { + $listPrefix = rtrim($this->scopedListPrefix($prefix), '/'); + $offset = max(0, (int) $marker); + $files = Storage::allFiles($listPrefix); + sort($files); + $page = array_slice($files, $offset, $this->boundedLimit($limit)); + + $items = []; + foreach ($page as $file) { + $key = $this->normalizeObjectKey($file); + if ($key === '') { + continue; + } + $realPath = Storage::path($file); + $items[] = [ + 'key' => $key, + 'size' => (int) Storage::size($file), + // 本地盘没有服务端 ETag,用 md5 顶上,素材库靠它判重与校验 + 'hash' => is_file($realPath) ? (string) md5_file($realPath) : '', + 'last_modified' => (int) Storage::lastModified($file), + 'url' => $this->publicUrlOf($key, asset('/storage/' . $key)), + ]; + } + + $next = $offset + count($page); + $finished = $next >= count($files); + return [ + 'items' => $items, + 'next_marker' => $finished ? '' : (string) $next, + 'finished' => $finished, + ]; + } + + public function deleteObject(string $key): bool + { + $key = $this->normalizeObjectKey($key); + if ($key === '') { + return false; + } + return $this->deleteFile($key); + } + /** * 写入本地 storage;兼容 UploadedFile 与路径字符串 */ diff --git a/app/Service/common/upload/ObjectKeyNormalizeTrait.php b/app/Service/common/upload/ObjectKeyNormalizeTrait.php new file mode 100644 index 00000000..65d2ce2a --- /dev/null +++ b/app/Service/common/upload/ObjectKeyNormalizeTrait.php @@ -0,0 +1,66 @@ +config(由 OssRuntimeConfigService 注入,含 path_prefix / domain)。 + */ +trait ObjectKeyNormalizeTrait +{ + /** + * 补上 path_prefix 并压平重复斜杠 + * + * 为什么必须压平:uploads//spa/a.jpg 与 uploads/spa/a.jpg 在 OSS 上是两个对象, + * 但拼出来的访问地址会被 CDN 归一成同一个。素材库按 path 建索引, + * 不统一就会落成两条记录,回收删掉其中一条后另一条变成指向已删对象的幽灵。 + */ + protected function normalizeObjectKey(string $key): string + { + $key = ltrim((string) preg_replace('#/{2,}#', '/', trim($key)), '/'); + if ($key === '') { + return ''; + } + $prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/'); + if ($prefix !== '' && $key !== $prefix && !str_starts_with($key, $prefix . '/')) { + $key = $prefix . '/' . $key; + } + return $key; + } + + /** + * 列举前缀:调用方没给就退回 path_prefix + * + * 同一个 bucket 常常被多个项目共用,不加这层兜底会把别人的对象也拉进素材库, + * 之后走回收流程就等于跨项目删文件。 + */ + protected function scopedListPrefix(string $prefix): string + { + $prefix = trim($prefix); + if ($prefix !== '') { + return $this->normalizeObjectKey($prefix); + } + $base = trim((string) ($this->config['path_prefix'] ?? ''), '/'); + return $base === '' ? '' : $base . '/'; + } + + /** + * 拼公开访问地址;未配置 domain 时回落到调用方给的默认地址 + */ + protected function publicUrlOf(string $key, string $fallback = ''): string + { + $domain = rtrim((string) ($this->config['domain'] ?? ''), '/'); + return $domain !== '' ? $domain . '/' . $key : $fallback; + } + + /** + * 单页列举条数收口,避免上游把 limit 传成 0 或者十万 + */ + protected function boundedLimit(int $limit): int + { + return max(1, min($limit, 1000)); + } +} diff --git a/app/Service/common/upload/ObjectListXmlTrait.php b/app/Service/common/upload/ObjectListXmlTrait.php new file mode 100644 index 00000000..0d8addac --- /dev/null +++ b/app/Service/common/upload/ObjectListXmlTrait.php @@ -0,0 +1,64 @@ +… 结构,只有续拉游标的节点名不同(NextMarker / + * NextContinuationToken)。三个驱动共用本 trait,避免同一段 XML 遍历写三遍。 + * 使用方需要有 $this->config 以及 ObjectKeyNormalizeTrait 提供的 key 规整方法。 + */ +trait ObjectListXmlTrait +{ + /** + * @param string $xml 响应体 + * @param string $urlBase 未配置 domain 时兜底拼地址用的主机前缀(不带尾斜杠) + * @param string $tokenNode 续拉游标节点名 + * @return array{items: array, next_marker: string, finished: bool} + */ + protected function parseObjectListXml(string $xml, string $urlBase, string $tokenNode = 'NextMarker'): array + { + $doc = @simplexml_load_string($xml); + if ($doc === false) { + UtilsService::getInstance()->errorThrow('列举结果解析失败,返回内容不是合法 XML'); + } + + $items = []; + $lastRawKey = ''; + $contents = isset($doc->Contents) ? $doc->Contents : []; + foreach ($contents as $node) { + $rawKey = (string) $node->Key; + // 续拉游标必须用服务端原样返回的 key,不能用补过 prefix 的规整值 + $lastRawKey = $rawKey; + $key = $this->normalizeObjectKey($rawKey); + if ($key === '' || str_ends_with($key, '/')) { + // 以 / 结尾的是控制台建目录留下的占位对象,不是素材 + continue; + } + $items[] = [ + 'key' => $key, + 'size' => (int) $node->Size, + 'hash' => strtolower(trim((string) $node->ETag, '"')), + 'last_modified' => (int) strtotime((string) $node->LastModified), + 'url' => $this->publicUrlOf($key, $urlBase . '/' . $key), + ]; + } + + $truncated = filter_var((string) ($doc->IsTruncated ?? 'false'), FILTER_VALIDATE_BOOLEAN); + $next = isset($doc->{$tokenNode}) ? (string) $doc->{$tokenNode} : ''; + if ($truncated && $next === '' && $tokenNode === 'NextMarker') { + // 部分兼容实现只给 IsTruncated 不给 NextMarker,按协议可用本页最后一个 key 续拉 + $next = $lastRawKey; + } + + return [ + 'items' => $items, + 'next_marker' => $truncated ? $next : '', + 'finished' => !$truncated || $next === '', + ]; + } +} diff --git a/app/Service/common/upload/QcloudStorageService.php b/app/Service/common/upload/QcloudStorageService.php index fdaca01d..f4d2fb0e 100644 --- a/app/Service/common/upload/QcloudStorageService.php +++ b/app/Service/common/upload/QcloudStorageService.php @@ -12,6 +12,9 @@ use Illuminate\Support\Facades\Http; */ class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface { + use ObjectKeyNormalizeTrait; + use ObjectListXmlTrait; + protected array $config = []; public function withConfig(array $config): static @@ -30,19 +33,60 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter return $this->uploadFile($filePath, $key); } + /** + * 列举 bucket 对象(GET Bucket) + * + * 与上传不同,列举带查询参数,而 COS 签名 v5 把查询串算进 HttpString, + * 且 q-url-param-list 必须和实际发出去的参数一一对应;因此这里自己拼查询串, + * 不能交给 Http::get($url, $query) 去编码,否则签名和请求会对不上。 + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array + { + $this->assertConfig(); + $params = ['max-keys' => (string) $this->boundedLimit($limit)]; + $listPrefix = $this->scopedListPrefix($prefix); + if ($listPrefix !== '') { + $params['prefix'] = $listPrefix; + } + if ($marker !== '') { + $params['marker'] = $marker; + } + ksort($params); + $host = $this->host(); + $query = $this->buildQuery($params); + $response = Http::withHeaders([ + 'Host' => $host, + 'Authorization' => $this->authorization('GET', '/', $params), + ])->get('https://' . $host . '/?' . $query); + if (!$response->successful()) { + UtilsService::getInstance()->errorThrow('腾讯云列举对象失败:' . $response->body()); + } + return $this->parseObjectListXml($response->body(), 'https://' . $host); + } + + public function deleteObject(string $key): bool + { + $this->assertConfig(); + $key = $this->normalizeObjectKey($key); + if ($key === '') { + return false; + } + $host = $this->host(); + $urlPath = '/' . $key; + $response = Http::withHeaders([ + 'Host' => $host, + 'Authorization' => $this->authorization('DELETE', $urlPath, []), + ])->delete('https://' . $host . $urlPath); + return $response->successful(); + } + /** * COS 对象上传(Sign Algorithm=sha1) */ private function uploadFile($filePath, string $key): bool|array { - $secretId = (string) ($this->config['access_key'] ?? ''); - $secretKey = (string) ($this->config['secret_key'] ?? ''); - $bucket = (string) ($this->config['bucket'] ?? ''); - $region = (string) ($this->config['region'] ?? ''); + $this->assertConfig(); $domain = rtrim((string) ($this->config['domain'] ?? ''), '/'); - if ($secretId === '' || $secretKey === '' || $bucket === '' || $region === '') { - UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)'); - } $prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/'); if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) { $key = $prefix . '/' . ltrim($key, '/'); @@ -51,25 +95,12 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter ? $filePath->getRealPath() : (string) $filePath; $content = file_get_contents($path); - $host = $bucket . '.cos.' . $region . '.myqcloud.com'; + $host = $this->host(); $urlPath = '/' . ltrim($key, '/'); - $now = time(); - $keyTime = $now . ';' . ($now + 600); - $signKey = hash_hmac('sha1', $keyTime, $secretKey); - $httpString = strtolower('put') . "\n" . $urlPath . "\n\nhost=" . strtolower($host) . "\n"; - $stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n"; - $signature = hash_hmac('sha1', $stringToSign, $signKey); - $authorization = 'q-sign-algorithm=sha1' - . '&q-ak=' . $secretId - . '&q-sign-time=' . $keyTime - . '&q-key-time=' . $keyTime - . '&q-header-list=host' - . '&q-url-param-list=' - . '&q-signature=' . $signature; $url = 'https://' . $host . $urlPath; $response = Http::withHeaders([ 'Host' => $host, - 'Authorization' => $authorization, + 'Authorization' => $this->authorization('PUT', $urlPath, []), 'Content-Type' => 'application/octet-stream', ])->withBody($content, 'application/octet-stream')->put($url); if (!$response->successful()) { @@ -78,4 +109,61 @@ class QcloudStorageService extends BaseNotAuthService implements OssStorageInter $publicUrl = $domain !== '' ? ($domain . $urlPath) : $url; return ['key' => $key, 'url' => $publicUrl]; } + + /** + * 签名 v5;上传、列举、删除共用,差别只在 method / 路径 / 查询参数 + * + * @param array $params 参与签名的查询参数(键需已排序) + */ + private function authorization(string $method, string $urlPath, array $params): string + { + $secretId = (string) $this->config['access_key']; + $secretKey = (string) $this->config['secret_key']; + $host = $this->host(); + $now = time(); + $keyTime = $now . ';' . ($now + 600); + $signKey = hash_hmac('sha1', $keyTime, $secretKey); + $paramList = implode(';', array_map('strtolower', array_keys($params))); + $httpString = strtolower($method) . "\n" . $urlPath . "\n" . $this->buildQuery($params) + . "\nhost=" . strtolower($host) . "\n"; + $stringToSign = "sha1\n{$keyTime}\n" . sha1($httpString) . "\n"; + $signature = hash_hmac('sha1', $stringToSign, $signKey); + return 'q-sign-algorithm=sha1' + . '&q-ak=' . $secretId + . '&q-sign-time=' . $keyTime + . '&q-key-time=' . $keyTime + . '&q-header-list=host' + . '&q-url-param-list=' . $paramList + . '&q-signature=' . $signature; + } + + /** + * 用 rawurlencode 拼查询串:COS 要求 RFC3986 编码,http_build_query 会把空格编成 + + * + * @param array $params + */ + private function buildQuery(array $params): string + { + $pairs = []; + foreach ($params as $name => $value) { + $pairs[] = strtolower(rawurlencode((string) $name)) . '=' . rawurlencode((string) $value); + } + return implode('&', $pairs); + } + + private function host(): string + { + return $this->config['bucket'] . '.cos.' . $this->config['region'] . '.myqcloud.com'; + } + + private function assertConfig(): void + { + if (trim((string) ($this->config['access_key'] ?? '')) === '' + || trim((string) ($this->config['secret_key'] ?? '')) === '' + || trim((string) ($this->config['bucket'] ?? '')) === '' + || trim((string) ($this->config['region'] ?? '')) === '' + ) { + UtilsService::getInstance()->errorThrow('腾讯云 COS 配置不完整(需要 SecretId/SecretKey/Bucket/Region)'); + } + } } diff --git a/app/Service/common/upload/QiniuStorageService.php b/app/Service/common/upload/QiniuStorageService.php index 4d608eac..60ddd905 100644 --- a/app/Service/common/upload/QiniuStorageService.php +++ b/app/Service/common/upload/QiniuStorageService.php @@ -11,6 +11,8 @@ use App\Service\common\UtilsService; */ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface { + use ObjectKeyNormalizeTrait; + protected array $config = [ 'access_key' => '', 'secret_key' => '', @@ -48,6 +50,54 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf return $this->deleteFile($key); } + /** + * 列举空间对象(BucketManager::listFiles,marker 分页) + * + * 七牛只在还有下一页时才回 marker,所以 marker 为空即等于列完了。 + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array + { + $bucketMgr = $this->bucketManager(); + $bucket = (string) ($this->config['bucket'] ?? ''); + [$ret, $err] = $bucketMgr->listFiles( + $bucket, + $this->scopedListPrefix($prefix), + $marker !== '' ? $marker : null, + $this->boundedLimit($limit) + ); + if ($err !== null) { + UtilsService::getInstance()->errorThrow('七牛云列举对象失败:' . $this->errorText($err)); + } + + $items = []; + foreach ((array) ($ret['items'] ?? []) as $row) { + $key = $this->normalizeObjectKey((string) ($row['key'] ?? '')); + if ($key === '' || str_ends_with($key, '/')) { + continue; + } + $items[] = [ + 'key' => $key, + 'size' => (int) ($row['fsize'] ?? 0), + 'hash' => (string) ($row['hash'] ?? ''), + // putTime 的单位是 100 纳秒,当秒用会得到五亿年后的时间戳 + 'last_modified' => intdiv((int) ($row['putTime'] ?? 0), 10000000), + 'url' => $this->publicUrlOf($key, $key), + ]; + } + + $next = (string) ($ret['marker'] ?? ''); + return [ + 'items' => $items, + 'next_marker' => $next, + 'finished' => $next === '', + ]; + } + + public function deleteObject(string $key): bool + { + return $this->deleteFile($this->normalizeObjectKey($key)); + } + /** * 上传到七牛;无 SDK 时抛业务异常引导安装或改本地 */ @@ -93,4 +143,35 @@ class QiniuStorageService extends BaseNotAuthService implements OssStorageInterf $err = $bucketMgr->delete($this->config['bucket'], $key); return $err === null; } + + /** + * 构造 BucketManager,顺手把「没装 SDK / 没填配置」两种情况前置拦掉 + */ + private function bucketManager(): \Qiniu\Storage\BucketManager + { + if (!class_exists(\Qiniu\Auth::class)) { + UtilsService::getInstance()->errorThrow('未安装 qiniu/php-sdk,请改用本地存储或安装依赖'); + } + $accessKey = (string) ($this->config['access_key'] ?? ''); + $secretKey = (string) ($this->config['secret_key'] ?? ''); + $bucket = (string) ($this->config['bucket'] ?? ''); + if ($accessKey === '' || $secretKey === '' || $bucket === '') { + UtilsService::getInstance()->errorThrow('七牛云配置不完整'); + } + return new \Qiniu\Storage\BucketManager(new \Qiniu\Auth($accessKey, $secretKey)); + } + + /** + * SDK 的错误对象没有统一契约,转成人能看懂的一行字 + */ + private function errorText(mixed $err): string + { + if (is_object($err) && method_exists($err, 'message')) { + return (string) $err->message(); + } + if (is_object($err) || is_array($err)) { + return (string) json_encode($err, JSON_UNESCAPED_UNICODE); + } + return (string) $err; + } } diff --git a/app/Service/common/upload/S3CompatibleStorageService.php b/app/Service/common/upload/S3CompatibleStorageService.php index 39d5b773..b1095483 100644 --- a/app/Service/common/upload/S3CompatibleStorageService.php +++ b/app/Service/common/upload/S3CompatibleStorageService.php @@ -5,6 +5,7 @@ namespace App\Service\common\upload; use App\BaseApp\BaseNotAuthService; use App\Service\common\oss\OssStorageInterface; use App\Service\common\UtilsService; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; /** @@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Http; */ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface { + use ObjectKeyNormalizeTrait; + use ObjectListXmlTrait; + protected array $config = []; public function withConfig(array $config): static @@ -31,21 +35,51 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag return $this->uploadFile($filePath, $key); } + /** + * ListObjectsV2;游标是 continuation-token,不是 V1 那种 marker + */ + public function listObjects(string $prefix = '', string $marker = '', int $limit = 100): array + { + $this->assertConfig(); + $bucket = (string) $this->config['bucket']; + $query = [ + 'list-type' => '2', + 'max-keys' => (string) $this->boundedLimit($limit), + ]; + $listPrefix = $this->scopedListPrefix($prefix); + if ($listPrefix !== '') { + $query['prefix'] = $listPrefix; + } + if ($marker !== '') { + $query['continuation-token'] = $marker; + } + $response = $this->signedRequest('GET', '/' . $bucket, $query); + if (!$response->successful()) { + UtilsService::getInstance()->errorThrow($this->driver() . ' 列举对象失败:' . $response->body()); + } + $endpoint = rtrim((string) $this->config['endpoint'], '/'); + return $this->parseObjectListXml($response->body(), $endpoint . '/' . $bucket, 'NextContinuationToken'); + } + + public function deleteObject(string $key): bool + { + $this->assertConfig(); + $key = $this->normalizeObjectKey($key); + if ($key === '') { + return false; + } + $response = $this->signedRequest('DELETE', '/' . $this->config['bucket'] . '/' . $key); + return $response->successful(); + } + /** * SigV4 PUT;endpoint 必填(如 https://s3.amazonaws.com 或 MinIO 地址) */ private function uploadFile($filePath, string $key): bool|array { - $accessKey = (string) ($this->config['access_key'] ?? ''); - $secretKey = (string) ($this->config['secret_key'] ?? ''); - $bucket = (string) ($this->config['bucket'] ?? ''); - $region = (string) ($this->config['region'] ?? 'us-east-1'); - $endpoint = rtrim((string) ($this->config['endpoint'] ?? ''), '/'); + $this->assertConfig(); + $bucket = (string) $this->config['bucket']; $domain = rtrim((string) ($this->config['domain'] ?? ''), '/'); - $driver = (string) ($this->config['driver'] ?? 'aws'); - if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') { - UtilsService::getInstance()->errorThrow(strtoupper($driver) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)'); - } $prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/'); if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) { $key = $prefix . '/' . ltrim($key, '/'); @@ -54,37 +88,96 @@ class S3CompatibleStorageService extends BaseNotAuthService implements OssStorag ? $filePath->getRealPath() : (string) $filePath; $payload = file_get_contents($path); + $response = $this->signedRequest('PUT', '/' . $bucket . '/' . $key, [], $payload); + if (!$response->successful()) { + UtilsService::getInstance()->errorThrow($this->driver() . ' 上传失败:' . $response->body()); + } + $url = rtrim((string) $this->config['endpoint'], '/') . $this->canonicalUri('/' . $bucket . '/' . $key); + $publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url; + return ['key' => $key, 'url' => $publicUrl]; + } + + /** + * SigV4 签名并发起请求 + * + * 上传、列举、删除三条路的差别只是 method / 路径 / 查询参数 / 请求体, + * 签名步骤一模一样,所以收在这里;三份复制粘贴改一处漏两处是必然的。 + * 走 path-style(endpoint/bucket/key),多数 MinIO 与 OBS 都接受。 + * + * @param string $path 未编码的路径,如 /bucket/dir/a.jpg + * @param array $query 参与 CanonicalQueryString 的查询参数 + */ + private function signedRequest(string $method, string $path, array $query = [], string $payload = ''): Response + { + $accessKey = (string) $this->config['access_key']; + $secretKey = (string) $this->config['secret_key']; + $region = (string) ($this->config['region'] ?? 'us-east-1'); + $endpoint = rtrim((string) $this->config['endpoint'], '/'); $host = parse_url($endpoint, PHP_URL_HOST) ?: preg_replace('#^https?://#', '', $endpoint); - // path-style: endpoint/bucket/key - $canonicalUri = '/' . rawurlencode($bucket) . '/' . str_replace('%2F', '/', rawurlencode($key)); - // 简化:多数 MinIO/OBS 接受 path-style - $url = $endpoint . '/' . $bucket . '/' . $key; + + $canonicalUri = $this->canonicalUri($path); + ksort($query); + $pairs = []; + foreach ($query as $name => $value) { + $pairs[] = rawurlencode((string) $name) . '=' . rawurlencode((string) $value); + } + $canonicalQuery = implode('&', $pairs); + $amzDate = gmdate('Ymd\THis\Z'); $dateStamp = gmdate('Ymd'); $payloadHash = hash('sha256', $payload); $canonicalHeaders = "host:{$host}\nx-amz-content-sha256:{$payloadHash}\nx-amz-date:{$amzDate}\n"; $signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; - $canonicalRequest = "PUT\n{$canonicalUri}\n\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}"; - $service = $driver === 'huawei' ? 's3' : 's3'; - $credentialScope = "{$dateStamp}/{$region}/{$service}/aws4_request"; + $canonicalRequest = strtoupper($method) . "\n{$canonicalUri}\n{$canonicalQuery}\n{$canonicalHeaders}\n{$signedHeaders}\n{$payloadHash}"; + $credentialScope = "{$dateStamp}/{$region}/s3/aws4_request"; $stringToSign = "AWS4-HMAC-SHA256\n{$amzDate}\n{$credentialScope}\n" . hash('sha256', $canonicalRequest); $kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true); $kRegion = hash_hmac('sha256', $region, $kDate, true); - $kService = hash_hmac('sha256', $service, $kRegion, true); + $kService = hash_hmac('sha256', 's3', $kRegion, true); $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true); $signature = hash_hmac('sha256', $stringToSign, $kSigning); - $authorization = "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}"; - $response = Http::withHeaders([ - 'Authorization' => $authorization, + + $request = Http::withHeaders([ + 'Authorization' => "AWS4-HMAC-SHA256 Credential={$accessKey}/{$credentialScope}, SignedHeaders={$signedHeaders}, Signature={$signature}", 'x-amz-content-sha256' => $payloadHash, 'x-amz-date' => $amzDate, - 'Content-Type' => 'application/octet-stream', 'Host' => $host, - ])->withBody($payload, 'application/octet-stream')->put($url); - if (!$response->successful()) { - UtilsService::getInstance()->errorThrow($driver . ' 上传失败:' . $response->body()); + ]); + $url = $endpoint . $canonicalUri . ($canonicalQuery !== '' ? '?' . $canonicalQuery : ''); + return match (strtoupper($method)) { + 'PUT' => $request->withBody($payload, 'application/octet-stream')->put($url), + 'DELETE' => $request->delete($url), + default => $request->get($url), + }; + } + + /** + * 逐段编码路径:整段 rawurlencode 会把分隔符 / 也编掉,签名与实际路径就对不上了 + */ + private function canonicalUri(string $path): string + { + $segments = array_map( + static fn ($segment) => rawurlencode($segment), + explode('/', ltrim($path, '/')) + ); + return '/' . implode('/', $segments); + } + + private function driver(): string + { + return (string) ($this->config['driver'] ?? 'aws'); + } + + private function assertConfig(): void + { + if (trim((string) ($this->config['access_key'] ?? '')) === '' + || trim((string) ($this->config['secret_key'] ?? '')) === '' + || trim((string) ($this->config['bucket'] ?? '')) === '' + || trim((string) ($this->config['endpoint'] ?? '')) === '' + ) { + UtilsService::getInstance()->errorThrow( + strtoupper($this->driver()) . ' 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint)' + ); } - $publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url; - return ['key' => $key, 'url' => $publicUrl]; } } diff --git a/app/Service/wx/WxAppService.php b/app/Service/wx/WxAppService.php new file mode 100644 index 00000000..4b1080ee --- /dev/null +++ b/app/Service/wx/WxAppService.php @@ -0,0 +1,130 @@ +currentCode(); + $query = WxAppModel::where('deleted_at', 0)->where('status', 0); + $app = $code !== '' ? $query->where('code', $code)->first() : $query->first(); + + // 库表命中:解密密钥列后返回 + if (!empty($app)) { + return $this->loadWithSecrets($app); + } + + // 库表未命中,回落 .env(仅 AppID/名称,不含密钥) + $envAppId = trim((string) config('nl.wx.env_app_id', '')); + if ($envAppId === '') { + // .env 没配 AppID 就没法兜底了,直接抛错 + UtilsService::getInstance()->errorThrow('未配置小程序应用(nl_wx_app),请先在后台添加或在 .env 配 WX_APP_ID'); + } + + $envCode = trim((string) config('nl.wx.default_app_code', '')); + $envName = trim((string) config('nl.wx.env_app_name', '')); + + // 兜底场景:AppID 来自 .env,但密钥仍只能从库里按 AppID 找一行来解密 + // 这样老项目硬编码密钥的脏习惯不会回到 .env,密钥始终加密入库 + $secretRow = WxAppModel::where('app_id', $envAppId) + ->where('deleted_at', 0) + ->first(['app_secret', 'mch_key', 'mch_private_key']); + if (empty($secretRow) || trim((string) ($secretRow['app_secret'] ?? '')) === '') { + // 表里既没匹配行、或行里没填密钥,只能报错让运维去后台补 + UtilsService::getInstance()->errorThrow( + '小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,新增/编辑 AppID=' . + $envAppId . + ' 的记录并填写 AppSecret(密钥加密入库,不要写进 .env)' + ); + } + + $encrypt = FieldEncryptService::getInstance(); + return [ + 'id' => 0, + 'code' => $envCode, + 'name' => $envName, + 'app_id' => $envAppId, + 'app_secret' => $encrypt->decryptFromStorage((string) $secretRow['app_secret'], true), + 'mch_id' => '', + 'mch_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_key'] ?? ''), true), + 'mch_serial_no' => '', + 'mch_private_key' => $encrypt->decryptFromStorage((string) ($secretRow['mch_private_key'] ?? ''), true), + 'platform_public_key' => '', + 'notify_url' => '', + 'template_code' => '', + ]; + } + + /** + * 当前请求声明的品牌标识 + * + * 优先级:请求头 X-App-Code > 请求参数 app_code > .env 的 WX_DEFAULT_APP_CODE + * 全部为空时返回空串,调用方会改走「取唯一启用行」逻辑 + */ + public function currentCode(): string + { + $code = (string) (request()->header('X-App-Code') ?: request()->input('app_code', '')); + $code = trim($code); + if ($code !== '') { + return $code; + } + // 请求没带品牌标识时回落 .env 默认值(单品牌部署兜底) + return trim((string) config('nl.wx.default_app_code', '')); + } + + /** + * 把模型行的密钥列解密后转数组返回 + */ + private function loadWithSecrets($app): array + { + $app = $app->toArray(); + $raw = WxAppModel::where('id', $app['id'])->first(['app_secret', 'mch_key', 'mch_private_key']); + $encrypt = FieldEncryptService::getInstance(); + $secret = $encrypt->decryptFromStorage($raw['app_secret'] ?? '', true); + if (trim((string) $secret) === '') { + UtilsService::getInstance()->errorThrow( + '小程序 AppSecret 未配置:请打开侧栏「小程序 → 应用配置」,编辑 code=' . + ($app['code'] ?? '') . + '(AppID=' . ($app['app_id'] ?? '') . ')并填写 AppSecret' + ); + } + $app['app_secret'] = $secret; + $app['mch_key'] = $encrypt->decryptFromStorage($raw['mch_key'] ?? '', true); + $app['mch_private_key'] = $encrypt->decryptFromStorage($raw['mch_private_key'] ?? '', true); + return $app; + } +} diff --git a/app/Service/wx/WxAuthService.php b/app/Service/wx/WxAuthService.php new file mode 100644 index 00000000..e88b1c23 --- /dev/null +++ b/app/Service/wx/WxAuthService.php @@ -0,0 +1,71 @@ +utils->errorThrow('缺少 code'); + } + $credentials = WxMiniService::getInstance()->jscode2session($code); + $openId = (string) $credentials['openid']; + $now = time(); + + $user = WxUserModel::where('open_id', $openId)->first(); + if (empty($user)) { + $userId = WxUserModel::insertGetId([ + 'open_id' => $openId, + 'session_key' => (string) ($credentials['session_key'] ?? ''), + 'avatar' => $params['avatar'] ?? self::DEFAULT_AVATAR, + 'nick_name' => $params['nick_name'] ?? '微信用户', + 'phone' => '', + 'created_at' => $now, + ]); + if (empty($userId)) { + $this->utils->errorThrow('用户创建失败'); + } + ListModel::insert([ + 'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'), + 'user_id' => $userId, + 'name' => '默认清单', + 'remark' => '系统默认生成的清单', + 'created_at' => $now, + ]); + } else { + $userId = (int) $user['id']; + WxUserModel::where('id', $userId)->update([ + 'session_key' => (string) ($credentials['session_key'] ?? ''), + 'updated_at' => $now, + ]); + } + + $userInfo = WxUserModel::where('id', $userId)->with('enterprise')->first(); + $userInfo = $userInfo ? $userInfo->toArray() : []; + unset($userInfo['session_key']); + + return [ + 'token' => WxTokenService::getInstance()->issue((int) $userId, (string) ($credentials['app_code'] ?? '')), + 'user_info' => $userInfo, + ]; + } +} diff --git a/app/Service/wx/WxHomeService.php b/app/Service/wx/WxHomeService.php new file mode 100644 index 00000000..e6a64681 --- /dev/null +++ b/app/Service/wx/WxHomeService.php @@ -0,0 +1,37 @@ +where('status', 0) + ->orderBy('sort', 'asc') + ->get(['id', 'url', 'to_path', 'sort']); + } + + /** + * 分类列表,pid=0 为一级 + * 按 sort 升序,同值按 id(需已执行 05_cc_category_add_sort.sql) + */ + public function categoryList(int $pid = 0): mixed + { + return CategoryModel::where('pid', $pid) + ->where('deleted_at', 0) + ->orderBy('sort', 'asc') + ->orderBy('id', 'asc') + ->get(); + } +} diff --git a/app/Service/wx/WxListService.php b/app/Service/wx/WxListService.php new file mode 100644 index 00000000..9317b650 --- /dev/null +++ b/app/Service/wx/WxListService.php @@ -0,0 +1,215 @@ +get('page'); + $paged = $page !== null && $page !== ''; + $page = max(1, (int) $page); + $pageSize = max(1, (int) (request()->get('pageSize') ?? 20)); + + $base = ListModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0)->select(['id', 'list_id']), + ])->where('user_id', $this->userId) + ->where('deleted_at', 0) + ->orderBy('id', 'desc'); + + // 不分页:一次拿全,保持老结构 + if (!$paged) { + $rows = $base->get(); + if ($rows->isEmpty()) { + return []; + } + $rows = $rows->toArray(); + foreach ($rows as &$row) { + $row['count'] = count($row['items'] ?? []); + } + unset($row); + return $rows; + } + + // 分页:拿当前页 + 总数,前端按 has_more 决定是否继续 loadMore + $total = (clone $base)->count(); + $rows = $base->forPage($page, $pageSize)->get(); + $items = $rows->isEmpty() ? [] : $rows->toArray(); + foreach ($items as &$row) { + $row['count'] = count($row['items'] ?? []); + } + unset($row); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + 'has_more' => ($page * $pageSize) < $total, + ]; + } + + /** + * 清单详情:带商品与规格,价格按当前用户的可见性与倍率处理 + */ + public function detail(int $id): array + { + $info = ListModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0), + 'items.catalogue', + 'items.catalogue.priceSheet' => fn ($query) => $query->where('deleted_at', 0), + ])->where('id', $id) + ->where('user_id', $this->userId) + ->where('deleted_at', 0) + ->first(); + if (empty($info)) { + $this->utils->errorThrow('清单不存在'); + } + $info = $info->toArray(); + + $price = PriceService::getInstance(); + $showPrice = $this->canSeePrice(); + $multiplier = $this->priceMultiplier(); + foreach ($info['items'] as &$item) { + $sheet = $item['catalogue']['price_sheet'] ?? []; + if (!empty($sheet)) { + $applied = $price->applyToRows($sheet, $showPrice, $multiplier); + $item['catalogue']['price_sheet'] = $applied['rows']; + } + // 老前端读的是 product 这个键名,保留别名避免小程序改字段 + $item['product'] = $item['catalogue'] ?? null; + } + unset($item); + $info['is_show_price'] = $showPrice; + return $info; + } + + public function create(array $params): mixed + { + $name = trim((string) ($params['name'] ?? '')); + if ($name === '') { + $this->utils->errorThrow('请填写清单名称'); + } + return ListModel::insertGetId([ + 'list_no' => SerialNoService::getInstance()->generate(SerialNoService::PREFIX_LIST, 'list', 'list_no'), + 'user_id' => $this->userId, + 'name' => $name, + 'remark' => (string) ($params['remark'] ?? ''), + 'created_at' => time(), + ]); + } + + public function update(int $id, array $params): mixed + { + return ListModel::where('id', $id)->where('user_id', $this->userId)->update([ + 'name' => (string) ($params['name'] ?? ''), + 'remark' => (string) ($params['remark'] ?? ''), + 'updated_at' => time(), + ]); + } + + public function delete(array|int $ids): mixed + { + return ListModel::whereIn('id', (array) $ids) + ->where('user_id', $this->userId) + ->update(['deleted_at' => time(), 'updated_at' => time()]); + } + + /** + * 删清单明细:先确认这些明细属于当前用户的清单 + */ + public function deleteItem(array|int $ids): mixed + { + $ids = (array) $ids; + $listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id'); + return ListItemModel::whereIn('id', $ids) + ->whereIn('list_id', $listIds) + ->update(['deleted_at' => time(), 'updated_at' => time()]); + } + + /** + * 加入清单;同一清单同一商品同一规格只留一条,重复则累加数量 + */ + public function toCart(array $params): mixed + { + $listId = (int) ($params['list_id'] ?? 0); + $catalogueId = (int) ($params['product_id'] ?? $params['catalogue_id'] ?? 0); + if ($listId <= 0 || $catalogueId <= 0) { + $this->utils->errorThrow('参数错误'); + } + $list = ListModel::where('id', $listId)->where('user_id', $this->userId)->where('deleted_at', 0)->first(); + if (empty($list)) { + $this->utils->errorThrow('清单不存在'); + } + $priceSheetId = (int) ($params['price_sheet_id'] ?? 0); + $quantity = max(1, (int) ($params['quantity'] ?? 1)); + + $exists = ListItemModel::where('list_id', $listId) + ->where('catalogue_id', $catalogueId) + ->where('price_sheet_id', $priceSheetId) + ->where('deleted_at', 0) + ->first(); + if (!empty($exists)) { + return ListItemModel::where('id', $exists['id'])->update([ + 'quantity' => (int) $exists['quantity'] + $quantity, + 'updated_at' => time(), + ]); + } + return ListItemModel::insertGetId([ + 'list_id' => $listId, + 'catalogue_id' => $catalogueId, + 'price_sheet_id' => $priceSheetId, + 'material_key' => (string) ($params['material_key'] ?? 'routine'), + 'quantity' => $quantity, + 'remark' => (string) ($params['remark'] ?? ''), + 'created_at' => time(), + ]); + } + + /** + * 改明细的规格与数量:转订单前用户要能在清单里补齐这些信息 + */ + public function updateItem(array $params): mixed + { + $itemId = (int) ($params['id'] ?? 0); + if ($itemId <= 0) { + $this->utils->errorThrow('参数错误'); + } + $listIds = ListModel::where('user_id', $this->userId)->where('deleted_at', 0)->pluck('id'); + $update = ['updated_at' => time()]; + if (array_key_exists('quantity', $params)) { + $update['quantity'] = max(1, (int) $params['quantity']); + } + if (array_key_exists('price_sheet_id', $params)) { + $update['price_sheet_id'] = (int) $params['price_sheet_id']; + } + if (array_key_exists('material_key', $params)) { + $update['material_key'] = (string) $params['material_key']; + } + if (array_key_exists('remark', $params)) { + $update['remark'] = (string) $params['remark']; + } + return ListItemModel::where('id', $itemId)->whereIn('list_id', $listIds)->update($update); + } +} diff --git a/app/Service/wx/WxMiniService.php b/app/Service/wx/WxMiniService.php new file mode 100644 index 00000000..c8111f59 --- /dev/null +++ b/app/Service/wx/WxMiniService.php @@ -0,0 +1,94 @@ +current(); + if (($app['app_id'] ?? '') === '' || ($app['app_secret'] ?? '') === '') { + UtilsService::getInstance()->errorThrow('小程序 AppID/AppSecret 未配置'); + } + $result = Http::asJson()->get($this->baseUrl . 'sns/jscode2session', [ + 'appid' => $app['app_id'], + 'secret' => $app['app_secret'], + 'js_code' => $code, + 'grant_type' => 'authorization_code', + ])->json(); + if (!empty($result['errcode'])) { + UtilsService::getInstance()->errorThrow('微信登录失败:' . ($result['errmsg'] ?? '未知错误')); + } + if (empty($result['openid'])) { + UtilsService::getInstance()->errorThrow('微信登录失败:未拿到 openid'); + } + $result['app_code'] = (string) ($app['code'] ?? ''); + return $result; + } + + /** + * 手机号快速验证:前端拿到的 code 换真实号码 + */ + public function getPhoneNumber(string $phoneCode): string + { + $token = $this->accessToken(); + $result = Http::asJson()->post( + $this->baseUrl . 'wxa/business/getuserphonenumber?access_token=' . $token, + ['code' => $phoneCode] + )->json(); + if (!empty($result['errcode'])) { + UtilsService::getInstance()->errorThrow('获取手机号失败:' . ($result['errmsg'] ?? '未知错误')); + } + return (string) ($result['phone_info']['phoneNumber'] ?? ''); + } + + /** + * 接口调用凭证,按 appid 缓存 + */ + public function accessToken(): string + { + $app = WxAppService::getInstance()->current(); + $redis = RedisService::getInstance()->init(config('nl.redis.wechat_token')); + $cached = $redis->get($app['app_id']); + if (!empty($cached)) { + return (string) $cached; + } + $result = Http::asJson()->get($this->baseUrl . 'cgi-bin/token', [ + 'grant_type' => 'client_credential', + 'appid' => $app['app_id'], + 'secret' => $app['app_secret'], + ])->json(); + if (empty($result['access_token'])) { + UtilsService::getInstance()->errorThrow('获取 access_token 失败:' . ($result['errmsg'] ?? '未知错误')); + } + // 提前 300 秒过期,避免临界点上拿到即将失效的 token + $redis->set($app['app_id'], $result['access_token'], max(60, (int) ($result['expires_in'] ?? 7200) - 300)); + return (string) $result['access_token']; + } +} diff --git a/app/Service/wx/WxOrderService.php b/app/Service/wx/WxOrderService.php new file mode 100644 index 00000000..575cd6f6 --- /dev/null +++ b/app/Service/wx/WxOrderService.php @@ -0,0 +1,112 @@ +get('status'); + $result = OrderModel::with([ + 'items' => fn ($query) => $query->where('deleted_at', 0), + ])->where('user_id', $this->userId) + ->where('deleted_at', 0) + ->when($status !== null && $status !== '', fn ($query) => $query->where('status', (int) $status)) + ->orderBy('id', 'desc') + ->paginate((int) request()->get('pageSize', 10)) + ->toArray(); + // 复用后台同款格式化,保证两端金额展示一致 + $price = PriceService::getInstance(); + foreach ($result['data'] as &$item) { + $item['total_amount_text'] = $price->centsToYuan((int) ($item['total_amount'] ?? 0)); + $item['paid_amount_text'] = $price->centsToYuan((int) ($item['paid_amount'] ?? 0)); + } + unset($item); + return [ + 'page' => $result['current_page'], + 'size' => $result['per_page'], + 'page_count' => $result['last_page'], + 'total' => $result['total'], + 'items' => $result['data'], + ]; + } + + public function detail(int $id): array + { + $order = OrderCoreService::getInstance()->detail($id); + if ((int) $order['user_id'] !== $this->userId) { + $this->utils->errorThrow('无权查看该订单'); + } + return $order; + } + + /** + * 清单转订单 + */ + public function createFromList(array $params): array + { + $listId = (int) ($params['list_id'] ?? 0); + if ($listId <= 0) { + $this->utils->errorThrow('请选择清单'); + } + return OrderCoreService::getInstance()->createFromList($listId, $this->userId, $params); + } + + /** + * 上传转账凭证 + */ + public function submitVoucher(array $params): array + { + $orderId = (int) ($params['order_id'] ?? 0); + return OrderCoreService::getInstance()->submitVoucher($orderId, $params, $this->userId); + } + + /** + * 调起微信支付 + */ + public function wechatPay(array $params): array + { + $orderId = (int) ($params['order_id'] ?? 0); + $order = $this->detail($orderId); + $user = WxUserModel::where('id', $this->userId)->first(['open_id']); + if (empty($user['open_id'])) { + $this->utils->errorThrow('缺少 openid,请重新登录'); + } + OrderModel::where('id', $orderId)->update([ + 'pay_type' => OrderModel::PAY_TYPE_WECHAT, + 'updated_at' => time(), + ]); + return WxPayService::getInstance()->jsapiOrder($order, (string) $user['open_id']); + } + + /** + * 取消订单(仅未付款可取消) + */ + public function cancel(int $id): array + { + return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_CANCELLED, $this->userId); + } + + /** + * 确认收货 + */ + public function complete(int $id): array + { + return OrderCoreService::getInstance()->transition($id, OrderModel::STATUS_DONE, $this->userId); + } +} diff --git a/app/Service/wx/WxPayService.php b/app/Service/wx/WxPayService.php new file mode 100644 index 00000000..4e1c326a --- /dev/null +++ b/app/Service/wx/WxPayService.php @@ -0,0 +1,221 @@ +current(); + if (!$this->configured($app)) { + UtilsService::getInstance()->errorThrow('微信支付商户信息未配置,请先使用转账凭证支付'); + } + $amount = (int) $order['total_amount'] - (int) $order['paid_amount']; + if ($amount <= 0) { + UtilsService::getInstance()->errorThrow('该订单无需支付'); + } + // 同一订单可能多次发起支付(用户中途放弃),每次都要新的 out_trade_no, + // 否则微信会以「订单号重复」拒单,而它又必须唯一以便回调对账 + $outTradeNo = 'WX' . $order['id'] . 'T' . time(); + OrderPaymentModel::insert([ + 'order_id' => (int) $order['id'], + 'pay_type' => OrderModel::PAY_TYPE_WECHAT, + 'amount' => $amount, + 'out_trade_no' => $outTradeNo, + 'status' => OrderPaymentModel::STATUS_AUDITING, + 'created_at' => time(), + ]); + + $body = [ + 'appid' => $app['app_id'], + 'mchid' => $app['mch_id'], + 'description' => '订单 ' . $order['order_no'], + 'out_trade_no' => $outTradeNo, + 'notify_url' => $app['notify_url'], + 'amount' => ['total' => $amount, 'currency' => 'CNY'], + 'payer' => ['openid' => $openId], + ]; + $path = '/v3/pay/transactions/jsapi'; + $payload = json_encode($body, JSON_UNESCAPED_UNICODE); + $response = Http::withHeaders([ + 'Authorization' => $this->authorization('POST', $path, $payload, $app), + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ])->withBody($payload, 'application/json')->post($this->baseUrl . $path); + $result = $response->json(); + if (empty($result['prepay_id'])) { + UtilsService::getInstance()->errorThrow('微信下单失败:' . ($result['message'] ?? $response->body())); + } + return $this->buildPayParams($app, (string) $result['prepay_id']); + } + + /** + * 处理支付回调 + * + * @param array $headers Wechatpay-* 头 + * @param string $rawBody 原始请求体(不能用解析后的数组重新序列化,会导致验签失败) + */ + public function handleNotify(array $headers, string $rawBody): array + { + $app = WxAppService::getInstance()->current(); + if (!$this->verifySignature($headers, $rawBody, $app)) { + return ['code' => 'FAIL', 'message' => '验签失败']; + } + $body = json_decode($rawBody, true) ?: []; + $resource = $body['resource'] ?? []; + $plain = $this->decryptResource($resource, (string) $app['mch_key']); + if (empty($plain['out_trade_no'])) { + return ['code' => 'FAIL', 'message' => '报文缺少订单号']; + } + if (($plain['trade_state'] ?? '') !== 'SUCCESS') { + // 非成功态直接应答成功,避免微信持续重推 + return ['code' => 'SUCCESS', 'message' => 'OK']; + } + OrderCoreService::getInstance()->confirmWechatPay( + (string) $plain['out_trade_no'], + (string) ($plain['transaction_id'] ?? ''), + (int) ($plain['amount']['total'] ?? 0) + ); + return ['code' => 'SUCCESS', 'message' => 'OK']; + } + + /** + * 小程序端调起支付的签名参数 + */ + private function buildPayParams(array $app, string $prepayId): array + { + $timestamp = (string) time(); + $nonce = bin2hex(random_bytes(16)); + $package = 'prepay_id=' . $prepayId; + $message = $app['app_id'] . "\n" . $timestamp . "\n" . $nonce . "\n" . $package . "\n"; + return [ + 'appId' => $app['app_id'], + 'timeStamp' => $timestamp, + 'nonceStr' => $nonce, + 'package' => $package, + 'signType' => 'RSA', + 'paySign' => $this->sign($message, (string) $app['mch_private_key']), + ]; + } + + /** + * APIv3 Authorization 头 + */ + private function authorization(string $method, string $path, string $body, array $app): string + { + $timestamp = time(); + $nonce = bin2hex(random_bytes(16)); + $message = $method . "\n" . $path . "\n" . $timestamp . "\n" . $nonce . "\n" . $body . "\n"; + $signature = $this->sign($message, (string) $app['mch_private_key']); + return sprintf( + 'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",timestamp="%d",serial_no="%s",signature="%s"', + $app['mch_id'], + $nonce, + $timestamp, + $app['mch_serial_no'], + $signature + ); + } + + private function sign(string $message, string $privateKey): string + { + $key = openssl_pkey_get_private($privateKey); + if ($key === false) { + UtilsService::getInstance()->errorThrow('商户私钥无效'); + } + openssl_sign($message, $signature, $key, 'sha256WithRSAEncryption'); + return base64_encode($signature); + } + + /** + * 回调验签 + * + * 需要微信支付平台证书公钥;未配置时一律判失败,绝不能「验不了就放过」。 + */ + private function verifySignature(array $headers, string $rawBody, array $app): bool + { + $publicKey = (string) ($app['platform_public_key'] ?? ''); + if ($publicKey === '') { + return false; + } + $timestamp = (string) ($headers['wechatpay-timestamp'] ?? ''); + $nonce = (string) ($headers['wechatpay-nonce'] ?? ''); + $signature = (string) ($headers['wechatpay-signature'] ?? ''); + if ($timestamp === '' || $nonce === '' || $signature === '') { + return false; + } + // 时间戳偏移超过 5 分钟视为重放 + if (abs(time() - (int) $timestamp) > 300) { + return false; + } + $message = $timestamp . "\n" . $nonce . "\n" . $rawBody . "\n"; + $key = openssl_pkey_get_public($publicKey); + if ($key === false) { + return false; + } + return openssl_verify($message, base64_decode($signature), $key, 'sha256WithRSAEncryption') === 1; + } + + /** + * 解密回调报文(AEAD_AES_256_GCM) + */ + private function decryptResource(array $resource, string $apiV3Key): array + { + $ciphertext = base64_decode((string) ($resource['ciphertext'] ?? '')); + $nonce = (string) ($resource['nonce'] ?? ''); + $associated = (string) ($resource['associated_data'] ?? ''); + if ($ciphertext === '' || $nonce === '' || $apiV3Key === '') { + return []; + } + $tagLength = 16; + $tag = substr($ciphertext, -$tagLength); + $data = substr($ciphertext, 0, -$tagLength); + $plain = openssl_decrypt($data, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associated); + return $plain === false ? [] : (json_decode($plain, true) ?: []); + } +} diff --git a/app/Service/wx/WxProductService.php b/app/Service/wx/WxProductService.php new file mode 100644 index 00000000..74682a1e --- /dev/null +++ b/app/Service/wx/WxProductService.php @@ -0,0 +1,125 @@ +get('title', ''); + $categoryId = request()->get('category_id'); + $childIds = []; + if (!empty($categoryId)) { + $childIds = CategoryModel::where('pid', $categoryId)->where('deleted_at', 0)->pluck('id')->all(); + } + + // 小程序前端发的是 size(见 pages/product|search getProductList),老接口曾用 pageSize, + // 这里两者都接,避免改一边漏一边 + $pageSize = (int) (request()->get('pageSize') ?: request()->get('size') ?: 10); + + $result = CatalogueModel::when($title !== '', function ($query) use ($title) { + $query->where('title', 'like', '%' . $title . '%'); + })->when(!empty($categoryId) && empty($childIds), function ($query) use ($categoryId) { + $query->where('category_id', $categoryId); + })->when(!empty($childIds), function ($query) use ($childIds) { + $query->whereIn('category_id', $childIds); + })->where('deleted_at', 0) + ->orderBy('id', 'desc') + ->paginate($pageSize) + ->toArray(); + + return [ + 'page' => $result['current_page'], + 'size' => $result['per_page'], + 'page_count' => $result['last_page'], + // 小程序 productWaterfall / product.vue 读的是 last_page(不是 page_count),两个键都给 + 'last_page' => $result['last_page'], + 'total' => $result['total'], + 'items' => $result['data'], + 'data' => $result['data'], + ]; + } + + /** + * 商品详情 + * + * 带 p_user_id 说明是代理商分享进来的:普通新用户会被绑到该代理商名下并继承其倍率, + * 这是老项目的既有行为,不能改,否则代理商体系的价格会全部失效。 + */ + public function detail(int $id, int $shareUserId = 0): array + { + if ($shareUserId > 0) { + $this->inheritAgentPrice($shareUserId); + } + + $info = CatalogueModel::with([ + 'category', + 'images' => fn ($query) => $query->where('deleted_at', 0), + 'priceSheet' => fn ($query) => $query->where('deleted_at', 0), + ])->where('deleted_at', 0)->find($id); + if (empty($info)) { + $this->utils->errorThrow('商品不存在'); + } + $info = $info->toArray(); + + // 渲染图作为详情轮播 + $info['carousel'] = []; + foreach ($info['images'] ?? [] as $image) { + if ((int) $image['type'] === ImageModel::TYPE_RENDER) { + $info['carousel'][] = $image['url']; + } + } + + $showPrice = $this->canSeePrice(); + $applied = PriceService::getInstance()->applyToRows($info['price_sheet'] ?? [], $showPrice, $this->priceMultiplier()); + $info['price_sheet'] = $applied['rows']; + $info['is_show_price'] = $applied['is_show_price']; + return $info; + } + + /** + * 继承分享代理商的倍率 + * + * 只有「还没有上级、自己也不是代理商」的用户会被绑定;已绑定同一个上级时同步最新倍率, + * 代理商改了倍率下级要跟着变。 + */ + private function inheritAgentPrice(int $shareUserId): void + { + $isAgent = (int) ($this->userInfo['is_p'] ?? 0) === 1; + $pid = (int) ($this->userInfo['pid'] ?? 0); + if ($isAgent) { + return; + } + if ($pid !== 0 && $pid !== $shareUserId) { + return; + } + $agent = WxUserModel::where('id', $shareUserId)->where('deleted_at', 0)->first(); + if (empty($agent) || (int) $agent['is_p'] !== 1) { + return; + } + $this->userInfo['show_price'] = 1; + $this->userInfo['price_number'] = $agent['price_number']; + WxUserModel::where('id', $this->userId)->update([ + 'pid' => $shareUserId, + 'show_price' => 1, + 'price_number' => $agent['price_number'], + 'updated_at' => time(), + ]); + } +} diff --git a/app/Service/wx/WxThemeService.php b/app/Service/wx/WxThemeService.php new file mode 100644 index 00000000..78c08999 --- /dev/null +++ b/app/Service/wx/WxThemeService.php @@ -0,0 +1,109 @@ + 登录经销商专属 template_code > 品牌配置 template_code + * > 该品牌默认模板 > 通用默认模板 > 内置预设 + */ + public function current(string $code = ''): array + { + $appCode = WxAppService::getInstance()->currentCode(); + $template = null; + + if ($code !== '') { + $template = WxTemplateModel::where('code', $code)->where('deleted_at', 0)->where('status', 0)->first(); + } + // 已登录经销商:可用专属模板覆盖品牌默认(未登录则 userInfo 为空,跳过) + if (empty($template) && (int) ($this->userInfo['is_p'] ?? 0) === 1) { + $userCode = trim((string) ($this->userInfo['template_code'] ?? '')); + if ($userCode === '' && !empty($this->userInfo['id'])) { + $userCode = (string) (WxUserModel::where('id', $this->userInfo['id']) + ->where('deleted_at', 0) + ->value('template_code') ?? ''); + } + if ($userCode !== '') { + $template = WxTemplateModel::where('code', $userCode) + ->where('deleted_at', 0)->where('status', 0)->first(); + } + } + if (empty($template) && $appCode !== '') { + $bound = (string) (WxAppModel::where('code', $appCode) + ->where('deleted_at', 0) + ->value('template_code') ?? ''); + if ($bound !== '') { + $template = WxTemplateModel::where('code', $bound)->where('deleted_at', 0)->where('status', 0)->first(); + } + } + if (empty($template) && $appCode !== '') { + $template = WxTemplateModel::where('app_code', $appCode) + ->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first(); + } + if (empty($template)) { + $template = WxTemplateModel::where('app_code', '') + ->where('is_default', 1)->where('deleted_at', 0)->where('status', 0)->first(); + } + + $schema = WxTemplateSchemaService::getInstance(); + if (empty($template)) { + $preset = WxTemplatePresetService::getInstance()->all()[0] ?? []; + $tokens = $preset['tokens'] ?? []; + $layout = $preset['layout'] ?? []; + return [ + 'code' => (string) ($preset['code'] ?? 'lux-champagne'), + 'name' => (string) ($preset['name'] ?? '轻奢·香槟金'), + 'style_tag' => (string) ($preset['style_tag'] ?? '轻奢'), + 'version' => 0, + 'tokens' => $tokens, + 'layout' => $layout, + 'css_vars' => $schema->toCssVariables($tokens), + 'fallback' => true, + ]; + } + + $template = $template->toArray(); + $tokens = is_array($template['tokens']) ? $template['tokens'] : []; + return [ + 'code' => (string) $template['code'], + 'name' => (string) $template['name'], + 'style_tag' => (string) $template['style_tag'], + 'version' => (int) $template['version'], + 'tokens' => $tokens, + 'layout' => is_array($template['layout']) ? $template['layout'] : [], + 'css_vars' => $schema->toCssVariables($tokens), + 'fallback' => false, + ]; + } + + /** + * 可选风格列表,用于小程序里让用户自己换肤 + */ + public function gallery(): mixed + { + $appCode = WxAppService::getInstance()->currentCode(); + return WxTemplateModel::where('deleted_at', 0) + ->where('status', 0) + ->when($appCode !== '', fn ($query) => $query->whereIn('app_code', ['', $appCode])) + ->orderBy('sort', 'asc') + ->get(['code', 'name', 'style_tag', 'preview']); + } +} diff --git a/app/Service/wx/WxTokenService.php b/app/Service/wx/WxTokenService.php new file mode 100644 index 00000000..c121f6da --- /dev/null +++ b/app/Service/wx/WxTokenService.php @@ -0,0 +1,104 @@ +secretKey = strlen($secret) >= 32 ? $secret : hash('sha256', $secret !== '' ? $secret : 'nl_wx_jwt_fallback'); + } + + public static function getInstance(): null|static + { + $name = get_called_class(); + if (!isset(self::$_instance[$name])) { + self::$_instance[$name] = new static(); + } + return self::$_instance[$name]; + } + + /** + * 签发令牌 + */ + public function issue(int $userId, string $appCode = ''): string + { + $now = time(); + return JWT::encode([ + 'iat' => $now, + 'exp' => $now + (int) config('nl.wx.token_ttl', 30 * 24 * 3600), + 'scope' => self::SCOPE, + 'uid' => $userId, + 'app' => $appCode, + ], $this->secretKey, 'HS256'); + } + + /** + * 校验令牌并返回用户;失败返回 null 由中间件统一响应 + */ + public function resolveUser(?string $token): ?array + { + if (empty($token)) { + return null; + } + try { + $payload = JWT::decode($token, new Key($this->secretKey, 'HS256')); + } catch (\Throwable) { + return null; + } + if (($payload->scope ?? '') !== self::SCOPE) { + return null; + } + $userId = (int) ($payload->uid ?? 0); + if ($userId <= 0) { + return null; + } + // 每次请求回查用户:倍率、价格可见性、是否被停用都可能刚被后台改过 + $user = WxUserModel::where('id', $userId)->where('deleted_at', 0)->first(); + if (empty($user)) { + return null; + } + $user = $user->toArray(); + unset($user['session_key']); + return $user; + } + + /** + * 取当前请求的小程序用户,取不到直接中断 + */ + public function requireUser(): array + { + $user = request()->attributes->get('wx_user'); + if (!empty($user)) { + return $user; + } + $user = $this->resolveUser(request()->bearerToken()); + if (empty($user)) { + UtilsService::getInstance()->notAuth('请先登录'); + } + return $user; + } +} diff --git a/app/Service/wx/WxUploadService.php b/app/Service/wx/WxUploadService.php new file mode 100644 index 00000000..f85f313c --- /dev/null +++ b/app/Service/wx/WxUploadService.php @@ -0,0 +1,70 @@ +utils->errorThrow('请选择图片'); + } + $ext = $file->getClientOriginalExtension(); + // 单独走 wx 命名空间避免与后台素材库扫描混目录 + $key = 'wx/voucher/' . date('Ymd') . '/' . 'wx_' . Str::random() . uniqid() . '.' . $ext; + $storage = $this->resolveStorage(); + $result = $storage->uploadVideo($file, $key); + if (!$result) { + $this->utils->errorThrow('图片上传失败'); + } + // 落一份上传流水到 nl_file,type=image,方便后台审计小程序上传的凭证 + FileModel::insert([ + 'user_id' => $this->userId, + 'url' => $result['url'], + 'type' => FileModel::TYPE_IMAGE, + 'source' => FileModel::SOURCE_UPLOAD, + 'created_at' => time(), + 'updated_at' => time(), + ]); + return $result; + } + + /** + * 解析当前启用的存储实现;配置异常时回退本地,避免整站上传不可用 + */ + private function resolveStorage() + { + try { + $config = OssRuntimeConfigService::getInstance()->getActiveConfig(); + return OssStorageFactory::getInstance()->make($config); + } catch (\Throwable $e) { + return OssStorageFactory::getInstance()->make([ + 'driver' => 'local', + 'path_prefix' => 'uploads', + 'domain' => '', + ]); + } + } +} diff --git a/app/Service/wx/WxUserCenterService.php b/app/Service/wx/WxUserCenterService.php new file mode 100644 index 00000000..08d21959 --- /dev/null +++ b/app/Service/wx/WxUserCenterService.php @@ -0,0 +1,67 @@ +userId)->where('deleted_at', 0)->count(); + $orderCount = OrderModel::where('user_id', $this->userId)->where('deleted_at', 0)->count(); + $user = WxUserModel::with('enterprise')->where('id', $this->userId)->first(); + $user = $user ? $user->toArray() : $this->userInfo; + unset($user['session_key']); + return [ + 'count' => $listCount, + 'order_count' => $orderCount, + 'user' => $user, + ]; + } + + /** + * 绑定手机号 + * + * 优先用微信手机号快速验证的 code 换真实号码(用户填的可能是假号); + * 只有客户端拿不到 code 时才退回直接写入。 + */ + public function bandPhone(array $params): array + { + $phone = trim((string) ($params['phone'] ?? '')); + $phoneCode = trim((string) ($params['phone_code'] ?? '')); + if ($phoneCode !== '') { + $phone = WxMiniService::getInstance()->getPhoneNumber($phoneCode); + } + if ($phone === '') { + $this->utils->errorThrow('手机号不能为空'); + } + WxUserModel::where('id', $this->userId)->update([ + 'phone' => $phone, + 'updated_at' => time(), + ]); + return ['phone' => $phone]; + } + + public function updateNickName(string $nickName): array + { + $nickName = trim($nickName); + if ($nickName === '') { + $this->utils->errorThrow('昵称不能为空'); + } + WxUserModel::where('id', $this->userId)->update([ + 'nick_name' => $nickName, + 'updated_at' => time(), + ]); + return ['nick_name' => $nickName]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 52e782db..ddb03ae3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,6 +21,12 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->api(append: [ \App\Http\Middleware\ApiOpLogMiddleware::class, ]); + // 登录态 + 接口级权限,只挂在 routes/api.php 的登录组上 + $middleware->alias([ + 'nl.auth' => \App\Http\Middleware\ApiAuthMiddleware::class, + // 小程序登录态,只挂在 wx 路由组上(与后台 token 互不通用) + 'nl.wx' => \App\Http\Middleware\WxAuthMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (Throwable $e) { diff --git a/composer.json b/composer.json index aa7041e3..5f81a8db 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,15 @@ "license": "MIT", "require": { "php": "^8.3", + "ext-pdo": "*", "ext-zip": "*", + "aliyuncs/oss-sdk-php": "^2.7", "firebase/php-jwt": "^7", "laravel/framework": "^13.0", "laravel/tinker": "^3", - "ext-pdo": "*" + "qcloud/cos-sdk-v5": "^2.6", + "qiniu/php-sdk": "^7.14", + "wechatpay/wechatpay": "^1.4" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index e63079f4..eb7339ce 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,53 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4be1c0b269902b568279d4dcde02ee4a", + "content-hash": "89abb9e31178235721580ac7d45128c4", "packages": [ + { + "name": "aliyuncs/oss-sdk-php", + "version": "v2.7.3", + "source": { + "type": "git", + "url": "https://github.com/aliyun/aliyun-oss-php-sdk.git", + "reference": "10dbd2a7253131da60629d431c8eb6c216fbbbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aliyun/aliyun-oss-php-sdk/zipball/10dbd2a7253131da60629d431c8eb6c216fbbbf7", + "reference": "10dbd2a7253131da60629d431c8eb6c216fbbbf7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "php-coveralls/php-coveralls": "*", + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "OSS\\": "src/OSS" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aliyuncs", + "homepage": "http://www.aliyun.com" + } + ], + "description": "Aliyun OSS SDK for PHP", + "homepage": "http://www.aliyun.com/product/oss/", + "support": { + "issues": "https://github.com/aliyun/aliyun-oss-php-sdk/issues", + "source": "https://github.com/aliyun/aliyun-oss-php-sdk/tree/v2.7.3" + }, + "time": "2026-06-26T07:23:48+00:00" + }, { "name": "brick/math", "version": "0.18.0", @@ -706,6 +751,90 @@ ], "time": "2025-12-27T19:43:20+00:00" }, + { + "name": "guzzlehttp/command", + "version": "1.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/command.git", + "reference": "5bf05727052d6d16617b2215e3a2307229332026" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/command/zipball/5bf05727052d6d16617b2215e3a2307229332026", + "reference": "5bf05727052d6d16617b2215e3a2307229332026", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.15", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.13", + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Command\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "Provides the foundation for building command-based web service clients", + "support": { + "issues": "https://github.com/guzzle/command/issues", + "source": "https://github.com/guzzle/command/tree/1.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/command", + "type": "tidelift" + } + ], + "time": "2026-07-17T12:30:59+00:00" + }, { "name": "guzzlehttp/guzzle", "version": "7.15.3", @@ -834,6 +963,96 @@ ], "time": "2026-08-05T19:48:21+00:00" }, + { + "name": "guzzlehttp/guzzle-services", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle-services.git", + "reference": "5d365c5adbc04c76c164a0bc09780323d02c39c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle-services/zipball/5d365c5adbc04c76c164a0bc09780323d02c39c0", + "reference": "5d365c5adbc04c76c164a0bc09780323d02c39c0", + "shasum": "" + }, + "require": { + "guzzlehttp/command": "^1.5.3", + "guzzlehttp/guzzle": "^7.15", + "guzzlehttp/psr7": "^2.13", + "guzzlehttp/uri-template": "^1.0.10", + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "guzzlehttp/test-server": "^0.5", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "gimler/guzzle-description-loader": "^0.0.4" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Command\\Guzzle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Stefano Kowalke", + "email": "blueduck@mail.org", + "homepage": "https://github.com/Konafets" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "Provides an implementation of the Guzzle Command library that uses Guzzle service descriptions to describe web services, serialize requests, and parse responses into easy to use model structures.", + "support": { + "issues": "https://github.com/guzzle/guzzle-services/issues", + "source": "https://github.com/guzzle/guzzle-services/tree/1.7.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle-services", + "type": "tidelift" + } + ], + "time": "2026-07-17T14:26:46+00:00" + }, { "name": "guzzlehttp/promises", "version": "2.5.2", @@ -2201,6 +2420,69 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, { "name": "nesbot/carbon", "version": "3.13.2", @@ -3174,6 +3456,135 @@ }, "time": "2026-06-29T15:41:09+00:00" }, + { + "name": "qcloud/cos-sdk-v5", + "version": "v2.6.17", + "source": { + "type": "git", + "url": "https://github.com/tencentyun/cos-php-sdk-v5.git", + "reference": "19b939580482d6e03f2e60ce8ef8b5b3aaf553fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tencentyun/cos-php-sdk-v5/zipball/19b939580482d6e03f2e60ce8ef8b5b3aaf553fa", + "reference": "19b939580482d6e03f2e60ce8ef8b5b3aaf553fa", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.2.1 || ^7.0", + "guzzlehttp/guzzle-services": "^1.1", + "guzzlehttp/psr7": "^1.3.1 || ^2.0", + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + } + }, + "autoload": { + "files": [ + "src/Common.php" + ], + "psr-4": { + "Qcloud\\Cos\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "yaozongyou", + "email": "yaozongyou@vip.qq.com" + }, + { + "name": "lewzylu", + "email": "327874225@qq.com" + }, + { + "name": "tuunalai", + "email": "550566181@qq.com" + } + ], + "description": "PHP SDK for QCloud COS", + "keywords": [ + "cos", + "php", + "qcloud" + ], + "support": { + "issues": "https://github.com/tencentyun/cos-php-sdk-v5/issues", + "source": "https://github.com/tencentyun/cos-php-sdk-v5/tree/v2.6.17" + }, + "time": "2026-07-27T09:02:13+00:00" + }, + { + "name": "qiniu/php-sdk", + "version": "v7.14.0", + "source": { + "type": "git", + "url": "https://github.com/qiniu/php-sdk.git", + "reference": "ee752ffa7263ce99fca0bd7340cf13c486a3516c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/qiniu/php-sdk/zipball/ee752ffa7263ce99fca0bd7340cf13c486a3516c", + "reference": "ee752ffa7263ce99fca0bd7340cf13c486a3516c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-xml": "*", + "myclabs/php-enum": "~1.5.2 || ~1.6.6 || ~1.7.7 || ~1.8.4", + "php": ">=5.3.3" + }, + "require-dev": { + "paragonie/random_compat": ">=2", + "phpunit/phpunit": "^4.8 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4", + "squizlabs/php_codesniffer": "^2.3 || ~3.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/Qiniu/functions.php", + "src/Qiniu/Http/Middleware/Middleware.php" + ], + "psr-4": { + "Qiniu\\": "src/Qiniu" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Qiniu", + "email": "sdk@qiniu.com", + "homepage": "http://www.qiniu.com" + } + ], + "description": "Qiniu Resource (Cloud) Storage SDK for PHP", + "homepage": "http://developer.qiniu.com/", + "keywords": [ + "cloud", + "qiniu", + "sdk", + "storage" + ], + "support": { + "issues": "https://github.com/qiniu/php-sdk/issues", + "source": "https://github.com/qiniu/php-sdk/tree/v7.14.0" + }, + "time": "2024-10-25T08:39:01+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -6064,6 +6475,73 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "wechatpay/wechatpay", + "version": "1.4.12", + "source": { + "type": "git", + "url": "https://github.com/wechatpay-apiv3/wechatpay-php.git", + "reference": "bd2148e0456f560df4d1c857d6cd1f8ad9f5222e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wechatpay-apiv3/wechatpay-php/zipball/bd2148e0456f560df4d1c857d6cd1f8ad9f5222e", + "reference": "bd2148e0456f560df4d1c857d6cd1f8ad9f5222e", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-libxml": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5 || ^7.0", + "guzzlehttp/uri-template": "^0.2 || ^1.0", + "php": ">=7.1.2" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.89 || ^1.0", + "phpunit/phpunit": "^7.5 || ^8.5.16 || ^9.3.5" + }, + "bin": [ + "bin/CertificateDownloader.php" + ], + "type": "library", + "autoload": { + "psr-4": { + "WeChatPay\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "James ZHANG", + "homepage": "https://github.com/TheNorthMemory" + }, + { + "name": "WeChatPay Community", + "homepage": "https://developers.weixin.qq.com/community/pay" + } + ], + "description": "[A]Sync Chainable WeChatPay v2&v3's OpenAPI SDK for PHP", + "homepage": "https://pay.weixin.qq.com/", + "keywords": [ + "AES-GCM", + "aes-ecb", + "openapi-chainable", + "rsa-oaep", + "wechatpay", + "xml-builder", + "xml-parser" + ], + "support": { + "issues": "https://github.com/wechatpay-apiv3/wechatpay-php/issues", + "source": "https://github.com/wechatpay-apiv3/wechatpay-php/tree/v1.4.12" + }, + "time": "2025-01-26T14:16:41+00:00" } ], "packages-dev": [ @@ -8351,9 +8829,9 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2", - "ext-zip": "*", - "ext-pdo": "*" + "php": "^8.3", + "ext-pdo": "*", + "ext-zip": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/database.php b/config/database.php index 491d3414..fd37944d 100644 --- a/config/database.php +++ b/config/database.php @@ -62,6 +62,28 @@ return [ ]) : [], ], + // 业务表连接:与 mysql 指向同一个库,仅前缀不同(nl_ 系统表 / cc_ 业务表) + // 业务 Model 需覆盖 protected $connection = 'business'; + 'business' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => env('DB_BUSINESS_PREFIX', 'cc_'), + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), diff --git a/config/lgp_menu.php b/config/lgp_menu.php new file mode 100644 index 00000000..74d9f734 --- /dev/null +++ b/config/lgp_menu.php @@ -0,0 +1,123 @@ + '商品中心', + 'name' => 'Goods', + 'path' => '/goods', + 'component' => 'BasicLayout', + 'icon' => 'lucide:sofa', + 'sort' => 100, + 'children' => [ + ['title' => '商品图册', 'name' => 'GoodsCatalogue', 'path' => '/goods/catalogue', 'component' => '/goods/catalogue/index', 'icon' => 'lucide:book-image', 'sort' => 0], + ['title' => '商品分类', 'name' => 'GoodsCategory', 'path' => '/goods/category', 'component' => '/goods/category/index', 'icon' => 'lucide:folder-tree', 'sort' => 1], + ['title' => '规格报价', 'name' => 'GoodsPriceSheet', 'path' => '/goods/price-sheet', 'component' => '/goods/price-sheet/index', 'icon' => 'lucide:ruler', 'sort' => 2], + ['title' => '商品相册', 'name' => 'GoodsImage', 'path' => '/goods/image', 'component' => '/goods/image/index', 'icon' => 'lucide:images', 'sort' => 3], + ['title' => '轮播图', 'name' => 'GoodsCarousel', 'path' => '/goods/carousel', 'component' => '/goods/carousel/index', 'icon' => 'lucide:gallery-horizontal', 'sort' => 4], + ['title' => '报价单', 'name' => 'GoodsQuote', 'path' => '/goods/quote', 'component' => '/goods/quote/index', 'icon' => 'lucide:file-text', 'sort' => 5], + ], + ], + [ + 'title' => '工厂产品', + 'name' => 'Factory', + 'path' => '/factory', + 'component' => 'BasicLayout', + 'icon' => 'lucide:factory', + 'sort' => 200, + 'children' => [ + ['title' => '工厂管理', 'name' => 'FactoryInfo', 'path' => '/factory/info', 'component' => '/factory/info/index', 'icon' => 'lucide:building-2', 'sort' => 0], + ['title' => '工厂分类', 'name' => 'FactoryClass', 'path' => '/factory/class', 'component' => '/factory/class/index', 'icon' => 'lucide:tags', 'sort' => 1], + ['title' => '工厂产品图片', 'name' => 'FactoryImage', 'path' => '/factory/image', 'component' => '/factory/image/index', 'icon' => 'lucide:image-plus', 'sort' => 2], + ], + ], + [ + 'title' => '色卡管理', + 'name' => 'Color', + 'path' => '/color', + 'component' => 'BasicLayout', + 'icon' => 'lucide:palette', + 'sort' => 300, + 'children' => [ + ['title' => '公司管理', 'name' => 'ColorCompany', 'path' => '/color/company', 'component' => '/color/company/index', 'icon' => 'lucide:landmark', 'sort' => 0], + ['title' => '色卡分类', 'name' => 'ColorCardClass', 'path' => '/color/card-class', 'component' => '/color/card-class/index', 'icon' => 'lucide:layers', 'sort' => 1], + ['title' => '色卡列表', 'name' => 'ColorCard', 'path' => '/color/card', 'component' => '/color/card/index', 'icon' => 'lucide:swatch-book', 'sort' => 2], + ], + ], + [ + 'title' => '客户中心', + 'name' => 'Customer', + 'path' => '/customer', + 'component' => 'BasicLayout', + 'icon' => 'lucide:users', + 'sort' => 400, + 'children' => [ + ['title' => '微信用户', 'name' => 'CustomerWxUser', 'path' => '/customer/wx-user', 'component' => '/customer/wx-user/index', 'icon' => 'ic:baseline-wechat', 'sort' => 0], + ['title' => '经销商管理', 'name' => 'CustomerDealer', 'path' => '/customer/dealer', 'component' => '/customer/dealer/index', 'icon' => 'lucide:handshake', 'sort' => 1], + ['title' => '企业管理', 'name' => 'CustomerEnterprise', 'path' => '/customer/enterprise', 'component' => '/customer/enterprise/index', 'icon' => 'lucide:building', 'sort' => 2], + ['title' => '清单管理', 'name' => 'CustomerList', 'path' => '/customer/list', 'component' => '/customer/list/index', 'icon' => 'lucide:clipboard-list', 'sort' => 3], + ['title' => '订单管理', 'name' => 'CustomerOrder', 'path' => '/customer/order', 'component' => '/customer/order/index', 'icon' => 'lucide:receipt-text', 'sort' => 4], + ], + ], + [ + 'title' => '素材库', + 'name' => 'Material', + 'path' => '/material', + 'component' => '/material/index', + 'icon' => 'lucide:folder-open', + 'sort' => 500, + ], + [ + 'title' => '小程序', + 'name' => 'Wx', + 'path' => '/wx', + 'component' => 'BasicLayout', + 'icon' => 'lucide:smartphone', + 'sort' => 600, + 'children' => [ + ['title' => '装修模板', 'name' => 'WxTemplate', 'path' => '/wx/template', 'component' => '/wx/template/index', 'icon' => 'lucide:paintbrush', 'sort' => 0], + ['title' => '应用配置', 'name' => 'WxApp', 'path' => '/wx/app', 'component' => '/wx/app/index', 'icon' => 'lucide:settings-2', 'sort' => 1], + ], + ], + [ + 'title' => '内容创作', + 'name' => 'Media', + 'path' => '/media', + 'component' => 'BasicLayout', + 'icon' => 'lucide:newspaper', + 'sort' => 700, + 'children' => [ + ['title' => '公众号账号', 'name' => 'MediaWechatAccount', 'path' => '/media/wechat-account', 'component' => '/media/wechat-account/index', 'icon' => 'ic:baseline-wechat', 'sort' => 0], + ['title' => '图文创作', 'name' => 'MediaWechatArticle', 'path' => '/media/wechat-article', 'component' => '/media/wechat-article/index', 'icon' => 'lucide:file-pen-line', 'sort' => 1], + // 图文编辑器是 router.push 进去的详情页,不在菜单里展示,但仍要注册路由 + ['title' => '图文编辑器', 'name' => 'MediaWechatArticleEdit', 'path' => '/media/wechat-article/editor', 'component' => '/media/wechat-article/editor/index', 'hide_in_menu' => 1, 'sort' => 2], + ], + ], + [ + // 开发工具:代码生成器(单页),挂在根目录下 + 'title' => '代码生成', + 'name' => 'CodeGeneration', + 'path' => '/code-generation', + 'component' => '/code-generation/index', + 'icon' => 'lucide:wand-sparkles', + 'sort' => 800, + ], + [ + // 挂到脚手架自带的「基础管理」下,parent 用 name 引用,避免依赖自增 id + 'title' => '部门管理', + 'name' => 'SystemDepartment', + 'path' => '/system/department', + 'component' => '/system/department/index', + 'icon' => 'lucide:network', + 'parent' => 'System', + 'sort' => 4, + ], +]; diff --git a/config/media_refs.php b/config/media_refs.php new file mode 100644 index 00000000..02d5894d --- /dev/null +++ b/config/media_refs.php @@ -0,0 +1,48 @@ +table($table) 自动补前缀 + * field 列名;列还不存在时扫描会自动跳过,所以可以提前登记规划中的字段 + * kind single 整个字段就是一个地址 + * multi 逗号分隔的多个地址 + * rich 富文本 / Markdown,需要正则从 src、href、url() 与 ![](…) 里抽取 + */ +return [ + /* + * ---------------- LGP 业务表(business 连接,cc_ 前缀)---------------- + */ + ['connection' => 'business', 'table' => 'catalogue', 'field' => 'cover', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'catalogue', 'field' => 'pdf', 'kind' => 'single'], + // video 列在部分环境还没上线,扫描时按 hasColumn 跳过,不影响其余字段 + ['connection' => 'business', 'table' => 'catalogue', 'field' => 'video', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'image', 'field' => 'url', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'carousel', 'field' => 'url', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'category', 'field' => 'url', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'colorcard', 'field' => 'cover', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'factory_info', 'field' => 'cover', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'factory_image', 'field' => 'url', 'kind' => 'single'], + ['connection' => 'business', 'table' => 'enterprise', 'field' => 'logo', 'kind' => 'single'], + // 微信头像多数是腾讯 CDN 地址,匹配不上任何素材;登记它是为了兜住 + // 「后台替用户换成自己上传的头像」这种情况 + ['connection' => 'business', 'table' => 'wx_user', 'field' => 'avatar', 'kind' => 'single'], + + /* + * ---------------- 系统表(mysql 连接,nl_ 前缀)---------------- + */ + ['connection' => 'mysql', 'table' => 'admin', 'field' => 'avatar', 'kind' => 'single'], + ['connection' => 'mysql', 'table' => 'wechat_article', 'field' => 'cover_url', 'kind' => 'single'], + // 图文正文是 Markdown,图片以 ![](url) 的形式散在全文里 + ['connection' => 'mysql', 'table' => 'wechat_article', 'field' => 'content_md', 'kind' => 'rich'], + // 版本流水存的是历史正文快照:回退到旧版本要能把图找回来,所以一并算引用 + ['connection' => 'mysql', 'table' => 'wechat_article_version', 'field' => 'content_md', 'kind' => 'rich'], +]; diff --git a/config/nl.php b/config/nl.php index 982769b3..bd36812b 100755 --- a/config/nl.php +++ b/config/nl.php @@ -23,6 +23,29 @@ return [ */ 'jwt' => [ 'secret' => env('JWT_SECRET', 'nl_admin_jwt_secret_key_change_me_32b'), + // token 过期后仍允许续签的宽限期,超过就必须重新登录 + 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 7 * 24 * 3600), + ], + /* + * 小程序端 + */ + 'wx' => [ + // 小程序不像浏览器那样会主动续签,token 有效期给长一点,作废靠后台停用账号 + 'token_ttl' => (int) env('WX_TOKEN_TTL', 30 * 24 * 3600), + // 单品牌部署兜底:请求头 X-App-Code 为空时用这个 code 查 nl_wx_app + // 双品牌共用进程时仍以表 + 请求头分流为主,这里只是「没带头的默认值」 + 'default_app_code' => env('WX_DEFAULT_APP_CODE', ''), + // 库表无数据时回落用的 AppID(仅公开标识,AppSecret 仍必须从库里加密读) + 'env_app_id' => env('WX_APP_ID', ''), + // 与 env_app_id 配套的品牌展示名(仅用于日志/默认记录),无业务影响 + 'env_app_name' => env('WX_APP_NAME', ''), + ], + /* + * 自助注册:后台管理端默认关闭,开着等于谁都能给自己开管理员账号 + */ + 'register' => [ + 'enabled' => (bool) env('REGISTER_ENABLED', false), + 'default_role_id' => (int) env('REGISTER_DEFAULT_ROLE_ID', 2), ], /* * 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_,读取兼容历史 ***) @@ -44,6 +67,8 @@ return [ 'phone' => 'nl_phone_', 'login_out_key' => 'nl_login_out_', 'menu_key' => 'nl_menu_', + // 角色权限码缓存前缀(key = 前缀 + roleId) + 'permission_key' => 'nl_perm_', // 公众号 stable access_token 缓存前缀(key = 前缀 + appid,TTL 随微信 expires_in) 'wechat_token' => 'nl_wechat_at_', ], @@ -51,10 +76,39 @@ return [ * api白名单 */ 'api' => [ + /* + * 免鉴权路径(不含 /api/ 前缀,中间件规范化后比较) + * 这里是「连 token 都不要求」的接口,注册在 routes/api.php 的免登录组里 + */ 'white_list' => [ - '/api/admin/login', - '/api/admin/register', - ] + 'login', + 'register', + 'logout', + 'refresh', + 'sql/test-connection', + 'sql/start-installation', + ], + /* + * 接口级权限 + * strict = true 未登记进 nl_api_endpoint 的接口一律拒绝(安全优先,上线前打开) + * strict = false 未登记的接口放行,只拦已登记但未授权的(迁移期默认) + */ + 'permission' => [ + 'strict' => (bool) env('API_PERMISSION_STRICT', false), + // 登录后人人可调、不参与角色授权的接口 + 'always_allow' => [ + 'admin/codes', + 'admin/menu', + 'admin/my-info', + 'admin/update-profile', + 'admin/change-password', + 'admin/login-log', + 'admin/op-log', + 'upload/image', + 'upload/video', + 'upload/file', + ], + ], ], /* * 阿里云oss配置 diff --git a/config/wx_templates.php b/config/wx_templates.php new file mode 100644 index 00000000..1d26ca50 --- /dev/null +++ b/config/wx_templates.php @@ -0,0 +1,234 @@ + 'lux-champagne', + 'name' => '轻奢·香槟金', + 'style_tag' => '轻奢', + 'palette' => ['primary' => '#B08D57', 'accent' => '#8C6A3F', 'bg' => '#FAF7F2', 'surface' => '#FFFFFF', 'text' => '#1C1917', 'border' => '#E7DFD3'], + 'scale' => 'serif-elegant', + 'radius' => 'soft', + 'shadow' => 'airy', + 'motion' => 'silk', + 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'scroll', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'lux-ink', + 'name' => '轻奢·墨玉', + 'style_tag' => '轻奢', + 'palette' => ['primary' => '#C9A063', 'accent' => '#E8D5AE', 'bg' => '#14110F', 'surface' => '#1F1B18', 'text' => '#F5F0E8', 'border' => '#332C25'], + 'scale' => 'serif-elegant', + 'radius' => 'soft', + 'shadow' => 'deep', + 'motion' => 'silk', + 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'sidebar', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'lux-pearl', + 'name' => '轻奢·珠贝白', + 'style_tag' => '轻奢', + 'palette' => ['primary' => '#9C8E7E', 'accent' => '#CBBFA8', 'bg' => '#FFFFFF', 'surface' => '#F7F5F1', 'text' => '#2B2724', 'border' => '#EAE5DC'], + 'scale' => 'sans-refined', + 'radius' => 'sharp', + 'shadow' => 'airy', + 'motion' => 'silk', + 'layout' => ['home' => ['hero' => 'split', 'category' => 'grid', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'lux-walnut', + 'name' => '轻奢·胡桃木', + 'style_tag' => '轻奢', + 'palette' => ['primary' => '#6F4E37', 'accent' => '#A9784F', 'bg' => '#F6F1EA', 'surface' => '#FFFDFA', 'text' => '#241B14', 'border' => '#E2D6C7'], + 'scale' => 'serif-elegant', + 'radius' => 'round', + 'shadow' => 'soft', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'minimal-linen', + 'name' => '极简·亚麻', + 'style_tag' => '极简', + 'palette' => ['primary' => '#3F3F46', 'accent' => '#A1A1AA', 'bg' => '#FAFAF9', 'surface' => '#FFFFFF', 'text' => '#18181B', 'border' => '#E4E4E7'], + 'scale' => 'sans-compact', + 'radius' => 'sharp', + 'shadow' => 'flat', + 'motion' => 'snappy', + 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'none', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'minimal-mono', + 'name' => '极简·黑白', + 'style_tag' => '极简', + 'palette' => ['primary' => '#000000', 'accent' => '#525252', 'bg' => '#FFFFFF', 'surface' => '#F5F5F5', 'text' => '#0A0A0A', 'border' => '#D4D4D4'], + 'scale' => 'sans-wide', + 'radius' => 'none', + 'shadow' => 'flat', + 'motion' => 'snappy', + 'layout' => ['home' => ['hero' => 'split', 'category' => 'scroll', 'product' => 'list'], 'product' => ['gallery' => 'stack', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'none']], + ], + [ + 'code' => 'modern-indigo', + 'name' => '现代·靛蓝', + 'style_tag' => '现代', + 'palette' => ['primary' => '#4F46E5', 'accent' => '#818CF8', 'bg' => '#F8FAFC', 'surface' => '#FFFFFF', 'text' => '#0F172A', 'border' => '#E2E8F0'], + 'scale' => 'sans-refined', + 'radius' => 'round', + 'shadow' => 'soft', + 'motion' => 'bouncy', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'card', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'modern-teal', + 'name' => '现代·青瓷', + 'style_tag' => '现代', + 'palette' => ['primary' => '#0D9488', 'accent' => '#5EEAD4', 'bg' => '#F0FDFA', 'surface' => '#FFFFFF', 'text' => '#134E4A', 'border' => '#CCFBF1'], + 'scale' => 'sans-refined', + 'radius' => 'round', + 'shadow' => 'soft', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'warm-terracotta', + 'name' => '暖调·陶土', + 'style_tag' => '暖调', + 'palette' => ['primary' => '#C05621', 'accent' => '#F6AD55', 'bg' => '#FFFAF0', 'surface' => '#FFFFFF', 'text' => '#2D2016', 'border' => '#FBD9B5'], + 'scale' => 'sans-compact', + 'radius' => 'round', + 'shadow' => 'soft', + 'motion' => 'bouncy', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'warm-sand', + 'name' => '暖调·沙丘', + 'style_tag' => '暖调', + 'palette' => ['primary' => '#A16207', 'accent' => '#FDE68A', 'bg' => '#FEFCE8', 'surface' => '#FFFFFF', 'text' => '#292524', 'border' => '#F3E8C8'], + 'scale' => 'serif-book', + 'radius' => 'soft', + 'shadow' => 'airy', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'stack', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'cool-slate', + 'name' => '冷调·石板', + 'style_tag' => '冷调', + 'palette' => ['primary' => '#334155', 'accent' => '#94A3B8', 'bg' => '#F1F5F9', 'surface' => '#FFFFFF', 'text' => '#0F172A', 'border' => '#CBD5E1'], + 'scale' => 'sans-wide', + 'radius' => 'sharp', + 'shadow' => 'flat', + 'motion' => 'snappy', + 'layout' => ['home' => ['hero' => 'banner', 'category' => 'sidebar', 'product' => 'list'], 'product' => ['gallery' => 'swiper', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'cool-mist', + 'name' => '冷调·雾蓝', + 'style_tag' => '冷调', + 'palette' => ['primary' => '#0369A1', 'accent' => '#7DD3FC', 'bg' => '#F0F9FF', 'surface' => '#FFFFFF', 'text' => '#0C4A6E', 'border' => '#BAE6FD'], + 'scale' => 'sans-refined', + 'radius' => 'round', + 'shadow' => 'airy', + 'motion' => 'silk', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'nature-olive', + 'name' => '自然·橄榄', + 'style_tag' => '自然', + 'palette' => ['primary' => '#4D7C0F', 'accent' => '#BEF264', 'bg' => '#F7FEE7', 'surface' => '#FFFFFF', 'text' => '#1A2E05', 'border' => '#D9F99D'], + 'scale' => 'sans-compact', + 'radius' => 'soft', + 'shadow' => 'soft', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'banner', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'grid'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'nature-clay', + 'name' => '自然·素坯', + 'style_tag' => '自然', + 'palette' => ['primary' => '#78716C', 'accent' => '#D6D3D1', 'bg' => '#FAFAF9', 'surface' => '#F5F5F4', 'text' => '#1C1917', 'border' => '#E7E5E4'], + 'scale' => 'serif-book', + 'radius' => 'soft', + 'shadow' => 'flat', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'fullbleed', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'bold-crimson', + 'name' => '张扬·绛红', + 'style_tag' => '浓烈', + 'palette' => ['primary' => '#9F1239', 'accent' => '#FB7185', 'bg' => '#FFF1F2', 'surface' => '#FFFFFF', 'text' => '#4C0519', 'border' => '#FECDD3'], + 'scale' => 'sans-wide', + 'radius' => 'round', + 'shadow' => 'deep', + 'motion' => 'bouncy', + 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'card', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'bold-violet', + 'name' => '张扬·紫罗兰', + 'style_tag' => '浓烈', + 'palette' => ['primary' => '#7E22CE', 'accent' => '#D8B4FE', 'bg' => '#FAF5FF', 'surface' => '#FFFFFF', 'text' => '#3B0764', 'border' => '#E9D5FF'], + 'scale' => 'sans-refined', + 'radius' => 'pill', + 'shadow' => 'deep', + 'motion' => 'bouncy', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'waterfall'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'zoom', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'dark-graphite', + 'name' => '暗色·石墨', + 'style_tag' => '暗色', + 'palette' => ['primary' => '#E5E5E5', 'accent' => '#A3A3A3', 'bg' => '#0A0A0A', 'surface' => '#171717', 'text' => '#FAFAFA', 'border' => '#262626'], + 'scale' => 'sans-compact', + 'radius' => 'sharp', + 'shadow' => 'deep', + 'motion' => 'snappy', + 'layout' => ['home' => ['hero' => 'banner', 'category' => 'grid', 'product' => 'grid'], 'product' => ['gallery' => 'stack', 'price' => 'sticky', 'action' => 'fixed'], 'list' => ['style' => 'table'], 'mine' => ['header' => 'plain', 'menu' => 'list'], 'effect' => ['transition' => 'slide', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'dark-emerald', + 'name' => '暗色·祖母绿', + 'style_tag' => '暗色', + 'palette' => ['primary' => '#34D399', 'accent' => '#065F46', 'bg' => '#04120D', 'surface' => '#0B1F18', 'text' => '#ECFDF5', 'border' => '#14392C'], + 'scale' => 'sans-refined', + 'radius' => 'round', + 'shadow' => 'deep', + 'motion' => 'silk', + 'layout' => ['home' => ['hero' => 'fullscreen', 'category' => 'sidebar', 'product' => 'waterfall'], 'product' => ['gallery' => 'fullbleed', 'price' => 'card', 'action' => 'fixed'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'shimmer']], + ], + [ + 'code' => 'retro-cream', + 'name' => '复古·奶油', + 'style_tag' => '复古', + 'palette' => ['primary' => '#92400E', 'accent' => '#FBBF24', 'bg' => '#FFFBEB', 'surface' => '#FEF3C7', 'text' => '#451A03', 'border' => '#FDE68A'], + 'scale' => 'serif-book', + 'radius' => 'soft', + 'shadow' => 'soft', + 'motion' => 'gentle', + 'layout' => ['home' => ['hero' => 'split', 'category' => 'card', 'product' => 'magazine'], 'product' => ['gallery' => 'swiper', 'price' => 'inline', 'action' => 'inline'], 'list' => ['style' => 'timeline'], 'mine' => ['header' => 'image', 'menu' => 'list'], 'effect' => ['transition' => 'fade', 'skeleton' => 'pulse']], + ], + [ + 'code' => 'retro-ocean', + 'name' => '复古·海蓝', + 'style_tag' => '复古', + 'palette' => ['primary' => '#155E75', 'accent' => '#67E8F9', 'bg' => '#ECFEFF', 'surface' => '#FFFFFF', 'text' => '#083344', 'border' => '#A5F3FC'], + 'scale' => 'sans-wide', + 'radius' => 'pill', + 'shadow' => 'airy', + 'motion' => 'bouncy', + 'layout' => ['home' => ['hero' => 'carousel', 'category' => 'scroll', 'product' => 'list'], 'product' => ['gallery' => 'swiper', 'price' => 'card', 'action' => 'inline'], 'list' => ['style' => 'card'], 'mine' => ['header' => 'gradient', 'menu' => 'grid'], 'effect' => ['transition' => 'slide', 'skeleton' => 'shimmer']], + ], +]; diff --git a/database/migrations/2026_08_13_000001_create_role_endpoint_relation_table.php b/database/migrations/2026_08_13_000001_create_role_endpoint_relation_table.php new file mode 100644 index 00000000..b1d8bb02 --- /dev/null +++ b/database/migrations/2026_08_13_000001_create_role_endpoint_relation_table.php @@ -0,0 +1,34 @@ +increments('id')->comment('主键'); + $table->unsignedInteger('role_id')->default(0)->comment('角色ID'); + $table->unsignedInteger('endpoint_id')->default(0)->comment('nl_api_endpoint 主键'); + $table->integer('created_at')->default(0)->comment('创建时间'); + $table->unique(['role_id', 'endpoint_id'], 'uk_role_endpoint'); + $table->index('endpoint_id', 'idx_endpoint'); + }); + } + + public function down(): void + { + Schema::dropIfExists('role_endpoint_relation'); + } +}; diff --git a/database/migrations/2026_08_13_000002_extend_business_enterprise_table.php b/database/migrations/2026_08_13_000002_extend_business_enterprise_table.php new file mode 100644 index 00000000..f7732a26 --- /dev/null +++ b/database/migrations/2026_08_13_000002_extend_business_enterprise_table.php @@ -0,0 +1,68 @@ +hasTable('enterprise')) { + return; + } + $schema->table('enterprise', function (Blueprint $table) use ($schema) { + if (!$schema->hasColumn('enterprise', 'contact_name')) { + $table->string('contact_name', 50)->default('')->comment('联系人'); + } + if (!$schema->hasColumn('enterprise', 'phone')) { + $table->string('phone', 30)->default('')->comment('联系电话'); + } + if (!$schema->hasColumn('enterprise', 'address')) { + $table->string('address', 255)->default('')->comment('地址'); + } + if (!$schema->hasColumn('enterprise', 'tax_no')) { + $table->string('tax_no', 50)->default('')->comment('税号'); + } + if (!$schema->hasColumn('enterprise', 'settle_type')) { + $table->tinyInteger('settle_type')->default(0)->comment('结算方式 0款到发货 1月结 2账期'); + } + if (!$schema->hasColumn('enterprise', 'price_number')) { + // 与 cc_wx_user.price_number 保持同类型,避免倍率计算时类型不一致 + $table->string('price_number', 20)->default('1')->comment('默认价格倍率'); + } + if (!$schema->hasColumn('enterprise', 'status')) { + $table->tinyInteger('status')->default(0)->comment('状态 0正常 1禁用'); + } + if (!$schema->hasColumn('enterprise', 'remark')) { + $table->string('remark', 255)->default('')->comment('备注'); + } + }); + } + + public function down(): void + { + $schema = Schema::connection('business'); + if (!$schema->hasTable('enterprise')) { + return; + } + $schema->table('enterprise', function (Blueprint $table) { + $table->dropColumn([ + 'contact_name', + 'phone', + 'address', + 'tax_no', + 'settle_type', + 'price_number', + 'status', + 'remark', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_13_000003_extend_file_table_for_material.php b/database/migrations/2026_08_13_000003_extend_file_table_for_material.php new file mode 100644 index 00000000..5d3e01fa --- /dev/null +++ b/database/migrations/2026_08_13_000003_extend_file_table_for_material.php @@ -0,0 +1,137 @@ +hasTable('file')) { + $schema->table('file', function (Blueprint $table) use ($schema) { + if (!$schema->hasColumn('file', 'oss_config_id')) { + $table->integer('oss_config_id')->default(0)->comment('所属存储配置 nl_oss_config.id,0 表示未知'); + } + if (!$schema->hasColumn('file', 'folder_id')) { + $table->integer('folder_id')->default(0)->comment('素材文件夹 nl_file_folder.id,0 根目录'); + } + if (!$schema->hasColumn('file', 'name')) { + $table->string('name', 255)->default('')->comment('素材名称'); + } + if (!$schema->hasColumn('file', 'path')) { + $table->string('path', 500)->default('')->comment('对象键,已去掉处理参数'); + } + if (!$schema->hasColumn('file', 'ext')) { + $table->string('ext', 20)->default('')->comment('扩展名,小写不带点'); + } + if (!$schema->hasColumn('file', 'size')) { + $table->bigInteger('size')->default(0)->comment('字节数'); + } + if (!$schema->hasColumn('file', 'width')) { + $table->integer('width')->default(0)->comment('图片宽,非图片为 0'); + } + if (!$schema->hasColumn('file', 'height')) { + $table->integer('height')->default(0)->comment('图片高,非图片为 0'); + } + if (!$schema->hasColumn('file', 'hash')) { + $table->string('hash', 64)->default('')->comment('内容哈希 / ETag'); + } + if (!$schema->hasColumn('file', 'ref_count')) { + $table->integer('ref_count')->default(0)->comment('引用次数,由引用扫描回填'); + } + if (!$schema->hasColumn('file', 'last_scan_at')) { + $table->integer('last_scan_at')->default(0)->comment('最后一次引用扫描时间,0 表示从未扫描过'); + } + if (!$schema->hasColumn('file', 'source')) { + $table->tinyInteger('source')->default(0)->comment('来源 0:上传 1:OSS拉取'); + } + }); + + // 索引单独一批 ALTER:与新增列写在同一批时,部分 MySQL 版本会报 + // Key column 'path' doesn't exist in table + $schema->table('file', function (Blueprint $table) use ($schema) { + foreach (self::INDEX_COLUMNS as $column) { + if ($schema->hasColumn('file', $column) && !$schema->hasIndex('file', [$column])) { + $table->index($column); + } + } + }); + + // url 原来是 varchar(255):对象键放到 500 之后,域名 + 键很容易超过 255, + // 严格模式下同步会直接写失败,所以跟着放宽。放在最后一批执行, + // 万一某些托管环境不允许改列,前面的扩列已经落库,素材库仍可用。 + $schema->table('file', function (Blueprint $table) { + $table->string('url', 500)->default('')->comment('文件地址')->change(); + }); + } + + if (!$schema->hasTable('file_folder')) { + $schema->create('file_folder', function (Blueprint $table) { + $table->increments('id')->comment('主键'); + $table->integer('pid')->default(0)->comment('上级文件夹,0 根目录'); + $table->string('name', 100)->default('')->comment('文件夹名称'); + $table->integer('sort')->default(0)->comment('排序,越小越靠前'); + $table->tinyInteger('status')->default(0)->comment('状态 0:正常 1:禁用'); + $table->integer('created_at')->default(0)->comment('创建时间'); + $table->integer('updated_at')->default(0)->comment('更新时间'); + $table->integer('deleted_at')->default(0)->comment('删除时间'); + $table->index('pid'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection('mysql'); + + if ($schema->hasTable('file')) { + $schema->table('file', function (Blueprint $table) use ($schema) { + foreach (self::INDEX_COLUMNS as $column) { + if ($schema->hasIndex('file', [$column])) { + $table->dropIndex([$column]); + } + } + }); + + $columns = array_values(array_filter( + self::ADDED_COLUMNS, + static fn ($column) => Schema::connection('mysql')->hasColumn('file', $column) + )); + if (!empty($columns)) { + $schema->table('file', function (Blueprint $table) use ($columns) { + $table->dropColumn($columns); + }); + } + // url 不缩回 255:现存数据里已经可能有更长的地址,缩列会被静默截断 + } + + $schema->dropIfExists('file_folder'); + } +}; diff --git a/database/migrations/2026_08_13_000004_create_business_order_tables.php b/database/migrations/2026_08_13_000004_create_business_order_tables.php new file mode 100644 index 00000000..0337e0f2 --- /dev/null +++ b/database/migrations/2026_08_13_000004_create_business_order_tables.php @@ -0,0 +1,233 @@ +connection); + + // 清单编号:QD_ + 日期 + 随机串,唯一索引 + 冲突重试保证不重号 + if ($schema->hasTable('list')) { + $schema->table('list', function (Blueprint $table) use ($schema) { + if (!$schema->hasColumn('list', 'list_no')) { + $table->string('list_no', 32)->default('')->comment('清单编号 QD_YYYYMMDDxxxxxx'); + } + if (!$schema->hasColumn('list', 'enterprise_id')) { + $table->integer('enterprise_id')->default(0)->comment('所属企业'); + } + if (!$schema->hasColumn('list', 'remark')) { + $table->string('remark', 255)->default('')->comment('备注'); + } + if (!$schema->hasColumn('list', 'status')) { + $table->tinyInteger('status')->default(0)->comment('0=正常 1=已下单'); + } + }); + // 存量清单先补号再加唯一索引,否则一堆空串直接撞唯一约束 + $this->backfillListNo(); + $this->addIndex('list', 'list_no', true); + $this->addIndex('list', 'user_id'); + } + + if ($schema->hasTable('list_item')) { + $schema->table('list_item', function (Blueprint $table) use ($schema) { + foreach ([ + 'price_sheet_id' => fn () => $table->integer('price_sheet_id')->default(0)->comment('规格行'), + 'material_key' => fn () => $table->string('material_key', 50)->default('routine')->comment('材质键,老库只有 routine 在用'), + 'quantity' => fn () => $table->integer('quantity')->default(1)->comment('数量'), + 'unit_price' => fn () => $table->integer('unit_price')->default(0)->comment('单价快照(分)'), + 'remark' => fn () => $table->string('remark', 255)->default('')->comment('备注'), + ] as $column => $add) { + if (!$schema->hasColumn('list_item', $column)) { + $add(); + } + } + }); + $this->addIndex('list_item', 'list_id'); + $this->addIndex('list_item', 'catalogue_id'); + } + + if (!$schema->hasTable('order')) { + $schema->create('order', function (Blueprint $table) { + $table->increments('id'); + $table->string('order_no', 32)->default('')->comment('订单号 DD_YYYYMMDDxxxxxx'); + $table->integer('list_id')->default(0)->comment('来源清单'); + $table->integer('user_id')->default(0)->comment('下单用户 cc_wx_user.id'); + $table->integer('enterprise_id')->default(0)->comment('所属企业'); + $table->integer('total_amount')->default(0)->comment('订单总额(分)'); + $table->integer('paid_amount')->default(0)->comment('已收款(分)'); + $table->tinyInteger('pay_type')->default(0)->comment('0=未选 1=转账凭证 2=微信支付'); + $table->tinyInteger('pay_status')->default(0)->comment('0=待付款 1=待审核 2=已付款 3=已驳回'); + $table->integer('paid_at')->default(0); + $table->tinyInteger('delivery_type')->default(0)->comment('0=未选 1=物流 2=自提 3=公司配送'); + // 收件信息必须快照:用户改了资料不能影响历史订单 + $table->string('receiver_name', 50)->default(''); + $table->string('receiver_phone', 20)->default(''); + $table->string('receiver_address', 255)->default(''); + $table->tinyInteger('status')->default(0)->comment('0=待付款 1=待发货 2=已发货 3=已完成 4=已取消'); + $table->string('remark', 255)->default(''); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->unique('order_no', 'uk_order_no'); + $table->index('user_id', 'idx_order_user'); + $table->index('list_id', 'idx_order_list'); + $table->index('status', 'idx_order_status'); + }); + } + + if (!$schema->hasTable('order_item')) { + $schema->create('order_item', function (Blueprint $table) { + $table->increments('id'); + $table->integer('order_id')->default(0); + $table->integer('catalogue_id')->default(0)->comment('仅作溯源,展示一律用下面的快照'); + $table->integer('price_sheet_id')->default(0); + $table->string('title', 255)->default('')->comment('商品名快照'); + $table->string('cover', 500)->default('')->comment('封面快照'); + $table->string('alias', 100)->default('')->comment('编号快照'); + $table->string('specification', 255)->default('')->comment('规格快照'); + $table->string('dimension', 255)->default('')->comment('尺寸快照'); + $table->string('material_key', 50)->default('routine'); + $table->integer('quantity')->default(1); + $table->integer('unit_price')->default(0)->comment('单价(分)'); + $table->integer('total_price')->default(0)->comment('小计(分)'); + $table->string('remark', 255)->default(''); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->index('order_id', 'idx_order_item_order'); + }); + } + + if (!$schema->hasTable('order_payment')) { + $schema->create('order_payment', function (Blueprint $table) { + $table->increments('id'); + $table->integer('order_id')->default(0); + $table->tinyInteger('pay_type')->default(1)->comment('1=转账凭证 2=微信支付'); + $table->integer('amount')->default(0)->comment('金额(分)'); + $table->string('voucher', 500)->default('')->comment('转账凭证图,多张逗号分隔'); + $table->string('transaction_id', 64)->default('')->comment('微信支付单号'); + $table->string('out_trade_no', 64)->default('')->comment('商户订单号,回调幂等靠它'); + $table->tinyInteger('status')->default(0)->comment('0=待审核 1=已确认 2=已驳回'); + $table->integer('auditor_id')->default(0)->comment('审核人 nl_admin.id'); + $table->integer('audited_at')->default(0); + $table->string('audit_remark', 255)->default(''); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->index('order_id', 'idx_order_payment_order'); + // 微信会重复推送回调,靠这个唯一索引兜住幂等 + $table->unique('out_trade_no', 'uk_out_trade_no'); + }); + } + + if (!$schema->hasTable('order_delivery')) { + $schema->create('order_delivery', function (Blueprint $table) { + $table->increments('id'); + $table->integer('order_id')->default(0); + $table->tinyInteger('delivery_type')->default(1)->comment('1=物流 2=自提 3=公司配送'); + $table->string('company', 100)->default('')->comment('物流公司'); + $table->string('tracking_no', 64)->default('')->comment('运单号'); + $table->string('pickup_point', 255)->default('')->comment('自提点'); + $table->string('driver_info', 255)->default('')->comment('配送司机与车牌'); + $table->integer('shipped_at')->default(0); + $table->string('remark', 255)->default(''); + $table->integer('operator_id')->default(0)->comment('操作人 nl_admin.id'); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->index('order_id', 'idx_order_delivery_order'); + }); + } + + // 小程序装修模板:tokens 是设计令牌(配色/字号/圆角/动效),layout 是页面骨架 + if (!$schema->hasTable('wx_template')) { + $schema->create('wx_template', function (Blueprint $table) { + $table->increments('id'); + $table->string('name', 50)->default(''); + $table->string('code', 50)->default('')->comment('模板标识,小程序按它取样式'); + $table->string('preview', 500)->default('')->comment('预览图'); + $table->string('style_tag', 50)->default('')->comment('风格标签,如轻奢/极简'); + $table->text('tokens')->nullable()->comment('设计令牌 JSON'); + $table->text('layout')->nullable()->comment('页面骨架 JSON'); + $table->tinyInteger('is_default')->default(0); + $table->tinyInteger('status')->default(0)->comment('0=启用 1=停用'); + $table->string('app_code', 20)->default('')->comment('绑定品牌,空=通用'); + $table->integer('version')->default(1); + $table->integer('sort')->default(0); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->unique(['code', 'app_code'], 'uk_wx_template_code'); + }); + } + } + + public function down(): void + { + $schema = Schema::connection($this->connection); + foreach (['wx_template', 'order_delivery', 'order_payment', 'order_item', 'order'] as $table) { + $schema->dropIfExists($table); + } + if ($schema->hasTable('list')) { + $schema->table('list', function (Blueprint $table) { + $table->dropColumn(['list_no', 'enterprise_id', 'status']); + }); + } + if ($schema->hasTable('list_item')) { + $schema->table('list_item', function (Blueprint $table) { + $table->dropColumn(['price_sheet_id', 'material_key', 'quantity', 'unit_price']); + }); + } + } + + /** + * 给存量清单补编号:用 id 派生而不是随机串,重跑迁移结果一致,也不会自己撞自己 + */ + private function backfillListNo(): void + { + DB::connection($this->connection) + ->table('list') + ->where('list_no', '') + ->orderBy('id') + ->chunkById(500, function ($rows) { + foreach ($rows as $row) { + $day = ((int) ($row->created_at ?? 0)) > 0 ? date('Ymd', (int) $row->created_at) : date('Ymd'); + DB::connection($this->connection) + ->table('list') + ->where('id', $row->id) + ->update(['list_no' => 'QD_' . $day . 'L' . str_pad((string) $row->id, 6, '0', STR_PAD_LEFT)]); + } + }); + } + + /** + * 加索引;已存在时静默跳过,避免重复执行迁移直接报错 + */ + private function addIndex(string $table, string $column, bool $unique = false): void + { + $schema = Schema::connection($this->connection); + $indexName = ($unique ? 'uk_' : 'idx_') . $table . '_' . $column; + try { + $schema->table($table, function (Blueprint $blueprint) use ($column, $unique, $indexName) { + $unique ? $blueprint->unique($column, $indexName) : $blueprint->index($column, $indexName); + }); + } catch (\Throwable) { + // 索引已存在 + } + } +}; diff --git a/database/migrations/2026_08_13_000005_create_wx_app_table.php b/database/migrations/2026_08_13_000005_create_wx_app_table.php new file mode 100644 index 00000000..48c43ca3 --- /dev/null +++ b/database/migrations/2026_08_13_000005_create_wx_app_table.php @@ -0,0 +1,48 @@ +increments('id'); + $table->string('code', 20)->default('')->comment('品牌标识 brm/wl'); + $table->string('name', 50)->default(''); + $table->string('app_id', 50)->default(''); + $table->string('app_secret', 255)->default('')->comment('密文存储'); + $table->string('mch_id', 50)->default('')->comment('微信支付商户号'); + $table->string('mch_key', 255)->default('')->comment('APIv3 密钥,密文存储'); + $table->string('mch_serial_no', 100)->default('')->comment('商户证书序列号'); + $table->text('mch_private_key')->nullable()->comment('商户私钥,密文存储'); + // 回调验签要用微信支付平台证书公钥;没有它就没法验签,回调只能一律判失败 + $table->text('platform_public_key')->nullable()->comment('微信支付平台证书公钥'); + $table->string('notify_url', 255)->default('')->comment('支付回调地址'); + $table->string('template_code', 50)->default('')->comment('默认装修模板 code'); + $table->tinyInteger('status')->default(0)->comment('0=启用 1=停用'); + $table->string('remark', 255)->default(''); + $table->integer('created_at')->default(0); + $table->integer('updated_at')->default(0); + $table->integer('deleted_at')->default(0); + $table->unique('code', 'uk_wx_app_code'); + }); + } + + public function down(): void + { + Schema::dropIfExists('wx_app'); + } +}; diff --git a/database/seeders/WxAppSeeder.php b/database/seeders/WxAppSeeder.php new file mode 100644 index 00000000..d457c39a --- /dev/null +++ b/database/seeders/WxAppSeeder.php @@ -0,0 +1,54 @@ +command->warn('WX_DEFAULT_APP_CODE / WX_APP_ID 未配置,跳过 WxAppSeeder。'); + return; + } + + // 用 firstOrCreate 防重复:已存在相同 code 的记录就只更新 AppID/名称,不动密钥 + // 不直接写 app_secret —— 密钥必须经后台加密入库 + $row = WxAppModel::where('code', $code)->where('deleted_at', 0)->first(); + if (!empty($row)) { + $row->update([ + 'app_id' => $appId, + 'name' => $name !== '' ? $name : $row->name, + 'updated_at' => time(), + ]); + $this->command->info("已更新品牌 [{$code}] 的 AppID 为 {$appId},AppSecret 未改动。"); + return; + } + + WxAppModel::create([ + 'code' => $code, + 'name' => $name, + 'app_id' => $appId, + 'status' => 0, + 'created_at' => time(), + 'updated_at' => time(), + ]); + $this->command->info("已新增品牌 [{$code}] 应用记录,请进后台「小程序应用配置」填写 AppSecret。"); + } +} diff --git a/database/sql/rbac_audit.sql b/database/sql/rbac_audit.sql new file mode 100644 index 00000000..2568eae6 --- /dev/null +++ b/database/sql/rbac_audit.sql @@ -0,0 +1,112 @@ +-- ============================================================ +-- 账号与角色迁移前数据体检(只读,可直接粘进 Navicat / phpMyAdmin 执行) +-- +-- 与 `php artisan lgp:rbac-audit` 等价,供未安装依赖时使用。 +-- 库:cc_new_stash(老业务表 cc_ 与新系统表 nl_ 同库) +-- ============================================================ + +-- ------------------------------------------------------------ +-- [1] 迁移规模 +-- ------------------------------------------------------------ +SELECT 'cc_user 待迁账号' AS item, COUNT(*) AS cnt FROM cc_user WHERE deleted_at = 0 +UNION ALL SELECT 'cc_role 待迁角色', COUNT(*) FROM cc_role WHERE deleted_at = 0 +UNION ALL SELECT 'cc_department 部门(复用不迁)', COUNT(*) FROM cc_department WHERE deleted_at = 0 +UNION ALL SELECT 'cc_user_role_relation 用户角色关系', COUNT(*) FROM cc_user_role_relation +UNION ALL SELECT 'cc_role_menu_relation 角色菜单关系', COUNT(*) FROM cc_role_menu_relation +UNION ALL SELECT 'nl_admin 现有账号', COUNT(*) FROM nl_admin WHERE deleted_at = 0 +UNION ALL SELECT 'nl_role 现有角色', COUNT(*) FROM nl_role WHERE deleted_at = 0; + +-- ------------------------------------------------------------ +-- [2] 绑定了多个角色的用户 +-- 新库 nl_admin.role_id 是单字段,多绑的必须人工决定取哪个 +-- ------------------------------------------------------------ +SELECT ur.user_id, u.account, u.nick_name, + COUNT(*) AS role_count, + GROUP_CONCAT(ur.role_id ORDER BY ur.role_id) AS role_ids +FROM cc_user_role_relation ur + JOIN cc_user u ON u.id = ur.user_id +WHERE u.deleted_at = 0 +GROUP BY ur.user_id, u.account, u.nick_name +HAVING role_count > 1; + +-- [2b] 完全没有角色关系的账号(迁过去 role_id=0,看不到任何菜单) +SELECT u.id, u.account, u.nick_name +FROM cc_user u +WHERE u.deleted_at = 0 + AND NOT EXISTS (SELECT 1 FROM cc_user_role_relation ur WHERE ur.user_id = u.id); + +-- ------------------------------------------------------------ +-- [3] 重复手机号 +-- 新库登录是 WHERE phone = ? LIMIT 1,重复会登录到错误的人 +-- ------------------------------------------------------------ +SELECT phone, COUNT(*) AS c, + GROUP_CONCAT(id ORDER BY id) AS user_ids, + GROUP_CONCAT(account ORDER BY id) AS accounts +FROM cc_user +WHERE deleted_at = 0 +GROUP BY phone +HAVING c > 1; + +-- ------------------------------------------------------------ +-- [4] 手机号长度异常 +-- 新库 nl_admin.phone 是 char(11),超长会被截断,空值无法登录 +-- ------------------------------------------------------------ +SELECT id, account, nick_name, phone, CHAR_LENGTH(phone) AS len +FROM cc_user +WHERE deleted_at = 0 + AND (phone = '' OR CHAR_LENGTH(phone) <> 11); + +-- ------------------------------------------------------------ +-- [5] 老角色清单(含账号数与菜单数) +-- 新库 nl_role.value 必填,需为每个角色指定英文标识 +-- ------------------------------------------------------------ +SELECT r.id, r.name, r.`desc`, r.status AS `status_0正常_1禁用`, r.color, + (SELECT COUNT(*) FROM cc_user_role_relation ur + JOIN cc_user u ON u.id = ur.user_id + WHERE ur.role_id = r.id AND u.deleted_at = 0) AS user_count, + (SELECT COUNT(*) FROM cc_role_menu_relation rm + WHERE rm.role_id = r.id) AS menu_count +FROM cc_role r +WHERE r.deleted_at = 0 +ORDER BY r.id; + +-- ------------------------------------------------------------ +-- [6] 角色 ID 撞车 +-- nl_role.id=1 是超级管理员且代码里硬判断 role_id===1 全量放行 +-- 老库 id=1 未必是超管,按原 ID 迁会把普通角色提权成超管 +-- ------------------------------------------------------------ +SELECT '老 cc_role' AS src, id, name FROM cc_role WHERE id = 1 +UNION ALL +SELECT '新 nl_role', id, name FROM nl_role WHERE id = 1; + +SELECT MAX(id) AS old_role_max_id, MAX(id) + 100 AS suggested_new_max +FROM cc_role WHERE deleted_at = 0; + +-- ------------------------------------------------------------ +-- [7] 密码格式 +-- 惰性升级依赖无盐 sha1(40 位 hex),非此格式只能走重置 +-- ------------------------------------------------------------ +SELECT + SUM(CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$') AS sha1_ok, + SUM(password = '') AS empty_pwd, + SUM(password <> '' AND NOT (CHAR_LENGTH(password) = 40 AND password REGEXP '^[0-9a-f]{40}$')) AS other_format +FROM cc_user +WHERE deleted_at = 0; + +-- ------------------------------------------------------------ +-- [8] 部门悬空引用(cc_user.department 指向已不存在的部门) +-- ------------------------------------------------------------ +SELECT u.id, u.account, u.department +FROM cc_user u +WHERE u.deleted_at = 0 + AND u.department > 0 + AND NOT EXISTS (SELECT 1 FROM cc_department d WHERE d.id = u.department AND d.deleted_at = 0); + +-- ------------------------------------------------------------ +-- [9] account 与 phone 不一致的账号 +-- account 从不参与登录(老 UserService::login 查的是 phone), +-- 迁移时把原 account 写进 nl_admin.desc 留痕即可,不新增列 +-- ------------------------------------------------------------ +SELECT COUNT(*) AS account_phone_diff +FROM cc_user +WHERE deleted_at = 0 AND account <> phone; diff --git a/database/sql/rbac_migrate_ddl.sql b/database/sql/rbac_migrate_ddl.sql new file mode 100644 index 00000000..533e2fce --- /dev/null +++ b/database/sql/rbac_migrate_ddl.sql @@ -0,0 +1,124 @@ +-- ============================================================ +-- RBAC 表结构补丁(幂等,可重复执行) +-- 文件:database/sql/rbac_migrate_ddl.sql +-- +-- 用途:给 nl_admin / nl_role 补齐迁移与日常接口所需列 +-- - nl_admin.department_id → 解除 admin/my-info 报 Unknown column +-- - nl_admin.legacy_password* → 老无盐 sha1 惰性升级 +-- - nl_role.status / color → 角色禁用与标签色 +-- +-- 用法(任选其一): +-- 1. Navicat:选中库 cc_new_stash → 新建查询 → 整份粘贴 → 运行 +-- (本文件不用 DELIMITER / 存储过程,Navicat 可直接跑) +-- 2. mysql 客户端: +-- mysql -h... -u... -p cc_new_stash < database/sql/rbac_migrate_ddl.sql +-- +-- 说明: +-- - 用 information_schema + PREPARE 实现「列/索引已存在则跳过」 +-- - 不依赖 Laravel DB_PREFIX +-- - uk_phone 唯一索引在文末【可选】段,有重复手机号时会失败 +-- ============================================================ + +-- ------------------------------------------------------------ +-- nl_admin.department_id +-- ------------------------------------------------------------ +SET @db := DATABASE(); +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'department_id' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_admin` ADD COLUMN `department_id` int NOT NULL DEFAULT 0 COMMENT ''所属部门ID,对应 cc_department.id'' AFTER `role_id`', + 'SELECT ''skip nl_admin.department_id'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ------------------------------------------------------------ +-- nl_admin.legacy_password +-- ------------------------------------------------------------ +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'legacy_password' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_admin` ADD COLUMN `legacy_password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '''' COMMENT ''老系统无盐sha1密码,登录成功后清空'' AFTER `password`', + 'SELECT ''skip nl_admin.legacy_password'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ------------------------------------------------------------ +-- nl_admin.legacy_password_expire_at +-- ------------------------------------------------------------ +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND COLUMN_NAME = 'legacy_password_expire_at' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_admin` ADD COLUMN `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT ''遗留密码失效时间,过期强制走重置'' AFTER `legacy_password`', + 'SELECT ''skip nl_admin.legacy_password_expire_at'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ------------------------------------------------------------ +-- nl_admin 索引 idx_role / idx_department +-- ------------------------------------------------------------ +SET @exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'idx_role' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_admin` ADD INDEX `idx_role`(`role_id` ASC, `deleted_at` ASC) USING BTREE', + 'SELECT ''skip idx_role'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'idx_department' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_admin` ADD INDEX `idx_department`(`department_id` ASC, `deleted_at` ASC) USING BTREE', + 'SELECT ''skip idx_department'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ------------------------------------------------------------ +-- nl_role.status / color (你当前 audit 缺的就是这两列) +-- ------------------------------------------------------------ +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_role' AND COLUMN_NAME = 'status' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_role` ADD COLUMN `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''状态 0正常 1禁用'' AFTER `desc`', + 'SELECT ''skip nl_role.status'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'nl_role' AND COLUMN_NAME = 'color' +); +SET @sql := IF(@exists = 0, + 'ALTER TABLE `nl_role` ADD COLUMN `color` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '''' COMMENT ''角色标签颜色'' AFTER `status`', + 'SELECT ''skip nl_role.color'' AS msg' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ------------------------------------------------------------ +-- 修正种子:超管 status 必须是 0(0正常 1禁用) +-- ------------------------------------------------------------ +UPDATE `nl_admin` SET `status` = 0 WHERE `id` = 1 AND `status` = 1; + +-- ============================================================ +-- 【可选】uk_phone —— 先跑 lgp:rbac-audit 确认手机号无重复后再执行 +-- ============================================================ +-- SET @exists := ( +-- SELECT COUNT(*) FROM information_schema.STATISTICS +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'nl_admin' AND INDEX_NAME = 'uk_phone' +-- ); +-- SET @sql := IF(@exists = 0, +-- 'ALTER TABLE `nl_admin` ADD UNIQUE INDEX `uk_phone`(`phone` ASC) USING BTREE', +-- 'SELECT ''skip uk_phone'' AS msg' +-- ); +-- PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/public/nl_admin.sql b/public/nl_admin.sql index 4ba3a703..ce6a6e74 100644 --- a/public/nl_admin.sql +++ b/public/nl_admin.sql @@ -14,6 +14,12 @@ Date: 23/01/2026 17:08:01 */ +/* + 注意:本文件是全新安装脚本,每张 nl_ 表都带 DROP TABLE。 + 账号与角色迁移完成后再执行会清空已迁移的管理员和角色,请勿在已投产的库上重跑。 + 只涉及 nl_ 前缀的系统表,同库的 cc_ 业务表不受影响。 +*/ + SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; @@ -27,10 +33,13 @@ CREATE TABLE `nl_admin` ( `avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '头像', `nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '昵称', `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '密码', + `legacy_password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '老系统无盐sha1密码,登录成功后清空', + `legacy_password_expire_at` int NOT NULL DEFAULT 0 COMMENT '遗留密码失效时间,过期强制走重置', `phone` char(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户手机', `email` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户邮箱', `code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '业务员推广码', `role_id` int NOT NULL DEFAULT 0 COMMENT '角色', + `department_id` int NOT NULL DEFAULT 0 COMMENT '所属部门ID,对应 cc_department.id', `province_id` int NOT NULL DEFAULT 0 COMMENT '省', `city_id` int NOT NULL DEFAULT 0 COMMENT '市', `reg_ip` bigint NOT NULL DEFAULT 0 COMMENT '注册IP', @@ -39,17 +48,30 @@ CREATE TABLE `nl_admin` ( `ip_table` json NOT NULL COMMENT '常用登录IP地址列表', `operation_password` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0' COMMENT '操作密码', `desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '备注', - `status` tinyint NOT NULL DEFAULT 1 COMMENT '用户状态 0正常 1禁用', + `status` tinyint NOT NULL DEFAULT 0 COMMENT '用户状态 0正常 1禁用', `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间', - PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '管理员表' ROW_FORMAT = DYNAMIC; + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_phone`(`phone` ASC) USING BTREE, + INDEX `idx_role`(`role_id` ASC, `deleted_at` ASC) USING BTREE, + INDEX `idx_department`(`department_id` ASC, `deleted_at` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 171 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '管理员表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of nl_admin -- ---------------------------- -INSERT INTO `nl_admin` VALUES (1, 'nl_28686ad68682180e26836c721a52', '', '超管', '$2y$12$FXKlnItms3NoCLsJKQyVa.03kuRq8ltzdmc.ksf7rLKKPfGznpjnu', '15100000000', 'workerqi@163.com', '517117', 1, 0, 0, 0, 0, '127.0.0.1', '[\"127.0.0.1\"]', '0', '', 1, 0, 1747026536, 0); +-- id=1 系统超管(bcrypt);其余为 cc_user 迁移(id=老id+100,密码进 legacy_password) +-- 跳过:phone 非 11 位(id 30/39/66/67/68/70)、与超管撞号 15100000000(id 51) +-- status 必须为 0(0正常 1禁用);legacy 过期约 2026-11-12(安装日起 90 天) +INSERT INTO `nl_admin` VALUES (1, 'nl_28686ad68682180e26836c721a52', '', '超管', '$2y$12$FXKlnItms3NoCLsJKQyVa.03kuRq8ltzdmc.ksf7rLKKPfGznpjnu', '', 0, '15100000000', 'workerqi@163.com', '517117', 1, 0, 0, 0, 0, 0, '127.0.0.1', '[\"127.0.0.1\"]', '0', '', 0, 0, 1747026536, 0); +INSERT INTO `nl_admin` VALUES (101, 'nl_mig000000000000000000000001', 'https://www.nailaoyun.cn/upload/R-C.png', '王二狗', '', 'ec650f6add68310c8120af969afe074ef35681aa', 1794441600, '15110873723', 'workerqi1@163.com', '', 101, 1, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#1 account=cc_3TuS4qoE0g1K20240491679] cs123111', 0, 1712660865, 1776144272, 0); +INSERT INTO `nl_admin` VALUES (119, 'nl_mig000000000000000000000013', 'https://www.nailaoyun.cn/upload/R-C.png', '林国朋', '', '541ab6a364236754d7c84e134a3c944303bc1621', 1794441600, '15626165388', '734593134@qq.com', '', 102, 1, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#19 account=cc_yPjIvucyoqTM2024106088X] 管理员账号', 0, 1728315965, 1781754211, 0); +INSERT INTO `nl_admin` VALUES (135, 'nl_mig000000000000000000000023', 'https://www.nailaoyun.cn/upload/R-C.png', '测试业务员', '', '7dbd69aaf8c211ea8b2702651bf8612567a724c9', 1794441600, '15110811111', 'workerqi@163.com', '', 123, 15, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#35 account=cc_9Aj9LgG36zwX20241061834] 123', 0, 1729470521, 0, 0); +INSERT INTO `nl_admin` VALUES (160, 'nl_mig00000000000000000000003c', 'https://www.nailaoyun.cn/upload/R-C.png', '莫海琴', '', 'acff09fac846a5c140f2fb314bac980793e991bc', 1794441600, '15018054745', '15018054745', '', 123, 15, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#60 account=cc_lx7r8QGGAoLN20260319600]', 0, 1772502022, 1773368699, 0); +INSERT INTO `nl_admin` VALUES (161, 'nl_mig00000000000000000000003d', 'https://www.nailaoyun.cn/upload/R-C.png', '王娇瑜', '', 'd8b53d4b0234105489ee64b553f478502cc93ef4', 1794441600, '18924693886', '18924693886', '', 123, 15, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#61 account=cc_1Fi89gWXDvT320260338347]', 0, 1772528112, 1773368674, 0); +INSERT INTO `nl_admin` VALUES (163, 'nl_mig00000000000000000000003f', 'https://www.nailaoyun.cn/upload/R-C.png', '林国比', '', 'a1e511650d60f7fa3673c6308654f7b7d4405672', 1794441600, '17811563575', '', '', 126, 16, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#63 account=cc_nQn2TyAfZWcg20260320421]', 0, 1773368558, 1783925179, 0); +INSERT INTO `nl_admin` VALUES (164, 'nl_mig000000000000000000000040', 'https://www.nailaoyun.cn/upload/R-C.png', '王如渊', '', '04061e84b7eb069ebf6cc47633f5fc24d147e3af', 1794441600, '18520478342', '18520478342', '', 123, 15, 0, 0, 0, 0, '0', '[]', '0', '[migrated:cc_user#64 account=cc_KuFpw5zeTgSx20260364658]', 0, 1773457968, 1773458184, 0); -- ---------------------------- -- Table structure for nl_admin_notice @@ -147,25 +169,65 @@ CREATE TABLE `nl_database_change_log` ( -- ---------------------------- -- ---------------------------- --- Table structure for nl_file +-- Table structure for nl_file(素材库主表) +-- 与 database/migrations/2026_08_13_000003_extend_file_table_for_material.php 等价, +-- 两条路都做了存在性判断,先跑哪个都行 -- ---------------------------- DROP TABLE IF EXISTS `nl_file`; CREATE TABLE `nl_file` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` int NOT NULL DEFAULT 0 COMMENT '用户ID', - `url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '文件地址', - `type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '文件类型 0:图片 1:视频 2:音频 3:excel 4:压缩包 ', - `source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源 0:后台 1:用户端', + `oss_config_id` int NOT NULL DEFAULT 0 COMMENT '所属存储配置 nl_oss_config.id,0 表示未知', + `folder_id` int NOT NULL DEFAULT 0 COMMENT '素材文件夹 nl_file_folder.id,0 根目录', + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '素材名称', + `url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '文件地址', + `path` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '对象键,已去掉处理参数', + `ext` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '扩展名,小写不带点', + `type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '文件类型 0:图片 1:视频 2:音频 3:excel 4:压缩包 5:文档 6:其他', + `size` bigint NOT NULL DEFAULT 0 COMMENT '字节数', + `width` int NOT NULL DEFAULT 0 COMMENT '图片宽,非图片为 0', + `height` int NOT NULL DEFAULT 0 COMMENT '图片高,非图片为 0', + `hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '内容哈希 / ETag', + `ref_count` int NOT NULL DEFAULT 0 COMMENT '引用次数,由引用扫描回填', + `last_scan_at` int NOT NULL DEFAULT 0 COMMENT '最后一次引用扫描时间,0 表示从未扫描过', + `source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源 0:上传 1:OSS拉取', `created_at` int NOT NULL COMMENT '上传时间', `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + INDEX `nl_file_path_index`(`path` ASC) USING BTREE, + INDEX `nl_file_hash_index`(`hash` ASC) USING BTREE, + INDEX `nl_file_folder_id_index`(`folder_id` ASC) USING BTREE, + INDEX `nl_file_ref_count_index`(`ref_count` ASC) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 84 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '文件表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of nl_file -- ---------------------------- +-- ---------------------------- +-- Table structure for nl_file_folder(素材文件夹) +-- 只做素材的逻辑归类,不影响对象在 OSS 上的实际路径: +-- 移动素材若跟着改对象键,历史地址会全部 404 +-- ---------------------------- +DROP TABLE IF EXISTS `nl_file_folder`; +CREATE TABLE `nl_file_folder` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `pid` int NOT NULL DEFAULT 0 COMMENT '上级文件夹,0 根目录', + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '文件夹名称', + `sort` int NOT NULL DEFAULT 0 COMMENT '排序,越小越靠前', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态 0:正常 1:禁用', + `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间', + PRIMARY KEY (`id`) USING BTREE, + INDEX `nl_file_folder_pid_index`(`pid` ASC) USING BTREE +) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '素材文件夹表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of nl_file_folder +-- ---------------------------- + -- ---------------------------- -- Table structure for nl_menu -- ---------------------------- @@ -192,11 +254,13 @@ CREATE TABLE `nl_menu` ( `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 12 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '菜单表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 164 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '菜单表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of nl_menu -- ---------------------------- +-- 1~11 脚手架;12 部门;100+ 来自 config/lgp_menu.php(与 lgp:menu-sync 口径一致) +-- keep_alive:0=开启缓存 1=关闭;affix_tab:0=固定 1=不固定(输出到前端 meta 时取反) INSERT INTO `nl_menu` VALUES (1, '概览', 'twemoji:house-with-garden', 'Dashboard', '/dashobard', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, -1, '', 1734485082, 1734486088, 0); INSERT INTO `nl_menu` VALUES (2, '分析页', 'lucide:area-chart', 'Analytics', '/analytics', '/dashboard/analytics/index', '', 1, 0, 0, '', 0, 0, '', 1, 0, '', 1734485206, 1734485814, 0); INSERT INTO `nl_menu` VALUES (3, '工作台', 'carbon:workspace', 'Workspace', '/workspace', '/dashboard/workspace/index', '', 1, 0, 1, '', 0, 0, '', 1, 0, '', 1734486030, 0, 0); @@ -205,9 +269,39 @@ INSERT INTO `nl_menu` VALUES (5, '基础管理', 'logos:openjs-foundation-icon', INSERT INTO `nl_menu` VALUES (6, '管理员管理', 'grommet-icons:user-admin', 'SystemUser', '/system/user', '/system/admin/index', '', 1, 0, 1, '', 0, 0, '', 5, 0, '', 1734486178, 0, 0); INSERT INTO `nl_menu` VALUES (7, '角色管理', 'carbon:user-role', 'SystemRole', '/system/role', '/system/role/index', '', 1, 0, 1, '', 0, 0, '', 5, 1, '', 1734486291, 1734489429, 0); INSERT INTO `nl_menu` VALUES (8, '菜单管理', 'line-md:menu', 'SystemMenu', '/system/menu', '/system/menu/index', '', 1, 0, 1, '', 0, 0, '', 5, 2, '', 1734486345, 0, 0); -INSERT INTO `nl_menu` VALUES (9, '代码生成', 'fluent-color:code-20', 'CodeGeneration', '/code-generation', '/code-generation/index', '', 1, 0, 1, '', 0, 0, '', 0, 99999999, '', 0, 0, 0); +INSERT INTO `nl_menu` VALUES (9, '代码生成', 'lucide:wand-sparkles', 'CodeGeneration', '/code-generation', '/code-generation/index', '', 1, 0, 1, '', 0, 0, '', 0, 800, '', 0, 0, 0); INSERT INTO `nl_menu` VALUES (10, '数据库管理', 'material-symbols:database', 'SystemDatabase', '/system/database', '/system/database/index', '', 1, 0, 1, '', 0, 0, '', 5, 3, '', 1734486345, 0, 0); INSERT INTO `nl_menu` VALUES (11, '系统配置', 'lucide:settings-2', 'SystemConfig', '/system/config', '/system/config/index', '', 1, 0, 1, '', 0, 0, '', 5, 50, '', 1786329600, 0, 0); +INSERT INTO `nl_menu` VALUES (12, '部门管理', 'lucide:network', 'SystemDepartment', '/system/department', '/system/department/index', '', 1, 0, 1, '', 0, 0, '', 5, 4, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (100, '商品中心', 'lucide:sofa', 'Goods', '/goods', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 100, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (101, '商品图册', 'lucide:book-image', 'GoodsCatalogue', '/goods/catalogue', '/goods/catalogue/index', '', 1, 0, 1, '', 0, 0, '', 100, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (102, '商品分类', 'lucide:folder-tree', 'GoodsCategory', '/goods/category', '/goods/category/index', '', 1, 0, 1, '', 0, 0, '', 100, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (103, '规格报价', 'lucide:ruler', 'GoodsPriceSheet', '/goods/price-sheet', '/goods/price-sheet/index', '', 1, 0, 1, '', 0, 0, '', 100, 2, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (104, '商品相册', 'lucide:images', 'GoodsImage', '/goods/image', '/goods/image/index', '', 1, 0, 1, '', 0, 0, '', 100, 3, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (105, '轮播图', 'lucide:gallery-horizontal', 'GoodsCarousel', '/goods/carousel', '/goods/carousel/index', '', 1, 0, 1, '', 0, 0, '', 100, 4, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (106, '报价单', 'lucide:file-text', 'GoodsQuote', '/goods/quote', '/goods/quote/index', '', 1, 0, 1, '', 0, 0, '', 100, 5, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (110, '工厂产品', 'lucide:factory', 'Factory', '/factory', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 200, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (111, '工厂管理', 'lucide:building-2', 'FactoryInfo', '/factory/info', '/factory/info/index', '', 1, 0, 1, '', 0, 0, '', 110, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (112, '工厂分类', 'lucide:tags', 'FactoryClass', '/factory/class', '/factory/class/index', '', 1, 0, 1, '', 0, 0, '', 110, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (113, '工厂产品图片', 'lucide:image-plus', 'FactoryImage', '/factory/image', '/factory/image/index', '', 1, 0, 1, '', 0, 0, '', 110, 2, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (120, '色卡管理', 'lucide:palette', 'Color', '/color', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 300, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (121, '公司管理', 'lucide:landmark', 'ColorCompany', '/color/company', '/color/company/index', '', 1, 0, 1, '', 0, 0, '', 120, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (122, '色卡分类', 'lucide:layers', 'ColorCardClass', '/color/card-class', '/color/card-class/index', '', 1, 0, 1, '', 0, 0, '', 120, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (123, '色卡列表', 'lucide:swatch-book', 'ColorCard', '/color/card', '/color/card/index', '', 1, 0, 1, '', 0, 0, '', 120, 2, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (130, '客户中心', 'lucide:users', 'Customer', '/customer', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 400, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (131, '微信用户', 'ic:baseline-wechat', 'CustomerWxUser', '/customer/wx-user', '/customer/wx-user/index', '', 1, 0, 1, '', 0, 0, '', 130, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (135, '经销商管理', 'lucide:handshake', 'CustomerDealer', '/customer/dealer', '/customer/dealer/index', '', 1, 0, 1, '', 0, 0, '', 130, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (132, '企业管理', 'lucide:building', 'CustomerEnterprise', '/customer/enterprise', '/customer/enterprise/index', '', 1, 0, 1, '', 0, 0, '', 130, 2, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (133, '清单管理', 'lucide:clipboard-list', 'CustomerList', '/customer/list', '/customer/list/index', '', 1, 0, 1, '', 0, 0, '', 130, 3, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (134, '订单管理', 'lucide:receipt-text', 'CustomerOrder', '/customer/order', '/customer/order/index', '', 1, 0, 1, '', 0, 0, '', 130, 4, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (140, '素材库', 'lucide:folder-open', 'Material', '/material', '/material/index', '', 1, 0, 1, '', 0, 0, '', 0, 500, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (150, '小程序', 'lucide:smartphone', 'Wx', '/wx', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 600, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (151, '装修模板', 'lucide:paintbrush', 'WxTemplate', '/wx/template', '/wx/template/index', '', 1, 0, 1, '', 0, 0, '', 150, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (152, '应用配置', 'lucide:settings-2', 'WxApp', '/wx/app', '/wx/app/index', '', 1, 0, 1, '', 0, 0, '', 150, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (160, '内容创作', 'lucide:newspaper', 'Media', '/media', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, 700, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (161, '公众号账号', 'ic:baseline-wechat', 'MediaWechatAccount', '/media/wechat-account', '/media/wechat-account/index', '', 1, 0, 1, '', 0, 0, '', 160, 0, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (162, '图文创作', 'lucide:file-pen-line', 'MediaWechatArticle', '/media/wechat-article', '/media/wechat-article/index', '', 1, 0, 1, '', 0, 0, '', 160, 1, '', 1786636800, 0, 0); +INSERT INTO `nl_menu` VALUES (163, '图文编辑器', 'lucide:file-pen-line', 'MediaWechatArticleEdit', '/media/wechat-article/editor', '/media/wechat-article/editor/index', '', 1, 1, 1, '', 0, 0, '', 160, 2, '', 1786636800, 0, 0); -- ---------------------------- -- Table structure for nl_role @@ -219,16 +313,23 @@ CREATE TABLE `nl_role` ( `value` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色值', `pid` int NOT NULL DEFAULT 0 COMMENT '上级角色', `desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '角色说明', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态 0正常 1禁用', + `color` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '角色标签颜色', `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 127 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of nl_role -- ---------------------------- -INSERT INTO `nl_role` VALUES (1, '超级管理员', 'admin', 0, '', 0, 0, 0); +-- id=1 系统超管(代码硬判断全量放行);其余 = 老 cc_role.id + 100 +INSERT INTO `nl_role` VALUES (1, '超级管理员', 'admin', 0, '', 0, '', 0, 0, 0); +INSERT INTO `nl_role` VALUES (101, '超级管理员', 'role_1', 0, '[migrated:cc_role#1] 作为系统或团队的终极管理者,超级管理员享有最高级别的权限和责任。他们负责制定和执行长期战略计划,监督和管理所有下属角色,确保整个团队的高效运作和目标的达成。金色标签象征着尊贵和权威,与超级管理员的职位相匹配。', 0, 'gold', 1712671937, 1745540892, 0); +INSERT INTO `nl_role` VALUES (102, '林国朋专用管理', 'role_2', 0, '[migrated:cc_role#2] 拥有系统或团队的至高权限,负责维护整个系统的稳定运行、数据安全和策略制定。超级管理员能够对系统进行全面的监控和管理,解决各种复杂问题,确保团队工作的顺利进行。', 0, '', 1712671937, 1746596976, 0); +INSERT INTO `nl_role` VALUES (123, '业务员', 'role_23', 0, '[migrated:cc_role#23] 业务人员', 0, '', 1727269523, 1729433224, 0); +INSERT INTO `nl_role` VALUES (126, '管理员号', 'role_26', 0, '[migrated:cc_role#26] 内部人员管理', 0, '', 1728900873, 1783925228, 0); -- ---------------------------- -- Table structure for nl_role_menu_relations @@ -240,11 +341,12 @@ CREATE TABLE `nl_role_menu_relations` ( `menu_id` int NOT NULL DEFAULT 0 COMMENT '菜单ID', `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', PRIMARY KEY (`id`) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 12 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色权限绑定表' ROW_FORMAT = DYNAMIC; +) ENGINE = InnoDB AUTO_INCREMENT = 200 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色权限绑定表' ROW_FORMAT = DYNAMIC; -- ---------------------------- -- Records of nl_role_menu_relations -- ---------------------------- +-- 角色 1/101/102/126:全部菜单;角色 123 业务员:业务菜单(不含基础管理/代码生成/内容创作) INSERT INTO `nl_role_menu_relations` VALUES (1, 1, 1, 1747033198); INSERT INTO `nl_role_menu_relations` VALUES (2, 1, 2, 1747033198); INSERT INTO `nl_role_menu_relations` VALUES (3, 1, 3, 1747033198); @@ -256,6 +358,69 @@ INSERT INTO `nl_role_menu_relations` VALUES (8, 1, 8, 1747033198); INSERT INTO `nl_role_menu_relations` VALUES (9, 1, 9, 1747033198); INSERT INTO `nl_role_menu_relations` VALUES (10, 1, 10, 1747033198); INSERT INTO `nl_role_menu_relations` VALUES (11, 1, 11, 1786329600); +INSERT INTO `nl_role_menu_relations` VALUES (12, 1, 12, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (13, 1, 100, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (14, 1, 101, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (15, 1, 102, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (16, 1, 103, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (17, 1, 104, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (18, 1, 105, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (19, 1, 106, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (20, 1, 110, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (21, 1, 111, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (22, 1, 112, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (23, 1, 113, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (24, 1, 120, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (25, 1, 121, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (26, 1, 122, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (27, 1, 123, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (28, 1, 130, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (29, 1, 131, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (41, 1, 135, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (30, 1, 132, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (31, 1, 133, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (32, 1, 134, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (33, 1, 140, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (34, 1, 150, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (35, 1, 151, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (36, 1, 152, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (37, 1, 160, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (38, 1, 161, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (39, 1, 162, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (40, 1, 163, 1786636800); +-- 迁入角色 101/102/126 绑全部(与超管同权,菜单可见;接口授权另走 endpoint) +INSERT INTO `nl_role_menu_relations` SELECT NULL, 101, menu_id, 1786636800 FROM nl_role_menu_relations WHERE role_id = 1; +INSERT INTO `nl_role_menu_relations` SELECT NULL, 102, menu_id, 1786636800 FROM nl_role_menu_relations WHERE role_id = 1; +INSERT INTO `nl_role_menu_relations` SELECT NULL, 126, menu_id, 1786636800 FROM nl_role_menu_relations WHERE role_id = 1; +-- 业务员 123:概览 + 业务模块 +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 1, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 2, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 3, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 100, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 101, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 102, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 103, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 104, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 105, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 106, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 110, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 111, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 112, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 113, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 120, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 121, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 122, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 123, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 130, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 131, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 135, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 132, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 133, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 134, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 140, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 150, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 151, 1786636800); +INSERT INTO `nl_role_menu_relations` VALUES (NULL, 123, 152, 1786636800); -- ---------------------------- -- Table structure for nl_system_config @@ -549,4 +714,165 @@ CREATE TABLE `nl_login_log` ( INDEX `idx_created_at`(`created_at` ASC) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '登录日志' ROW_FORMAT = DYNAMIC; +-- ---------------------------- +-- Table structure for nl_wechat_account +-- ---------------------------- +DROP TABLE IF EXISTS `nl_wechat_account`; +CREATE TABLE `nl_wechat_account` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '公众号名称', + `appid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '公众号 AppID', + `app_secret` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'AppSecret,AES 密文(前缀 nl_ase_256_),永不回显明文', + `original_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '公众号原始ID', + `is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否默认发布账号 0否1是,全局仅一个', + `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '备注', + `sort` int NOT NULL DEFAULT 0 COMMENT '排序,倒序', + `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间,0表示未删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_appid`(`appid` ASC, `deleted_at` ASC) USING BTREE, + INDEX `idx_default`(`is_default` ASC, `status` ASC, `deleted_at` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '公众号账号配置表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Table structure for nl_wechat_article +-- ---------------------------- +DROP TABLE IF EXISTS `nl_wechat_article`; +CREATE TABLE `nl_wechat_article` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `account_id` int NOT NULL DEFAULT 0 COMMENT '发布所用公众号账号ID', + `title` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '文章标题,发布时截断 64 字', + `author` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '作者', + `digest` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '摘要,发布时截断 120 字', + `cover_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '封面图地址', + `content_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '正文 Markdown 源码,可能数百 KB,列表接口不查此字段', + `theme_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '排版主题标识', + `theme_color` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主题主色 HEX', + `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0草稿 1发布中 2已发布 3发布失败', + `version_no` int NOT NULL DEFAULT 1 COMMENT '当前版本号,每次编辑 +1', + `media_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '微信侧草稿 media_id', + `article_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '发布成功后的文章链接', + `publish_error` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '发布失败原因', + `created_by` int NOT NULL DEFAULT 0 COMMENT '创建人管理员ID', + `updated_by` int NOT NULL DEFAULT 0 COMMENT '最后修改人管理员ID', + `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间,0表示未删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_account_status`(`account_id` ASC, `status` ASC, `deleted_at` ASC) USING BTREE, + INDEX `idx_deleted_at`(`deleted_at` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '公众号图文文章表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Table structure for nl_wechat_article_version +-- ---------------------------- +DROP TABLE IF EXISTS `nl_wechat_article_version`; +CREATE TABLE `nl_wechat_article_version` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `article_id` int NOT NULL DEFAULT 0 COMMENT '文章ID', + `version_no` int NOT NULL DEFAULT 0 COMMENT '版本号快照', + `action` tinyint(1) NOT NULL DEFAULT 0 COMMENT '1创建 2修改 3删除 4回退 5发布', + `title` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '标题快照', + `digest` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '摘要快照', + `content_md` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '正文快照', + `theme_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主题标识快照', + `theme_color` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '主题主色快照', + `operator_id` int NOT NULL DEFAULT 0 COMMENT '操作人管理员ID', + `operator_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作人昵称快照', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '备注', + `created_at` int NOT NULL DEFAULT 0 COMMENT '产生时间。日志型表,无 updated_at / deleted_at', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_article_version`(`article_id` ASC, `version_no` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '公众号图文版本流水表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Table structure for nl_wechat_theme +-- ---------------------------- +DROP TABLE IF EXISTS `nl_wechat_theme`; +CREATE TABLE `nl_wechat_theme` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '模板名称', + `palette` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '色板 JSON,最多 8 个 HEX', + `styles` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '样式表 JSON,仅白名单元素键', + `created_by` int NOT NULL DEFAULT 0 COMMENT '导入人管理员ID', + `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` int NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间,0表示未删除', + PRIMARY KEY (`id`) USING BTREE, + INDEX `idx_deleted_at`(`deleted_at` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '公众号自定义排版主题表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Records of nl_api_endpoint(公众号模块) +-- ---------------------------- +INSERT INTO `nl_api_endpoint` (`url`, `method`, `name`, `description`, `controller`, `is_log`, `status`, `created_at`, `updated_at`, `deleted_at`) VALUES + ('wechat-account/list', 1, '公众号账号列表', '账号配置分页', 'WechatAccountController', 0, 1, 1786329600, 0, 0), + ('wechat-account/option', 1, '公众号账号下拉', '启用账号选项', 'WechatAccountController', 0, 1, 1786329600, 0, 0), + ('wechat-account/detail', 1, '公众号账号详情', '账号配置详情', 'WechatAccountController', 0, 1, 1786329600, 0, 0), + ('wechat-account/create', 2, '创建公众号账号', '新增账号配置', 'WechatAccountController', 1, 1, 1786329600, 0, 0), + ('wechat-account/update', 2, '更新公众号账号', '编辑账号配置', 'WechatAccountController', 1, 1, 1786329600, 0, 0), + ('wechat-account/delete', 2, '删除公众号账号', '软删账号配置', 'WechatAccountController', 1, 1, 1786329600, 0, 0), + ('wechat-account/set-default', 2, '设为默认账号', '切换默认发布账号', 'WechatAccountController', 1, 1, 1786329600, 0, 0), + ('wechat-account/test-connection', 2, '测试公众号连通性', '直连微信换 token 验证', 'WechatAccountController', 1, 1, 1786329600, 0, 0), + ('wechat-article/list', 1, '图文列表', '图文文章分页', 'WechatArticleController', 0, 1, 1786329600, 0, 0), + ('wechat-article/detail', 1, '图文详情', '含正文全文', 'WechatArticleController', 0, 1, 1786329600, 0, 0), + ('wechat-article/create', 2, '创建图文', '新增图文并落 v1 流水', 'WechatArticleController', 1, 1, 1786329600, 0, 0), + ('wechat-article/update', 2, '更新图文', '版本号+1 并落流水', 'WechatArticleController', 1, 1, 1786329600, 0, 0), + ('wechat-article/delete', 2, '删除图文', '软删并落流水', 'WechatArticleController', 1, 1, 1786329600, 0, 0), + ('wechat-article/version-list', 1, '图文版本流水', '版本流水分页', 'WechatArticleController', 0, 1, 1786329600, 0, 0), + ('wechat-article/version-detail', 1, '图文版本详情', '单版本正文快照', 'WechatArticleController', 0, 1, 1786329600, 0, 0), + ('wechat-article/rollback', 2, '图文版本回退', '回退到指定版本', 'WechatArticleController', 1, 1, 1786329600, 0, 0), + ('wechat-article/publish', 2, '发布图文', '推送到公众号草稿箱', 'WechatArticleController', 1, 1, 1786329600, 0, 0), + ('wechat-article/publish-status', 1, '查询发布状态', '回填发布结果', 'WechatArticleController', 0, 1, 1786329600, 0, 0), + ('wechat-theme/list', 1, '排版主题列表', '自定义模板全量列表', 'WechatThemeController', 0, 1, 1786329600, 0, 0), + ('wechat-theme/create', 2, '导入排版主题', '模板 JSON 清洗入库', 'WechatThemeController', 1, 1, 1786329600, 0, 0), + ('wechat-theme/update', 2, '更新排版主题', '整体覆盖模板', 'WechatThemeController', 1, 1, 1786329600, 0, 0), + ('wechat-theme/delete', 2, '删除排版主题', '软删自定义模板', 'WechatThemeController', 1, 1, 1786329600, 0, 0); + +-- ---------------------------- +-- Table structure for nl_role_endpoint_relation(接口级权限) +-- 与 database/migrations 里的同名迁移等价,两条路都做了存在性判断,先跑哪个都行 +-- ---------------------------- +DROP TABLE IF EXISTS `nl_role_endpoint_relation`; +CREATE TABLE `nl_role_endpoint_relation` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `role_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '角色ID', + `endpoint_id` int UNSIGNED NOT NULL DEFAULT 0 COMMENT 'nl_api_endpoint 主键', + `created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_role_endpoint`(`role_id` ASC, `endpoint_id` ASC) USING BTREE, + INDEX `idx_endpoint`(`endpoint_id` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色接口授权表' ROW_FORMAT = DYNAMIC; + +-- ---------------------------- +-- Table structure for nl_wx_app(小程序应用配置) +-- 与 database/migrations/2026_08_13_000005_create_wx_app_table.php 等价; +-- migrate 内有 hasTable 幂等,先跑本 SQL 再 migrate 安全 +-- AppSecret 等敏感字段由后台加密入库,不要在本文件写明文密钥 +-- ---------------------------- +DROP TABLE IF EXISTS `nl_wx_app`; +CREATE TABLE `nl_wx_app` ( + `id` int UNSIGNED NOT NULL AUTO_INCREMENT, + `code` varchar(20) NOT NULL DEFAULT '' COMMENT '品牌标识 brm/wl', + `name` varchar(50) NOT NULL DEFAULT '', + `app_id` varchar(50) NOT NULL DEFAULT '', + `app_secret` varchar(255) NOT NULL DEFAULT '' COMMENT '密文存储', + `mch_id` varchar(50) NOT NULL DEFAULT '' COMMENT '微信支付商户号', + `mch_key` varchar(255) NOT NULL DEFAULT '' COMMENT 'APIv3 密钥,密文存储', + `mch_serial_no` varchar(100) NOT NULL DEFAULT '' COMMENT '商户证书序列号', + `mch_private_key` text NULL COMMENT '商户私钥,密文存储', + `platform_public_key` text NULL COMMENT '微信支付平台证书公钥', + `notify_url` varchar(255) NOT NULL DEFAULT '' COMMENT '支付回调地址', + `template_code` varchar(50) NOT NULL DEFAULT '' COMMENT '默认装修模板 code', + `status` tinyint NOT NULL DEFAULT 0 COMMENT '0=启用 1=停用', + `remark` varchar(255) NOT NULL DEFAULT '', + `created_at` int NOT NULL DEFAULT 0, + `updated_at` int NOT NULL DEFAULT 0, + `deleted_at` int NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_wx_app_code`(`code` ASC) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '小程序应用配置' ROW_FORMAT = DYNAMIC; + SET FOREIGN_KEY_CHECKS = 1; diff --git a/routes/api.php b/routes/api.php index 2612daa0..7e8f6fa3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,15 +22,43 @@ Route::post('/sql/start-installation', [SqlInstallController::class, 'startInsta */ UtilsService::class::getInstance()->autoRouteRegister([ '' => \App\Http\Controllers\Api\LoginController::class, // 登录控制器 - 'code' => \App\Http\Controllers\core\CodeGenerationController::class, // 代码生成控制器 // 不需要登录的路由生成地址 ]); -Route::group([], function () { +/* + * 小程序接口(原 lgp-wx-api 归并进来) + * + * 小程序侧只需把 baseURL 改成 .../api/wx/,路径与老接口逐字对齐。 + * 免鉴权组:登录、首页、主题、支付回调;其余一律走 nl.wx 中间件。 + */ +Route::group(['prefix' => 'wx'], function () { UtilsService::class::getInstance()->autoRouteRegister([ + 'auth' => \App\Http\Controllers\Wx\AuthController::class, // 小程序登录 + 'home' => \App\Http\Controllers\Wx\HomeController::class, // 首页轮播与分类 + '' => \App\Http\Controllers\Wx\ThemeController::class, // 主题下发与支付回调 + ]); + + Route::group(['middleware' => 'nl.wx'], function () { + UtilsService::class::getInstance()->autoRouteRegister([ + 'product' => \App\Http\Controllers\Wx\ProductController::class, // 商品 + 'list' => \App\Http\Controllers\Wx\ListController::class, // 清单 + 'user' => \App\Http\Controllers\Wx\UserController::class, // 我的 + 'order' => \App\Http\Controllers\Wx\OrderController::class, // 订单 + 'upload' => \App\Http\Controllers\Wx\WxUploadController::class, // 上传(转账凭证等) + ]); + }); +}); + +// 登录组:中间件先校验 token 与接口权限,再进控制器 +Route::group(['middleware' => 'nl.auth'], function () { + UtilsService::class::getInstance()->autoRouteRegister([ + // 代码生成会往磁盘写 Controller/Service/Model 并执行建表 DDL, + // 原先挂在免登录组等于把远程写文件的能力开放给公网,必须放进登录组 + 'code' => \App\Http\Controllers\core\CodeGenerationController::class, // 代码生成控制器 'admin' => \App\Http\Controllers\Api\AdminController::class, // 用户控制器 'role' => \App\Http\Controllers\Api\RoleController::class, // 角色管理 'menu' => \App\Http\Controllers\Api\MenuController::class, // 菜单管理 + 'department' => \App\Http\Controllers\Api\DepartmentController::class, // 部门管理(复用 cc_department 表) 'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件 'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理 'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥) @@ -39,6 +67,29 @@ Route::group([], function () { 'wechat-account' => \App\Http\Controllers\Api\WechatAccountController::class, // 公众号账号配置 'wechat-article' => \App\Http\Controllers\Api\WechatArticleController::class, // 公众号图文创作 'wechat-theme' => \App\Http\Controllers\Api\WechatThemeController::class, // 自定义排版主题(我的模板) + 'material' => \App\Http\Controllers\Api\MaterialController::class, // 素材库(OSS 同步 / 引用扫描 / 回收) + 'file-folder' => \App\Http\Controllers\Api\FileFolderController::class, // 素材库文件夹 + + /* + * ---------------- LGP 业务模块(表在 business 连接,前缀 cc_)---------------- + */ + 'catalogue' => \App\Http\Controllers\Api\CatalogueController::class, // 商品图册 + 'category' => \App\Http\Controllers\Api\CategoryController::class, // 商品分类 + 'price-sheet' => \App\Http\Controllers\Api\PriceSheetController::class, // 报价单(规格) + 'image' => \App\Http\Controllers\Api\ImageController::class, // 商品相册 + 'carousel' => \App\Http\Controllers\Api\CarouselController::class, // 小程序轮播图 + 'factory-info' => \App\Http\Controllers\Api\FactoryInfoController::class, // 工厂管理 + 'factory-classification' => \App\Http\Controllers\Api\FactoryClassificationController::class, // 工厂分类 + 'factory-image' => \App\Http\Controllers\Api\FactoryImageController::class, // 工厂产品图 + 'company' => \App\Http\Controllers\Api\CompanyController::class, // 色卡所属公司 + 'card-class' => \App\Http\Controllers\Api\CardClassController::class, // 色卡分类 + 'colorcard' => \App\Http\Controllers\Api\ColorcardController::class, // 色卡 + 'wx-user' => \App\Http\Controllers\Api\WxUserController::class, // 微信用户 + 'enterprise' => \App\Http\Controllers\Api\EnterpriseController::class, // 企业管理 + 'list' => \App\Http\Controllers\Api\ListController::class, // 清单管理 + 'order' => \App\Http\Controllers\Api\OrderController::class, // 订单管理 + 'wx-template' => \App\Http\Controllers\Api\WxTemplateController::class, // 小程序装修模板 + 'wx-app' => \App\Http\Controllers\Api\WxAppController::class, // 小程序应用配置 // 需要登录的路由生成地址 ]); }); diff --git a/scripts/db-check.php b/scripts/db-check.php new file mode 100644 index 00000000..153f3056 --- /dev/null +++ b/scripts/db-check.php @@ -0,0 +1,64 @@ + __DIR__ . '/../.env', + 'lgp-api/.env' => __DIR__ . '/../../lgp-api/.env', + 'lgp-wx-api/.env' => '/www/sites/lgp-wx-api/index/lgp-wx-api/.env', +]; + +function readEnv(string $path): array +{ + if (!is_file($path)) { + return []; + } + $values = []; + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $values[trim($key)] = trim(trim($value), "\"'"); + } + return $values; +} + +$target = $argv[1] ?? null; + +// 环境重建后 host 也可能变了,所以每套凭据都对「自带 host」和「当前 mysql 容器」各试一次 +$hosts = ['mysql8']; + +foreach ($candidates as $label => $path) { + $env = readEnv($path); + if (empty($env)) { + echo str_pad($label, 32) . " 文件不存在\n"; + continue; + } + $envHost = $env['DB_HOST'] ?? '127.0.0.1'; + foreach (array_unique([$envHost, ...$hosts]) as $host) { + $port = $env['DB_PORT'] ?? '3306'; + $db = $target ?: ($env['DB_DATABASE'] ?? ''); + $user = $env['DB_USERNAME'] ?? 'root'; + try { + $pdo = new PDO( + "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4", + $user, + $env['DB_PASSWORD'] ?? '', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5] + ); + $count = $pdo->query('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()')->fetchColumn(); + echo str_pad($label, 32) . " 可连接 host={$host} db={$db} 表数={$count}\n"; + } catch (Throwable $e) { + $reason = preg_replace('/using password: (YES|NO)/', 'using password: ***', $e->getMessage()); + echo str_pad($label, 32) . " 连不上 host={$host} " . $reason . "\n"; + } + } +} diff --git a/scripts/lint-all.sh b/scripts/lint-all.sh new file mode 100644 index 00000000..18d72c5e --- /dev/null +++ b/scripts/lint-all.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Read-only syntax check: php -l over app/config/database/routes, +# plus a guard for the historical return -> retun typo. +# +# Usage (inside an environment that has php, e.g. the 1Panel php-fpm container): +# sh scripts/lint-all.sh /www/sites/lgp-api/index/lgp-admin-plus-api +# This script never modifies files. +set -eu + +BASE=${1:-} +if [ -z "$BASE" ]; then + BASE=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +fi +cd "$BASE" + +TARGETS="app config database routes" + +echo "== syntax ==" +fail=0 +count=0 +for f in $(find $TARGETS -name '*.php' | sort); do + count=$((count + 1)) + out=$(php -l "$f" 2>&1 | grep -v swoole || true) + case "$out" in + *"No syntax errors"*) ;; + *) echo "FAIL $f"; echo "$out"; fail=1 ;; + esac +done +echo "checked $count files" + +echo "== retun typo ==" +if grep -rn "retun" $TARGETS; then + fail=1 +else + echo "none" +fi + +if [ "$fail" -eq 0 ]; then + echo "ALL OK" +else + echo "HAS ERRORS" + exit 1 +fi diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100644 index 00000000..6838b9b9 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# 语法检查 + 行尾规整 +# +# 从 Windows 侧的 UNC 路径写文件会带 CRLF,PHP 能跑但和仓库里其余 LF 文件混排, +# diff 会整片变红。这里统一转成 LF 再 php -l。 +# +# 用法:bash scripts/lint.sh [目录或文件 ...] 不带参数则检查 app/ routes/ config/ +set -uo pipefail + +cd "$(diname "$0")/.." || exit 1 + +TARGETS=("$@") +if [ ${#TARGETS[@]} -eq 0 ]; then + TARGETS=(app routes config database) +fi + +mapfile -t FILES < <(find "${TARGETS[@]}" -type f -name '*.php' 2>/dev/null | sort) + +if [ ${#FILES[@]} -eq 0 ]; then + echo "no php files under: ${TARGETS[*]}" + exit 0 +fi + +CRLF_FIXED=0 +for f in "${FILES[@]}"; do + if grep -qU $'\r' "$f" 2>/dev/null; then + perl -pi -e 's/\r\n/\n/' "$f" + CRLF_FIXED=$((CRLF_FIXED + 1)) + fi +done + +FAILED=0 +for f in "${FILES[@]}"; do + if ! out=$(php -l "$f" 2>&1); then + echo "$out" + FAILED=$((FAILED + 1)) + fi +done + +echo "checked=${#FILES[@]} crlf_fixed=${CRLF_FIXED} syntax_errors=${FAILED}" +[ "$FAILED" -eq 0 ]