Compare commits

..

29 Commits

Author SHA1 Message Date
李琦
6e2769c528 优化部分代码,增加微信公众号模板
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-08-13 19:02:05 +08:00
李琦
2f5e298d6b 优化部分代码,增加微信公众号模板 2026-08-13 19:01:21 +08:00
李琦
21f39eed85 AI代码生成工具
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-08-11 09:39:41 +08:00
李琦
cab69b4193 AI代码生成工具
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-08-10 18:32:06 +08:00
李琦
ab417bd0f2 AI代码生成工具 2026-08-10 15:57:48 +08:00
李琦
bd226c260f AI代码生成工具 2026-08-10 15:51:00 +08:00
李琦
d0018a0173 更新依赖、添加规则 2026-08-10 14:42:21 +08:00
李琦
04d755b115 更新依赖、添加规则 2026-08-10 14:25:48 +08:00
李琦
1e4c30208b 更新依赖、添加规则 2026-08-10 14:24:55 +08:00
李琦
6bd74bb7d2 更新依赖、添加规则 2026-08-10 13:58:14 +08:00
李琦
d718c267ef 更新依赖、添加规则 2026-08-10 13:00:10 +08:00
李琦
a1abba3835 表前缀
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2026-01-23 17:13:23 +08:00
李琦
0d21609586 表前缀 2026-01-23 09:43:16 +08:00
lq
527373853e 优化是否鉴权
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-12-01 10:56:30 +08:00
lq
120a88da30 是否鉴权 2025-12-01 10:42:28 +08:00
lq
9c28ca2744 是否鉴权 2025-12-01 10:41:41 +08:00
lq
7359cf2972 新增请求基类的方法 2025-12-01 10:25:19 +08:00
lq
ba073d6187 代码生成记录
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-11-28 14:21:43 +08:00
lq
9720e1a9e0 更新模板 2025-11-28 13:11:55 +08:00
lq
f071fa0cba 更新模板 2025-11-28 08:57:36 +08:00
e4be2c1326 更新 public/nl_admin.sql
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
修复登陆后路由冲突的问题
2025-10-31 09:02:05 +08:00
lq
53b3dc8f36 更新代码生成
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-09-01 14:01:48 +08:00
lq
428d668e4f 更新代码生成 2025-09-01 13:14:31 +08:00
lq
1bb8cbcecd 更新代码生成 2025-09-01 13:08:26 +08:00
lq
b2c9aecfb9 更新sql安装器(web版本需要修复)
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-06-20 09:02:33 +08:00
lq
9f77221bdf api模板更新
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-05-19 14:04:19 +08:00
lq
bc5bb19a14 sql更新
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-05-14 16:05:34 +08:00
lq
2299813664 代码下载接口
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled
2025-05-13 16:49:45 +08:00
lq
93d99abc97 代码下载接口 2025-05-13 16:35:18 +08:00
99 changed files with 15150 additions and 2053 deletions

View File

@@ -0,0 +1,31 @@
---
description: 代码生成双端产出与 uniapp C 端三页模板约定
alwaysApply: true
---
# 代码生成(双端前端)
`CodeGenerationService::generate` 一次生成必须包含:
1. **后端**Controller / Service / Model / 路由 / 建表 / PC 菜单
2. **PC 前端 zip**`view/<dash-case>/` → `nl-admin-view/.../views/my-gen/`
3. **uniapp 前端 zip**`uniapp/<dash-case>/` → `nl-admin-uniapp/src/pages-sub/my-gen/<dash-case>/`
模板目录:
- `app/template/view/` — Vben Admin
- `app/template/uniapp/` — 移动超管C 端)
# uniapp 生成物强制
- 兼容 **微信小程序 + App + H5**
- **交互必须是三页**`pages/list.vue` → `pages/detail.vue` → `pages/form.vue`
- 列表点击进详情;新增/编辑用全屏表单;**禁止**底部抽屉做主 CRUD
- **C 端 UI**大留白卡片、DetailHero、InfoGroup、BottomBar、PageForm禁止 PC 宽表
- 复用:`AppCardList` / `AppFilterBar` / `AppSearchBar` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`
- `AppFormDrawer` 不得作为生成主路径
- zip 内附带 `PAGES_JSON_SNIPPET.md`(注册 list/detail/form 三页 + features.ts
# 与移动端规则对齐
以 `nl-admin-uniapp` 的 `Code-Standards.mdc` 为准;本仓库生成模板不得与之冲突。

View File

@@ -0,0 +1,109 @@
---
description: nl-admin 后端Laravel 13 + PHP 8.3 + 自动路由)代码规范
alwaysApply: true
---
# 技术栈基线(升级后必守)
- **Laravel 13**`composer.json``laravel/framework: ^13.0`
- **PHP ≥ 8.3**Laravel 13 官方最低要求;禁止按 8.2 写法或回退依赖)
- 应用启动走 `bootstrap/app.php` 的 fluent API`withRouting` / `withMiddleware` / `withExceptions`**不要**再按旧版 `Http/Kernel` 拆分中间件与异常
- 代码风格优先 `laravel/pint`;测试栈为 PHPUnit 12
- Laravel 13 的 PHP Attribute如 `#[Middleware]`、`#[Authorize]`、Job 的 `#[Tries]` 等)可用,但本项目的 **HTTP 谓词仍以 PHPDoc `@Method` 为准**`autoRouteRegister` 扫描依赖它)
# 基础规范(所有项目通用)
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于 2 行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用 page-container 来防止用户意外退出页面(本仓库为 API此项约束 uniapp
5. 数据库的created_at、updated_at、deleted_at统一使用时间戳不要使用字符串并且我在查询器中一级格式化成字符串了无需再次格式化
# 全局架构规范
## 分层架构Controller → Service → Model禁止跨层
严格遵循三层架构,禁止跨层调用:
- **Controller**`app/Http/Controllers/Api/` 业务接口,`app/Http/Controllers/core/` 代码生成/安装等):只做参数接收、调用 Service、统一返回`jok/jerr`**禁止写业务逻辑**
- **Service**(业务放 `app/Service/`,通用能力放 `app/Service/common/`,代码生成等放 `app/Service/core/`):业务规则、数据组装、事务控制都在这里
- **Model**`app/Models/*Model.php`,扁平目录):**只定义表名、连接、字段黑名单 `$guarded=[]`、访问器**,禁止写业务查询方法(如 `getXxxByCondition` 应放 Service
跨多个 Service 共用的工具放 `app/Service/common/UtilsService.php`(已存在)或 `app/Service/common/<域>/`。
**本项目是单端管理后台骨架**,不要照搬其他项目的多端目录(`mobile` / `DoctorWx` / `ClinicAdminWx` 等)。
## 基类强制继承(不要直接继承框架原生基类)
- 控制器必须继承 `App\BaseApp\BaseController`
- 需要登录态的服务继承 `App\BaseApp\BaseService`;登录等免鉴权服务继承 `App\BaseApp\BaseNotAuthService`
- 调用方统一单例:`XxxService::getInstance()`
- 模型必须继承 `App\BaseApp\BaseModel``$connection = 'mysql'`**不要**直接继承 `Illuminate\Database\Eloquent\Model`
- 本项目**没有** `BaseOldModel` / `old_mysql` 老库分层,禁止再引入
- HTTP 客户端继承 `App\BaseApp\BaseClient`,禁止在业务代码里直接 `new Client()`
- 控制器构造里按需设置 `$this->service`、`$this->insertField` / `$this->updateField` / `$this->notRequest`(参考 `AdminController`
## 通用方法封装(避免复制粘贴)
- 禁止在 Controller 直接复用业务逻辑,必须抽到 Service
- 禁止在多个 Service 中复制粘贴相同逻辑,必须抽到 `app/Service/common/`
- 若后续引入队列:统一放 `app/Jobs/<域>/`,实现 `ShouldQueue` + `Queueable`;单 Job 可用 `->onQueue('xxx')`,多 Job 默认路由优先用 Laravel 13 的 `Queue::route(Job::class, connection: 'redis', queue: 'xxx')` 集中声明(当前仓库尚未建 Jobs按需新增
## 路由规范(自动注册,禁止手写业务路由)
- 业务路由在 `routes/api.php` 用 `UtilsService::getInstance()->autoRouteRegister([...])` 注册
- **只建 Controller 不注册会 404**;免登录与需登录分两组 `autoRouteRegister`(见现有 `api.php`
- 映射键用 kebab-case如 `'admin' => AdminController::class`),方法名小驼峰自动转连字符(`myInfo` → `/admin/my-info`
- 空前缀 `'' => LoginController::class` 表示根路径方法(如登录)
- 控制器每个方法必须用 PHPDoc `@Method GET/POST/NO` 声明 HTTP 谓词,未声明默认 `any``NO` 表示跳过
- **禁止**手写业务 `Route::get/post`(安装向导等极少数例外可保留,如现有 `/sql/*`
- `helpers.php` 里的 `cc_auto_route_register` 与 Utils 方法能力类似,**新代码以 `routes/api.php` 现有写法为准**
## 第三方对接规范
- 第三方 HTTP 客户端放 `app/Service/common/`(或后续建 `common/http/`),命名 `XxxApiClient` / `XxxClient`,继承 `BaseClient`
- 需要工厂/策略时定义 `XxxInterface`,实现类与接口同域存放
- 密钥走 `config/nl.php` + `.env`**禁止在代码中硬编码**
- **禁止**再引入其他项目残留约定:`config/xk.php`、`config/ase.php`、`EncryptorService`、`VipGateService`、`mes/` 等
## 响应与异常
- 成功返回 `jok($data, $msg)`,失败返回 `jerr($msg)`(定义在 `config/response.php`
- 业务异常抛 `UtilsService::getInstance()->errorThrow($msg)`(或 Service 内 `$this->utils->errorThrow($msg)`
## 常量管理
- 枚举值必须用 PHP native `enum`PHP 8.3+)放 `app/Enum/`,禁止在代码中散落魔法数字
- 状态约定以现有枚举为准:`StatusEnum` / `UserStatusEnum` 为 **0=正常1=禁用/冻结**(不要写成其他项目的 1 启用 0 禁用)
- 新增状态类字段优先复用或扩展 `app/Enum/` 中的枚举
- CSRF / 请求伪造防护中间件类名以 Laravel 13 为准:`PreventRequestForgery`(旧名 `VerifyCsrfToken` 仅为兼容别名,新代码不要再写)
## 命名规范
| 维度 | 规范 | 示例 |
|---|---|---|
| 类名 | PascalCase | `AdminService`、`MenuController` |
| 方法名 | camelCase | `getPageList`、`changePassword` |
| 文件名 | 与类名同名 PascalCase | `AdminService.php` |
| 命名空间 | PSR-4目录大小写与现有一致 | `App\Http\Controllers\Api`、`App\Service\common` |
| 数据库物理表 | 前缀 `nl_``DB_PREFIX`),全小写下划线 | `nl_admin`、`nl_menu` |
| 模型 `$table` | **无前缀**表名(由 `DB_PREFIX` 拼接) | `admin`、`menu`、`role_menu_relations` |
| 数据库字段 | snake_case | `created_at`、`role_id`、`nick_name` |
| 配置键 | snake_case | `config('nl.xxx')` |
## 注释规范
- 每个类必须有类级注释,说明用途
- 每个 public 方法必须有详细中文注释,包括「做什么」「为什么这样写」「关键参数说明」
- 复杂的 private 方法也要注释
- 不要写「// 自增主键」这种废话注释,注释要解释**意图**
## 代码生成
- 模板在 `app/template/``view/`PC Vben与 `uniapp/`(移动端)两套并存;服务在 `app/Service/core/CodeGenerationService`
- **`generate` 必须同时产出** zip 内的 `view/<dash-case>/` 与 `uniapp/<dash-case>/`,缺一不可
- 生成物需仍遵守本文件的分层、基类、路由注册与命名约定;生成后记得在 `routes/api.php` 的 `autoRouteRegister` 中挂上控制器(当前 `genRoute` 会自动写入)
- **uniapp 模板强制约定**(与移动端规则同口径):
- 必须兼容 **微信小程序 + App + H5**
- 交互必须是 **list → detail → form 三页**;禁止底部抽屉做主 CRUD禁止 PC 宽表
- 复用:`AppCardList` / `AppFilterBar` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`
- 粘贴落点:`nl-admin-uniapp/src/pages-sub/my-gen/<dash-case>/`,并同步 `pages.json`(三页)与 `features.ts`

View File

@@ -0,0 +1,38 @@
---
description: 双后端同步本仓库Laravel与 nl-admin-api-gfGoFrame互为镜像实现改一端必须同步另一端
alwaysApply: true
---
# 双后端同步Laravel ↔ GoFrame
本仓库Laravel 13 版 nl-admin-api与工作区内 `d:\myCode\nl-admin\nl-admin-api-gf`GoFrame v2 的 Go 版同功能实现)**互为镜像**
接口路径、入参出参、业务规则、响应结构、数据表结构必须保持一致,两端 JWT/密码/加密互通(同库同 Redis 可互认)。
## 强制要求
- 本仓库任何**接口、业务逻辑、表结构、配置键**的改动,必须同步修改 `nl-admin-api-gf` 对应实现
- 若本次会话无法当场同步如用户明确说只改一端必须在回复末尾列出「gf 侧待同步清单」(文件级 + 行为级)
- 新增接口两端都实现完才算完成;禁止只改一端悄悄收尾
- 改动 `config/nl.php` 的键值(如 log.no_insert、redis 前缀、jwt同步改 gf 的 `manifest/config/config.yaml` 的 `nl` 段
## 层级对应关系(本仓库 → nl-admin-api-gf
| Laravel本仓库 | GoFramenl-admin-api-gf |
|---|---|
| routes/api.php autoRouteRegister | internal/cmd/cmd.go 手写注册 + internal/router/genroutes |
| app/Http/Controllers/Api及 core/*Controller | internal/controller/<模块>/ |
| app/Service/*Service | internal/logic/<模块>/ |
| app/Service/common/JWT/Utils/加密/OSS/AI/CURD 基类) | internal/library/auth/utils/crypto/oss/ai/curd/response/xerr |
| app/Models/*Model | internal/model/entity + internal/dao |
| app/Enum/ | internal/consts/consts.go |
| app/Http/Middleware/ApiOpLogMiddleware | internal/middleware/oplog.go |
| config/nl.php | manifest/config/config.yaml 的 nl 段 |
| app/template/api/view/uniapp 代码生成模板) | resource/template/api 模板为 Go 版view/uniapp 语义一致) |
## 必须对齐的行为基线(两端一致,改动需双端验证)
- 鉴权:`Authorization: Bearer <JWT>`HS256同 secret 可互认)+ Redis `nl_login_{id}`(不设 TTL无独立鉴权中间件差异不影响行为口径
- 响应:`{code, message, result, type}`,成功 code=0失败默认 500HTTP 恒 200sql 安装向导 `{success,...}` 除外)
- 密码 bcryptPHP password_hash ↔ Go bcrypt cost12 互通);库内敏感字段 AES-256-CBC密文前缀 `nl_ase_256_`
- 分页 `page`/`pageSize` 默认 1/20个人 login-log/op-log 默认 10 上限 100兼容 `size`);软删 `deleted_at`;批量删除入参 `ids`
- 操作日志按 `nl_api_endpoint` 的 `is_log`/`status` 落 `nl_api_op_log`,脱敏键与跳过白名单两端同步
- 代码生成产物:后端代码 + 建表 + 路由挂载 + `nl_menu` 菜单 + zipview/ 与 uniapp/ 双前端),两端能力必须同口径

View File

@@ -43,8 +43,8 @@ CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=GS2H37yZAxNPBtXA
REDIS_HOST=1Panel-redis-TxPD
REDIS_PASSWORD=redis_Pa2Y7J
REDIS_PORT=6379
# 邮件发送配置
@@ -79,5 +79,12 @@ VITE_APP_NAME="${APP_NAME}"
BASE_OLD_API_URL=http://www.xk888.com
BASE_OLD_SHOP_API_URL=https://shop.xiaokang88.com
# 密钥
# 密钥(库内敏感字段 AES前缀 nl_ase_256_
ENCRYPT_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a
# JWT HS256 密钥(至少 32 字符;生产环境务必改成随机长串)
JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b
# AI 供应商环境回落(库内未配置密钥时可用)
AI_DEFAULT_PROVIDER=deepseek
SPARK_API_PASSWORD=
DEEPSEEK_API_KEY=

View File

@@ -1,61 +1,20 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Nl Admin
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## 配置好数据库连接信息后执行
```aiignore
php artisan sql:run-nl-admin
```
### 使用示例
```cmd
# 基本用法
php artisan sql:run-nl-admin
## About Laravel
# 指定SQL文件路径
php artisan sql:run-nl-admin --file=storage/sql/setup.sql
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
# 强制重置已有数据库
php artisan sql:run-nl-admin --force
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
# 创建新数据库
php artisan sql:run-nl-admin --database=my_new_db
```

View File

@@ -2,22 +2,111 @@
namespace App\BaseApp;
class BaseClient
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
class BaseClient extends BaseService
{
private static mixed $_instance;
protected $client;
protected string $baseUrl = '';
protected array $headers = [];
public function __construct()
{
$this->isAuth = false;
parent::__construct();
}
/**
* 获取实例
* @return null|static
* 初始化请求类
* @return $this
* @throws Exception
*/
public static function getInstance(): null|static
protected function init(): static
{
$name = get_called_class();
if (!isset(self::$_instance[$name])) {
self::$_instance[$name] = new static();
if (empty($this->baseUrl)) {
$this->utils->errorThrow('请设置baseUrl');
}
$this->client = new Client([
'timeout' => 60.0,
'connect_timeout' => 10.0,
'base_uri' => $this->baseUrl,
'verify' => false, // 禁用SSL证书验证不推荐用于生产环境
]);
return $this;
}
return self::$_instance[$name];
/**
* GET 请求
* @param $uri
* @param array $options
* @return array
* @throws Exception
*/
public function get($uri, array $options = []): array
{
return $this->init()->request('GET', $uri, [ 'query' => $options, 'headers' => $this->headers ]);
}
/**
* POST 请求
* @param $uri
* @param array $options
* @return array
* @throws Exception
*/
public function post($uri, array $options = []): array
{
return $this->init()->request('POST', $uri, [ 'form_params' => $options, 'headers' => $this->headers ]);
}
/**
* POST 请求
* @param $uri
* @param array $options
* @return array
* @throws Exception
*/
public function postJson($uri, array $options = []): array
{
// ds([ 'json' => $options, 'headers' => $this->headers, 'url' => $this->baseUrl. $uri ]);
return $this->init()->request('POST', $uri, [ 'json' => $options, 'headers' => $this->headers ]);
}
/**
* 请求
* @param $method
* @param $uri
* @param array $options
* @return array
*/
private function request($method, $uri, array $options): array
{
try {
$response = $this->client->request($method, $uri, $options);
return [
'status_code' => $response->getStatusCode(),
'body' => json_decode((string)$response->getBody(), true),
];
} catch (RequestException $e) {
return [
'status_code' => $e->getCode(),
'error_message' => $e->getMessage(),
'type' => 'RequestException',
'body' => []
];
} catch (GuzzleException $e) {
return [
'status_code' => $e->getCode(),
'error_message' => $e->getMessage(),
'type' => 'GuzzleException',
'body' => []
];
}
}
}

View File

@@ -141,13 +141,19 @@ class BaseController
$result = [];
foreach ($checkFields as $fieldName) {
$checkValue = $data[$fieldName] ?? '';
if (is_array($checkValue) && !empty($checkValue)) {
$result[$fieldName] = $checkValue;
if (is_array($checkValue)) {
// 数组值单独处理:非空才收;空数组按「跳过」(与 GF 版一致),
// 且绝不对数组做 strval避免触发 "Array to string conversion" 警告
if (!empty($checkValue)) {
$result[$fieldName] = $checkValue;
}
} else {
if (mb_strlen(strval($checkValue)) <= 0 && !in_array($fieldName, $this->notRequest)) {
$requiredFields[] = $fieldName;
} else {
if (!empty($checkValue) || $checkValue === 0) {
// 用字符串长度判断是否有值,避免 !empty 把合法的 0 / "0" 误当空丢弃
// HTTP 参数皆为字符串status=0 会传成 "0",原写法会导致状态无法写入/改回启用)
if (mb_strlen(strval($checkValue)) > 0) {
$result[$fieldName] = $checkValue;
}
}

View File

@@ -89,12 +89,18 @@ class BaseService
* @var string 时间字段
*/
protected string $timeField = 'created_at';
/**
* @var bool 是否鉴权
*/
protected $isAuth = true;
public function __construct()
{
$this->userInfo = JWTService::getInstance()->getToken()->getUserInfo();
$this->userId = $this->userInfo['id'] ?? 0;
$this->roleId = $this->userInfo['role_id'] ?? 0;
if ($this->isAuth === true) {
$this->userInfo = JWTService::getInstance()->getToken()->getUserInfo();
$this->userId = $this->userInfo['id'] ?? 0;
$this->roleId = $this->userInfo['role_id'] ?? 0;
}
$this->utils = UtilsService::getInstance();
}

View File

@@ -0,0 +1,661 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use PDOException;
class RunNlAdminSql extends Command
{
protected $signature = 'sql:run-nl-admin
{--file=public/nl_admin.sql : SQL文件路径}
{--force : 强制执行,跳过确认}
{--database= : 指定数据库名称}
{--charset=utf8mb4 : 数据库字符集}
{--collation=utf8mb4_unicode_ci : 数据库排序规则}';
protected $description = '执行 SQL 文件,自动处理数据库相关事项';
protected $maxFileSize = 50; // 最大文件大小 (MB)
protected $chunkSize = 10; // 分块大小 (MB)
public function handle()
{
$this->info('🚀 开始数据库安装程序');
// 1. 确保基础配置加载
$this->ensureConfigLoaded();
// 2. 优先使用.env配置创建数据库
$filePath = $this->getSqlFilePath();
// 3. 配置数据库连接(如果需要)
$this->ensureDatabaseConnection();
// 4. 处理目标数据库
$this->prepareTargetDatabase();
// 5. 执行SQL文件
$this->executeSql($filePath);
$this->info('✅ 数据库安装成功完成');
return 0;
}
/**
* 确保配置已加载
*/
protected function ensureConfigLoaded(): void
{
$this->call('config:clear');
// 确保数据库配置存在
$envPath = base_path('.env');
if (!file_exists($envPath)) {
File::copy(base_path('.env.example'), $envPath);
$this->info('📄 .env文件已创建基于.example');
}
}
/**
* 获取SQL文件路径
*/
protected function getSqlFilePath(): string
{
$filePath = base_path($this->option('file'));
// 验证文件是否存在
if (!file_exists($filePath)) {
$this->error("❌ SQL 文件未找到:{$filePath}");
$this->info("💡 请使用 --file 选项指定正确的文件路径");
exit(1);
}
// 检查文件大小
$fileSize = filesize($filePath) / 1024 / 1024; // MB
if ($fileSize > $this->maxFileSize) {
$this->warn("⚠️ 注意SQL文件较大 (" . round($fileSize, 2) . "MB),执行可能需要较长时间");
}
$this->info("📁 SQL文件位置: {$filePath}");
$this->info("📏 文件大小: " . round($fileSize, 2) . 'MB');
return $filePath;
}
/**
* 确保数据库连接可用
*/
protected function ensureDatabaseConnection(): void
{
$this->line('🔌 检查数据库连接...');
$retryCount = 0;
while (true) {
try {
DB::connection()->getPdo();
$this->info('✅ 数据库连接正常');
return;
} catch (\Exception $e) {
$this->error("❌ 数据库连接失败: " . $e->getMessage());
if ($retryCount > 0 || !$this->confirm('❓ 是否配置数据库连接?', true)) {
$this->error('🔌 无法连接数据库,操作终止');
exit(1);
}
$this->configureDatabase();
$retryCount++;
}
}
}
/**
* 配置数据库连接信息优先使用env默认值
*/
protected function configureDatabase(): void
{
$this->info('🔧 请提供数据库连接信息(留空使用默认值)');
$currentConfig = Config::get('database.connections.mysql');
$config = [
'DB_HOST' => $this->ask('数据库主机', $currentConfig['host'] ?? '127.0.0.1'),
'DB_PORT' => $this->ask('端口', $currentConfig['port'] ?? '3306'),
'DB_DATABASE' => $this->ask('数据库名', $currentConfig['database'] ?? 'nl_admin'),
'DB_USERNAME' => $this->ask('用户名', $currentConfig['username'] ?? 'root'),
'DB_PASSWORD' => $this->secret('密码') ?: ($currentConfig['password'] ?? ''),
];
// 更新 .env 文件
$this->updateEnvFile($config);
$this->info('📝 .env 文件已更新');
// 刷新配置缓存
$this->call('config:clear');
// 更新运行时配置
foreach ($config as $key => $value) {
$configKey = strtolower(substr($key, 3));
Config::set("database.connections.mysql.{$configKey}", $value);
}
// 测试连接并创建数据库
$this->testDatabaseConnection();
}
/**
* 测试数据库连接并创建数据库(如果需要)
*/
protected function testDatabaseConnection(): void
{
try {
// 测试连接
DB::connection('mysql')->getPdo();
$this->info('✅ 数据库连接测试成功');
} catch (\Exception $e) {
$this->error("❌ 配置后连接测试失败: " . $e->getMessage());
if ($this->confirm('是否尝试创建数据库?', true)) {
$this->createDatabaseFromEnv();
$this->info('✅ 数据库创建成功');
// 再次尝试连接
try {
DB::connection()->getPdo();
$this->info('✅ 数据库连接成功');
} catch (\Exception $e) {
$this->error("❌ 创建数据库后连接失败: " . $e->getMessage());
exit(1);
}
} else {
$this->error('❌ 无法连接数据库,操作终止');
exit(1);
}
}
}
/**
* 使用.env配置创建数据库
*/
protected function createDatabaseFromEnv(): void
{
$dbConfig = [
'driver' => 'mysql',
'host' => config('database.connections.mysql.host'),
'port' => config('database.connections.mysql.port'),
'username' => config('database.connections.mysql.username'),
'password' => config('database.connections.mysql.password'),
'database' => config('database.connections.mysql.database'),
'charset' => config('database.connections.mysql.charset', 'utf8mb4'),
'collation' => config('database.connections.mysql.collation', 'utf8mb4_unicode_ci')
];
$this->createDatabase($dbConfig);
}
/**
* 更新 .env 文件
*/
protected function updateEnvFile(array $config): void
{
$envPath = base_path('.env');
$envContents = file_exists($envPath) ? File::get($envPath) : '';
foreach ($config as $key => $value) {
$escapedValue = str_replace('"', '\\"', $value);
$line = "{$key}=\"{$escapedValue}\"";
if (preg_match("/^{$key}=.*/m", $envContents)) {
$envContents = preg_replace("/^{$key}=.*/m", $line, $envContents);
} else {
$envContents .= PHP_EOL . $line;
}
}
File::put($envPath, $envContents);
}
/**
* 准备目标数据库(基于.env配置
*/
protected function prepareTargetDatabase(): void
{
$this->line('🗄️ 检查目标数据库...');
$connection = config('database.default');
$dbConfig = config("database.connections.{$connection}");
$driver = $dbConfig['driver'];
// 处理数据库名称选项(优先使用命令行)
$databaseName = $this->getDatabaseName($dbConfig);
$this->info("📊 数据库驱动: {$driver}");
$this->info("🗃️ 目标数据库: {$databaseName}");
// 检查数据库是否存在
if (!$this->databaseExists($dbConfig)) {
$this->warn("❓ 数据库 {$databaseName} 不存在");
if ($this->option('force') || $this->confirm("是否创建数据库 {$databaseName}?", true)) {
$this->createDatabase([
'driver' => $driver,
'host' => $dbConfig['host'],
'port' => $dbConfig['port'],
'username' => $dbConfig['username'],
'password' => $dbConfig['password'],
'database' => $databaseName,
'charset' => $this->option('charset'),
'collation' => $this->option('collation')
]);
$this->info("✅ 数据库 {$databaseName} 创建成功");
} else {
$this->error('❌ 操作已取消');
exit(1);
}
}
// 刷新数据库连接确保使用的是目标数据库
$this->ensureUsingTargetDatabase($databaseName);
// 检查数据库表
$this->checkDatabaseTables($databaseName);
}
/**
* 获取数据库名称(优先使用命令行选项)
*/
protected function getDatabaseName(array $defaultConfig): string
{
if ($database = $this->option('database')) {
return $database;
}
return $defaultConfig['database'];
}
/**
* 确保连接到目标数据库
*/
protected function ensureUsingTargetDatabase(string $databaseName): void
{
// 更新配置为当前目标数据库
Config::set("database.connections.".config('database.default').".database", $databaseName);
// 刷新连接
DB::purge(config('database.default'));
DB::reconnect(config('database.default'));
$this->line("🔁 已切换至数据库: {$databaseName}");
}
/**
* 检查数据库表
*/
protected function checkDatabaseTables(string $databaseName): void
{
// 优化的表检查逻辑
$tableCount = $this->getTableCount($databaseName);
if ($tableCount > 0) {
$this->warn("⚠️ 数据库 {$databaseName} 包含 {$tableCount} 张表");
if (!$this->option('force') &&
!$this->confirm("此操作将重置数据库 {$databaseName} 中的所有数据。继续执行吗?", false)) {
$this->error('❌ 操作已取消');
exit(1);
}
} elseif ($tableCount === 0) {
$this->info("📭 数据库 {$databaseName} 为空");
} else {
$this->warn("⚠️ 无法确定数据库表数量,请谨慎操作");
}
}
/**
* 获取数据库中的表数量(优化版)
*/
protected function getTableCount(string $databaseName): int
{
try {
$driver = DB::getDriverName();
if ($driver === 'mysql') {
$result = DB::select("SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = ?",
[$databaseName]);
return (int) ($result[0]->count ?? 0);
}
if ($driver === 'sqlite') {
$result = DB::select("SELECT COUNT(*) AS count
FROM sqlite_master
WHERE type='table'");
return (int) ($result[0]->count ?? 0);
}
if ($driver === 'pgsql') {
$result = DB::select("SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema='public'");
return (int) ($result[0]->count ?? 0);
}
// 其他数据库使用默认方法
$result = DB::select("SHOW TABLES");
return count($result);
} catch (\Exception $e) {
// 出现错误时返回-1表示无法确定
$this->warn("⚠️ 无法确定表数量: " . $e->getMessage());
return -1;
}
}
/**
* 执行SQL
*/
protected function executeSql(string $filePath): void
{
$fileSize = filesize($filePath) / 1024 / 1024; // MB
if ($fileSize > $this->maxFileSize) {
$this->executeSqlFileChunks($filePath);
} else {
$this->executeSingleSqlFile($filePath);
}
}
/**
* 单块执行SQL文件
*/
protected function executeSingleSqlFile(string $filePath): void
{
$this->line('🚀 开始导入SQL文件...');
try {
$sql = file_get_contents($filePath);
$this->smartTransactionHandler(function() use ($sql) {
$this->executeRawSql($sql);
});
$this->info('✅ SQL文件导入成功');
} catch (\Exception $e) {
$this->handleSqlError($e, $sql ?? '');
}
}
/**
* 分块执行SQL文件
*/
protected function executeSqlFileChunks(string $filePath): void
{
$this->line('🚀 开始分块导入SQL文件...');
$fileSize = filesize($filePath);
$chunkSize = $this->chunkSize * 1024 * 1024;
$chunkCount = ceil($fileSize / $chunkSize);
$this->info("📦 文件大小: " . round($fileSize / 1024 / 1024, 2) . "MB");
$this->info("🧩 分块数量: {$chunkCount} (每块 {$this->chunkSize}MB)");
$handle = fopen($filePath, 'r');
$chunkNum = 1;
$position = 0;
try {
while (!feof($handle)) {
// 定位到下一分块
fseek($handle, $position);
// 读取当前分块
$chunkData = fread($handle, $chunkSize);
// 确保完整语句 (找到下一个分号)
$lastSemicolon = strrpos($chunkData, ';');
if ($lastSemicolon !== false) {
$chunkData = substr($chunkData, 0, $lastSemicolon + 1);
}
$chunkSizeActual = strlen($chunkData);
$this->line("🔧 处理分块 {$chunkNum}/{$chunkCount} (" . round($chunkSizeActual / 1024 / 1024, 2) . "MB)...");
$this->smartTransactionHandler(function() use ($chunkData) {
$this->executeRawSql($chunkData);
});
$position += $chunkSizeActual;
$chunkNum++;
}
$this->info('✅ SQL文件导入成功');
} catch (\Exception $e) {
$this->handleSqlError($e, $chunkData ?? '');
} finally {
fclose($handle);
}
}
/**
* 智能事务处理
*/
protected function smartTransactionHandler(callable $callback): void
{
$driver = DB::getDriverName();
$this->prepareDatabaseForExecution($driver);
try {
// 对于支持事务的数据库驱动使用事务
if (in_array($driver, ['mysql', 'pgsql', 'sqlsrv', 'sqlite'])) {
DB::beginTransaction();
$callback();
DB::commit();
} else {
// 不支持事务的数据库直接执行
$callback();
}
} catch (\Exception $e) {
// 回滚事务(如果已经开启)
if (in_array($driver, ['mysql', 'pgsql', 'sqlsrv', 'sqlite']) &&
DB::connection()->transactionLevel() > 0) {
DB::rollBack();
}
throw $e;
} finally {
$this->restoreDatabaseSettings($driver);
}
}
/**
* 准备数据库执行环境
*/
protected function prepareDatabaseForExecution(string $driver): void
{
// MySQL 需要禁用外键检查
if ($driver === 'mysql') {
DB::statement('SET FOREIGN_KEY_CHECKS=0');
DB::statement('SET AUTOCOMMIT=0');
}
// PostgreSQL 需要禁用触发器
if ($driver === 'pgsql') {
DB::statement('SET session_replication_role = replica;');
}
}
/**
* 恢复数据库设置
*/
protected function restoreDatabaseSettings(string $driver): void
{
// MySQL 恢复设置
if ($driver === 'mysql') {
DB::statement('SET FOREIGN_KEY_CHECKS=1');
DB::statement('SET AUTOCOMMIT=1');
}
// PostgreSQL 恢复设置
if ($driver === 'pgsql') {
DB::statement('SET session_replication_role = DEFAULT;');
}
}
/**
* 执行原始SQL语句
*/
protected function executeRawSql(string $sql): void
{
// 使用原生方法执行以避免事务问题
try {
DB::getPdo()->exec($sql);
} catch (PDOException $e) {
// 处理部分SQL的错误记录但不中断
if (str_contains($e->getMessage(), 'already exists') ||
str_contains($e->getMessage(), 'doesn\'t exist')) {
$this->warn("⚠️ SQL警告: " . $e->getMessage());
} else {
throw $e;
}
}
}
/**
* 处理SQL错误
*/
protected function handleSqlError(\Exception $e, string $sqlFragment): void
{
$errorMsg = $e->getMessage();
$this->error("❌ SQL执行错误: {$errorMsg}");
// MySQL错误处理
if ($this->isMysql() && preg_match('/at line (\d+)/i', $errorMsg, $matches)) {
$lineNum = $matches[1];
$this->error("📌 错误位置: 大约第 {$lineNum}");
}
// 创建错误报告
$this->createErrorReport($e, $sqlFragment);
$this->error('❌ 数据库安装失败');
exit(1);
}
/**
* 创建错误报告
*/
protected function createErrorReport(\Exception $e, string $sqlFragment): void
{
$timestamp = date('Ymd-His');
$reportFile = storage_path("logs/sql_error_{$timestamp}.log");
$content = "SQL 错误报告: " . date('Y-m-d H:i:s') . PHP_EOL;
$content .= "错误信息: " . $e->getMessage() . PHP_EOL;
$content .= "错误位置: " . $e->getFile() . ':' . $e->getLine() . PHP_EOL;
$content .= "数据库驱动: " . config('database.default') . PHP_EOL;
$content .= "数据库名称: " . config("database.connections.".config('database.default').".database") . PHP_EOL;
if (!empty($sqlFragment)) {
$content .= PHP_EOL . "相关SQL片段: " . PHP_EOL;
$content .= substr($sqlFragment, -2000) . PHP_EOL; // 最后2000字符
}
file_put_contents($reportFile, $content);
$this->warn("📝 错误报告已保存到: {$reportFile}");
$this->line("💡 请将此文件提供给开发人员以诊断问题");
}
/**
* 检查是否为MySQL驱动
*/
protected function isMysql(): bool
{
return DB::getDriverName() === 'mysql';
}
/**
* 检查数据库是否存在基于env配置
*/
protected function databaseExists(array $config): bool
{
if ($config['driver'] === 'mysql') {
try {
$dbName = $this->getDatabaseName($config);
$result = DB::select("SELECT SCHEMA_NAME
FROM information_schema.SCHEMATA
WHERE SCHEMA_NAME = ?",
[$dbName]);
return !empty($result);
} catch (\Exception $e) {
$this->warn("⚠️ 数据库检查错误: " . $e->getMessage());
return false;
}
}
// SQLite 检查
if ($config['driver'] === 'sqlite') {
return file_exists($config['database']);
}
// PostgreSQL 检查
if ($config['driver'] === 'pgsql') {
try {
DB::statement("SELECT 1 FROM pg_database WHERE datname = ?", [$config['database']]);
return true;
} catch (\Exception) {
return false;
}
}
// 默认检查方法
try {
DB::statement("USE {$config['database']}");
return true;
} catch (\Exception) {
return false;
}
}
/**
* 创建数据库基于env配置
*/
protected function createDatabase(array $config): void
{
$dbName = $this->getDatabaseName($config);
$charset = $this->option('charset');
$collation = $this->option('collation');
$this->line("⚙️ 创建 {$config['driver']} 数据库: {$dbName} (字符集: {$charset}, 排序规则: {$collation})");
if ($config['driver'] === 'mysql') {
// 临时连接到系统数据库以创建新数据库
DB::purge('mysql');
Config::set('database.connections.mysql.database', null);
DB::reconnect('mysql');
$query = "CREATE DATABASE IF NOT EXISTS `{$dbName}` ";
$query .= "CHARACTER SET {$charset} COLLATE {$collation}";
DB::statement($query);
// 恢复配置
Config::set('database.connections.mysql.database', $dbName);
return;
}
if ($config['driver'] === 'sqlite') {
file_put_contents($config['database'], '');
return;
}
if ($config['driver'] === 'pgsql') {
DB::statement("CREATE DATABASE \"{$dbName}\"");
return;
}
$this->error("🚫 不支持的数据库驱动: {$config['driver']}");
exit(1);
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Core;
use Exception;
use RuntimeException;
/**
* 库内敏感字段 AES-256-CBC 加解密
* 密文格式nl_ase_256_ + base64(iv + ciphertext)
* 密钥派生hash('sha256', ENCRYPT_KEY, true) 32 字节
* 读取时兼容历史前缀 ***(迁移期)
*/
class DatabaseEncryptor
{
/** 新密文前缀(入库统一用这个) */
public const PREFIX = 'nl_ase_256_';
/** 历史密文前缀(仅解密兼容) */
public const LEGACY_PREFIX = '***';
private string $cipherMethod = 'AES-256-CBC';
private string $secretKey;
private int $ivLength;
/**
* @param string $key 来自 config('nl.encrypt_key') / env ENCRYPT_KEY
*/
public function __construct(string $key)
{
$key = trim($key);
if ($key === '') {
throw new RuntimeException('AES 加密密钥未配置(请设置 ENCRYPT_KEY');
}
$this->secretKey = hash('sha256', $key, true);
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
if ($ivLength === false) {
throw new RuntimeException('不支持的加密算法:' . $this->cipherMethod);
}
$this->ivLength = $ivLength;
}
/**
* 加密后入库;空串不加密,已是密文则原样返回避免二次加密
*
* @param string|null $data 明文
* @throws Exception
*/
public function encrypt($data): string
{
if ($data === null || $data === '') {
return '';
}
$data = (string) $data;
if (self::isEncrypted($data)) {
return $data;
}
$iv = openssl_random_pseudo_bytes($this->ivLength);
if ($iv === false) {
throw new RuntimeException('生成 IV 失败');
}
$encryptedData = openssl_encrypt($data, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($encryptedData === false) {
throw new RuntimeException('加密失败:' . (openssl_error_string() ?: 'unknown'));
}
return self::PREFIX . base64_encode($iv . $encryptedData);
}
/**
* 从库内密文解密;无已知前缀视为明文原样返回
*
* @param string|null $base64EncodedEncryptedData 库内值
* @throws Exception
*/
public function decrypt($base64EncodedEncryptedData): string
{
if ($base64EncodedEncryptedData === null || $base64EncodedEncryptedData === '') {
return '';
}
$base64EncodedEncryptedData = (string) $base64EncodedEncryptedData;
$prefix = self::detectPrefix($base64EncodedEncryptedData);
if ($prefix === null) {
return $base64EncodedEncryptedData;
}
$payload = substr($base64EncodedEncryptedData, strlen($prefix));
$decodedData = base64_decode($payload, true);
if ($decodedData === false) {
throw new RuntimeException('Base64 解码失败');
}
if (strlen($decodedData) <= $this->ivLength) {
throw new RuntimeException('密文长度非法');
}
$iv = substr($decodedData, 0, $this->ivLength);
$encryptedData = substr($decodedData, $this->ivLength);
$decryptedData = openssl_decrypt($encryptedData, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($decryptedData === false) {
throw new RuntimeException('解密失败:' . (openssl_error_string() ?: 'unknown'));
}
return $decryptedData;
}
/**
* 是否已是库内密文(含历史前缀)
*/
public static function isEncrypted(?string $value): bool
{
return self::detectPrefix($value) !== null;
}
/**
* 识别密文前缀;新前缀优先
*/
public static function detectPrefix(?string $value): ?string
{
if (!is_string($value) || $value === '') {
return null;
}
if (str_starts_with($value, self::PREFIX)) {
return self::PREFIX;
}
if (str_starts_with($value, self::LEGACY_PREFIX)) {
return self::LEGACY_PREFIX;
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
/**
* AI 生成场景(本项目可扩展,勿塞入其他业务域专有场景)
*/
enum AiGenerationSceneEnum: string
{
/** 根据描述直接生成代码结构 JSON */
case CODE_GENERATION = 'code_generation';
/** 根据模块名生成/优化需求概览与代码生成提示词 */
case REQUIREMENT_PROMPT = 'requirement_prompt';
public function description(): string
{
return match ($this) {
self::CODE_GENERATION => '代码生成',
self::REQUIREMENT_PROMPT => '需求概览/提示词',
};
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
/**
* AI 生成记录状态
*/
enum AiGenerationStatusEnum: int
{
case RUNNING = 0;
case SUCCESS = 1;
case FAIL = 2;
public function description(): string
{
return match ($this) {
self::RUNNING => '进行中',
self::SUCCESS => '成功',
self::FAIL => '失败',
};
}
}

112
app/Enum/OssDriverEnum.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
namespace App\Enum;
/**
* OSS 存储驱动枚举:管理端卡片展示与上传分发共用
*/
enum OssDriverEnum: string
{
case Local = 'local';
case Aliyun = 'aliyun';
case Qcloud = 'qcloud';
case Qiniu = 'qiniu';
case Huawei = 'huawei';
case Aws = 'aws';
case Minio = 'minio';
case Baidu = 'baidu';
/**
* 驱动编码(与库表 driver 一致)
*/
public function code(): string
{
return $this->value;
}
/**
* 中文展示名
*/
public function label(): string
{
return match ($this) {
self::Local => '本地存储',
self::Aliyun => '阿里云 OSS',
self::Qcloud => '腾讯云 COS',
self::Qiniu => '七牛云',
self::Huawei => '华为云 OBS',
self::Aws => 'AWS S3',
self::Minio => 'MinIO',
self::Baidu => '百度云 BOS',
};
}
/**
* 卡片说明文案
*/
public function description(): string
{
return match ($this) {
self::Local => '文件保存到应用服务器本地磁盘',
self::Aliyun => '阿里云对象存储 OSS',
self::Qcloud => '腾讯云对象存储 COS',
self::Qiniu => '七牛云对象存储 Kodo',
self::Huawei => '华为云对象存储 OBS',
self::Aws => '亚马逊 S3 对象存储',
self::Minio => 'S3 兼容的私有化对象存储',
self::Baidu => '百度智能云对象存储 BOS',
};
}
/**
* 前端卡片图标iconify
*/
public function icon(): string
{
return match ($this) {
self::Local => 'lucide:hard-drive',
self::Aliyun => 'simple-icons:alibabadotcom',
self::Qcloud => 'simple-icons:tencentqq',
self::Qiniu => 'lucide:cloud',
self::Huawei => 'simple-icons:huawei',
self::Aws => 'simple-icons:amazonaws',
self::Minio => 'simple-icons:minio',
self::Baidu => 'simple-icons:baidu',
};
}
/**
* 是否需要 AccessKey / SecretKey
*/
public function needsSecret(): bool
{
return $this !== self::Local;
}
/**
* 前端驱动选项列表(含图标,供卡片展示)
*/
public static function options(): array
{
$list = [];
foreach (self::cases() as $case) {
$list[] = [
'code' => $case->code(),
'name' => $case->label(),
'label' => $case->label(),
'description' => $case->description(),
'icon' => $case->icon(),
'needs_secret' => $case->needsSecret() ? 1 : 0,
];
}
return $list;
}
/**
* 校验驱动编码是否合法
*/
public static function isValid(string $code): bool
{
return self::tryFrom($code) !== null;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Enum;
/**
* 公众号图文文章状态枚举
* 发布走微信 freepublish 异步审核:提交后先置「发布中」,由 publish-status 轮询回填终态
*/
enum WechatArticleStatusEnum: int
{
// 0草稿 1发布中 2已发布 3发布失败
case DRAFT = 0;
case PUBLISHING = 1;
case PUBLISHED = 2;
case FAILED = 3;
/**
* 中文展示名
*/
public function description(): string
{
return match ($this) {
self::DRAFT => '草稿',
self::PUBLISHING => '发布中',
self::PUBLISHED => '已发布',
self::FAILED => '发布失败',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Enum;
/**
* 公众号图文版本流水的操作类型枚举
* 每次创建/修改/删除/回退/发布都会写一条版本快照action 标记这次快照的来源
*/
enum WechatVersionActionEnum: int
{
// 1创建 2修改 3删除 4回退 5发布
case CREATE = 1;
case UPDATE = 2;
case DELETE = 3;
case ROLLBACK = 4;
case PUBLISH = 5;
/**
* 中文展示名(版本列表直接用)
*/
public function description(): string
{
return match ($this) {
self::CREATE => '创建',
self::UPDATE => '修改',
self::DELETE => '删除',
self::ROLLBACK => '回退',
self::PUBLISH => '发布',
};
}
}

View File

@@ -73,6 +73,33 @@ class AdminController extends BaseController
);
}
/**
* 个人中心更新资料
* @Method POST
*/
public function updateProfile(): JsonResponse
{
return jok($this->service->updateProfile(request()->post()), '保存成功');
}
/**
* 个人中心:登录日志
* @Method GET
*/
public function loginLog(): JsonResponse
{
return jok($this->service->loginLogList(), '获取成功');
}
/**
* 个人中心:操作日志
* @Method GET
*/
public function opLog(): JsonResponse
{
return jok($this->service->opLogList(), '获取成功');
}
/**
* 获取菜单列表
* @Method GET

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\AiConfigService;
use Illuminate\Http\JsonResponse;
/**
* AI 配置控制器:运行时选用 + 密钥管理
*/
class AiConfigController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = AiConfigService::getInstance();
}
/**
* 禁用基类通用列表(本模块用 keyList
* @Method NO
*/
public function list(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function create(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function update(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function delete(): JsonResponse
{
return jerr('不支持');
}
/**
* 获取 AI 管理页选项(平台/模型/密钥卡片数据 + 当前激活项)
* @Method GET
*/
public function runtimeOptions(): JsonResponse
{
return jok($this->service->getRuntimeOptions(), '获取成功');
}
/**
* 保存默认 AI 平台/模型/密钥组合
* @Method POST
*/
public function saveRuntime(): JsonResponse
{
$params = request()->post();
return jok($this->service->saveRuntime($params), '保存成功');
}
/**
* 密钥列表(卡片展示)
* @Method GET
*/
public function keyList(): JsonResponse
{
return jok($this->service->listKeys(), '获取成功');
}
/**
* 平台下拉
* @Method GET
*/
public function platformOption(): JsonResponse
{
return jok($this->service->platformOptions(), '获取成功');
}
/**
* 新增密钥
* @Method POST
*/
public function createKey(): JsonResponse
{
$params = request()->post();
return jok($this->service->createKey($params), '创建成功');
}
/**
* 更新密钥
* @Method POST
*/
public function updateKey(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->updateKey($id, $params), '更新成功');
}
/**
* 删除密钥
* @Method POST
*/
public function deleteKey(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids)) {
return jerr('参数错误');
}
if (!is_array($ids)) {
$ids = [$ids];
}
return jok($this->service->deleteKeys($ids), '删除成功');
}
/**
* AI 生成历史列表
* @Method GET
*/
public function generationList(): JsonResponse
{
return jok($this->service->generationList(), '获取成功');
}
/**
* AI 生成历史详情
* @Method GET
*/
public function generationDetail(): JsonResponse
{
$id = (int) request()->get('id', 0);
if ($id <= 0) {
return jerr('参数错误');
}
return jok($this->service->generationDetail($id), '获取成功');
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\ApiEndpointService;
use Illuminate\Http\JsonResponse;
/**
* 接口注册表控制器:仅开放 list / update
*/
class ApiEndpointController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = ApiEndpointService::getInstance();
}
/**
* 分页列表
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->list(), '获取成功');
}
/**
* 更新接口说明 / 是否记日志 / 状态
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->update($id, $params), '更新成功');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function create(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function delete(): JsonResponse
{
return jerr('不支持');
}
}

View File

@@ -0,0 +1 @@
# 生成的代码放在这里

View File

@@ -0,0 +1,308 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\DatabaseService;
use Exception;
use Illuminate\Http\JsonResponse;
class DatabaseController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = DatabaseService::getInstance();
}
/**
* 获取表列表
* @Method GET
* @return JsonResponse
*/
public function listTables(): JsonResponse
{
return jok($this->service->listTables());
}
/**
* 获取表详细信息
* @Method GET
* @return JsonResponse
* @throws Exception
*/
public function getTableInfo(): JsonResponse
{
$tableName = request()->get('table_name');
if (!$tableName) {
return jerr('表名不能为空');
}
return jok($this->service->getTableInfo($tableName));
}
/**
* 获取表数据(分页)
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function getTableData(): JsonResponse
{
$tableName = request()->post('table_name');
$page = request()->post('page', 1);
$pageSize = request()->post('page_size', 20);
$where = request()->post('where', []);
if (!$tableName) {
return jerr('表名不能为空');
}
return jok($this->service->getTableData($tableName, $page, $pageSize, $where));
}
/**
* 更新表数据
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function updateTableData(): JsonResponse
{
$tableName = request()->post('table_name');
$data = request()->post('data', []);
$where = request()->post('where', []);
if (!$tableName) {
return jerr('表名不能为空');
}
if (empty($data)) {
return jerr('更新数据不能为空');
}
if (empty($where)) {
return jerr('WHERE条件不能为空');
}
$result = $this->service->updateTableData($tableName, $data, $where);
return jok($result, '更新成功');
}
/**
* 删除表数据
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function deleteTableData(): JsonResponse
{
$tableName = request()->post('table_name');
$where = request()->post('where', []);
if (!$tableName) {
return jerr('表名不能为空');
}
if (empty($where)) {
return jerr('WHERE条件不能为空');
}
$result = $this->service->deleteTableData($tableName, $where);
return jok($result, '删除成功');
}
/**
* 批量删除表数据
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function batchDeleteTableData(): JsonResponse
{
$tableName = request()->post('table_name');
$where = request()->post('where', []);
if (!$tableName) {
return jerr('表名不能为空');
}
if (empty($where)) {
return jerr('WHERE条件不能为空');
}
$count = $this->service->batchDeleteTableData($tableName, $where);
return jok(['count' => $count], "成功删除 {$count} 条记录");
}
/**
* 插入表数据
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function insertTableData(): JsonResponse
{
$tableName = request()->post('table_name');
$data = request()->post('data', []);
if (!$tableName) {
return jerr('表名不能为空');
}
if (empty($data)) {
return jerr('插入数据不能为空');
}
$id = $this->service->insertTableData($tableName, $data);
return jok(['id' => $id], '插入成功');
}
/**
* 更新表注释
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function updateTableComment(): JsonResponse
{
$tableName = request()->post('table_name');
$comment = request()->post('comment', '');
if (!$tableName) {
return jerr('表名不能为空');
}
$this->service->updateTableComment($tableName, $comment);
return jok(true, '更新成功');
}
/**
* 更新字段注释
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function updateColumnComment(): JsonResponse
{
$tableName = request()->post('table_name');
$columnName = request()->post('column_name');
$comment = request()->post('comment', '');
if (!$tableName) {
return jerr('表名不能为空');
}
if (!$columnName) {
return jerr('字段名不能为空');
}
$this->service->updateColumnComment($tableName, $columnName, $comment);
return jok(true, '更新成功');
}
/**
* 添加索引
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function addIndex(): JsonResponse
{
$tableName = request()->post('table_name');
$indexName = request()->post('index_name');
$columns = request()->post('columns', []);
$unique = request()->post('unique', false);
$type = request()->post('type', 'BTREE');
if (!$tableName) {
return jerr('表名不能为空');
}
if (!$indexName) {
return jerr('索引名不能为空');
}
if (empty($columns)) {
return jerr('索引列不能为空');
}
$this->service->addIndex($tableName, $indexName, $columns, $unique, $type);
return jok(true, '添加成功');
}
/**
* 删除索引
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function dropIndex(): JsonResponse
{
$tableName = request()->post('table_name');
$indexName = request()->post('index_name');
if (!$tableName) {
return jerr('表名不能为空');
}
if (!$indexName) {
return jerr('索引名不能为空');
}
$this->service->dropIndex($tableName, $indexName);
return jok(true, '删除成功');
}
/**
* 修改表结构
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function updateTableStructure(): JsonResponse
{
$tableName = request()->post('table_name');
$action = request()->post('action'); // add|modify|drop
$columnInfo = request()->post('column_info', []);
if (!$tableName) {
return jerr('表名不能为空');
}
if (!in_array($action, ['add', 'modify', 'drop'])) {
return jerr('操作类型不正确');
}
$this->service->updateTableStructure($tableName, $action, $columnInfo);
return jok(true, '操作成功');
}
/**
* 执行SQL查询仅SELECT
* @Method POST
* @return JsonResponse
* @throws Exception
*/
public function executeSqlQuery(): JsonResponse
{
$sql = request()->post('sql');
if (empty($sql)) {
return jerr('SQL语句不能为空');
}
$results = $this->service->executeSelectQuery($sql);
return jok($results, '查询成功');
}
/**
* 获取变更记录列表
* @Method GET
* @return JsonResponse
* @throws Exception
*/
public function getChangeLog(): JsonResponse
{
$page = request()->get('page', 1);
$pageSize = request()->get('page_size', 20);
$filters = [
'table_name' => request()->get('table_name'),
'operation_type' => request()->get('operation_type'),
'start_time' => request()->get('start_time'),
'end_time' => request()->get('end_time'),
];
$result = $this->service->getChangeLog((int)$page, (int)$pageSize, array_filter($filters));
return jok($result, '获取成功');
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\OssConfigService;
use Illuminate\Http\JsonResponse;
/**
* OSS 存储配置控制器:驱动选项 / 启用切换 / 配置 CRUD
*/
class OssConfigController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = OssConfigService::getInstance();
}
/**
* 配置列表(卡片,无密钥明文)
* @Method GET
*/
public function list(): JsonResponse
{
return jok($this->service->listConfigs(), '获取成功');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* 新增配置(覆盖基类,走加密逻辑)
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->createConfig(request()->post()), '创建成功');
}
/**
* 更新配置
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->updateConfig($id, $params), '更新成功');
}
/**
* 删除配置
* @Method POST
*/
public function delete(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids)) {
return jerr('参数错误');
}
if (!is_array($ids)) {
$ids = [$ids];
}
return jok($this->service->deleteConfigs($ids), '删除成功');
}
/**
* 驱动枚举选项
* @Method GET
*/
public function driverOptions(): JsonResponse
{
return jok($this->service->driverOptions(), '获取成功');
}
/**
* 运行时选项(卡片 + 当前启用)
* @Method GET
*/
public function runtimeOptions(): JsonResponse
{
return jok($this->service->getRuntimeOptions(), '获取成功');
}
/**
* 启用指定配置
* @Method POST
*/
public function saveRuntime(): JsonResponse
{
return jok($this->service->saveRuntime(request()->post()), '保存成功');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\WechatAccountService;
use Illuminate\Http\JsonResponse;
/**
* 公众号账号配置控制器CRUDSecret 加密入库)/ 默认账号切换 / 连通性测试
* list/option/detail 走基类Service 内已做脱敏create/update 覆盖走加密逻辑
*/
class WechatAccountController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = WechatAccountService::getInstance();
}
/**
* 新增账号覆盖基类Secret 加密、AppID 查重在 Service 内处理)
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '创建成功');
}
/**
* 更新账号app_secret 传空 = 不修改密钥)
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->update($id, $params), '更新成功');
}
/**
* 设为默认发布账号
* @Method POST
*/
public function setDefault(): JsonResponse
{
return jok($this->service->setDefault(request()->post()), '设置成功');
}
/**
* 测试连通性appid+secret 直连微信换 token 验证)
* @Method POST
*/
public function testConnection(): JsonResponse
{
return jok($this->service->testConnection(request()->post()), '连接成功,凭据有效');
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\WechatArticleService;
use Illuminate\Http\JsonResponse;
/**
* 公众号图文控制器CRUD / 版本流水 / 版本回退 / 一键发布 / 发布状态
* 图文正文content_md体量大且含任意字符create/update 覆盖基类直接透传给 Service 白名单处理
*/
class WechatArticleController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = WechatArticleService::getInstance();
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* 新增图文(标题必填在 Service 校验;创建即落 v1 版本流水)
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '创建成功');
}
/**
* 更新图文(版本号 +1 并落「修改」流水)
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->update($id, $params), '更新成功');
}
/**
* 版本流水分页(?article_id=
* @Method GET
*/
public function versionList(): JsonResponse
{
return jok($this->service->versionList((int) request()->get('article_id', 0)), '获取成功');
}
/**
* 单版本详情(含正文快照,回退前预览)
* @Method GET
*/
public function versionDetail(): JsonResponse
{
return jok($this->service->versionDetail((int) request()->get('id', 0)), '获取成功');
}
/**
* 版本回退 {id, version_id}
* @Method POST
*/
public function rollback(): JsonResponse
{
return jok($this->service->rollback(request()->post()), '回退成功');
}
/**
* 一键发布 {id, content_html, account_id?}content_html 为前端渲染好的内联样式 HTML
* @Method POST
*/
public function publish(): JsonResponse
{
return jok($this->service->publish(request()->post()), '已提交发布,正在审核');
}
/**
* 查询发布状态并回填(?id=
* @Method GET
*/
public function publishStatus(): JsonResponse
{
return jok($this->service->publishStatus((int) request()->get('id', 0)), '获取成功');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\WechatThemeService;
use Illuminate\Http\JsonResponse;
/**
* 自定义排版主题控制器:图文编辑器「我的模板」导入/列表/更新/删除
* list/delete 走基类create/update 覆盖直传 Service清洗校验都在 Service 内)
*/
class WechatThemeController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = WechatThemeService::getInstance();
}
/**
* 导入模板JSON 清洗入库)
* @Method POST
*/
public function create(): JsonResponse
{
return jok($this->service->create(request()->post()), '导入成功');
}
/**
* 更新模板(名称/色板/样式整体覆盖)
* @Method POST
*/
public function update(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->update($id, $params), '更新成功');
}
}

View File

@@ -3,8 +3,10 @@
namespace App\Http\Controllers\core;
use App\BaseApp\BaseController;
use App\Service\common\ai\AiCodeGenService;
use App\Service\common\ai\AiRequirementPromptService;
use App\Service\common\JWTService;
use App\Service\core\CodeGenerationService;
use App\Service\LoginService;
use Illuminate\Http\JsonResponse;
class CodeGenerationController extends BaseController
@@ -14,9 +16,20 @@ class CodeGenerationController extends BaseController
public function __construct()
{
parent::__construct();
// 代码生成会写后端源码、改路由、执行建表 DDL属超管工具必须登录后使用。
// 本控制器的 CodeGenerationService 未继承 BaseService故在此显式校验登录态
// Bearer JWT + Redis 会话),口径与 BaseService 构造中的鉴权一致;
// autoRouteRegister 用 ReflectionClass 读方法、不实例化控制器,故不会在启动时误触发鉴权
JWTService::getInstance()->getToken()->getUserInfo();
$this->service = CodeGenerationService::getInstance();
}
/**
* 生成代码
* @Method POST
* @return JsonResponse
* @throws \Exception
*/
public function generation()
{
$this->insertField = [
@@ -36,6 +49,49 @@ class CodeGenerationController extends BaseController
);
}
/**
* 批量生成代码
* @Method POST
* @return JsonResponse
* @throws \Exception
*/
public function batchGeneration()
{
$params = request()->post();
if (!is_array($params) || empty($params)) {
return jerr('参数错误,需要数组格式!');
}
$results = [];
$errors = [];
foreach ($params as $index => $item) {
try {
// 验证必填字段
if (empty($item['class_name']) || empty($item['field']) || !is_array($item['field'])) {
$errors[] = "模块 " . ($index + 1) . " 参数不完整";
continue;
}
$result = $this->service->generate($item);
$results[] = $result;
} catch (\Exception $e) {
$errors[] = "模块 " . ($index + 1) . " 生成失败: " . $e->getMessage();
}
}
if (empty($results)) {
return jerr('批量生成失败:' . implode('; ', $errors));
}
if (!empty($errors)) {
return jok($results, '部分生成成功,共生成 ' . count($results) . ' 个模块。错误:' . implode('; ', $errors));
}
return jok($results, '批量生成成功,共生成 ' . count($results) . ' 个模块!');
}
public function download()
{
// $id = request()->post('id');
@@ -46,4 +102,95 @@ class CodeGenerationController extends BaseController
return $this->service->download($id);
}
public function downloadInfo()
{
// $id = request()->post('id');
$id = request()->get('id');
if (!$id) {
return jerr('参数错误!');
}
return jok(
$this->service->downloadInfo($id)
);
}
/**
* 获取数据库表列表
* @Method GET
* @return JsonResponse
*/
public function getTables()
{
return jok(
$this->service->getTableList()
);
}
/**
* 获取表结构
* @Method GET
* @return JsonResponse
* @throws \Exception
*/
public function getTableStructure()
{
$tableName = request()->get('table_name');
if (!$tableName) {
return jerr('表名不能为空!');
}
return jok(
$this->service->getTableColumns($tableName)
);
}
/**
* 从数据库表生成代码生成数据
* @Method POST
* @return JsonResponse
* @throws \Exception
*/
public function generateFromTable()
{
$tableName = request()->post('table_name');
$className = request()->post('class_name', '');
$classComment = request()->post('class_comment', '');
$icon = request()->post('icon', '');
$sort = request()->post('sort', 9999);
$pid = request()->post('pid', 0);
if (!$tableName) {
return jerr('表名不能为空!');
}
$codeGenData = $this->service->convertTableToCodeGenData($tableName, $className, $classComment, $icon, $sort, $pid);
return jok($codeGenData);
}
/**
* AI 提示词生成代码结构(调用工厂 Agent返回可灌入表单的 JSON
* @Method POST
*/
public function aiGenerate(): JsonResponse
{
$params = request()->post();
return jok(
AiCodeGenService::getInstance()->generate($params),
'AI 生成成功'
);
}
/**
* 根据模块名生成 / 优化需求概览与代码生成提示词(写入生成历史,供前端替换导入)
* @Method POST
*/
public function aiRequirementPrompt(): JsonResponse
{
$params = request()->post();
return jok(
AiRequirementPromptService::getInstance()->generate($params),
'需求概览生成成功'
);
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Http\Controllers\core;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class SqlInstallController extends Controller
{
public function testConnection(Request $request)
{
$validated = $request->validate([
'host' => 'required|string',
'port' => 'required|integer',
'database' => 'required|string',
'username' => 'required|string',
'password' => 'required|string',
]);
// 创建临时配置
config([
'database.connections.install' => [
'driver' => 'mysql',
'host' => $validated['host'],
'port' => $validated['port'],
'database' => $validated['database'],
'username' => $validated['username'],
'password' => $validated['password'],
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
]
]);
try {
// 使用临时连接测试
DB::connection('install')->getPdo();
return response()->json([
'success' => true,
'message' => '✅ 成功连接到数据库'
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '❌ 连接失败: ' . $e->getMessage()
], 500);
}
}
public function startInstallation(Request $request)
{
// 设置超时时间
set_time_limit(600);
$validated = $request->validate([
'file' => 'required|string',
'host' => 'required|string',
'port' => 'required|integer',
'database' => 'required|string',
'username' => 'required|string',
'password' => 'required|string',
'force' => 'boolean',
'backup' => 'boolean'
]);
$basePath = base_path();
$logs = ["[开始] SQL安装过程启动"];
$logs[] = "[项目目录] {$basePath}";
try {
// 更新环境变量
$config = [
'DB_HOST' => $validated['host'],
'DB_PORT' => $validated['port'],
'DB_DATABASE' => $validated['database'],
'DB_USERNAME' => $validated['username'],
'DB_PASSWORD' => $validated['password'],
];
// 更新 .env 文件
$this->updateEnvFile($config);
$logs[] = "✅ 环境变量已更新";
// 更新运行时配置
foreach ($config as $key => $value) {
$configKey = strtolower(substr($key, 3));
Config::set("database.connections.mysql.{$configKey}", $value);
}
// ds($logs);
$logs[] = "🔧 使用主机: {$validated['host']}";
$logs[] = "🔧 使用端口: {$validated['port']}";
$logs[] = "🔧 使用数据库: {$validated['database']}";
$logs[] = "🔧 SQL文件: {$validated['file']}";
// 检查数据库是否存在,如果不存在则创建
$logs[] = "🔍 检查数据库是否存在";
// $connection = DB::connection('mysql');
// $databaseName = $validated['database'];
// $result = $connection->select("
// SELECT SCHEMA_NAME
// FROM INFORMATION_SCHEMA.SCHEMATA
// WHERE SCHEMA_NAME = ?
// ", [$databaseName]);
//
// if (empty($result)) {
// $logs[] = "🔧 数据库不存在,正在创建...";
// $connection->statement("CREATE DATABASE IF NOT EXISTS `$databaseName`");
// $logs[] = "✅ 数据库已创建";
// }
// 构建 Artisan 命令 - 使用相对路径解决权限问题
$command = [
'php',
'artisan',
'sql:run-nl-admin',
'--file=' . $validated['file'],
'--database=' . $validated['database'],
];
// ds($command);
if ($validated['force'] ?? false) {
$command[] = '--force';
$logs[] = "⚠️ 强制模式已启用 (将覆盖现有数据)";
}
if ($validated['backup'] ?? false) {
$command[] = '--backup';
$logs[] = "📦 数据库备份已启用";
}
$logs[] = "🔄 正在执行: " . implode(' ', $command);
// 改变工作目录解决路径问题
// $process = new Process($command, $basePath);
$process = new Process($command, $basePath);
$process->setTimeout(300);
$process->start();
// 实时捕获输出
$process->wait(function ($type, $buffer) use (&$logs) {
if (!empty(trim($buffer))) {
$bufferLines = array_map('trim', explode("\n", trim($buffer)));
foreach ($bufferLines as $line) {
if (!empty($line)) {
$logs[] = $line;
}
}
}
});
// 检查执行结果
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
// 添加详细成功日志
$logs[] = "✅ SQL文件执行成功";
$logs[] = "📊 执行命令: " . $process->getCommandLine();
$logs[] = "🆗 退出状态: " . $process->getExitCode();
// 获取SQL文件大小
$filePath = base_path($validated['file']);
$fileSize = file_exists($filePath) ? filesize($filePath) : 0;
return response()->json([
'success' => true,
'logs' => $logs,
'file_size' => $fileSize
]);
} catch (\Exception $e) {
$logs[] = "❌ 安装过程中出错: " . $e->getMessage();
// 添加详细的错误分析
if ($e instanceof ProcessFailedException) {
$logs[] = "🛠 退出代码: " . $e->getProcess()->getExitCode();
$logs[] = "📜 错误输出: " . $e->getProcess()->getErrorOutput();
// 分析常见权限问题
$errorOutput = $e->getProcess()->getErrorOutput();
if (strpos($errorOutput, 'Permission denied') !== false) {
$logs[] = "🔑 权限问题检测:";
$logs[] = " 1. 确保Web服务器对扩展目录有读取权限";
$logs[] = " 2. 尝试运行: sudo chmod -R a+r /usr/local/lib/php/extensions/";
$logs[] = " 3. 检查SELinux/AppArmor设置";
}
}
return response()->json([
'success' => false,
'message' => $e->getMessage(),
'logs' => $logs
], 500);
}
}
/**
* 更新 .env 文件
*/
protected function updateEnvFile(array $config): void
{
$envPath = base_path('.env');
$envContents = file_exists($envPath) ? File::get($envPath) : '';
foreach ($config as $key => $value) {
$escapedValue = str_replace('"', '\\"', $value);
$line = "{$key}=\"{$escapedValue}\"";
if (preg_match("/^{$key}=.*/m", $envContents)) {
$envContents = preg_replace("/^{$key}=.*/m", $line, $envContents);
} else {
$envContents .= PHP_EOL . $line;
}
}
File::put($envPath, $envContents);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Middleware;
use App\Models\ApiEndpointModel;
use App\Models\ApiOpLogModel;
use App\Service\common\UserAgentService;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API 操作日志中间件:响应结束后按接口注册表决定是否落库
*/
class ApiOpLogMiddleware
{
/**
* 透传请求;真正写日志在 terminate避免拖慢接口响应
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
/**
* 响应结束后写操作日志
* 匹配:去掉 /api/ 前缀的 url method=0(ANY) 或等于请求方法is_log=1、status=1
*/
public function terminate(Request $request, Response $response): void
{
try {
$path = $this->normalizePath($request->path());
if ($path === '') {
return;
}
$noInsert = config('nl.log.no_insert', []);
if (is_array($noInsert)) {
$full = 'api/' . $path;
if (in_array($path, $noInsert, true)
|| in_array($full, $noInsert, true)
|| in_array('/' . $full, $noInsert, true)
) {
return;
}
}
$methodCode = $this->mapMethod($request->method());
$endpoint = ApiEndpointModel::where('url', $path)
->where('deleted_at', 0)
->where('status', 1)
->where('is_log', 1)
->where(function ($q) use ($methodCode) {
$q->where('method', 0)->orWhere('method', $methodCode);
})
->orderByDesc('method')
->first();
if (!$endpoint) {
return;
}
$userId = $this->resolveUserId($request);
$body = $response->getContent();
$result = json_decode((string) $body, true);
if (!is_array($result)) {
$result = ['raw' => mb_substr((string) $body, 0, 2000)];
}
$code = $result['code'] ?? null;
$type = ((int) $code === 0) ? 0 : 1;
$ua = UserAgentService::getInstance();
ApiOpLogModel::insert([
'user_id' => $userId,
'url' => $path,
'method' => $methodCode,
'controller' => (string) ($endpoint->controller ?? ''),
'ip' => (string) get_ip(),
'param' => json_encode($this->collectParams($request), JSON_UNESCAPED_UNICODE),
'result' => json_encode($result, JSON_UNESCAPED_UNICODE),
'type' => $type,
'result_code' => (string) ($code ?? ''),
'platform_type' => 0,
'belong_id' => 0,
'user_type' => 0,
'equipment' => $ua->parseEquipment(),
'browser' => $ua->parseBrowser(),
'created_at' => time(),
]);
} catch (\Throwable $e) {
// 日志失败不影响主流程
}
}
/**
* 规范化路径:去掉 api/ 前缀
*/
private function normalizePath(string $path): string
{
$path = trim($path, '/');
if (str_starts_with($path, 'api/')) {
$path = substr($path, 4);
}
return trim($path, '/');
}
/**
* HTTP 方法映射GET=1 POST=2 其他=0
*/
private function mapMethod(string $method): int
{
return match (strtoupper($method)) {
'GET' => 1,
'POST' => 2,
default => 0,
};
}
/**
* Bearer JWT 解析 user_id失败返回 0(不抛鉴权异常)
*/
private function resolveUserId(Request $request): int
{
try {
$token = $request->bearerToken();
if (empty($token)) {
return 0;
}
$secret = (string) config('nl.jwt.secret', '');
if (strlen($secret) < 32) {
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
}
$decoded = JWT::decode($token, new Key($secret, 'HS256'));
return (int) ($decoded->data->id ?? 0);
} catch (\Throwable $e) {
return 0;
}
}
/**
* 收集请求参数并脱敏密码类字段
*
* @return array<string,mixed>
*/
private function collectParams(Request $request): array
{
$all = array_merge($request->query(), $request->request->all());
$maskKeys = [
'password', 'old_password', 'new_password', 'confirm_password',
'access_key', 'secret_key', 'api_key', 'token',
];
foreach ($maskKeys as $k) {
if (array_key_exists($k, $all) && $all[$k] !== '' && $all[$k] !== null) {
$all[$k] = '******';
}
}
return $all;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 接口注册表模型:维护 URL / 操作名 / 是否记入操作日志
*/
class ApiEndpointModel extends BaseModel
{
protected $table = 'api_endpoint';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 登录日志模型(仅 created_at无软删
*/
class LoginLogModel extends BaseModel
{
protected $table = 'login_log';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 系统配置表模型kv 存储)
*/
class SystemConfigModel extends BaseModel
{
protected $table = 'system_config';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI API 密钥表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiApiKeyModel extends BaseModel
{
protected $table = 'ai_api_key';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 生成历史表模型(仅表名与字段约定)
*/
class AiGenerationModel extends BaseModel
{
protected $table = 'ai_generation';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 模型表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiModelModel extends BaseModel
{
protected $table = 'ai_model';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 平台表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiPlatformModel extends BaseModel
{
protected $table = 'ai_platform';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\oss;
use App\BaseApp\BaseModel;
/**
* OSS 存储配置表模型(仅表名与字段约定,查询逻辑放 Service
*/
class OssConfigModel extends BaseModel
{
protected $table = 'oss_config';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\wechat;
use App\BaseApp\BaseModel;
/**
* 微信公众号账号配置表模型app_secret 库内 AES 加密存储,查询逻辑放 Service
*/
class WechatAccountModel extends BaseModel
{
protected $table = 'wechat_account';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\wechat;
use App\BaseApp\BaseModel;
/**
* 公众号图文文章表模型(存 Markdown 源码 + 主题 key/主色,查询逻辑放 Service
*/
class WechatArticleModel extends BaseModel
{
protected $table = 'wechat_article';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\wechat;
use App\BaseApp\BaseModel;
/**
* 公众号图文版本流水表模型(日志型,仅 created_at每次创建/修改/删除/回退/发布落一条快照)
*/
class WechatArticleVersionModel extends BaseModel
{
protected $table = 'wechat_article_version';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\wechat;
use App\BaseApp\BaseModel;
/**
* 公众号自定义排版主题表模型(用户上传的模板 JSON查询逻辑放 Service
*/
class WechatThemeModel extends BaseModel
{
protected $table = 'wechat_theme';
protected $guarded = [];
}

View File

@@ -96,7 +96,17 @@ class AdminService extends BaseService
{
$params['ip_table'] = json_encode([]);
$params['password'] = password_hash($params['password'], PASSWORD_DEFAULT);
$existsUser = $this->model::where('phone', $params['phone'])->whereOr('email', $params['email'])->exists();
// 手机号或邮箱任一重复都拒绝且必须排除软删记录deleted_at=0
// 原写法 whereOr('email',...) 是 Laravel 动态 where 的空操作(等于只按 phone 判断且不排软删),
// 这里用闭包 orWhere 正确表达「phone OR email」email 为空时不参与判重,避免误伤空邮箱账号
$email = $params['email'] ?? '';
$existsUser = $this->model::where('deleted_at', 0)
->where(function ($q) use ($params, $email) {
$q->where('phone', $params['phone']);
if ($email !== '') {
$q->orWhere('email', $email);
}
})->exists();
if ($existsUser) {
$this->utils->errorThrow('账号或邮箱已存在');
}
@@ -148,7 +158,7 @@ class AdminService extends BaseService
}
$result = $this->model::where('id', $this->userId)->update([
'password' => password_hash($newPassword, PASSWORD_DEFAULT),
'last_reset_password_at' => get_time()
'updated_at' => get_time(),
]);
if (!$result) {
return $this->utils->errorThrow('重置密码失败');
@@ -178,7 +188,7 @@ class AdminService extends BaseService
}
$result = $this->model::where('id', $id)->update([
'password' => password_hash($newPassword, PASSWORD_DEFAULT),
'last_reset_password_at' => get_time()
'updated_at' => get_time(),
]);
if (!$result) {
return $this->utils->errorThrow('重置密码失败');
@@ -187,7 +197,7 @@ class AdminService extends BaseService
}
/**
* 获取用户信息
* 获取用户信息(本人完整字段,供个人中心回填,不做手机号脱敏)
* @return mixed
* @throws Exception
*/
@@ -196,12 +206,121 @@ class AdminService extends BaseService
$this->with = [
'role'
];
$result = $this->getDetail($this->userId);
$result = $this->getDetail($this->userId);
$result['created_day_at'] = format_time(strtotime($result['created_at']), 'Y-m-d');
$result['phone'] = desensitization($result['phone']);
// 兼容前端 Profile 组件字段
$result['realName'] = $result['nick_name'] ?? '';
$result['username'] = $result['phone'] ?? '';
return $result;
}
/**
* 个人中心更新资料(昵称/头像/邮箱/备注/手机)
* 为什么单独接口:不走管理员 CRUD避免越权改角色等字段
*/
public function updateProfile(array $params): mixed
{
$allow = ['nick_name', 'avatar', 'email', 'desc', 'phone'];
$data = [];
foreach ($allow as $field) {
if (array_key_exists($field, $params)) {
$data[$field] = is_string($params[$field]) ? trim($params[$field]) : $params[$field];
}
}
if (isset($data['nick_name']) && $data['nick_name'] === '') {
$this->utils->errorThrow('昵称不能为空');
}
if (isset($data['phone']) && $data['phone'] !== '') {
$exists = $this->model::where('phone', $data['phone'])
->where('id', '<>', $this->userId)
->where('deleted_at', 0)
->exists();
if ($exists) {
$this->utils->errorThrow('手机号已被占用');
}
}
if (isset($data['email']) && $data['email'] !== '') {
$exists = $this->model::where('email', $data['email'])
->where('id', '<>', $this->userId)
->where('deleted_at', 0)
->exists();
if ($exists) {
$this->utils->errorThrow('邮箱已被占用');
}
}
if (empty($data)) {
return $this->myInfo();
}
$data['updated_at'] = get_time();
$this->model::where('id', $this->userId)->update($data);
return $this->myInfo();
}
/**
* 当前用户登录日志分页
*/
public function loginLogList(): array
{
$page = max(1, (int) request()->get('page', 1));
$size = max(1, min(100, (int) request()->get('pageSize', request()->get('size', 10))));
$query = \App\Models\LoginLogModel::where('user_id', $this->userId)->orderByDesc('id');
$total = (clone $query)->count();
$items = $query->forPage($page, $size)->get()->toArray();
return [
'page' => $page,
'size' => $size,
'page_count' => (int) ceil($total / max($size, 1)),
'total' => $total,
'items' => $items,
];
}
/**
* 当前用户操作日志分页(附带接口操作名)
*/
public function opLogList(): array
{
$page = max(1, (int) request()->get('page', 1));
$size = max(1, min(100, (int) request()->get('pageSize', request()->get('size', 10))));
$query = \App\Models\ApiOpLogModel::where('user_id', $this->userId)->orderByDesc('id');
$total = (clone $query)->count();
$items = $query->forPage($page, $size)->get()->toArray();
$urls = array_values(array_unique(array_column($items, 'url')));
$endpointMap = [];
if (!empty($urls)) {
$endpointMap = \App\Models\ApiEndpointModel::whereIn('url', $urls)
->where('deleted_at', 0)
->get(['url', 'name', 'description'])
->keyBy('url')
->toArray();
}
foreach ($items as &$item) {
$ep = $endpointMap[$item['url'] ?? ''] ?? null;
$item['name'] = $ep['name'] ?? '';
$item['description'] = $ep['description'] ?? '';
$item['method_text'] = match ((int) ($item['method'] ?? 0)) {
1 => 'GET',
2 => 'POST',
default => 'ANY',
};
$item['type_text'] = ((int) ($item['type'] ?? 0) === 0) ? '成功' : '失败';
if (is_string($item['param'] ?? null)) {
$item['param'] = json_decode($item['param'], true) ?: [];
}
if (is_string($item['result'] ?? null)) {
$item['result'] = json_decode($item['result'], true) ?: [];
}
}
unset($item);
return [
'page' => $page,
'size' => $size,
'page_count' => (int) ceil($total / max($size, 1)),
'total' => $total,
'items' => $items,
];
}
/**
* 获取菜单列表
* @return array

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\ai\AiApiKeyModel;
use App\Models\ai\AiPlatformModel;
use App\Service\common\ai\AiGenerationLogService;
use App\Service\common\ai\AiRuntimeConfigService;
use App\Service\common\FieldEncryptService;
/**
* AI 配置管理 Service密钥 CRUD + 运行时选项读写
* 平台/模型以种子数据为主,密钥需后台录入后才能真正调用
*/
class AiConfigService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = AiApiKeyModel::class;
$this->selectField = ['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'created_at', 'updated_at'];
$this->queryField = [
'platform_id' => '=',
'name' => 'like',
'status' => '=',
];
$this->orderBy = [
'name' => 'sort',
'sort' => 'desc',
];
}
/**
* 获取 AI 管理页选项(平台卡片 + 当前激活组合)
*/
public function getRuntimeOptions(): array
{
return AiRuntimeConfigService::getInstance()->optionsForConfig();
}
/**
* 保存默认平台/模型/密钥
*/
public function saveRuntime(array $params): array
{
$provider = (string) ($params['provider'] ?? '');
$model = (string) ($params['model'] ?? '');
$apiKeyId = (int) ($params['api_key_id'] ?? 0);
return AiRuntimeConfigService::getInstance()->saveActiveConfig($provider, $model, $apiKeyId);
}
/**
* 密钥列表(卡片展示用,不返回明文密钥)
* 直接查表,避免单例 where update 污染导致列表为空
*/
public function listKeys(): array
{
$items = AiApiKeyModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get(['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'api_key', 'created_at', 'updated_at'])
->toArray();
$platforms = AiPlatformModel::where('deleted_at', 0)
->get(['id', 'code', 'name', 'logo'])
->keyBy('id')
->toArray();
foreach ($items as &$item) {
$pid = (int) ($item['platform_id'] ?? 0);
$item['platform'] = $platforms[$pid] ?? null;
$item['has_key'] = trim((string) ($item['api_key'] ?? '')) !== '';
unset($item['api_key']);
}
unset($item);
return [
'page' => 1,
'size' => count($items),
'page_count' => 1,
'total' => count($items),
'items' => $items,
];
}
/**
* 平台下拉(密钥表单用)
*/
public function platformOptions(): array
{
return AiPlatformModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'code', 'name', 'logo'])
->toArray();
}
/**
* 新增密钥(明文入库前加密)
*/
public function createKey(array $params): mixed
{
$platformId = (int) ($params['platform_id'] ?? 0);
$name = trim((string) ($params['name'] ?? ''));
$apiKey = trim((string) ($params['api_key'] ?? ''));
if ($platformId <= 0) {
$this->utils->errorThrow('请选择所属平台');
}
if ($name === '') {
$this->utils->errorThrow('请填写密钥别名');
}
if ($apiKey === '') {
$this->utils->errorThrow('请填写 API Key');
}
$platform = AiPlatformModel::where('id', $platformId)
->where('deleted_at', 0)
->first();
if (!$platform) {
$this->utils->errorThrow('平台不存在');
}
$isDefault = (int) ($params['is_default'] ?? 0);
if ($isDefault === 1) {
// 同平台只保留一个默认密钥
AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->update(['is_default' => 0, 'updated_at' => time()]);
}
return $this->insert([
'platform_id' => $platformId,
'name' => $name,
'api_key' => FieldEncryptService::getInstance()->encryptForStorage($apiKey),
'remark' => trim((string) ($params['remark'] ?? '')),
'is_default' => $isDefault,
'status' => (int) ($params['status'] ?? 1),
'sort' => (int) ($params['sort'] ?? 0),
'created_at' => time(),
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 更新密钥api_key 为空表示不改密钥本身
*/
public function updateKey(int $id, array $params): mixed
{
$info = AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('密钥不存在');
}
$data = [];
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('密钥别名不能为空');
}
$data['name'] = $name;
}
if (array_key_exists('remark', $params)) {
$data['remark'] = trim((string) $params['remark']);
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'];
}
if (array_key_exists('sort', $params)) {
$data['sort'] = (int) $params['sort'];
}
if (array_key_exists('platform_id', $params)) {
$platformId = (int) $params['platform_id'];
if ($platformId <= 0) {
$this->utils->errorThrow('请选择所属平台');
}
$data['platform_id'] = $platformId;
}
$platformId = (int) ($data['platform_id'] ?? $info->platform_id);
if (array_key_exists('is_default', $params)) {
$isDefault = (int) $params['is_default'];
$data['is_default'] = $isDefault;
if ($isDefault === 1) {
AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('id', '<>', $id)
->update(['is_default' => 0, 'updated_at' => time()]);
}
}
$apiKey = trim((string) ($params['api_key'] ?? ''));
if ($apiKey !== '') {
$data['api_key'] = FieldEncryptService::getInstance()->encryptForStorage($apiKey);
}
if (empty($data)) {
return true;
}
$data['updated_at'] = time();
// 直接 update避免 BaseService::save 污染单例 where 导致后续列表为空
return AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
/**
* 软删除密钥
*/
public function deleteKeys(array $ids): mixed
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
return AiApiKeyModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
}
/**
* AI 生成历史列表
*/
public function generationList(): array
{
return AiGenerationLogService::getInstance()->getPageListForAdmin();
}
/**
* AI 生成历史详情
*/
public function generationDetail(int $id): array
{
return AiGenerationLogService::getInstance()->getDetailForAdmin($id);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\ApiEndpointModel;
/**
* 接口注册表 Service分页查询 + 更新是否记日志等元信息
*/
class ApiEndpointService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = ApiEndpointModel::class;
$this->selectField = [
'id', 'url', 'method', 'name', 'description', 'controller',
'is_log', 'status', 'created_at', 'updated_at',
];
$this->queryField = [
'url' => 'like',
'name' => 'like',
'is_log' => '=',
'method' => '=',
'status' => '=',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
}
/**
* 分页列表;支持按 url/name/is_log/method 筛选
*/
public function list(): array
{
return $this->getPageList();
}
/**
* 更新接口元信息:仅允许改 name/description/is_log/status
* 为什么不开放改 url/method路由由代码决定随意改会导致日志匹配错乱
*
* @param int $id 接口 ID
* @param array $params 可更新字段
*/
public function update(int $id, array $params): mixed
{
$info = ApiEndpointModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('接口不存在');
}
$data = [];
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('操作名不能为空');
}
$data['name'] = $name;
}
if (array_key_exists('description', $params)) {
$data['description'] = trim((string) $params['description']);
}
if (array_key_exists('is_log', $params)) {
$data['is_log'] = (int) $params['is_log'] ? 1 : 0;
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'] ? 1 : 0;
}
if (empty($data)) {
return true;
}
$data['updated_at'] = time();
return ApiEndpointModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
}

View File

@@ -0,0 +1 @@
# 生成的代码放在这里

File diff suppressed because it is too large Load Diff

View File

@@ -4,50 +4,85 @@ namespace App\Service;
use App\BaseApp\BaseNotAuthService;
use App\Models\AdminModel;
use App\Models\LoginLogModel;
use App\Service\common\JWTService;
use App\Service\common\UserAgentService;
use App\Service\common\UtilsService;
use Exception;
use Illuminate\Support\Str;
/**
* 登录 / 注册服务:成功与失败均写登录日志,成功时更新 last_login_time
*/
class LoginService extends BaseNotAuthService
{
/**
* 登录
* @param $phone
* @param $password
* @return array
* 账号密码登录
* 失败时先写日志再抛错,保证审计完整;成功始终更新 last_login_time
*
* @param string $phone 手机号/账号
* @param string $password 明文密码
* @return array 用户信息 + token
* @throws Exception
*/
public function login($phone, $password): array
{
$userModel = AdminModel::with(['role:id,name,value'])->where('phone', $phone)->first();
$ua = UserAgentService::getInstance();
$equipment = $ua->parseEquipment();
$browser = $ua->parseBrowser();
// 必须排除软删账号deleted_at=0否则被删管理员仍能用原密码登录
$userModel = AdminModel::with(['role:id,name,value'])->where('phone', $phone)->where('deleted_at', 0)->first();
if (empty($userModel)) {
$this->writeLoginLog(0, (string) $phone, '', 1, '用户或密码错误', $equipment, $browser);
UtilsService::getInstance()->errorThrow('用户或密码错误!');
// return $this->register($phone, $password);
}
if (!password_verify($password, $userModel->password)) {
UtilsService::getInstance()->errorThrow('用户或密码错误2');
if (!password_verify($password, $userModel->password)) {
$this->writeLoginLog(
(int) $userModel->id,
(string) $phone,
(string) $userModel->nick_name,
1,
'用户或密码错误',
$equipment,
$browser
);
UtilsService::getInstance()->errorThrow('用户或密码错误!');
}
$updateData = [
'last_login_time' => get_time(),
'updated_at' => get_time(),
];
if ($userModel->ip !== get_ip()) {
$ipTable = json_decode($userModel->ip_table, true);
if (!in_array(get_ip(), $ipTable?? [])) {
if (!in_array(get_ip(), $ipTable ?? [])) {
$ipTable[] = get_ip();
}
$update = AdminModel::where('id', $userModel->id)->update([
'ip' => get_ip(),
'ip_table' => json_encode($ipTable),
'updated_at' => get_time()
]);
if (!$update) {
UtilsService::getInstance()->errorThrow('更新失败!');
}
$userModel = AdminModel::where('id', $userModel->id)->first();
$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('更新失败!');
}
$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,
'登录成功',
$equipment,
$browser
);
$result = [
'id' => $userModel->id,
'phone' => $userModel->phone,
@@ -66,45 +101,42 @@ class LoginService extends BaseNotAuthService
}
/**
* 注册
* @param $phone
* @param $password
* @param $email
* @param $code
* 注册管理员账号
*
* @param string $phone 手机号
* @param string $password 密码
* @param string $email 邮箱
* @param mixed $code 验证码(预留)
* @return array
* @throws Exception
*/
public function register($phone, $password, $email, $code): array
{
$userModel = AdminModel::where('phone', $phone)->first();
$userModel = AdminModel::where('phone', $phone)->where('deleted_at', 0)->first();
if ($userModel) {
UtilsService::getInstance()->errorThrow('账号已被占用!');
}
$createModel = AdminModel::create([
'phone' => $phone,
'email' => $email,
'password' => password_hash($password, PASSWORD_DEFAULT),
'nick_name' => '新用户'. Str::random(),
'nick_name' => '新用户' . Str::random(),
'avatar' => 'https://pic.rmb.bdstatic.com/bjh/80852bfe7c321988191838517ba64e309354.jpeg@h_1280',
'role_id' => 2,
'ip' => get_ip(),
'ip_table' => json_encode([get_ip()]),
'created_at' => get_time()
'created_at' => get_time(),
]);
if (!$createModel) {
UtilsService::getInstance()->errorThrow('注册失败!');
}
$userInfo = AdminModel::with(['role:id,name,value'])->where('id', $createModel->id)->first();
$result = [
'id' => $userInfo->id,
'phone' => $userInfo->phone,
'nick_name' => $userInfo->nick_name,
'avatar' => $userInfo->avatar,
'email' => $userInfo->email?? '',
'email' => $userInfo->email ?? '',
'role_name' => $userInfo->role->name,
'role_value' => $userInfo->role->value,
'ip' => $userInfo->ip,
@@ -112,7 +144,43 @@ class LoginService extends BaseNotAuthService
];
$token = JWTService::getInstance()->generateToken($result);
$result['token'] = $token;
return $result;
}
/**
* 写入登录日志(失败也尽量落库,内部吞异常避免掩盖业务错误)
*
* @param int $userId 管理员 ID失败未知用户可为 0
* @param string $phone 登录账号
* @param string $nickName 昵称快照
* @param int $status 0成功 1失败
* @param string $message 结果说明
* @param string $equipment 操作系统
* @param string $browser 浏览器
*/
private function writeLoginLog(
int $userId,
string $phone,
string $nickName,
int $status,
string $message,
string $equipment,
string $browser
): void {
try {
LoginLogModel::insert([
'user_id' => $userId,
'phone' => $phone,
'nick_name' => $nickName,
'ip' => (string) get_ip(),
'equipment' => $equipment,
'browser' => $browser,
'status' => $status,
'message' => $message,
'created_at' => time(),
]);
} catch (\Throwable $e) {
// 日志失败不影响登录主流程
}
}
}

View File

@@ -9,7 +9,7 @@ use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 角色服务类
* 菜单服务:列表树、搜索(含子菜单)、下拉
*/
class MenuService extends BaseService
{
@@ -29,29 +29,122 @@ class MenuService extends BaseService
}
/**
* 获取列表
* 获取菜单列表(扁平树,前端 transform
* title 搜索时:命中节点 + 祖先 + 子孙,保证子菜单可搜到并完整展示路径
* 无搜索时:仍按根菜单分页,再挂两级子节点
*
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list()
{
// /order/product-order
$title = trim((string) request()->get('title', ''));
if ($title !== '') {
return $this->listByTitleSearch($title);
}
$this->where[] = ['pid', '=', 0];
$list = $this->getPageList();
$ids = array_column($list['items'], 'id');
$items = $this->model::whereIn('pid', $ids)->get($this->selectField);
if (empty($ids)) {
return $list;
}
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $ids)->orderBy('sort')->get($this->selectField);
$childrenIds = [];
foreach ($items as $v) {
$list['items'][] = $v;
$childrenIds[] = $v['id'];
}
$items = $this->model::whereIn('pid', $childrenIds)->get($this->selectField);
foreach ($items as $v) {
$list['items'][] = $v;
if (!empty($childrenIds)) {
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $childrenIds)->orderBy('sort')->get($this->selectField);
foreach ($items as $v) {
$list['items'][] = $v;
}
}
return $list;
}
/**
* 按标题模糊搜索:返回命中项及其祖先、子孙,供树表正确拼装
* 为什么要带祖先:仅返回子节点时 pid 找不到父级,树会丢节点或挂到根外
*/
private function listByTitleSearch(string $title): array
{
$all = $this->model::where('deleted_at', 0)
->orderBy('sort')
->get($this->selectField)
->keyBy('id');
if ($all->isEmpty()) {
return $this->emptyPageList();
}
$matchedIds = [];
foreach ($all as $id => $row) {
$rowTitle = (string) ($row['title'] ?? '');
if ($rowTitle !== '' && mb_stripos($rowTitle, $title) !== false) {
$matchedIds[] = (int) $id;
}
}
if (empty($matchedIds)) {
return $this->emptyPageList();
}
// 子节点按 pid 分组,便于向下展开子孙
$childrenMap = [];
foreach ($all as $id => $row) {
$pid = (int) ($row['pid'] ?? 0);
$childrenMap[$pid][] = (int) $id;
}
$needIds = [];
foreach ($matchedIds as $mid) {
// 向上:自身 + 祖先
$cur = $mid;
while ($cur > 0 && $all->has($cur) && !isset($needIds[$cur])) {
$needIds[$cur] = true;
$cur = (int) ($all[$cur]['pid'] ?? 0);
}
// 向下:命中节点的全部子孙(搜父级时仍能看到子树)
$stack = [$mid];
while (!empty($stack)) {
$nodeId = array_pop($stack);
foreach ($childrenMap[$nodeId] ?? [] as $cid) {
if (!isset($needIds[$cid])) {
$needIds[$cid] = true;
$stack[] = $cid;
}
}
}
}
$items = [];
foreach ($all as $id => $row) {
if (isset($needIds[(int) $id])) {
$items[] = $row->toArray();
}
}
return [
'items' => $items,
'page' => 1,
'size' => count($items),
'total' => count($items),
];
}
/**
* 空分页结构(与 getPageList 对齐)
*/
private function emptyPageList(): array
{
return [
'items' => [],
'page' => 1,
'size' => 0,
'total' => 0,
];
}
/**
* 获取树形结构的菜单下拉
* @param bool $isSelect
@@ -119,7 +212,6 @@ class MenuService extends BaseService
* @param $id
* @param $params
* @return mixed
* @throws Exception
*/
public function update($id, $params): mixed
{
@@ -131,15 +223,11 @@ class MenuService extends BaseService
/**
* 删除数据
* @param $id
* @return mixed|true
* @throws Exception
* @param $ids
* @return mixed
*/
public function delete($id): mixed
public function delete($ids): mixed
{
if (in_array($id, [1, 2])) {
$this->utils->errorThrow('管理员角色禁止删除');
}
return $this->del($id);
return $this->del($ids);
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Enum\OssDriverEnum;
use App\Models\oss\OssConfigModel;
use App\Service\common\FieldEncryptService;
/**
* OSS 配置管理:驱动选项、启用切换、配置 CRUD密钥入库加密
*/
class OssConfigService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = OssConfigModel::class;
$this->selectField = [
'id', 'driver', 'name', 'endpoint', 'region', 'bucket', 'domain',
'path_prefix', 'is_active', 'status', 'remark', 'sort', 'created_at', 'updated_at',
];
$this->queryField = [
'driver' => '=',
'name' => 'like',
'status' => '=',
'is_active' => '=',
];
$this->orderBy = [
'name' => 'sort',
'sort' => 'desc',
];
}
/**
* 驱动枚举选项(前端表单 / 卡片文案)
*/
public function driverOptions(): array
{
return OssDriverEnum::options();
}
/**
* 运行时页数据:配置卡片 + 当前启用 ID不回显密钥明文
*/
public function getRuntimeOptions(): array
{
$items = $this->formatConfigRows(
OssConfigModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get()
->toArray()
);
$activeId = 0;
foreach ($items as $item) {
if ((int) ($item['is_active'] ?? 0) === 1) {
$activeId = (int) $item['id'];
break;
}
}
if ($activeId <= 0) {
$activeId = (int) SystemConfigService::getInstance()->getValue('oss_active_config_id', 0);
}
return [
'drivers' => OssDriverEnum::options(),
'items' => $items,
'active_id' => $activeId,
];
}
/**
* 切换当前启用的 OSS 配置
* 为什么双写 is_active + system_config上传链路优先读 kv列表页靠 is_active 展示徽章
*/
public function saveRuntime(array $params): array
{
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
$this->utils->errorThrow('请选择要启用的配置');
}
$info = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('配置不存在');
}
if ((int) $info->status !== 1) {
$this->utils->errorThrow('该配置已禁用,无法启用');
}
$now = time();
OssConfigModel::where('deleted_at', 0)->update(['is_active' => 0, 'updated_at' => $now]);
OssConfigModel::where('id', $id)->update(['is_active' => 1, 'updated_at' => $now]);
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
return ['id' => $id];
}
/**
* 配置列表(卡片,无密钥明文)
*/
public function listConfigs(): array
{
$items = $this->formatConfigRows(
OssConfigModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get()
->toArray()
);
return [
'page' => 1,
'size' => count($items),
'page_count' => 1,
'total' => count($items),
'items' => $items,
];
}
/**
* 新增 OSS 配置;云驱动要求填写密钥
*/
public function createConfig(array $params): mixed
{
$driver = trim((string) ($params['driver'] ?? ''));
$name = trim((string) ($params['name'] ?? ''));
if (!OssDriverEnum::isValid($driver)) {
$this->utils->errorThrow('不支持的存储驱动');
}
if ($name === '') {
$this->utils->errorThrow('请填写配置别名');
}
$enum = OssDriverEnum::from($driver);
$accessKey = trim((string) ($params['access_key'] ?? ''));
$secretKey = trim((string) ($params['secret_key'] ?? ''));
if ($enum->needsSecret() && ($accessKey === '' || $secretKey === '')) {
$this->utils->errorThrow('该驱动需要填写 AccessKey 与 SecretKey');
}
$encrypt = FieldEncryptService::getInstance();
$isActive = (int) ($params['is_active'] ?? 0);
$now = time();
if ($isActive === 1) {
OssConfigModel::where('deleted_at', 0)->update(['is_active' => 0, 'updated_at' => $now]);
}
$id = OssConfigModel::insertGetId([
'driver' => $driver,
'name' => $name,
'access_key' => $accessKey !== '' ? $encrypt->encryptForStorage($accessKey) : '',
'secret_key' => $secretKey !== '' ? $encrypt->encryptForStorage($secretKey) : '',
'endpoint' => trim((string) ($params['endpoint'] ?? '')),
'region' => trim((string) ($params['region'] ?? '')),
'bucket' => trim((string) ($params['bucket'] ?? '')),
'domain' => trim((string) ($params['domain'] ?? '')),
'path_prefix' => trim((string) ($params['path_prefix'] ?? '')),
'extra_json' => null,
'is_active' => $isActive,
'status' => (int) ($params['status'] ?? 1),
'remark' => trim((string) ($params['remark'] ?? '')),
'sort' => (int) ($params['sort'] ?? 0),
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
if ($isActive === 1) {
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
}
return ['id' => $id];
}
/**
* 更新配置access_key/secret_key 空串表示不改密钥
*/
public function updateConfig(int $id, array $params): mixed
{
$info = OssConfigModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('配置不存在');
}
$data = [];
if (array_key_exists('driver', $params)) {
$driver = trim((string) $params['driver']);
if (!OssDriverEnum::isValid($driver)) {
$this->utils->errorThrow('不支持的存储驱动');
}
$data['driver'] = $driver;
}
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('配置别名不能为空');
}
$data['name'] = $name;
}
foreach (['endpoint', 'region', 'bucket', 'domain', 'path_prefix', 'remark'] as $field) {
if (array_key_exists($field, $params)) {
$data[$field] = trim((string) $params[$field]);
}
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'];
}
if (array_key_exists('sort', $params)) {
$data['sort'] = (int) $params['sort'];
}
$encrypt = FieldEncryptService::getInstance();
$accessKey = trim((string) ($params['access_key'] ?? ''));
if ($accessKey !== '') {
$data['access_key'] = $encrypt->encryptForStorage($accessKey);
}
$secretKey = trim((string) ($params['secret_key'] ?? ''));
if ($secretKey !== '') {
$data['secret_key'] = $encrypt->encryptForStorage($secretKey);
}
$now = time();
if (array_key_exists('is_active', $params) && (int) $params['is_active'] === 1) {
OssConfigModel::where('deleted_at', 0)->where('id', '<>', $id)->update(['is_active' => 0, 'updated_at' => $now]);
$data['is_active'] = 1;
SystemConfigService::getInstance()->setValue('oss_active_config_id', (string) $id, '当前启用的 OSS 配置 ID');
} elseif (array_key_exists('is_active', $params)) {
$data['is_active'] = (int) $params['is_active'];
}
if (empty($data)) {
return true;
}
$data['updated_at'] = $now;
return OssConfigModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
/**
* 软删除;若删的是当前启用项则回落到本地配置,避免上传无可用驱动
*/
public function deleteConfigs(array $ids): mixed
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
$activeId = (int) SystemConfigService::getInstance()->getValue('oss_active_config_id', 0);
$deletingActive = ($activeId > 0 && in_array($activeId, $ids, true))
|| OssConfigModel::whereIn('id', $ids)->where('is_active', 1)->where('deleted_at', 0)->exists();
$fallbackId = 0;
if ($deletingActive) {
$local = OssConfigModel::where('driver', 'local')
->where('deleted_at', 0)
->where('status', 1)
->whereNotIn('id', $ids)
->orderBy('id')
->first();
if (!$local) {
$this->utils->errorThrow('不能删除当前启用的配置:请先切换,或保留至少一条本地存储');
}
$fallbackId = (int) $local->id;
}
$ok = OssConfigModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
'is_active' => 0,
]);
if ($fallbackId > 0) {
$this->saveRuntime(['id' => $fallbackId]);
}
return $ok;
}
/**
* 行格式化:补驱动名、密钥是否已配置标记
*/
private function formatConfigRows(array $rows): array
{
foreach ($rows as &$row) {
$driver = (string) ($row['driver'] ?? '');
$enum = OssDriverEnum::tryFrom($driver);
$row['driver_name'] = $enum?->label() ?? $driver;
$row['driver_desc'] = $enum?->description() ?? '';
$row['has_access_key'] = trim((string) ($row['access_key'] ?? '')) !== '';
$row['has_secret_key'] = trim((string) ($row['secret_key'] ?? '')) !== '';
unset($row['access_key'], $row['secret_key']);
}
unset($row);
return $rows;
}
}

View File

@@ -153,7 +153,10 @@ class RoleService extends BaseService
*/
public function delete($id): mixed
{
if (in_array($id, [1, 2])) {
// BaseController::delete 传入的是 ids 数组,直接 in_array($id,[1,2]) 会因「数组与标量比较恒 false」绕过保护
// 这里统一归一为数组后用 array_intersect 判断是否命中内置角色1超管 / 2默认
$ids = is_array($id) ? $id : [$id];
if (array_intersect(array_map('intval', $ids), [1, 2])) {
$this->utils->errorThrow('管理员角色禁止删除');
}
return $this->del($id);

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\SystemConfigModel;
/**
* 系统配置 Service key 读写 kv AI 运行时等模块复用
*/
class SystemConfigService extends BaseService
{
public function __construct()
{
// 配置读写不依赖登录态(工厂解析时可能被其他 Service 调用)
$this->isAuth = false;
parent::__construct();
$this->model = SystemConfigModel::class;
}
/**
* 读取配置值;不存在时返回默认值
* 为什么:业务侧只关心 value不关心行结构
*/
public function getValue(string $key, mixed $default = null): mixed
{
$key = trim($key);
if ($key === '') {
return $default;
}
$row = SystemConfigModel::where('key', $key)
->where('deleted_at', 0)
->first();
if (!$row) {
return $default;
}
$value = $row->value;
if ($value === null) {
return $default;
}
return $value;
}
/**
* 写入/更新配置;存在则更新,不存在则插入
* 为什么用 upsert配置项数量少 key 幂等更稳
*/
public function setValue(string $key, mixed $value, string $remark = ''): bool
{
$key = trim($key);
if ($key === '') {
$this->utils->errorThrow('配置键不能为空');
}
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
$value = (string) $value;
$now = time();
$row = SystemConfigModel::where('key', $key)
->where('deleted_at', 0)
->first();
if ($row) {
$row->value = $value;
if ($remark !== '') {
$row->remark = $remark;
}
$row->updated_at = $now;
return (bool) $row->save();
}
return (bool) SystemConfigModel::insert([
'key' => $key,
'value' => $value,
'remark' => $remark,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 批量写入配置
*
* @param array<string, mixed> $pairs key => value
*/
public function setValues(array $pairs): bool
{
foreach ($pairs as $key => $value) {
$this->setValue((string) $key, $value);
}
return true;
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\wechat\WechatAccountModel;
use App\Service\common\FieldEncryptService;
use App\Service\common\wechat\WechatApiClient;
/**
* 公众号账号配置服务CRUDAppSecret 入库 AES 加密)、默认账号切换、连通性测试
* 对齐 OssConfigService 的加密模式list/detail 永不回显 secret 明文,只给 has_secret 标记
*/
class WechatAccountService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = WechatAccountModel::class;
$this->selectField = [
'id', 'name', 'appid', 'app_secret', 'original_id',
'is_default', 'status', 'remark', 'sort', 'created_at', 'updated_at',
];
$this->queryField = [
'name' => 'like',
'appid' => 'like',
'status' => '=',
];
$this->orderBy = [
'name' => 'sort',
'sort' => 'desc',
];
}
/**
* 分页列表脱敏app_secret 整字段剔除,返回 has_secret 布尔)
*/
public function list(): array
{
$result = $this->getPageList();
$result['items'] = $this->maskRows($result['items'] ?? []);
return $result;
}
/**
* 下拉选项(发布弹窗选账号用):仅启用账号,默认账号排最前
*/
public function option(): mixed
{
return WechatAccountModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_default')
->orderByDesc('sort')
->get(['id', 'name', 'appid', 'is_default']);
}
/**
* 详情(同样剔除 secret只给 has_secret
*/
public function detail($id): mixed
{
$info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first($this->selectField);
if (empty($info)) {
return $this->utils->notFound('账号不存在');
}
$rows = $this->maskRows([$info->toArray()]);
return $rows[0];
}
/**
* 新增账号secret AES 加密入库is_default=1 时清掉其它默认标记
*/
public function create($params): mixed
{
$name = trim((string) ($params['name'] ?? ''));
$appid = trim((string) ($params['appid'] ?? ''));
$secret = trim((string) ($params['app_secret'] ?? ''));
if ($name === '' || $appid === '' || $secret === '') {
$this->utils->errorThrow('请填写公众号名称、AppID 与 AppSecret');
}
// 同一 AppID 不允许重复配置(未删除范围内)
$exists = WechatAccountModel::where('appid', $appid)->where('deleted_at', 0)->exists();
if ($exists) {
$this->utils->errorThrow('该 AppID 已存在,请勿重复配置');
}
$now = time();
$isDefault = (int) ($params['is_default'] ?? 0);
if ($isDefault === 1) {
WechatAccountModel::where('deleted_at', 0)->update(['is_default' => 0, 'updated_at' => $now]);
}
$id = WechatAccountModel::insertGetId([
'name' => $name,
'appid' => $appid,
'app_secret' => FieldEncryptService::getInstance()->encryptForStorage($secret),
'original_id' => trim((string) ($params['original_id'] ?? '')),
'is_default' => $isDefault,
'status' => (int) ($params['status'] ?? 1),
'remark' => trim((string) ($params['remark'] ?? '')),
'sort' => (int) ($params['sort'] ?? 0),
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
return ['id' => $id];
}
/**
* 更新账号app_secret 传空 = 不改密钥(前端「已配置」占位不回传)
*/
public function update($id, $params): mixed
{
$id = (int) $id;
$info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('账号不存在');
}
$data = [];
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('公众号名称不能为空');
}
$data['name'] = $name;
}
if (array_key_exists('appid', $params)) {
$appid = trim((string) $params['appid']);
if ($appid === '') {
$this->utils->errorThrow('AppID 不能为空');
}
// 改 AppID 时同样查重(排除自己)
$exists = WechatAccountModel::where('appid', $appid)
->where('deleted_at', 0)->where('id', '<>', $id)->exists();
if ($exists) {
$this->utils->errorThrow('该 AppID 已被其它账号使用');
}
$data['appid'] = $appid;
}
foreach (['original_id', 'remark'] as $field) {
if (array_key_exists($field, $params)) {
$data[$field] = trim((string) $params[$field]);
}
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'];
}
if (array_key_exists('sort', $params)) {
$data['sort'] = (int) $params['sort'];
}
// 密钥字段只在非空时更新(空串代表用户没改)
$secret = trim((string) ($params['app_secret'] ?? ''));
if ($secret !== '') {
$data['app_secret'] = FieldEncryptService::getInstance()->encryptForStorage($secret);
}
$now = time();
if (array_key_exists('is_default', $params) && (int) $params['is_default'] === 1) {
WechatAccountModel::where('deleted_at', 0)->where('id', '<>', $id)
->update(['is_default' => 0, 'updated_at' => $now]);
$data['is_default'] = 1;
} elseif (array_key_exists('is_default', $params)) {
$data['is_default'] = (int) $params['is_default'];
}
if (empty($data)) {
return true;
}
$data['updated_at'] = $now;
return WechatAccountModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
/**
* 软删除(批量)
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
return WechatAccountModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
'is_default' => 0,
]);
}
/**
* 切换默认发布账号(全局仅一个)
*/
public function setDefault(array $params): array
{
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
$this->utils->errorThrow('请选择账号');
}
$info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
$this->utils->notFound('账号不存在');
}
if ((int) $info->status !== 1) {
$this->utils->errorThrow('该账号已禁用,无法设为默认');
}
$now = time();
WechatAccountModel::where('deleted_at', 0)->update(['is_default' => 0, 'updated_at' => $now]);
WechatAccountModel::where('id', $id)->update(['is_default' => 1, 'updated_at' => $now]);
return ['id' => $id];
}
/**
* 测试连通性:解密 secret 后直连微信换 token 验证(不落缓存)
* 支持两种入参:已有账号传 id表单未保存时直接传 appid+app_secret
*/
public function testConnection(array $params): array
{
$id = (int) ($params['id'] ?? 0);
$appid = trim((string) ($params['appid'] ?? ''));
$secret = trim((string) ($params['app_secret'] ?? ''));
if ($id > 0) {
$info = WechatAccountModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
$this->utils->notFound('账号不存在');
}
$appid = $appid !== '' ? $appid : (string) $info->appid;
// 表单里没重填 secret 时,用库里已配置的密文解密测试
if ($secret === '') {
$secret = FieldEncryptService::getInstance()->decryptFromStorage((string) $info->app_secret);
}
}
if ($appid === '' || $secret === '') {
$this->utils->errorThrow('请填写 AppID 与 AppSecret');
}
return WechatApiClient::getInstance()->testCredential($appid, $secret);
}
/**
* 发布链路取账号:指定 id 优先,缺省回落默认账号;返回含解密后 secret 的数组
* 为什么放这里:解密动作收敛在账号服务内,文章服务只拿可用凭据
*/
public function getPublishAccount(int $accountId = 0): array
{
$query = WechatAccountModel::where('deleted_at', 0)->where('status', 1);
if ($accountId > 0) {
$info = (clone $query)->where('id', $accountId)->first();
if (empty($info)) {
$this->utils->errorThrow('指定的公众号账号不存在或已禁用');
}
} else {
$info = (clone $query)->where('is_default', 1)->first();
if (empty($info)) {
$this->utils->errorThrow('未配置默认公众号账号,请先到「公众号账号」设置默认账号');
}
}
$secret = FieldEncryptService::getInstance()->decryptFromStorage((string) $info->app_secret);
if ($secret === '') {
$this->utils->errorThrow('该账号的 AppSecret 解密失败,请重新保存密钥');
}
return [
'id' => (int) $info->id,
'name' => (string) $info->name,
'appid' => (string) $info->appid,
'secret' => $secret,
];
}
/**
* 行脱敏secret 密文剔除,替换为 has_secret 布尔标记
*/
private function maskRows(array $rows): array
{
foreach ($rows as &$row) {
$row['has_secret'] = trim((string) ($row['app_secret'] ?? '')) !== '';
unset($row['app_secret']);
}
unset($row);
return $rows;
}
}

View File

@@ -0,0 +1,480 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Enum\WechatArticleStatusEnum;
use App\Enum\WechatVersionActionEnum;
use App\Models\AdminModel;
use App\Models\wechat\WechatAccountModel;
use App\Models\wechat\WechatArticleModel;
use App\Models\wechat\WechatArticleVersionModel;
use App\Service\common\wechat\WechatApiClient;
use Illuminate\Support\Facades\DB;
/**
* 公众号图文服务CRUD每次操作落版本流水、版本回退、一键发布与发布状态回填
*
* 版本机制:创建=v1每次修改/回退 version_no+1;删除/发布不递增版本号但同样落流水,
* 快照存全量排版要素(标题/摘要/正文MD/主题/主色),回退即用快照覆盖回文章
*/
class WechatArticleService extends BaseService
{
/**
* 文章表允许写入的排版要素字段create/update 白名单,防止前端塞非法字段)
*/
private const EDITABLE_FIELDS = ['account_id', 'title', 'author', 'digest', 'cover_url', 'content_md', 'theme_key', 'theme_color'];
public function __construct()
{
parent::__construct();
$this->model = WechatArticleModel::class;
// 列表不查 content_md正文可能几百 KB分页列表带上会拖慢接口
$this->selectField = [
'id', 'account_id', 'title', 'author', 'digest', 'cover_url', 'theme_key', 'theme_color',
'status', 'version_no', 'article_url', 'publish_error', 'created_by', 'updated_by', 'created_at', 'updated_at',
];
$this->queryField = [
'title' => 'like',
'status' => '=',
'account_id' => '=',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
}
/**
* 分页列表,补账号名与操作人昵称
*/
public function list(): array
{
$result = $this->getPageList();
$result['items'] = $this->enrichRows($result['items'] ?? []);
return $result;
}
/**
* 详情(含 content_md 全文,编辑器回显用)
*/
public function detail($id): mixed
{
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('文章不存在');
}
$rows = $this->enrichRows([$info->toArray()]);
return $rows[0];
}
/**
* 新增文章:写入 v1 并落「创建」版本流水
*/
public function create($params): mixed
{
$data = $this->pickEditable($params);
if (trim((string) ($data['title'] ?? '')) === '') {
$this->utils->errorThrow('请填写文章标题');
}
$now = time();
$data['status'] = WechatArticleStatusEnum::DRAFT->value;
$data['version_no'] = 1;
$data['created_by'] = $this->userId;
$data['updated_by'] = $this->userId;
$data['created_at'] = $now;
$data['updated_at'] = 0;
$data['deleted_at'] = 0;
DB::beginTransaction();
try {
$id = WechatArticleModel::insertGetId($data);
$this->writeVersion(array_merge($data, ['id' => $id]), WechatVersionActionEnum::CREATE);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
$this->utils->errorThrow($e->getMessage());
}
return ['id' => $id];
}
/**
* 编辑文章:版本号 +1 并落「修改」版本流水(快照为修改后的内容)
*/
public function update($id, $params): mixed
{
$id = (int) $id;
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('文章不存在');
}
$data = $this->pickEditable($params);
if (array_key_exists('title', $data) && trim((string) $data['title']) === '') {
$this->utils->errorThrow('文章标题不能为空');
}
if (empty($data)) {
return true;
}
$data['version_no'] = (int) $info->version_no + 1;
$data['updated_by'] = $this->userId;
$data['updated_at'] = time();
DB::beginTransaction();
try {
WechatArticleModel::where('id', $id)->update($data);
// 快照 = 旧数据叠加本次变更后的最终态
$this->writeVersion(array_merge($info->toArray(), $data, ['id' => $id]), WechatVersionActionEnum::UPDATE);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
$this->utils->errorThrow($e->getMessage());
}
return true;
}
/**
* 删除文章(软删):每篇都落「删除」版本流水,保留删除前快照可追溯
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
$rows = WechatArticleModel::whereIn('id', $ids)->where('deleted_at', 0)->get();
if ($rows->isEmpty()) {
return $this->utils->notFound('文章不存在');
}
DB::beginTransaction();
try {
WechatArticleModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
foreach ($rows as $row) {
$this->writeVersion($row->toArray(), WechatVersionActionEnum::DELETE, '删除前快照');
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
$this->utils->errorThrow($e->getMessage());
}
return true;
}
/**
* 版本流水分页(不带正文快照,抽屉列表用;查看单版本正文走 versionDetail
*/
public function versionList(int $articleId): array
{
if ($articleId <= 0) {
$this->utils->errorThrow('参数错误');
}
$result = WechatArticleVersionModel::where('article_id', $articleId)
->orderByDesc('id')
->select(['id', 'article_id', 'version_no', 'action', 'title', 'digest', 'theme_key', 'theme_color', 'operator_id', 'operator_name', 'remark', 'created_at'])
->paginate(request()->get('pageSize', 20))
->toArray();
$items = $result['data'] ?? [];
foreach ($items as &$item) {
$item['action_text'] = WechatVersionActionEnum::tryFrom((int) $item['action'])?->description() ?? '未知';
}
unset($item);
return [
'page' => $result['current_page'],
'size' => $result['per_page'],
'page_count' => $result['last_page'],
'total' => $result['total'],
'items' => $items,
];
}
/**
* 单版本详情(含正文 MD 快照,回退前预览用)
*/
public function versionDetail(int $id): array
{
$info = WechatArticleVersionModel::where('id', $id)->first();
if (empty($info)) {
$this->utils->notFound('版本不存在');
}
$row = $info->toArray();
$row['action_text'] = WechatVersionActionEnum::tryFrom((int) $row['action'])?->description() ?? '未知';
return $row;
}
/**
* 版本回退:用指定版本快照覆盖文章,版本号 +1 并落「回退」流水
* 为什么回退也是新版本:保证 version_no 单调递增,历史不被改写,可再次回退回去
*/
public function rollback(array $params): array
{
$id = (int) ($params['id'] ?? 0);
$versionId = (int) ($params['version_id'] ?? 0);
if ($id <= 0 || $versionId <= 0) {
$this->utils->errorThrow('参数错误');
}
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
$this->utils->notFound('文章不存在');
}
$version = WechatArticleVersionModel::where('id', $versionId)->where('article_id', $id)->first();
if (empty($version)) {
$this->utils->notFound('版本不存在或不属于该文章');
}
$newVersionNo = (int) $info->version_no + 1;
$data = [
'title' => (string) $version->title,
'digest' => (string) $version->digest,
'content_md' => $version->content_md,
'theme_key' => (string) $version->theme_key,
'theme_color' => (string) $version->theme_color,
'version_no' => $newVersionNo,
'updated_by' => $this->userId,
'updated_at' => time(),
];
DB::beginTransaction();
try {
WechatArticleModel::where('id', $id)->update($data);
$this->writeVersion(
array_merge($info->toArray(), $data, ['id' => $id]),
WechatVersionActionEnum::ROLLBACK,
'回退自 v' . (int) $version->version_no
);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
$this->utils->errorThrow($e->getMessage());
}
return ['id' => $id, 'version_no' => $newVersionNo];
}
/**
* 一键发布:搬图 封面素材 草稿 freepublish 提交,置「发布中」并落「发布」流水
*
* 入参 content_html 是前端主题引擎渲染好的内联样式 HTML公众号编辑器只认内联样式
* 后端不重复渲染 Markdown避免两端渲染器排版不一致
*/
public function publish(array $params): array
{
$id = (int) ($params['id'] ?? 0);
$contentHtml = (string) ($params['content_html'] ?? '');
if ($id <= 0 || trim($contentHtml) === '') {
$this->utils->errorThrow('参数错误:缺少文章 ID 或渲染后的正文');
}
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
$this->utils->notFound('文章不存在');
}
if (trim((string) $info->cover_url) === '') {
$this->utils->errorThrow('请先设置封面图(发布需要上传封面素材)');
}
// 指定账号 > 文章绑定账号 > 默认账号
$accountId = (int) ($params['account_id'] ?? 0);
if ($accountId <= 0) {
$accountId = (int) $info->account_id;
}
$account = WechatAccountService::getInstance()->getPublishAccount($accountId);
$client = WechatApiClient::getInstance();
$token = $client->getStableToken($account['appid'], $account['secret']);
// 1. 正文外链图搬微信图床(公众号正文只显示微信域名图片)
$contentHtml = $this->migrateContentImages($client, $token, $contentHtml);
// 2. 封面传永久素材拿 thumb_media_id
$thumbMediaId = $client->addImageMaterial($token, (string) $info->cover_url);
// 3. 建草稿
$mediaId = $client->addDraft($token, [
'title' => mb_substr((string) $info->title, 0, 64),
'author' => (string) $info->author,
'digest' => mb_substr((string) $info->digest, 0, 120),
'content' => $contentHtml,
'content_source_url' => '',
'thumb_media_id' => $thumbMediaId,
'need_open_comment' => 0,
'only_fans_can_comment' => 0,
]);
// 4. 提交发布(异步审核)
$publishId = $client->submitPublish($token, $mediaId);
$now = time();
DB::beginTransaction();
try {
WechatArticleModel::where('id', $id)->update([
'status' => WechatArticleStatusEnum::PUBLISHING->value,
'account_id' => $account['id'],
'media_id' => $mediaId,
'publish_id' => $publishId,
'publish_error' => '',
'updated_by' => $this->userId,
'updated_at' => $now,
]);
$this->writeVersion(
array_merge($info->toArray(), ['id' => $id]),
WechatVersionActionEnum::PUBLISH,
'发布到「' . $account['name'] . '」'
);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
$this->utils->errorThrow($e->getMessage());
}
return [
'id' => $id,
'publish_id' => $publishId,
'status' => WechatArticleStatusEnum::PUBLISHING->value,
];
}
/**
* 查询发布状态并回填0成功→已发布+文章链接1审核中其余→发布失败+原因
*/
public function publishStatus(int $id): array
{
if ($id <= 0) {
$this->utils->errorThrow('参数错误');
}
$info = WechatArticleModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
$this->utils->notFound('文章不存在');
}
if (trim((string) $info->publish_id) === '') {
$this->utils->errorThrow('该文章尚未提交发布');
}
$account = WechatAccountService::getInstance()->getPublishAccount((int) $info->account_id);
$client = WechatApiClient::getInstance();
$token = $client->getStableToken($account['appid'], $account['secret']);
$body = $client->getPublishStatus($token, (string) $info->publish_id);
$publishStatus = (int) ($body['publish_status'] ?? -1);
$articleUrl = '';
// 成功时从 article_detail 里取第一篇的线上链接
if ($publishStatus === 0) {
$items = $body['article_detail']['item'] ?? [];
$articleUrl = (string) ($items[0]['article_url'] ?? '');
}
[$status, $failReason] = match (true) {
$publishStatus === 0 => [WechatArticleStatusEnum::PUBLISHED->value, ''],
$publishStatus === 1 => [WechatArticleStatusEnum::PUBLISHING->value, ''],
default => [WechatArticleStatusEnum::FAILED->value, $this->failReason($publishStatus, $body)],
};
$data = [
'status' => $status,
'publish_error' => $failReason,
'updated_at' => time(),
];
if ($articleUrl !== '') {
$data['article_url'] = $articleUrl;
}
WechatArticleModel::where('id', $id)->update($data);
return [
'id' => $id,
'status' => $status,
'status_text' => WechatArticleStatusEnum::tryFrom($status)?->description() ?? '',
'publish_status' => $publishStatus,
'article_url' => $articleUrl !== '' ? $articleUrl : (string) $info->article_url,
'fail_reason' => $failReason,
];
}
/**
* 微信 publish_status 失败码转人话fail_idx 标记第几篇出问题,单篇场景直接拼说明)
*/
private function failReason(int $publishStatus, array $body): string
{
$text = match ($publishStatus) {
2 => '原创声明审核不通过',
3 => '常规失败(内容可能违规或素材失效)',
4 => '平台审核不通过',
5 => '发布成功后用户删除了所有文章',
6 => '发布成功后系统封禁了所有文章',
default => '未知失败publish_status=' . $publishStatus . '',
};
$failIdx = $body['fail_idx'] ?? [];
if (!empty($failIdx)) {
$text .= ',失败篇目序号:' . implode(',', array_map('intval', (array) $failIdx));
}
return $text;
}
/**
* 正文外链图搬家:提取 <img src>,非微信域名的下载后传图床并替换
* 为什么跳过 mmbiz 域名与 data URI微信自家图床无需搬base64 内联图公众号不支持,直接提示
*/
private function migrateContentImages(WechatApiClient $client, string $token, string $html): string
{
preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $html, $matches);
$urls = array_values(array_unique($matches[1] ?? []));
foreach ($urls as $url) {
if (str_contains($url, 'mmbiz.qpic.cn') || str_contains($url, 'mmbiz.qlogo.cn')) {
continue;
}
if (str_starts_with($url, 'data:')) {
$this->utils->errorThrow('正文包含 base64 内联图片,请改用「上传图片」插图后再发布');
}
$wxUrl = $client->uploadContentImage($token, $url);
$html = str_replace($url, $wxUrl, $html);
}
return $html;
}
/**
* 白名单挑选可编辑字段并做基础清洗
*/
private function pickEditable(array $params): array
{
$data = [];
foreach (self::EDITABLE_FIELDS as $field) {
if (!array_key_exists($field, $params)) {
continue;
}
$value = $params[$field];
$data[$field] = match ($field) {
'account_id' => (int) $value,
'content_md' => (string) $value,
default => trim((string) $value),
};
}
return $data;
}
/**
* 列表/详情行补充:账号名、创建人/修改人昵称、状态文案
*/
private function enrichRows(array $rows): array
{
if (empty($rows)) {
return $rows;
}
$accountIds = array_values(array_unique(array_filter(array_column($rows, 'account_id'))));
$adminIds = array_values(array_unique(array_filter(array_merge(
array_column($rows, 'created_by'),
array_column($rows, 'updated_by')
))));
$accountMap = empty($accountIds) ? [] : WechatAccountModel::whereIn('id', $accountIds)->pluck('name', 'id')->toArray();
$adminMap = empty($adminIds) ? [] : AdminModel::whereIn('id', $adminIds)->pluck('nick_name', 'id')->toArray();
foreach ($rows as &$row) {
$row['account_name'] = $accountMap[$row['account_id'] ?? 0] ?? '';
$row['created_by_name'] = $adminMap[$row['created_by'] ?? 0] ?? '';
$row['updated_by_name'] = $adminMap[$row['updated_by'] ?? 0] ?? '';
$row['status_text'] = WechatArticleStatusEnum::tryFrom((int) ($row['status'] ?? 0))?->description() ?? '';
}
unset($row);
return $rows;
}
/**
* 落一条版本流水(快照 + 操作人;操作人取当前登录态 userInfo
*/
private function writeVersion(array $article, WechatVersionActionEnum $action, string $remark = ''): void
{
WechatArticleVersionModel::insert([
'article_id' => (int) ($article['id'] ?? 0),
'version_no' => (int) ($article['version_no'] ?? 0),
'action' => $action->value,
'title' => (string) ($article['title'] ?? ''),
'digest' => (string) ($article['digest'] ?? ''),
'content_md' => $article['content_md'] ?? null,
'theme_key' => (string) ($article['theme_key'] ?? ''),
'theme_color' => (string) ($article['theme_color'] ?? ''),
'operator_id' => $this->userId,
'operator_name' => (string) ($this->userInfo['nick_name'] ?? ''),
'remark' => $remark,
'created_at' => time(),
]);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\wechat\WechatThemeModel;
/**
* 自定义排版主题服务:用户上传的模板 JSON 落库,供图文编辑器「我的模板」使用
*
* 安全设计两端后端同口径GF 版为 internal/logic/wechattheme
* - styles 只允许白名单元素键container/h1/h2/p/blockquote 21 个)
* - 每个样式值清洗:去掉 <>"\ 字符(防止逃逸 style 属性注入标签)、
* 去掉 javascript: / expression( 危险片段,长度截断 2000
* - palette 仅接受合法 HEX 色值数组(最多 8 个)
*/
class WechatThemeService extends BaseService
{
/** 样式表允许的元素键(与前端 ThemeStyleSheet 类型一一对应) */
private const ALLOWED_STYLE_KEYS = [
'container', 'h1', 'h2', 'h3', 'p', 'blockquote', 'blockquoteP',
'ul', 'ol', 'li', 'strong', 'em', 'a', 'hr', 'img',
'code', 'pre', 'preCode', 'table', 'th', 'td',
];
/** 自定义主题数量上限(防止无限膨胀拖慢编辑器加载) */
private const MAX_THEME_COUNT = 100;
public function __construct()
{
parent::__construct();
$this->model = WechatThemeModel::class;
}
/**
* 全量列表(编辑器主题面板一次性加载,非分页)
* palette/styles 解码成数组返回,前端拿到即可注册渲染
*/
public function list(): array
{
$rows = WechatThemeModel::where('deleted_at', 0)
->orderByDesc('id')
->limit(self::MAX_THEME_COUNT)
->get(['id', 'name', 'palette', 'styles', 'created_by', 'created_at', 'updated_at'])
->toArray();
foreach ($rows as &$row) {
$row['palette'] = $this->decodeJson($row['palette'] ?? '');
$row['styles'] = $this->decodeJson($row['styles'] ?? '');
}
unset($row);
return $rows;
}
/**
* 导入模板:清洗校验后入库,返回新 id
*/
public function create($params): mixed
{
[$name, $palette, $styles] = $this->validatePayload($params);
$count = WechatThemeModel::where('deleted_at', 0)->count();
if ($count >= self::MAX_THEME_COUNT) {
$this->utils->errorThrow('自定义模板已达上限(' . self::MAX_THEME_COUNT . ' 个),请先删除不用的模板');
}
$id = WechatThemeModel::insertGetId([
'name' => $name,
'palette' => $this->encodeJson($palette),
'styles' => $this->encodeJson($styles),
'created_by' => $this->userId,
'created_at' => time(),
'updated_at' => 0,
'deleted_at' => 0,
]);
return ['id' => $id];
}
/**
* 更新模板(名称/色板/样式整体覆盖,与导入同一套清洗)
*/
public function update($id, $params): mixed
{
$id = (int) $id;
$info = WechatThemeModel::where('id', $id)->where('deleted_at', 0)->first();
if (empty($info)) {
return $this->utils->notFound('模板不存在');
}
[$name, $palette, $styles] = $this->validatePayload($params);
return WechatThemeModel::where('id', $id)->update([
'name' => $name,
'palette' => $this->encodeJson($palette),
'styles' => $this->encodeJson($styles),
'updated_at' => time(),
]);
}
/**
* 批量软删除
*/
public function delete($id): mixed
{
$ids = array_values(array_filter(array_map('intval', is_array($id) ? $id : [$id])));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
return WechatThemeModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
}
/**
* 入参校验 + 清洗:返回 [name, palette, styles]
* 为什么集中在一处create/update 共用,保证任何入口都过同一套白名单
*/
private function validatePayload(array $params): array
{
$name = mb_substr(trim((string) ($params['name'] ?? '')), 0, 50);
if ($name === '') {
$this->utils->errorThrow('请填写模板名称');
}
$styles = $params['styles'] ?? [];
if (is_string($styles)) {
$styles = $this->decodeJson($styles);
}
if (!is_array($styles) || empty($styles)) {
$this->utils->errorThrow('模板样式styles不能为空');
}
$cleanStyles = [];
foreach (self::ALLOWED_STYLE_KEYS as $key) {
if (!array_key_exists($key, $styles)) {
continue;
}
$value = $this->cleanStyleValue((string) $styles[$key]);
if ($value !== '') {
$cleanStyles[$key] = $value;
}
}
if (empty($cleanStyles)) {
$this->utils->errorThrow('styles 里没有可用的元素样式(支持键:' . implode('/', self::ALLOWED_STYLE_KEYS) . '');
}
$palette = $params['palette'] ?? [];
if (is_string($palette)) {
$palette = $this->decodeJson($palette);
}
$cleanPalette = [];
foreach (is_array($palette) ? $palette : [] as $colorItem) {
$colorItem = trim((string) $colorItem);
// 只收合法 HEX#rgb/#rrggbb/#rrggbbaa防止色板被塞入任意字符串
if (preg_match('/^#[0-9a-fA-F]{3,8}$/', $colorItem)) {
$cleanPalette[] = strtolower($colorItem);
}
if (count($cleanPalette) >= 8) {
break;
}
}
return [$name, $cleanPalette, $cleanStyles];
}
/**
* 单个样式值清洗:内联 CSS 会被前端注入 style="" 属性,
* 必须去掉能逃逸属性/注入脚本的字符与片段
*/
private function cleanStyleValue(string $value): string
{
// 去掉双引号/尖括号/反斜杠:双引号会截断 style 属性,尖括号可注入标签
$value = str_replace(['"', '<', '>', '\\'], '', $value);
// 去掉历史遗留的危险 CSS 片段(旧 IE expression、javascript 伪协议)
$value = preg_replace('/javascript\s*:/i', '', $value) ?? '';
$value = preg_replace('/expression\s*\(/i', '', $value) ?? '';
return mb_substr(trim($value), 0, 2000);
}
/** JSON 解码兜底:解不开返回空数组,不让脏数据打崩接口 */
private function decodeJson($raw): array
{
if (is_array($raw)) {
return $raw;
}
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
/** JSON 编码(保留中文与斜杠原样,方便库内直读排查) */
private function encodeJson(array $data): string
{
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Service\common;
use App\BaseApp\BaseNotAuthService;
use App\Core\DatabaseEncryptor;
/**
* 库内敏感字段加解密服务API Key 等)
* 入库写 nl_ase_256_读取兼容历史 ***
*/
class FieldEncryptService extends BaseNotAuthService
{
private ?DatabaseEncryptor $encryptor = null;
/**
* 获取加解密器(懒加载,密钥来自 config/nl.php
*/
private function encryptor(): DatabaseEncryptor
{
if ($this->encryptor === null) {
$key = (string) config('nl.encrypt_key', '');
if ($key === '') {
$this->utils->errorThrow('未配置 ENCRYPT_KEY无法加解密敏感字段');
}
$this->encryptor = new DatabaseEncryptor($key);
}
return $this->encryptor;
}
/**
* 入库前加密:明文 nl_ase_256_...
*
* @param string|null $plain 明文
*/
public function encryptForStorage(?string $plain): string
{
try {
return $this->encryptor()->encrypt($plain);
} catch (\Throwable $e) {
$this->utils->errorThrow('敏感字段加密失败:' . $e->getMessage());
}
return '';
}
/**
* 读取后解密nl_ase_256_ / 历史 *** 明文
*
* @param string|null $cipher 库内值
* @param bool $silentFail true 时解密失败返回空串false 抛业务异常
*/
public function decryptFromStorage(?string $cipher, bool $silentFail = false): string
{
try {
return $this->encryptor()->decrypt($cipher);
} catch (\Throwable $e) {
if ($silentFail) {
return '';
}
$this->utils->errorThrow('敏感字段解密失败:' . $e->getMessage());
}
return '';
}
/**
* 是否已是库内密文
*/
public function isEncrypted(?string $value): bool
{
return DatabaseEncryptor::isEncrypted($value);
}
}

View File

@@ -6,13 +6,29 @@ use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
/**
* JWT 签发与解析服务
* firebase/php-jwt HS256 要求密钥至少 256 bit32 字节),过短会抛 Provided key is too short
*/
class JWTService
{
private static mixed $_instance;
private string $secretKey = 'cc_spa_jwt_secret_key'; // 请替换为你的密钥
private string $secretKey;
private string $token;
/**
* 构造时从配置读取密钥,并兜底保证满足 HS256 最小长度
*/
public function __construct()
{
// 优先读环境变量 JWT_SECRET长度不足时用 hash 派生,避免库直接报错
$secret = (string) config('nl.jwt.secret', '');
if (strlen($secret) < 32) {
$secret = hash('sha256', $secret !== '' ? $secret : 'nl_admin_jwt_fallback_secret');
}
$this->secretKey = $secret;
}
/**
* 获取实例
* @return null|static

View File

@@ -3,87 +3,72 @@
namespace App\Service\common;
use App\BaseApp\BaseService;
use App\Models\ProjectModel;
use App\Models\RoleModel;
use App\Models\AdminModel;
use App\Service\common\upload\LocalhostStorageService;
use App\Service\common\upload\QiniuStorageService;
use App\Service\common\oss\OssRuntimeConfigService;
use App\Service\common\oss\OssStorageFactory;
use App\Service\common\oss\OssStorageInterface;
use App\Service\FileService;
use Exception;
use Illuminate\Support\Str;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 统一上传入口:按数据库启用的 OSS 配置,经工厂分发到对应存储实现
*/
class UploadService extends BaseService
{
private $uploadService;
private OssStorageInterface $uploadService;
public function __construct()
{
parent::__construct();
$this->model = RoleModel::class;
switch (env('ECS')) {
case 'aliyun':
// $this->uploadService = AliyunStorageService::getInstance();
break;
case 'qcloud':
// $this->uploadService = QcloudStorageService::getInstance();
break;
case 'qiniu':
$this->uploadService = QiniuStorageService::getInstance();
break;
default:
$this->uploadService = LocalhostStorageService::getInstance();
break;
try {
$config = OssRuntimeConfigService::getInstance()->getActiveConfig();
$this->uploadService = OssStorageFactory::getInstance()->make($config);
} catch (\Throwable $e) {
// 配置异常时回退本地,避免整站上传不可用
$this->uploadService = OssStorageFactory::getInstance()->make([
'driver' => 'local',
'path_prefix' => 'uploads',
'domain' => '',
]);
}
}
/**
* 上传OSS图片
* @param $file
* @return array|bool
* @throws Exception
* 上传图片到当前启用的存储
*
* @param mixed $file 上传文件对象
*/
public function uploadImage($file): array|bool
{
// 获取文件后缀
$ext = $file->getClientOriginalExtension();
$key = 'spa/image/'. date('Ymd') . '/' . 'cc_upload_'. Str::random(). uniqid() . '.' . $ext;
$key = 'spa/image/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
$result = $this->uploadService->uploadVideo($file, $key);
// $result = [
// 'key' => $key,
// 'url' => 'https://img2.baidu.com/it/u=1629222614,1358629025&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500&a='. rand(1111111, 9999999)
// ];
if (!$result) {
$this->utils->errorThrow('图片上传失败');
}
FileService::getInstance()->create([
'user_id' => $this->userId,
'url' => $result['url'],
]);
return $result;
}
/**
* 上传OSS视频
* @param $file
* @return array|bool
* @throws Exception
* 上传视频到当前启用的存储
*
* @param mixed $file 上传文件对象
*/
public function uploadVideo($file): array|bool
{
// 获取文件后缀
$ext = $file->getClientOriginalExtension();
$key = 'spa/video/'. date('Ymd') . '/' . 'cc_upload_'. Str::random(). uniqid() . '.' . $ext;
$key = 'spa/video/' . date('Ymd') . '/' . 'cc_upload_' . Str::random() . uniqid() . '.' . $ext;
$result = $this->uploadService->uploadVideo($file, $key);
// $result = [
// 'key' => $key,
// 'url' => 'http://d-jy.nailaoyun.cn//storage/video//20250327/2cc1abf9bd69410ce7c69660daf54260.mp4'
// ];
if (!$result) {
$this->utils->errorThrow('视频上传失败');
}
FileService::getInstance()->create([
'user_id' => $this->userId,
'url' => $result['url'],
]);
return $result;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Service\common;
use App\BaseApp\BaseNotAuthService;
/**
* User-Agent 解析:登录日志 / 操作日志共用
*/
class UserAgentService extends BaseNotAuthService
{
/**
* UA 推断操作系统名称
* 为什么允许空参:中间件/登录处直接取当前请求头,避免到处传 UA
*
* @param string|null $ua 为空时取当前请求 User-Agent
*/
public function parseEquipment(?string $ua = null): string
{
$ua = $ua ?? (string) request()->header('User-Agent', '');
if ($ua === '') {
return '未知';
}
return match (true) {
str_contains($ua, 'Windows NT 10') => 'Windows 10/11',
str_contains($ua, 'Windows') => 'Windows',
str_contains($ua, 'Macintosh') || str_contains($ua, 'Mac OS') => 'macOS',
str_contains($ua, 'Android') => 'Android',
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
str_contains($ua, 'Linux') => 'Linux',
default => '其他',
};
}
/**
* UA 推断浏览器名称
* Edge 需先于 Chrome 匹配,避免被 Chrome/ 抢先
*
* @param string|null $ua 为空时取当前请求 User-Agent
*/
public function parseBrowser(?string $ua = null): string
{
$ua = $ua ?? (string) request()->header('User-Agent', '');
if ($ua === '') {
return '未知';
}
return match (true) {
str_contains($ua, 'Edg/') => 'Edge',
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
str_contains($ua, 'Chrome/') && !str_contains($ua, 'Edg/') => 'Chrome',
str_contains($ua, 'Firefox/') => 'Firefox',
str_contains($ua, 'Safari/') && !str_contains($ua, 'Chrome/') => 'Safari',
str_contains($ua, 'MSIE') || str_contains($ua, 'Trident/') => 'IE',
default => '其他',
};
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
/**
* AI Agent 工厂按系统配置nl_system_config + 平台表)选择供应商
* 未配置时回落 envconfig/ai.php目前支持讯飞星火 / DeepSeek
*/
class AiAgentFactory extends BaseService
{
public const PROVIDER_SPARK = 'spark';
public const PROVIDER_DEEPSEEK = 'deepseek';
public function __construct()
{
// 工厂解析不强制登录(内部调用)
$this->isAuth = false;
parent::__construct();
}
/**
* 获取 Agent 实例
*
* @param string|null $provider 为空则用系统配置 ai_active_provider / env 默认
*/
public function make(?string $provider = null): AiAgentInterface
{
$resolved = AiRuntimeConfigService::getInstance()->resolve($provider);
$name = strtolower(trim((string) ($resolved['provider'] ?? self::PROVIDER_DEEPSEEK)));
if ($name === self::PROVIDER_DEEPSEEK) {
return DeepSeekAiAgent::getInstance();
}
if ($name === self::PROVIDER_SPARK) {
return SparkAiAgent::getInstance();
}
$this->utils->errorThrow('不支持的 AI 供应商:' . $name);
return SparkAiAgent::getInstance();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Service\common\ai;
/**
* AI Agent 统一接口OpenAI 兼容 chat completions
* 各供应商(讯飞 Spark / DeepSeek 等)实现本接口,业务层只依赖工厂
*/
interface AiAgentInterface
{
/**
* 发起对话补全
*
* @param array $messages OpenAI 格式 messages[['role'=>'system|user|assistant','content'=>'...'], ...]
* @param array $options 可选覆盖model / temperature / max_tokens
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult;
/**
* 供应商标识spark / deepseek
*/
public function provider(): string;
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Service\common\ai;
/**
* AI 对话结果 DTO
* 统一各供应商响应,便于落库与业务解析
*/
class AiChatResult
{
/** 助手文本内容(已从 choices[0].message.content 取出) */
public string $content = '';
/** 供应商原始响应数组(便于排障) */
public array $raw = [];
/** 供应商标识 */
public string $provider = '';
/** 实际使用的模型名 */
public string $model = '';
/** token 用量(若供应商返回) */
public array $usage = [];
/** 本次调用使用的 API Key ID库内密钥env 回落为 0 */
public int $apiKeyId = 0;
/**
* @param string $content 助手文本
* @param array $raw 原始响应
* @param string $provider 供应商
* @param string $model 模型
* @param array $usage usage 字段
* @param int $apiKeyId 密钥 ID
*/
public function __construct(
string $content = '',
array $raw = [],
string $provider = '',
string $model = '',
array $usage = [],
int $apiKeyId = 0
) {
$this->content = $content;
$this->raw = $raw;
$this->provider = $provider;
$this->model = $model;
$this->usage = $usage;
$this->apiKeyId = $apiKeyId;
}
/**
* 转为数组(落库 / 接口返回用)
*/
public function toArray(): array
{
return [
'content' => $this->content,
'provider' => $this->provider,
'model' => $this->model,
'usage' => $this->usage,
'api_key_id' => $this->apiKeyId,
];
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use Throwable;
/**
* 代码生成 AI 助手:根据模块名+功能描述,产出可直接灌入代码生成表单的 JSON
* 每次调用写入 nl_ai_generation便于排查用量与失败原因
*/
class AiCodeGenService extends BaseService
{
/** 允许的 MySQL 字段类型(与代码生成器一致) */
private const ALLOWED_TYPES = [
'varchar', 'int', 'tinyint', 'text', 'decimal', 'bigint', 'char', 'json',
];
/** 系统自动维护的字段,禁止 AI 再生成 */
private const RESERVED_FIELDS = [
'id', 'created_at', 'updated_at', 'deleted_at', 'status',
];
public function __construct()
{
parent::__construct();
}
/**
* 调用当前默认 AI生成代码生成标准结构
*
* @param array{module_name?:string,description?:string,pid?:int} $params
* @return array 与前端 CodeGenData 对齐的数组
*/
public function generate(array $params): array
{
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
$description = trim((string) ($params['description'] ?? ''));
$pid = (int) ($params['pid'] ?? 0);
if ($moduleName === '') {
$this->utils->errorThrow('请输入模块名');
}
if ($description === '') {
$this->utils->errorThrow('请输入功能描述');
}
$runtime = AiRuntimeConfigService::getInstance()->resolve();
$log = AiGenerationLogService::getInstance();
$generationId = $log->begin([
'scene' => AiGenerationSceneEnum::CODE_GENERATION->value,
'name' => $moduleName,
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
'admin_id' => $this->userId,
'input_snapshot' => [
'module_name' => $moduleName,
'description' => $description,
'pid' => $pid,
],
]);
try {
$agent = AiAgentFactory::getInstance()->make();
$messages = [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $pid)],
];
$result = $agent->chatCompletions($messages, [
'temperature' => 0.2,
'max_tokens' => 4096,
]);
$parsed = $this->parseJsonContent($result->content);
// 强制回填用户选择的父级菜单,避免模型改写 pid
$parsed['pid'] = $pid;
if (empty($parsed['class_comment'])) {
$parsed['class_comment'] = $moduleName;
}
if (empty($parsed['icon'])) {
$parsed['icon'] = 'lucide:box';
}
if (!isset($parsed['sort']) || $parsed['sort'] === '') {
$parsed['sort'] = 9999;
}
$this->validateResult($parsed);
$log->markSuccess($generationId, [
'provider' => $result->provider,
'model' => $result->model,
'api_key_id' => $result->apiKeyId,
'usage' => $result->usage,
'result_json' => $parsed,
'raw_response' => $result->content,
'name' => (string) ($parsed['class_comment'] ?? $moduleName),
]);
return $parsed;
} catch (Throwable $e) {
$log->markFail($generationId, $e->getMessage(), [
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
]);
throw $e;
}
}
/**
* 系统提示词:用「完整示例」约束输出,避免模型把说明文字当成字段值
* 为什么不用 schema 占位描述:模型常把 "snake_case 字段名,不要 id..." 原样抄进 name导致建表 SQL 语法错误
*/
private function buildSystemPrompt(): string
{
return <<<'PROMPT'
你是 nl-admin 后台代码生成助手。根据用户的模块名与功能描述,输出【仅一个】合法 JSON 对象。
禁止输出 markdown、代码围栏、解释文字。禁止把规则说明文字当作字段值。
【输出示例】(请按同样结构填写真实业务字段,不要照抄示例字段名):
{
"class_name": "VipMember",
"class_comment": "VIP会员",
"icon": "lucide:crown",
"sort": 9999,
"pid": 0,
"field": [
{
"name": "title",
"type": "varchar",
"type_length": "64",
"default": "",
"comment": "标题",
"not_null": true,
"formShow": true,
"tableShow": true,
"formType": "VbenInput",
"search": true,
"searchValue": "like"
},
{
"name": "level",
"type": "tinyint",
"type_length": "1",
"default": "0",
"comment": "等级",
"not_null": true,
"formShow": true,
"tableShow": true,
"formType": "InputNumber",
"search": true,
"searchValue": "="
},
{
"name": "remark",
"type": "varchar",
"type_length": "255",
"default": "",
"comment": "备注",
"not_null": false,
"formShow": true,
"tableShow": false,
"formType": "Textarea",
"search": false,
"searchValue": "="
}
]
}
硬性规则:
1. class_name仅英文 PascalCase User、OrderItem、VipMember禁止中文、空格、下划线、连字符。
2. field[].name仅小写 snake_case user_name、phone禁止中文、空格、说明性长句。
3. 禁止生成这些字段名id、status、created_at、updated_at、deleted_at系统自动加
4. type 只能是varchar、int、tinyint、text、decimal、bigint、char、json。
5. formType 只能是VbenInput、Textarea、InputNumber、VbenSelect、Upload、Avatar。
6. searchValue 只能是:=、like、><、between。
7. field 至少 2 个、最多 20 个业务字段;按描述合理推断,不要编造无关字段。
8. 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }
PROMPT;
}
/**
* 用户提示词:带上模块上下文
*/
private function buildUserPrompt(string $moduleName, string $description, int $pid): string
{
return "模块名:{$moduleName}\n功能描述:{$description}\n父级菜单ID{$pid}\n请按系统示例结构输出 JSON填真实业务字段不要照抄示例";
}
/**
* 从模型回复中提取 JSON兼容偶发的 ```json 包裹)
*/
private function parseJsonContent(string $content): array
{
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未返回内容');
}
if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $content, $m)) {
$content = trim($m[1]);
}
$start = strpos($content, '{');
$end = strrpos($content, '}');
if ($start === false || $end === false || $end <= $start) {
$this->utils->errorThrow('AI 返回不是有效 JSON');
}
$json = substr($content, $start, $end - $start + 1);
$data = json_decode($json, true);
if (!is_array($data)) {
$this->utils->errorThrow('AI 返回 JSON 解析失败');
}
return $data;
}
/**
* 严格校验 AI 结果,避免脏数据进入建表 SQL
* 为什么要严:模型偶发把提示词说明抄进 name/class_name会导致 CREATE TABLE 语法错误
*/
private function validateResult(array $data): void
{
$className = trim((string) ($data['class_name'] ?? ''));
if ($className === '') {
$this->utils->errorThrow('AI 未生成有效类名');
}
if (!preg_match('/^[A-Z][A-Za-z0-9]{0,63}$/', $className)) {
$this->utils->errorThrow("类名非法「{$className}」,须为英文 PascalCase如 VipMember");
}
if (empty($data['field']) || !is_array($data['field'])) {
$this->utils->errorThrow('AI 未生成字段列表');
}
if (count($data['field']) < 1) {
$this->utils->errorThrow('AI 字段列表为空');
}
$names = [];
foreach ($data['field'] as $i => $field) {
if (!is_array($field)) {
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项格式错误');
}
$name = trim((string) ($field['name'] ?? ''));
$type = strtolower(trim((string) ($field['type'] ?? '')));
if ($name === '') {
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项缺少 name');
}
// 过滤模型把整段说明塞进 name 的情况
if (mb_strlen($name) > 64 || preg_match('/\s||。||:|\||||\(|\)/u', $name)) {
$this->utils->errorThrow("字段名非法「{$name}」,须为短小写 snake_case");
}
if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $name)) {
$this->utils->errorThrow("字段名非法「{$name}」,须为小写 snake_case如 user_name");
}
if (in_array($name, self::RESERVED_FIELDS, true)) {
$this->utils->errorThrow("字段「{$name}」为系统保留字段,请勿生成");
}
if (!in_array($type, self::ALLOWED_TYPES, true)) {
$this->utils->errorThrow("字段「{$name}」类型非法「{$type}");
}
if (isset($names[$name])) {
$this->utils->errorThrow("字段名重复「{$name}");
}
$names[$name] = true;
}
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Models\new\ai\AiDoctorConsentModel;
use App\Models\new\ai\AiFeatureCopyModel;
use App\Models\new\vip\VipFeatureModel;
/**
* AI 醒目提示文案 + 首次知情同意
* 文案按 VIP 功能码隔离;医生签署后仅该功能无需再次确认
*/
class AiDisclaimerService extends BaseService
{
public const FEATURE_MR = 'ai_medical_record';
public const FEATURE_RX = 'ai_prescription';
/**
* 系统配置:启用中的文案列表(含 VIP 功能名)
*/
public function listCopiesForConfig(): array
{
$rows = AiFeatureCopyModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderBy('id')
->get()
->toArray();
$nameMap = VipFeatureModel::where('deleted_at', 0)
->whereIn('code', array_column($rows, 'feature_code'))
->get(['code', 'name'])
->keyBy('code')
->toArray();
foreach ($rows as &$row) {
$code = (string) ($row['feature_code'] ?? '');
$row['feature_name'] = $nameMap[$code]['name'] ?? $code;
$row['disclaimer_text'] = (string) ($row['disclaimer_text'] ?? '');
$row['consent_text'] = (string) ($row['consent_text'] ?? '');
}
unset($row);
return $rows;
}
/**
* 批量保存文案(系统配置 AI 模型 Tab
*
* @param list<array{id?:int,feature_code?:string,record_label?:string,disclaimer_text?:string,consent_text?:string}> $items
*/
public function saveCopies(array $items): bool
{
if (empty($items)) {
$this->utils->errorThrow('无保存内容');
}
$now = time();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$id = (int) ($item['id'] ?? 0);
$code = trim((string) ($item['feature_code'] ?? ''));
$row = null;
if ($id > 0) {
$row = AiFeatureCopyModel::where('id', $id)->where('deleted_at', 0)->first();
}
if (!$row && $code !== '') {
$row = AiFeatureCopyModel::where('feature_code', $code)->where('deleted_at', 0)->first();
}
if (!$row) {
continue;
}
if (array_key_exists('record_label', $item)) {
$row->record_label = trim((string) $item['record_label']);
}
if (array_key_exists('disclaimer_text', $item)) {
$row->disclaimer_text = (string) $item['disclaimer_text'];
}
if (array_key_exists('consent_text', $item)) {
$row->consent_text = (string) $item['consent_text'];
}
$row->updated_at = $now;
$row->save();
}
return true;
}
/**
* 按功能码取文案;不存在则回落内置默认
*/
public function getCopyByFeature(string $featureCode): array
{
$featureCode = trim($featureCode);
if ($featureCode === '') {
$this->utils->errorThrow('功能码不能为空');
}
$row = AiFeatureCopyModel::where('feature_code', $featureCode)
->where('deleted_at', 0)
->where('status', 1)
->first();
if ($row) {
return [
'id' => (int) $row->id,
'feature_code' => (string) $row->feature_code,
'record_label' => (string) ($row->record_label ?: $this->defaultRecordLabel($featureCode)),
'disclaimer_text' => (string) ($row->disclaimer_text ?: $this->defaultDisclaimer($featureCode)),
'consent_text' => (string) ($row->consent_text ?: $this->defaultConsent()),
];
}
return [
'id' => 0,
'feature_code' => $featureCode,
'record_label' => $this->defaultRecordLabel($featureCode),
'disclaimer_text' => $this->defaultDisclaimer($featureCode),
'consent_text' => $this->defaultConsent(),
];
}
/**
* 门闸:文案 + 是否已签署(按医生+功能码)
*/
public function getGate(string $featureCode, int $doctorId): array
{
if ($doctorId <= 0) {
$this->utils->errorThrow('无法识别医生身份');
}
$copy = $this->getCopyByFeature($featureCode);
$consented = AiDoctorConsentModel::where('doctor_id', $doctorId)
->where('feature_code', $featureCode)
->where('deleted_at', 0)
->exists();
return array_merge($copy, [
'consented' => $consented,
]);
}
/**
* 医生签署知情同意(同一功能幂等)
*/
public function agree(string $featureCode, int $doctorId, int $storeId = 0): array
{
if ($doctorId <= 0) {
$this->utils->errorThrow('无法识别医生身份');
}
$featureCode = trim($featureCode);
if ($featureCode === '') {
$this->utils->errorThrow('功能码不能为空');
}
$copy = $this->getCopyByFeature($featureCode);
$exists = AiDoctorConsentModel::where('doctor_id', $doctorId)
->where('feature_code', $featureCode)
->where('deleted_at', 0)
->first();
if ($exists) {
return [
'consented' => true,
'feature_code' => $featureCode,
'already' => true,
];
}
$now = time();
AiDoctorConsentModel::query()->insert([
'doctor_id' => $doctorId,
'feature_code' => $featureCode,
'copy_id' => (int) ($copy['id'] ?? 0),
'store_id' => $storeId,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
return [
'consented' => true,
'feature_code' => $featureCode,
'already' => false,
];
}
private function defaultRecordLabel(string $featureCode): string
{
return $featureCode === self::FEATURE_RX ? '处方' : '病历';
}
private function defaultDisclaimer(string $featureCode): string
{
$label = $this->defaultRecordLabel($featureCode);
return '本' . $label . '由 AI 根据问诊/语音转写内容辅助生成,仅供起草参考。'
. "\nAI 可能存在漏记、误读或“幻觉”,不能替代医师专业判断。"
. "\n诊断、处置、用药、知情告知等关键内容须由你逐条核对、修改后确认。";
}
private function defaultConsent(): string
{
return "我已知悉:\n"
. "1. 本平台 AI 仅用于病历起草、信息归纳、术语规范化;\n"
. "2. 不得用于自动诊断、自动开嘱、自动出报告;\n"
. "3. 所有 AI 输出须由具备资质的医师审核、修改、电子签名;\n"
. "4. 因未审核直接使用 AI 内容导致的医疗差错,由操作医师及医疗机构承担责任。";
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use App\Enum\AiGenerationStatusEnum;
use App\Models\ai\AiGenerationModel;
/**
* AI 生成历史落库:开始 / 成功 / 失败
* 为什么单独抽:多场景复用同一流水表,业务只关心 begin/finish
*/
class AiGenerationLogService extends BaseService
{
/**
* 创建一条「进行中」记录,返回主键 ID
*
* @param array{
* scene?:string,
* name?:string,
* provider?:string,
* model?:string,
* api_key_id?:int,
* input_snapshot?:array|null,
* admin_id?:int
* } $params
*/
public function begin(array $params): int
{
$now = time();
$scene = trim((string) ($params['scene'] ?? AiGenerationSceneEnum::CODE_GENERATION->value));
$adminId = (int) ($params['admin_id'] ?? $this->userId);
return (int) AiGenerationModel::insertGetId([
'admin_id' => $adminId,
'scene' => $scene,
'name' => mb_substr(trim((string) ($params['name'] ?? '')), 0, 128),
'provider' => trim((string) ($params['provider'] ?? '')),
'model' => trim((string) ($params['model'] ?? '')),
'status' => AiGenerationStatusEnum::RUNNING->value,
'api_key_id' => (int) ($params['api_key_id'] ?? 0),
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
'usage_json' => null,
'started_at' => $now,
'finished_at' => 0,
'duration_ms' => 0,
'error_msg' => '',
'input_snapshot' => $this->encodeJson($params['input_snapshot'] ?? null),
'result_json' => null,
'raw_response' => null,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 标记成功并写入结果 / token
*
* @param array{
* provider?:string,
* model?:string,
* api_key_id?:int,
* usage?:array,
* result_json?:array|null,
* raw_response?:string|null,
* name?:string
* } $payload
*/
public function markSuccess(int $id, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::SUCCESS->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'prompt_tokens' => (int) ($usage['prompt_tokens'] ?? 0),
'completion_tokens' => (int) ($usage['completion_tokens'] ?? 0),
'total_tokens' => (int) ($usage['total_tokens'] ?? 0),
'usage_json' => $this->encodeJson($usage ?: null),
'result_json' => $this->encodeJson($payload['result_json'] ?? null),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'error_msg' => '',
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
if (!empty($payload['name'])) {
$data['name'] = mb_substr(trim((string) $payload['name']), 0, 128);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 标记失败
*/
public function markFail(int $id, string $errorMsg, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::FAIL->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'error_msg' => mb_substr(trim($errorMsg), 0, 1000),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
if (!empty($usage)) {
$data['prompt_tokens'] = (int) ($usage['prompt_tokens'] ?? 0);
$data['completion_tokens'] = (int) ($usage['completion_tokens'] ?? 0);
$data['total_tokens'] = (int) ($usage['total_tokens'] ?? 0);
$data['usage_json'] = $this->encodeJson($usage);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 分页列表(管理端生成记录)
*/
public function getPageListForAdmin(): array
{
$this->model = AiGenerationModel::class;
$this->selectField = [
'id', 'admin_id', 'scene', 'name', 'provider', 'model', 'status',
'api_key_id', 'prompt_tokens', 'completion_tokens', 'total_tokens',
'started_at', 'finished_at', 'duration_ms', 'error_msg', 'created_at',
];
$this->queryField = [
'scene' => '=',
'status' => '=',
'provider' => '=',
'name' => 'like',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
// 重置 where避免单例污染
$this->where = [['deleted_at', '=', 0]];
$list = $this->getPageList();
foreach ($list['items'] as &$item) {
$status = (int) ($item['status'] ?? 0);
$scene = (string) ($item['scene'] ?? '');
$item['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$item['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
}
unset($item);
return $list;
}
/**
* 详情(含 input/result不含超大 raw 时可按需)
*/
public function getDetailForAdmin(int $id): array
{
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
$this->utils->notFound('记录不存在');
}
$data = $row->toArray();
$status = (int) ($data['status'] ?? 0);
$scene = (string) ($data['scene'] ?? '');
$data['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$data['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
return $data;
}
private function calcDurationMs(int $startedAt, int $finishedAt): int
{
if ($startedAt <= 0 || $finishedAt <= 0 || $finishedAt < $startedAt) {
return 0;
}
return ($finishedAt - $startedAt) * 1000;
}
private function encodeJson(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
private function clipText(string $text, int $max): string
{
if ($text === '' || mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use Throwable;
/**
* 需求概览 / 提示词助手:按模块名生成或优化「适配 nl-admin 代码生成器」的中文文案
* 模型只输出纯文本(不要 JSON落库时仍包一层 result_json 方便历史查询
*/
class AiRequirementPromptService extends BaseService
{
public function __construct()
{
parent::__construct();
}
/**
* 生成或优化功能描述文案(纯文本)
*
* @param array{module_name?:string,description?:string,mode?:string} $params
* mode: generate=仅模块名生成optimize=在已有描述上优化;空则自动判断
* @return array{text:string,prompt:string,overview:string,mode:string,module_name:string}
*/
public function generate(array $params): array
{
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
$description = trim((string) ($params['description'] ?? ''));
$mode = trim((string) ($params['mode'] ?? ''));
if ($moduleName === '') {
$this->utils->errorThrow('请输入模块名');
}
if ($mode === '') {
$mode = $description !== '' ? 'optimize' : 'generate';
}
if (!in_array($mode, ['generate', 'optimize'], true)) {
$this->utils->errorThrow('mode 仅支持 generate 或 optimize');
}
if ($mode === 'optimize' && $description === '') {
$this->utils->errorThrow('优化模式请先填写当前功能描述');
}
$runtime = AiRuntimeConfigService::getInstance()->resolve();
$log = AiGenerationLogService::getInstance();
$generationId = $log->begin([
'scene' => AiGenerationSceneEnum::REQUIREMENT_PROMPT->value,
'name' => $moduleName,
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
'admin_id' => $this->userId,
'input_snapshot' => [
'module_name' => $moduleName,
'description' => $description,
'mode' => $mode,
],
]);
try {
$agent = AiAgentFactory::getInstance()->make();
$messages = [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $mode)],
];
$result = $agent->chatCompletions($messages, [
'temperature' => 0.35,
'max_tokens' => 2048,
]);
$text = $this->normalizePlainText($result->content);
$normalized = [
// text 为主字段prompt/overview 兼容旧前端「替换导入」
'text' => $text,
'prompt' => $text,
'overview' => $text,
'mode' => $mode,
'module_name' => $moduleName,
];
$log->markSuccess($generationId, [
'provider' => $result->provider,
'model' => $result->model,
'api_key_id' => $result->apiKeyId,
'usage' => $result->usage,
'result_json' => $normalized,
'raw_response' => $result->content,
'name' => $moduleName,
]);
return $normalized;
} catch (Throwable $e) {
$log->markFail($generationId, $e->getMessage(), [
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
]);
throw $e;
}
}
/**
* 系统提示:只要中文功能描述文案,禁止 JSON / markdown 代码块
*/
private function buildSystemPrompt(): string
{
return <<<'PROMPT'
你是 nl-adminVben Admin + Laravel 三层 CRUD 代码生成器)的需求分析助手。
任务:写出一段可直接填入「功能描述」输入框的中文文案,供下一步 AI 生成表结构与 CRUD 代码使用。
必须贴合本系统:
1. 面向后台管理 CRUD列表、搜索、新增/编辑、软删除、启停。
2. 不要要求 id / status / created_at / updated_at / deleted_at系统自动加
3. 说明要管理哪些业务数据、建议字段(用中文含义即可)、哪些适合检索、哪些适合下拉/上传等。
4. 不要写接口、鉴权、微服务、路由实现细节。
5. 200500 字为宜,条理清晰,可用分点,但不要标题堆砌。
输出要求(非常重要):
- 只输出中文正文文案本身。
- 禁止输出 JSON、禁止用 ``` 代码块、禁止前言后语(如「好的」「如下」)。
PROMPT;
}
/**
* 用户提示:生成 vs 优化
*/
private function buildUserPrompt(string $moduleName, string $description, string $mode): string
{
if ($mode === 'optimize') {
return "请优化下面的功能描述,使其更适合 nl-admin 代码生成(保留原意,补全字段与管理动作)。\n模块名:{$moduleName}\n当前描述:\n{$description}";
}
return "请根据模块名「{$moduleName}」写一段后台管理功能描述文案(适配 nl-admin 代码生成)。";
}
/**
* 清洗模型回复为纯文案(去掉偶发的 markdown / JSON 包裹)
*/
private function normalizePlainText(string $content): string
{
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未返回内容');
}
// 去掉 ``` 包裹
if (preg_match('/```(?:\w+)?\s*([\s\S]*?)```/i', $content, $m)) {
$content = trim($m[1]);
}
// 若模型仍返回 JSON尽量抽出 prompt/text/overview
if (str_starts_with($content, '{')) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$content = trim((string) ($decoded['text'] ?? $decoded['prompt'] ?? $decoded['overview'] ?? ''));
}
}
$content = trim($content);
// 去掉常见客套开头
$content = preg_replace('/^(好的[,。]?|如下[:]?|以下是[^\\n]*[:]?)\\s*/u', '', $content) ?? $content;
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未生成有效文案');
}
return $content;
}
}

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Models\ai\AiApiKeyModel;
use App\Models\ai\AiModelModel;
use App\Models\ai\AiPlatformModel;
use App\Service\common\FieldEncryptService;
use App\Service\SystemConfigService;
/**
* AI 运行时配置:从系统配置 + 平台/模型/密钥表解析当前要用的供应商与明文密钥
* 优先数据库配置;未配置时回落 envconfig/ai.php
*/
class AiRuntimeConfigService extends BaseService
{
public const CFG_PROVIDER = 'ai_active_provider';
public const CFG_API_KEY_ID = 'ai_active_api_key_id';
public const CFG_MODEL = 'ai_active_model';
public function __construct()
{
// 工厂解析不依赖登录态
$this->isAuth = false;
parent::__construct();
}
/**
* 解析指定供应商的调用配置
*
* @return array{provider:string,api_url:string,api_key:string,model:string,timeout:float,platform_id:int,api_key_id:int}
*/
public function resolve(?string $providerOverride = null): array
{
$sys = SystemConfigService::getInstance();
$provider = strtolower(trim((string) (
$providerOverride
?: $sys->getValue(self::CFG_PROVIDER, '')
?: config('ai.default_provider', 'deepseek')
)));
$modelOverride = trim((string) $sys->getValue(self::CFG_MODEL, ''));
$keyId = (int) $sys->getValue(self::CFG_API_KEY_ID, '0');
$platform = AiPlatformModel::where('code', $provider)
->where('deleted_at', 0)
->where('status', 1)
->first();
$apiUrl = '';
$model = '';
$apiKey = '';
$platformId = 0;
$timeout = 60.0;
if ($platform) {
$platformId = (int) $platform->id;
$apiUrl = trim((string) $platform->api_url);
// 模型:系统配置覆盖 → 模型表默认 → 模型表首个启用
if ($modelOverride !== '') {
$model = $modelOverride;
} else {
$defaultModel = AiModelModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1)
->where('is_default', 1)
->value('code');
if ($defaultModel) {
$model = trim((string) $defaultModel);
} else {
$firstModel = AiModelModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->value('code');
$model = trim((string) ($firstModel ?: ''));
}
}
// 指定密钥优先;否则默认密钥;再否则取该平台启用中的第一条
$keyQuery = AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1);
if ($keyId > 0) {
$keyRow = (clone $keyQuery)->where('id', $keyId)->first();
} else {
$keyRow = null;
}
if (!$keyRow) {
$keyRow = (clone $keyQuery)->where('is_default', 1)->first()
?: $keyQuery->orderByDesc('sort')->orderBy('id')->first();
}
if ($keyRow) {
$apiKey = $this->decryptApiKey((string) $keyRow->api_key);
$keyId = (int) $keyRow->id;
}
}
// 回落 env保证未录入密钥时仍可用旧配置
$envCfg = config('ai.' . $provider, []);
if ($apiUrl === '') {
$apiUrl = (string) ($envCfg['api_url'] ?? '');
}
if ($model === '') {
$model = (string) ($envCfg['model'] ?? '');
}
if ($apiKey === '') {
if ($provider === 'spark') {
$apiKey = (string) ($envCfg['password'] ?? '');
} else {
$apiKey = (string) ($envCfg['api_key'] ?? '');
}
}
$timeout = (float) ($envCfg['timeout'] ?? 60);
return [
'provider' => $provider,
'api_url' => $apiUrl,
'api_key' => $apiKey,
'model' => $model,
'timeout' => $timeout,
'platform_id' => $platformId,
'api_key_id' => $keyId,
];
}
/**
* 库内 AES 解密 api_key非加密串原样返回兼容过渡
* 委托 FieldEncryptService nl_ai_api_key 密文格式一致
*/
public function decryptApiKey(string $cipher): string
{
return FieldEncryptService::getInstance()->decryptFromStorage($cipher, true);
}
/**
* 明文入库前 AES 加密
* 委托 FieldEncryptService写出 nl_ase_256_ 前缀密文
*/
public function encryptApiKey(string $plain): string
{
return FieldEncryptService::getInstance()->encryptForStorage($plain);
}
/**
* 系统配置页:启用中的平台卡片 + 模型 + 密钥选项
* 为什么组装成卡片结构:前端 C 端风格按平台展示,减少二次请求
*/
public function optionsForConfig(): array
{
$platforms = AiPlatformModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->get()
->toArray();
$models = AiModelModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_default')
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'platform_id', 'code', 'name', 'description', 'is_default'])
->toArray();
$keys = AiApiKeyModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_default')
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'platform_id', 'name', 'is_default'])
->toArray();
$modelMap = [];
foreach ($models as $m) {
$pid = (int) $m['platform_id'];
$modelMap[$pid][] = [
'id' => (int) $m['id'],
'label' => $m['name'] . '' . $m['code'] . '',
'value' => (string) $m['code'],
'code' => (string) $m['code'],
'name' => (string) $m['name'],
'description' => (string) ($m['description'] ?? ''),
'is_default' => (int) ($m['is_default'] ?? 0),
];
}
$keyMap = [];
foreach ($keys as $k) {
$pid = (int) $k['platform_id'];
$name = trim((string) ($k['name'] ?? ''));
if ($name === '') {
$name = '密钥#' . (int) $k['id'];
}
$keyMap[$pid][] = [
'id' => (int) $k['id'],
'key_name' => $name,
'name' => $name,
'label' => $name,
'value' => (int) $k['id'],
'is_default' => (int) ($k['is_default'] ?? 0),
];
}
$cards = [];
foreach ($platforms as $p) {
$id = (int) $p['id'];
$platModels = $modelMap[$id] ?? [];
$defaultModel = '';
foreach ($platModels as $pm) {
if ((int) $pm['is_default'] === 1) {
$defaultModel = $pm['code'];
break;
}
}
if ($defaultModel === '' && !empty($platModels[0]['code'])) {
$defaultModel = (string) $platModels[0]['code'];
}
$cards[] = [
'id' => $id,
'code' => $p['code'],
'name' => $p['name'],
'logo' => $p['logo'],
'description' => $p['description'],
'default_model' => $defaultModel,
'api_url' => $p['api_url'],
'models' => $platModels,
'keys' => $keyMap[$id] ?? [],
];
}
$sys = SystemConfigService::getInstance();
return [
'platforms' => $cards,
'active' => [
'provider' => (string) $sys->getValue(self::CFG_PROVIDER, config('ai.default_provider', 'deepseek')),
'api_key_id' => (int) $sys->getValue(self::CFG_API_KEY_ID, '0'),
'model' => (string) $sys->getValue(self::CFG_MODEL, ''),
],
];
}
/**
* 保存默认运行时组合(平台 / 模型 / 密钥)
* 为什么校验平台存在:避免写入无效编码导致代码生成 AI 调用失败
*/
public function saveActiveConfig(string $provider, string $model, int $apiKeyId): array
{
$provider = strtolower(trim($provider));
$model = trim($model);
if ($provider === '') {
$this->utils->errorThrow('请选择 AI 平台');
}
$platform = AiPlatformModel::where('code', $provider)
->where('deleted_at', 0)
->where('status', 1)
->first();
if (!$platform) {
$this->utils->errorThrow('AI 平台不存在或已禁用');
}
if ($model === '') {
$this->utils->errorThrow('请选择模型');
}
$modelExists = AiModelModel::where('platform_id', (int) $platform->id)
->where('code', $model)
->where('deleted_at', 0)
->where('status', 1)
->exists();
if (!$modelExists) {
$this->utils->errorThrow('模型不属于当前平台或已禁用');
}
if ($apiKeyId > 0) {
$keyExists = AiApiKeyModel::where('id', $apiKeyId)
->where('platform_id', (int) $platform->id)
->where('deleted_at', 0)
->where('status', 1)
->exists();
if (!$keyExists) {
$this->utils->errorThrow('密钥不属于当前平台或已禁用');
}
}
$sys = SystemConfigService::getInstance();
$sys->setValue(self::CFG_PROVIDER, $provider, '当前启用的 AI 平台编码');
$sys->setValue(self::CFG_MODEL, $model, '当前启用的模型编码');
$sys->setValue(self::CFG_API_KEY_ID, (string) $apiKeyId, '当前启用的 API 密钥 ID');
return [
'provider' => $provider,
'model' => $model,
'api_key_id' => $apiKeyId,
];
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* DeepSeek AgentOpenAI 兼容协议)
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
*/
class DeepSeekAiAgent extends BaseService implements AiAgentInterface
{
public function __construct()
{
// Agent 本身不读用户态,避免工厂链路强制鉴权
$this->isAuth = false;
parent::__construct();
}
public function provider(): string
{
return 'deepseek';
}
/**
* 调用 DeepSeek chat completions
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult
{
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
$apiUrl = (string) ($cfg['api_url'] ?? '');
$apiKey = (string) ($cfg['api_key'] ?? '');
$defaultModel = (string) ($cfg['model'] ?? 'deepseek-chat');
$timeout = (float) ($cfg['timeout'] ?? 60);
if ($apiKey === '') {
$this->utils->errorThrow('DeepSeek 暂未开通请在「AI密钥管理」或 .env DEEPSEEK_API_KEY 配置)');
}
if ($apiUrl === '') {
$this->utils->errorThrow('未配置 DeepSeek 接口地址');
}
if (empty($messages)) {
$this->utils->errorThrow('AI 对话消息不能为空');
}
$model = (string) ($options['model'] ?? $defaultModel);
$body = [
'model' => $model,
'messages' => array_values($messages),
'stream' => false,
];
foreach (['temperature', 'max_tokens', 'top_p'] as $key) {
if (array_key_exists($key, $options)) {
$body[$key] = $options[$key];
}
}
try {
$client = new Client([
'timeout' => $timeout,
'connect_timeout' => min(10.0, $timeout),
]);
$response = $client->request('POST', $apiUrl, [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
],
'json' => $body,
]);
$raw = json_decode((string) $response->getBody(), true);
if (!is_array($raw)) {
$this->utils->errorThrow('DeepSeek 返回非 JSON');
}
$content = (string) data_get($raw, 'choices.0.message.content', '');
if ($content === '') {
$errMsg = (string) ($raw['error']['message'] ?? '');
$this->utils->errorThrow($errMsg !== '' ? ('DeepSeek 调用失败:' . $errMsg) : 'DeepSeek 未返回有效内容');
}
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
return new AiChatResult(
$content,
$raw,
$this->provider(),
$model,
$usage,
(int) ($cfg['api_key_id'] ?? 0)
);
} catch (Throwable $e) {
if ($e instanceof GuzzleException) {
$this->utils->errorThrow('DeepSeek 请求失败:' . $e->getMessage());
}
throw $e;
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* 讯飞星火 OpenAPI AgentOpenAI 兼容 /v1/chat/completions
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
*/
class SparkAiAgent extends BaseService implements AiAgentInterface
{
public function __construct()
{
// Agent 本身不读用户态,避免工厂链路强制鉴权
$this->isAuth = false;
parent::__construct();
}
public function provider(): string
{
return 'spark';
}
/**
* 调用星火 chat completions
* Authorization 使用 Bearer + API Key与官方 OpenAPI 一致)
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult
{
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
$apiUrl = (string) ($cfg['api_url'] ?? '');
$password = (string) ($cfg['api_key'] ?? '');
$defaultModel = (string) ($cfg['model'] ?? 'lite');
$timeout = (float) ($cfg['timeout'] ?? 60);
if ($password === '') {
$this->utils->errorThrow('未配置讯飞星火 API 密钥请在「AI密钥管理」或 .env SPARK_API_PASSWORD 配置)');
}
if ($apiUrl === '') {
$this->utils->errorThrow('未配置讯飞星火接口地址');
}
if (empty($messages)) {
$this->utils->errorThrow('AI 对话消息不能为空');
}
$model = (string) ($options['model'] ?? $defaultModel);
$body = [
'model' => $model,
'messages' => array_values($messages),
];
// 可选参数透传,便于业务微调温度等
foreach (['temperature', 'max_tokens', 'top_p', 'stream'] as $key) {
if (array_key_exists($key, $options)) {
$body[$key] = $options[$key];
}
}
// 默认关闭流式,业务侧解析整包 JSON 更简单
if (!isset($body['stream'])) {
$body['stream'] = false;
}
try {
$client = new Client([
'timeout' => $timeout,
'connect_timeout' => min(10.0, $timeout),
]);
$response = $client->request('POST', $apiUrl, [
'headers' => [
'Authorization' => 'Bearer ' . $password,
'Content-Type' => 'application/json',
],
'json' => $body,
]);
$raw = json_decode((string) $response->getBody(), true);
if (!is_array($raw)) {
$this->utils->errorThrow('讯飞星火返回非 JSON');
}
$content = (string) data_get($raw, 'choices.0.message.content', '');
if ($content === '') {
$errMsg = (string) ($raw['error']['message'] ?? $raw['message'] ?? '');
$this->utils->errorThrow($errMsg !== '' ? ('讯飞星火调用失败:' . $errMsg) : '讯飞星火未返回有效内容');
}
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
return new AiChatResult(
$content,
$raw,
$this->provider(),
$model,
$usage,
(int) ($cfg['api_key_id'] ?? 0)
);
} catch (Throwable $e) {
if ($e instanceof GuzzleException) {
$this->utils->errorThrow('讯飞星火请求失败:' . $e->getMessage());
}
throw $e;
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Service\common\oss;
use App\BaseApp\BaseService;
use App\Models\oss\OssConfigModel;
use App\Service\common\FieldEncryptService;
use App\Service\SystemConfigService;
/**
* OSS 运行时配置:解析当前启用的存储配置并解密密钥,供上传工厂使用
*/
class OssRuntimeConfigService extends BaseService
{
public const CFG_ACTIVE_ID = 'oss_active_config_id';
public function __construct()
{
// 上传链路可能在无登录上下文触发,不强制鉴权
$this->isAuth = false;
parent::__construct();
}
/**
* 获取当前启用的明文配置
* 为什么要解密:上传客户端需要明文 access/secret库内仅存 AES 密文
*
* @return array{
* id:int,driver:string,name:string,access_key:string,secret_key:string,
* endpoint:string,region:string,bucket:string,domain:string,
* path_prefix:string,extra_json:mixed
* }
*/
public function getActiveConfig(): array
{
$sys = SystemConfigService::getInstance();
$activeId = (int) $sys->getValue(self::CFG_ACTIVE_ID, '0');
$row = null;
if ($activeId > 0) {
$row = OssConfigModel::where('id', $activeId)
->where('deleted_at', 0)
->where('status', 1)
->first();
}
if (!$row) {
// 回落 is_active 标记,避免仅写了表未写 system_config 时上传失败
$row = OssConfigModel::where('is_active', 1)
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('id')
->first();
}
if (!$row) {
// 最终回落本地默认行或内存默认
$row = OssConfigModel::where('driver', 'local')
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_active')
->orderBy('id')
->first();
}
if (!$row) {
return [
'id' => 0,
'driver' => 'local',
'name' => '本地存储',
'access_key' => '',
'secret_key' => '',
'endpoint' => '',
'region' => '',
'bucket' => '',
'domain' => '',
'path_prefix' => 'uploads',
'extra_json' => null,
];
}
$enc = FieldEncryptService::getInstance();
$extra = $row->extra_json;
if (is_string($extra) && $extra !== '') {
$decoded = json_decode($extra, true);
$extra = is_array($decoded) ? $decoded : $extra;
}
return [
'id' => (int) $row->id,
'driver' => (string) $row->driver,
'name' => (string) $row->name,
'access_key' => $enc->decryptFromStorage((string) ($row->access_key ?? ''), true),
'secret_key' => $enc->decryptFromStorage((string) ($row->secret_key ?? ''), true),
'endpoint' => (string) ($row->endpoint ?? ''),
'region' => (string) ($row->region ?? ''),
'bucket' => (string) ($row->bucket ?? ''),
'domain' => (string) ($row->domain ?? ''),
'path_prefix' => (string) ($row->path_prefix ?? ''),
'extra_json' => $extra,
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Service\common\oss;
use App\BaseApp\BaseNotAuthService;
use App\Enum\OssDriverEnum;
use App\Service\common\upload\AliyunStorageService;
use App\Service\common\upload\LocalhostStorageService;
use App\Service\common\upload\QcloudStorageService;
use App\Service\common\upload\QiniuStorageService;
use App\Service\common\upload\S3CompatibleStorageService;
use App\Service\common\UtilsService;
/**
* OSS 存储工厂:按 OssDriverEnum 分发到对应实现(工厂 + 策略)
* 覆盖local / aliyun / qcloud / qiniu / huawei / aws / minio / baidu
*/
class OssStorageFactory extends BaseNotAuthService
{
/**
* 根据明文运行时配置构建上传客户端
* 为什么集中在工厂UploadService 不关心具体 SDK/签名差异,只拿接口用
*
* @param array $config getActiveConfig() 返回值(含 driver
*/
public function make(array $config): OssStorageInterface
{
$driverCode = strtolower(trim((string) ($config['driver'] ?? OssDriverEnum::Local->value)));
$driver = OssDriverEnum::tryFrom($driverCode);
if ($driver === null) {
UtilsService::getInstance()->errorThrow(
'不支持的存储驱动:' . $driverCode . ',请改用本地存储或完善配置'
);
}
// 按驱动枚举创建策略实现S3 兼容族共用一套 SigV4 客户端
return match ($driver) {
OssDriverEnum::Local => LocalhostStorageService::getInstance()->withConfig($config),
OssDriverEnum::Aliyun => AliyunStorageService::getInstance()->withConfig($config),
OssDriverEnum::Qcloud => QcloudStorageService::getInstance()->withConfig($config),
OssDriverEnum::Qiniu => QiniuStorageService::getInstance()->withConfig($config),
OssDriverEnum::Huawei,
OssDriverEnum::Aws,
OssDriverEnum::Minio,
OssDriverEnum::Baidu => S3CompatibleStorageService::getInstance()->withConfig($config),
};
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Service\common\oss;
/**
* OSS 存储驱动统一契约:工厂按 driver 返回实现类UploadService 只依赖本接口
*/
interface OssStorageInterface
{
/**
* 注入本请求的运行时配置(密钥已解密)
*
* @param array $config OssRuntimeConfigService::getActiveConfig()
*/
public function withConfig(array $config): static;
/**
* 上传图片对象
*
* @param mixed $filePath 本地路径或 UploadedFile
* @param string $key 对象键
* @return array{key:string,url:string}|false
*/
public function uploadImage($filePath, string $key): bool|array;
/**
* 上传视频/通用文件对象
*
* @param mixed $filePath 本地路径或 UploadedFile
* @param string $key 对象键
* @return array{key:string,url:string}|false
*/
public function uploadVideo($filePath, string $key): bool|array;
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 阿里云 OSSREST PutObjectAuthorization 签名)
*/
class AliyunStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 使用 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'] ?? '');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
if ($accessKey === '' || $secretKey === '' || $bucket === '' || $endpoint === '') {
UtilsService::getInstance()->errorThrow('阿里云 OSS 配置不完整(需要 AccessKey/Secret/Bucket/Endpoint');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$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;
$response = Http::withHeaders([
'Date' => $date,
'Content-Type' => $contentType,
'Authorization' => 'OSS ' . $accessKey . ':' . $signature,
])->withBody($content, $contentType)->put($url);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow('阿里云上传失败:' . $response->body());
}
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}

View File

@@ -2,105 +2,72 @@
namespace App\Service\common\upload;
use App\BaseApp\BaseService;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use Illuminate\Support\Facades\Storage;
class LocalhostStorageService extends BaseService
/**
* 本地磁盘上传;支持 domain / path_prefix 覆盖
*/
class LocalhostStorageService extends BaseNotAuthService implements OssStorageInterface
{
/**
* @var mixed 单例实例,确保该类只有一个全局实例
*/
protected static mixed $_instance;
protected array $config = [
'domain' => '',
'path_prefix' => '',
];
public function __construct()
/**
* 注入运行时配置(链式)
*/
public function withConfig(array $config): static
{
parent::__construct();
$this->config = array_merge($this->config, $config);
return $this;
}
/**
* 获取实例
* @return null|static
*/
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 string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
*/
public function uploadImage($filePath, $key): bool|array
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 上传视频
*
* @param string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
*/
public function uploadVideo($filePath, $key): bool|array
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 删除图片
*
* @param string $key 本地存储的文件名
* @return bool
*/
public function deleteImage($key): bool
{
return $this->deleteFile($key);
}
/**
* 删除视频
*
* @param string $key 本地存储的文件名
* @return bool
*/
public function deleteVideo($key): bool
{
return $this->deleteFile($key);
}
/**
* 上传文件
*
* @param string $filePath 文件本地路径
* @param string $key 上传到本地存储的文件名
* @return array|bool
* 写入本地 storage兼容 UploadedFile 与路径字符串
*/
private function uploadFile(string $filePath, string $key): bool|array
private function uploadFile(mixed $filePath, string $key): bool|array
{
if (Storage::put($key, file_get_contents($filePath))) {
return [
'key' => $key,
'url' => asset('/storage/' . $key)
];
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
return false;
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
if (!Storage::put($key, file_get_contents($path))) {
return false;
}
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
$url = $domain !== '' ? ($domain . '/' . $key) : asset('/storage/' . $key);
return [
'key' => $key,
'url' => $url,
];
}
/**
* 删除文件
*
* @param string $key 本地存储的文件名
* @return bool
*/
private function deleteFile(string $key): bool
{
return Storage::delete($key);

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* 腾讯云 COS简易 PUTAuthorization 签名 v5
*/
class QcloudStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 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'] ?? '');
$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, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$content = file_get_contents($path);
$host = $bucket . '.cos.' . $region . '.myqcloud.com';
$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,
'Content-Type' => 'application/octet-stream',
])->withBody($content, 'application/octet-stream')->put($url);
if (!$response->successful()) {
UtilsService::getInstance()->errorThrow('腾讯云上传失败:' . $response->body());
}
$publicUrl = $domain !== '' ? ($domain . $urlPath) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}

View File

@@ -2,131 +2,95 @@
namespace App\Service\common\upload;
use App\BaseApp\BaseService;
use Exception;
use Qiniu\Auth;
use Qiniu\Storage\BucketManager;
use Qiniu\Storage\UploadManager;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
class QiniuStorageService extends BaseService
/**
* 七牛云上传;优先使用官方 SDK未安装时给出明确提示
*/
class QiniuStorageService extends BaseNotAuthService implements OssStorageInterface
{
/**
* @var mixed 单例实例,确保该类只有一个全局实例
*/
protected static mixed $_instance;
private $accessKey;
private $secretKey;
private $bucket;
private $domain;
protected array $config = [
'access_key' => '',
'secret_key' => '',
'bucket' => '',
'domain' => '',
'path_prefix' => '',
];
public function __construct()
/**
* 注入解密后的运行时配置
*/
public function withConfig(array $config): static
{
parent::__construct();
$this->accessKey = config('nl.oss.qiniu.access_key');
$this->secretKey = config('nl.oss.qiniu.secret_key');
$this->bucket = config('nl.oss.qiniu.bucket');
$this->domain = config('nl.oss.qiniu.domain');
$this->config = array_merge($this->config, $config);
return $this;
}
/**
* 获取实例
* @return null|static
*/
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 string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
* @throws Exception
*/
public function uploadImage($filePath, $key): bool|array
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 上传视频
*
* @param string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
*/
public function uploadVideo($filePath, $key): bool|array
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* 删除图片
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
public function deleteImage($key): bool
{
return $this->deleteFile($key);
}
/**
* 删除视频
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
public function deleteVideo($key): bool
{
return $this->deleteFile($key);
}
/**
* 上传文件
*
* @param string $filePath 文件本地路径
* @param string $key 上传到七牛云的文件名
* @return array|bool
* @throws Exception
* 上传到七牛;无 SDK 时抛业务异常引导安装或改本地
*/
private function uploadFile(string $filePath, string $key): bool|array
private function uploadFile($filePath, string $key): bool|array
{
$auth = new Auth($this->accessKey, $this->secretKey);
$token = $auth->uploadToken($this->bucket);
$uploadMgr = new UploadManager();
list($ret, $err) = $uploadMgr->putFile($token, $key, $filePath);
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'] ?? '');
$domain = rtrim((string) ($this->config['domain'] ?? ''), '/');
if ($accessKey === '' || $secretKey === '' || $bucket === '') {
UtilsService::getInstance()->errorThrow('七牛云配置不完整');
}
$prefix = trim((string) ($this->config['path_prefix'] ?? ''), '/');
if ($prefix !== '' && !str_starts_with($key, $prefix . '/')) {
$key = $prefix . '/' . ltrim($key, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$auth = new \Qiniu\Auth($accessKey, $secretKey);
$token = $auth->uploadToken($bucket);
$uploadMgr = new \Qiniu\Storage\UploadManager();
list($ret, $err) = $uploadMgr->putFile($token, $key, $path);
if ($err !== null) {
return false;
} else {
return [
'key' => $ret['key'],
'url' => $this->domain . '/' . $ret['key']
];
}
return [
'key' => $ret['key'],
'url' => $domain . '/' . $ret['key'],
];
}
/**
* 删除文件
*
* @param string $key 七牛云存储的文件名
* @return bool
*/
private function deleteFile(string $key): bool
{
$auth = new Auth($this->accessKey, $this->secretKey);
$bucketMgr = new BucketManager($auth);
$err = $bucketMgr->delete($this->bucket, $key);
if (!class_exists(\Qiniu\Auth::class)) {
return false;
}
$auth = new \Qiniu\Auth($this->config['access_key'], $this->config['secret_key']);
$bucketMgr = new \Qiniu\Storage\BucketManager($auth);
$err = $bucketMgr->delete($this->config['bucket'], $key);
return $err === null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Service\common\upload;
use App\BaseApp\BaseNotAuthService;
use App\Service\common\oss\OssStorageInterface;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Http;
/**
* S3 兼容上传AWS / MinIO / 华为 OBS / 百度 BOS
* 使用 AWS Signature Version 4 PUT Object
*/
class S3CompatibleStorageService extends BaseNotAuthService implements OssStorageInterface
{
protected array $config = [];
public function withConfig(array $config): static
{
$this->config = $config;
return $this;
}
public function uploadImage($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
public function uploadVideo($filePath, string $key): bool|array
{
return $this->uploadFile($filePath, $key);
}
/**
* SigV4 PUTendpoint 必填(如 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'] ?? ''), '/');
$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, '/');
}
$path = is_object($filePath) && method_exists($filePath, 'getRealPath')
? $filePath->getRealPath()
: (string) $filePath;
$payload = file_get_contents($path);
$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;
$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";
$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);
$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,
'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());
}
$publicUrl = $domain !== '' ? ($domain . '/' . $key) : $url;
return ['key' => $key, 'url' => $publicUrl];
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace App\Service\common\wechat;
use App\BaseApp\BaseClient;
use App\Service\common\RedisService;
use GuzzleHttp\Exception\GuzzleException;
/**
* 微信公众号 API 客户端(继承 BaseClient禁止业务代码直接 new Guzzle
*
* 职责:
* 1. stable_token 获取 + Redis 缓存key 前缀 config('nl.redis.wechat_token')TTL 随微信返回的 expires_in
* 2. 正文外链图搬家media/uploadimg公众号正文只认微信图床 URL
* 3. 封面永久素材material/add_material thumb_media_id
* 4. 草稿 draft/add、发布 freepublish/submit、发布状态 freepublish/get
*
* 为什么单独封 postWxJson / postWxMultipart
* - BaseClient::postJson Guzzle json 选项,中文会被 unicode 转义,微信标题/摘要偶发显示 \uXXXX
* 这里手动 JSON_UNESCAPED_UNICODE 编码后按 body 发送
* - 素材上传要 multipart 文件流BaseClient 没有该能力,本类内部补齐(复用父类 init Guzzle 实例)
*/
class WechatApiClient extends BaseClient
{
protected string $baseUrl = 'https://api.weixin.qq.com';
/**
* 常见错误码 人话(其余透出 errcode + errmsg 原文)
*/
private const ERROR_MAP = [
40001 => 'AppSecret 错误或 access_token 已失效,请检查账号配置',
40013 => 'AppID 无效,请检查账号配置',
40125 => 'AppSecret 无效,请检查账号配置',
41004 => '缺少 AppSecret 参数',
40164 => '服务器出口 IP 不在公众号后台的 IP 白名单中,请到公众平台「设置与开发-安全中心」添加',
48001 => '该公众号无此接口权限(个人订阅号无发布能力,需认证服务号/订阅号)',
45009 => '接口调用次数超过当日限额,请明天再试',
45028 => '该公众号无留言/相关高级能力权限',
40007 => '无效的 media_id素材可能已过期请重新发布',
53503 => '该草稿未通过发布检查',
53504 => '需前往公众平台官网使用草稿',
53505 => '请手动保存成功后再发布',
];
/**
* 获取稳定版 access_token优先走 Redis 缓存)
*
* 为什么用 stable_token 而不是旧 token 接口stable 模式重复获取不会互踢,
* 多实例/双后端Laravel+GF共用同一个 Redis key 也不会导致 token 失效竞争
*
* @param string $appid 公众号 AppID
* @param string $secret AppSecret 明文(调用方负责解密)
* @param bool $forceRefresh true 时绕过缓存强刷(如 token 失效重试场景)
*/
public function getStableToken(string $appid, string $secret, bool $forceRefresh = false): string
{
$redis = RedisService::getInstance()->init((string) config('nl.redis.wechat_token', 'nl_wechat_at_'));
if (!$forceRefresh) {
$cached = $redis->get($appid);
if (!empty($cached)) {
return (string) $cached;
}
}
$body = $this->postWxJson('/cgi-bin/stable_token', [
'grant_type' => 'client_credential',
'appid' => $appid,
'secret' => $secret,
'force_refresh' => $forceRefresh,
], '获取access_token');
$token = (string) ($body['access_token'] ?? '');
if ($token === '') {
$this->utils->errorThrow('获取 access_token 失败:微信未返回 token');
}
// 提前 5 分钟过期,避免边界时刻拿到将失效的 token
$expire = max(60, (int) ($body['expires_in'] ?? 7200) - 300);
$redis->set($appid, $token, $expire);
return $token;
}
/**
* 校验 appid/secret 连通性(不落缓存,直接强刷验证凭据有效)
* 返回微信侧 expires_in便于前端提示
*/
public function testCredential(string $appid, string $secret): array
{
$body = $this->postWxJson('/cgi-bin/stable_token', [
'grant_type' => 'client_credential',
'appid' => $appid,
'secret' => $secret,
], '测试连通');
return [
'ok' => true,
'expires_in' => (int) ($body['expires_in'] ?? 0),
];
}
/**
* 正文图片搬家:下载外链图后传微信图床,返回微信侧 URL
* 微信限制:仅 jpg/png、单张 ≤1MB URL 不占素材库额度,仅可用于图文正文
*/
public function uploadContentImage(string $token, string $imageUrl): string
{
[$content, $ext] = $this->downloadImage($imageUrl);
if (!in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
$this->utils->errorThrow("正文图片仅支持 jpg/png{$imageUrl}");
}
if (strlen($content) > 1024 * 1024) {
$this->utils->errorThrow("正文图片超过 1MB无法上传微信图床{$imageUrl}");
}
$body = $this->postWxMultipart(
'/cgi-bin/media/uploadimg?access_token=' . $token,
'media', $content, 'content_' . substr(md5($imageUrl), 0, 8) . '.' . $ext,
'正文图片上传'
);
$url = (string) ($body['url'] ?? '');
if ($url === '') {
$this->utils->errorThrow('正文图片上传微信图床失败:未返回 URL');
}
return $url;
}
/**
* 封面图上传为永久图片素材,返回 media_id草稿 thumb_media_id 用)
* 微信限制:图片素材 ≤10MB支持 bmp/png/jpeg/jpg/gif
*/
public function addImageMaterial(string $token, string $imageUrl): string
{
[$content, $ext] = $this->downloadImage($imageUrl);
if (strlen($content) > 10 * 1024 * 1024) {
$this->utils->errorThrow("封面图超过 10MB无法上传素材库{$imageUrl}");
}
$body = $this->postWxMultipart(
'/cgi-bin/material/add_material?access_token=' . $token . '&type=image',
'media', $content, 'cover_' . substr(md5($imageUrl), 0, 8) . '.' . $ext,
'封面素材上传'
);
$mediaId = (string) ($body['media_id'] ?? '');
if ($mediaId === '') {
$this->utils->errorThrow('封面上传素材库失败:未返回 media_id');
}
return $mediaId;
}
/**
* 新建草稿,返回草稿 media_id
*
* @param array $article 单篇图文title/author/digest/content/thumb_media_id
*/
public function addDraft(string $token, array $article): string
{
$body = $this->postWxJson('/cgi-bin/draft/add?access_token=' . $token, [
'articles' => [$article],
], '新建草稿');
$mediaId = (string) ($body['media_id'] ?? '');
if ($mediaId === '') {
$this->utils->errorThrow('新建草稿失败:未返回 media_id');
}
return $mediaId;
}
/**
* 提交发布任务freepublish 为异步审核,返回 publish_id 供轮询)
*/
public function submitPublish(string $token, string $mediaId): string
{
$body = $this->postWxJson('/cgi-bin/freepublish/submit?access_token=' . $token, [
'media_id' => $mediaId,
], '提交发布');
$publishId = (string) ($body['publish_id'] ?? '');
if ($publishId === '') {
$this->utils->errorThrow('提交发布失败:未返回 publish_id');
}
return $publishId;
}
/**
* 查询发布任务状态
* publish_status0成功 1发布中 2原创审核不通过 3常规失败 4平台审核不通过 5成功后用户删除 6成功后系统封禁
*/
public function getPublishStatus(string $token, string $publishId): array
{
return $this->postWxJson('/cgi-bin/freepublish/get?access_token=' . $token, [
'publish_id' => $publishId,
], '查询发布状态');
}
/**
* 微信 JSON 接口统一出口JSON_UNESCAPED_UNICODE 编码 + errcode 校验
*
* @param string $scene 场景名(拼错误提示用)
*/
private function postWxJson(string $uri, array $data, string $scene): array
{
$this->init();
try {
$response = $this->client->request('POST', $uri, [
'body' => json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'headers' => ['Content-Type' => 'application/json'],
]);
$body = json_decode((string) $response->getBody(), true) ?: [];
} catch (GuzzleException $e) {
$this->utils->errorThrow("微信接口请求失败({$scene}" . $e->getMessage());
return [];
}
$this->assertWxOk($body, $scene);
return $body;
}
/**
* 微信 multipart 文件上传统一出口
*/
private function postWxMultipart(string $uri, string $field, string $content, string $filename, string $scene): array
{
$this->init();
try {
$response = $this->client->request('POST', $uri, [
'multipart' => [[
'name' => $field,
'contents' => $content,
'filename' => $filename,
]],
]);
$body = json_decode((string) $response->getBody(), true) ?: [];
} catch (GuzzleException $e) {
$this->utils->errorThrow("微信接口请求失败({$scene}" . $e->getMessage());
return [];
}
$this->assertWxOk($body, $scene);
return $body;
}
/**
* errcode 0 即失败,映射常见错误码后抛业务异常
*/
private function assertWxOk(array $body, string $scene): void
{
$errcode = (int) ($body['errcode'] ?? 0);
if ($errcode === 0) {
return;
}
$friendly = self::ERROR_MAP[$errcode] ?? ('errmsg: ' . (string) ($body['errmsg'] ?? '未知错误'));
$this->utils->errorThrow("微信{$scene}失败({$errcode}{$friendly}");
}
/**
* 下载图片(本地/OSS/任意外链),返回 [二进制内容, 扩展名]
* 为什么按 Content-Type 兜底扩展名OSS 直链可能不带扩展名,微信上传要求文件名有效
*/
private function downloadImage(string $url): array
{
$this->init();
try {
$response = $this->client->request('GET', $url, ['timeout' => 30]);
} catch (GuzzleException $e) {
$this->utils->errorThrow("下载图片失败:{$url}" . $e->getMessage() . '');
return ['', ''];
}
$content = (string) $response->getBody();
if ($content === '') {
$this->utils->errorThrow("下载图片失败:{$url}(内容为空)");
}
// 优先从 URL 扩展名判断,取不到再从 Content-Type 兜底
$ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'bmp'], true)) {
$contentType = strtolower($response->getHeaderLine('Content-Type'));
$ext = match (true) {
str_contains($contentType, 'png') => 'png',
str_contains($contentType, 'gif') => 'gif',
str_contains($contentType, 'bmp') => 'bmp',
default => 'jpg',
};
}
return [$content, $ext];
}
}

360
app/Service/core/CodeGenerationService.php Normal file → Executable file
View File

@@ -9,13 +9,17 @@ use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class CodeGenerationService
{
const TEXT_TYPE = [
'LONGTEXT',
'TEXT'
'TEXT',
'longtext',
'text',
];
const INT_TYPE = [
'INT',
@@ -23,6 +27,13 @@ class CodeGenerationService
'BIGINT',
'SMALLINT',
'FLOAT',
'DECIMAL',
'int',
'tinyint',
'bigint',
'smallint',
'float',
'decimal',
];
private static mixed $_instance;
@@ -56,6 +67,19 @@ class CodeGenerationService
private string $viewSearch = '';
private string $searchField = '';
private string $viewData = '';
/** uniapp C 端表单字段 HTML 片段 */
private string $uniFormFields = '';
/** uniapp 表单默认值片段 */
private string $uniFormDefaults = '';
/** uniapp 表单编辑回填片段 */
private string $uniFormAssign = '';
/** uniapp 详情信息行片段 */
private string $uniDetailRows = '';
/** uniapp 卡片标题字段名 */
private string $uniTitleField = 'id';
/** uniapp 卡片副标题字段名 */
private string $uniSubtitleField = '';
private bool $uniTitlePicked = false;
private array $tmpFile = [];
public function __construct()
@@ -63,6 +87,45 @@ class CodeGenerationService
$this->utils = UtilsService::getInstance();
}
/**
* 获取代码生成记录列表
* @return array
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list(): array
{
// ds(json_encode());
$page = request()->get('page', 1);
$pageSize = request()->get('pageSize', 20);
$title = request()->get('title', '');
$query = CodeGenerationModel::where('deleted_at', 0);
// 如果有标题搜索条件
if (!empty($title)) {
$query->where('title', 'like', '%' . $title . '%');
}
$result = $query->orderBy('id', 'desc')
->paginate($pageSize, ['id', 'title', 'file_name', 'params', 'created_at', 'updated_at'], 'page', $page)
->toArray();
foreach ($result['data'] as &$item) {
$item['params'] = json_decode($item['params'], true);
}
// /www/sites/nl-admin-api/index/nl-admin-api/storage/app/public/zip/code-generation/企业信息-69290eaaac899.zip
// www\sites\nl-admin-api\index\nl-admin-api\storage\app\public\zip\code-generation\企业信息-69290eaaac899.zip
return [
'page' => $result['current_page'],
'size' => $result['per_page'],
'page_count' => $result['last_page'],
'total' => $result['total'],
'items' => $result['data'],
];
}
/**
* 获取实例
* @return null|static
@@ -77,6 +140,12 @@ class CodeGenerationService
return self::$_instance[$name];
}
/**
* 下载文件
* @param $id
* @return BinaryFileResponse
* @throws Exception
*/
public function download($id)
{
$model = CodeGenerationModel::find($id);
@@ -88,7 +157,23 @@ class CodeGenerationService
}
/**
* 创建临时文件夹,复制模板文件,压缩并返回文件流
* 下载文件信息
* @param $id
* @return mixed
* @throws Exception
*/
public function downloadInfo($id)
{
$model = CodeGenerationModel::find($id);
if (!$model) {
$this->utils->errorThrow('文件不存在');
}
// 返回下载 $path 文件流
return $model;
}
/**
* 创建临时文件夹,复制模板文件,压缩并文件信息
*/
public function downloadUrl()
{
@@ -118,7 +203,12 @@ class CodeGenerationService
'created_at' => time(),
'updated_at' => time(),
]);
return $insertModel->id;
// 删除Store/app/www
Storage::deleteDirectory('www');
return [
'id' => $insertModel->id,
'file_name' => $insertModel->file_name
];
} catch (\Exception $e) {
return response()->json([
'error' => '生成模板文件失败',
@@ -127,6 +217,10 @@ class CodeGenerationService
}
}
/**
* 初始化参数
* @return void
*/
private function init()
{
// 生成uuid
@@ -147,6 +241,23 @@ class CodeGenerationService
// 生成唯一的临时目录名
$this->tempDir = 'temp/templates/' . $this->upperCamelCase . '-' . $this->uniqid;
Storage::makeDirectory($this->tempDir);
// 重置动态拼接字段,避免批量生成串数据
$this->selectField = '"id"';
$this->insertField = '';
$this->updateField = '';
$this->viewForm = '';
$this->viewSearch = '';
$this->searchField = '';
$this->viewData = '';
$this->uniFormFields = '';
$this->uniFormDefaults = '';
$this->uniFormAssign = '';
$this->uniDetailRows = '';
$this->uniTitleField = 'id';
$this->uniSubtitleField = '';
$this->uniTitlePicked = false;
$this->sql = '';
$this->tmpFile = [];
}
/**
@@ -158,7 +269,6 @@ class CodeGenerationService
*/
public function generate($params): mixed
{
ds($params);
$this->param = $params;
DB::beginTransaction();
try {
@@ -176,10 +286,13 @@ class CodeGenerationService
$this->genRoute();
// 执行sql
$this->querySql();
// 生成视图
// 生成 PC 视图Vben
$this->genViews();
// 生成 uniapp 视图(微信小程序 + App + H5C 端风格)
$this->genUniappViews();
// 生成菜单
$this->genMenu();
DB::commit();
} catch (Exception $e) {
DB::rollBack();
// ds([
@@ -231,12 +344,13 @@ class CodeGenerationService
$v['default'] = (strpos($v['type'], 'INT') || in_array($v['type'], self::INT_TYPE)) ? 'DEFAULT ' . 0 : 'DEFAULT ' . '\'\'';
}
// 字段名加反引号,避免非法标识符直接破坏 CREATE TABLE 语法
$col = '`' . str_replace('`', '', (string) $v['name']) . '`';
$comment = str_replace("'", "\\'", (string) $v['comment']);
if (in_array($v['type'], self::TEXT_TYPE)) {
// 编写sql语句
$sql .= " {$v['name']} {$v['type']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
$sql .= " {$col} {$v['type']} {$v['not_null']} NULL COMMENT '{$comment}', ";
} else {
// 编写sql语句
$sql .= " {$v['name']} {$v['type']}({$v['type_number']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
$sql .= " {$col} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$comment}', ";
}
}
@@ -307,7 +421,7 @@ class CodeGenerationService
}
/**
* 生成前端部分文件
* 生成 PC 前端文件Vben写入 zip view/ 目录
* @return void
*/
private function genViews(): void
@@ -331,6 +445,31 @@ class CodeGenerationService
}
}
/**
* 生成 uniapp 前端文件C list/detail/form 三页),写入 zip uniapp/ 目录
* 禁止抽屉主路径;必须兼容微信小程序+App+H5
* @return void
*/
private function genUniappViews(): void
{
$appPath = app_path();
$uniList = [
'uni-api' => 'api/index.ts',
'uni-list' => 'pages/list.vue',
'uni-detail' => 'pages/detail.vue',
'uni-form' => 'pages/form.vue',
'uni-readme' => 'PAGES_JSON_SNIPPET.md',
];
foreach ($uniList as $k => $v) {
$this->strReplaceTmp(
$appPath . '/template/uniapp/' . $v,
$this->tempDir . "/uniapp/{$this->dashCase}/" . $v,
'uniapp',
$k
);
}
}
/**
* 生成菜单/授权
*
@@ -395,6 +534,20 @@ class CodeGenerationService
case 'view-table':
$newFile = str_replace("// 生成的列表字段", "// 生成的列表字段 \r ". $this->viewData, $newFile);
break;
case 'uni-list':
$newFile = str_replace('UNI_TITLE_FIELD', $this->uniTitleField, $newFile);
$newFile = str_replace('UNI_SUBTITLE_FIELD', $this->uniSubtitleField ?: $this->uniTitleField, $newFile);
break;
case 'uni-detail':
$newFile = str_replace('// UNI_DETAIL_ROWS', $this->uniDetailRows ?: '', $newFile);
$newFile = str_replace('UNI_TITLE_FIELD', $this->uniTitleField, $newFile);
$newFile = str_replace('UNI_SUBTITLE_FIELD', $this->uniSubtitleField ?: $this->uniTitleField, $newFile);
break;
case 'uni-form':
$newFile = str_replace('<!-- UNI_FORM_FIELDS -->', $this->uniFormFields ?: '<!-- 无表单字段 -->', $newFile);
$newFile = str_replace('// UNI_FORM_DEFAULTS', $this->uniFormDefaults ?: '', $newFile);
$newFile = str_replace('// UNI_FORM_ASSIGN', $this->uniFormAssign ?: '', $newFile);
break;
}
$this->tmpFile[$type][$fileKey] = $newFile;
@@ -416,6 +569,9 @@ class CodeGenerationService
{
if (empty($item)) return;
// 设置列表字段
if (!isset($item['tableShow'])) {
ds($item);
}
if ($item['tableShow']) {
$this->selectField .= ', "' . $item['name'] . '"';
}
@@ -425,6 +581,22 @@ class CodeGenerationService
$this->insertField .= !empty($this->insertField) ? '","' . $item['name'] : $item['name'];
$this->updateField .= !empty($this->updateField) ? '","' . $item['name'] : 'id","' . $item['name'];
$this->viewForm .= "{\n fieldName: '" . $item['name'] . "',\n label: '" . $item['comment'] . "',\n component: '" . $item['formType'] . "',\n rules: " . $isNull . ",\n },\n";
// uniapp C 端表单:用 input 承载,避免生成 Vben 组件名
$name = $item['name'];
$label = $item['comment'] ?: $name;
$this->uniFormDefaults .= " {$name}: '',\n";
$this->uniFormAssign .= " {$name}: info?.{$name} ?? '',\n";
$this->uniDetailRows .= " { label: '{$label}', value: info.value.{$name} },\n";
$this->uniFormFields .= "<view class=\"form-item\">\n"
. " <text class=\"label\">{$label}</text>\n"
. " <input v-model=\"form.{$name}\" class=\"input\" placeholder=\"{$label}\" />\n"
. " </view>\n";
if (!$this->uniTitlePicked) {
$this->uniTitleField = $name;
$this->uniTitlePicked = true;
} elseif ($this->uniSubtitleField === '') {
$this->uniSubtitleField = $name;
}
}
// 设置搜索字段
if ($item['search']) {
@@ -454,4 +626,172 @@ class CodeGenerationService
$this->utils->errorThrow($e->getMessage());
}
}
/**
* 获取数据库表列表
* @return array
*/
public function getTableList(): array
{
$database = config('database.connections.mysql.database');
$prefix = config('database.prefix', 'nl_');
$tables = DB::select("
SELECT
TABLE_NAME as `name`,
TABLE_COMMENT as `comment`,
TABLE_ROWS as `rows`,
CREATE_TIME as create_time
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME LIKE ?
ORDER BY TABLE_NAME
", [$database, $prefix . '%']);
$result = [];
foreach ($tables as $table) {
$result[] = [
'name' => $table->name,
'comment' => $table->comment ?: $table->name,
'rows' => $table->rows,
'create_time' => $table->create_time,
];
}
return $result;
}
/**
* 获取表字段信息
* @param string $tableName
* @return array
* @throws Exception
*/
public function getTableColumns(string $tableName): array
{
$database = config('database.connections.mysql.database');
$columns = DB::select("
SELECT
COLUMN_NAME as `name`,
DATA_TYPE as `type`,
CHARACTER_MAXIMUM_LENGTH as `length`,
COLUMN_DEFAULT as default_value,
COLUMN_COMMENT as `comment`,
IS_NULLABLE as nullable,
COLUMN_KEY as key_type,
EXTRA as extra
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
", [$database, $tableName]);
if (empty($columns)) {
$this->utils->errorThrow('表不存在或没有字段');
}
$result = [];
foreach ($columns as $column) {
// 跳过id字段代码生成会自动添加
if ($column->name === 'id') {
continue;
}
// 跳过系统字段
if (in_array($column->name, ['created_at', 'updated_at', 'deleted_at', 'status'])) {
continue;
}
// 转换数据类型
$type = strtoupper($column->type);
$typeLength = '';
if (in_array($type, ['VARCHAR', 'CHAR'])) {
$typeLength = $column->length ?: '255';
} elseif (in_array($type, ['INT', 'TINYINT', 'BIGINT', 'SMALLINT'])) {
$typeLength = $column->length ?: '11';
} elseif (in_array($type, ['DECIMAL', 'FLOAT', 'DOUBLE'])) {
$typeLength = $column->length ?: '10,2';
}
// 判断表单组件类型
$formType = 'VbenInput';
if (strpos($type, 'TEXT') !== false) {
$formType = 'VbenTextarea';
} elseif (in_array($type, ['TINYINT', 'INT']) && $column->length == 1) {
$formType = 'VbenSwitch';
} elseif (strpos($column->name, 'time') !== false || strpos($column->name, 'date') !== false) {
$formType = 'VbenDatePicker';
}
$result[] = [
'name' => $column->name,
'type' => $type,
'type_length' => $typeLength,
'default' => $column->default_value ?? '',
'comment' => $column->comment ?: $column->name,
'not_null' => $column->nullable === 'NO' ? 1 : 0,
'formShow' => 1,
'tableShow' => 1,
'formType' => $formType,
'search' => in_array($type, ['VARCHAR', 'CHAR', 'INT', 'TINYINT', 'BIGINT']) ? 1 : 0,
'searchValue' => in_array($type, ['VARCHAR', 'CHAR']) ? 'like' : '=',
];
}
return $result;
}
/**
* 将数据库表结构转换为代码生成数据格式
* @param string $tableName
* @param string $className
* @param string $classComment
* @param string $icon
* @param int $sort
* @param int $pid
* @return array
* @throws Exception
*/
public function convertTableToCodeGenData(
string $tableName,
string $className = '',
string $classComment = '',
string $icon = '',
int $sort = 9999,
int $pid = 0
): array {
// 如果没有提供类名,从表名生成
if (empty($className)) {
$prefix = config('database.prefix', 'nl_');
$tableNameWithoutPrefix = str_replace($prefix, '', $tableName);
// 下划线转驼峰
$className = str_replace('_', '', ucwords($tableNameWithoutPrefix, '_'));
}
// 如果没有提供中文名称,使用表注释或表名
if (empty($classComment)) {
$database = config('database.connections.mysql.database');
$tableInfo = DB::selectOne("
SELECT TABLE_COMMENT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
", [$database, $tableName]);
$classComment = $tableInfo->TABLE_COMMENT ?: $tableName;
}
// 获取字段信息
$fields = $this->getTableColumns($tableName);
return [
'class_name' => $className,
'class_comment' => $classComment,
'icon' => $icon,
'sort' => $sort,
'pid' => $pid,
'field' => $fields,
];
}
}

View File

@@ -0,0 +1,36 @@
# 模板名称 — uniapp 粘贴说明C 端三页)
## 落点
`nl-admin-uniapp/src/pages-sub/my-gen/dashCase/`
## pages.json 片段
`subPackages``root: pages-sub/my-gen``pages` 追加:
```json
{ "path": "dashCase/pages/list", "style": { "navigationBarTitleText": "模板名称" } },
{ "path": "dashCase/pages/detail", "style": { "navigationBarTitleText": "模板名称详情" } },
{ "path": "dashCase/pages/form", "style": { "navigationBarTitleText": "编辑模板名称" } }
```
## features.ts 片段
```ts
{
key: 'dashCase',
title: '模板名称',
desc: '代码生成模块',
icon: '码',
category: 'gen',
path: '/pages-sub/my-gen/dashCase/pages/list',
keywords: ['模板名称'],
}
```
## 强制约定
- 兼容微信小程序 + App + H5
- 交互:列表 → 详情 → 全屏表单;**禁止**底部抽屉做主 CRUD
- 复用 `AppCardList` / `AppDetailHero` / `AppInfoGroup` / `AppBottomBar` / `AppPageForm`
- 列表页统一:`AppPageAtmosphere`(氛围底)+ `AppSkeleton`(首屏骨架)+ `AppFab`(新增悬浮按钮)

View File

@@ -0,0 +1,33 @@
/**
* 模板名称 APIuniapp
* 粘贴后路径pages-sub/my-gen/dashCase/api/index.ts
* 必须兼容微信小程序 + App + H5
*/
import { get, post } from '@/utils/request'
const prefix = 'dashCase/'
/** 分页列表 */
export function getupperCamelCaseList(params: Record<string, any> = {}) {
return get<any>(`${prefix}list`, params)
}
/** 详情 */
export function getupperCamelCaseInfo(id: number) {
return get<any>(`${prefix}detail`, { id })
}
/** 新增 */
export function createupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}create`, data)
}
/** 更新 */
export function updateupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}update`, data)
}
/** 删除 */
export function deleteupperCamelCase(data: Record<string, any>) {
return post<any>(`${prefix}delete`, data)
}

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* 模板名称详情:信息展示 + 底栏编辑/删除(禁止抽屉主路径)
*/
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { deleteupperCamelCase, getupperCamelCaseInfo } from '../api'
import AppBottomBar from '@/components/AppBottomBar.vue'
import AppDetailHero from '@/components/AppDetailHero.vue'
import AppInfoGroup, { type AppInfoRow } from '@/components/AppInfoGroup.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
const id = ref(0)
const info = ref<Record<string, any>>({})
onLoad((q) => { id.value = Number(q?.id || 0) })
onShow(async () => {
if (id.value) info.value = (await getupperCamelCaseInfo(id.value)) || {}
})
const title = computed(() => String(info.value.UNI_TITLE_FIELD || `模板名称#${id.value}`))
const rows = computed<AppInfoRow[]>(() => [
// UNI_DETAIL_ROWS
{ label: '状态', value: Number(info.value.status) === 0 ? '正常' : '禁用' },
{ label: '创建时间', value: info.value.created_at },
])
function goEdit() {
uni.navigateTo({ url: `/pages-sub/my-gen/dashCase/pages/form?id=${id.value}` })
}
function onDelete() {
uni.showModal({
title: '删除确认',
content: '确定删除该记录吗?',
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
await deleteupperCamelCase({ ids: [id.value] })
uni.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
},
})
}
</script>
<template>
<!-- 氛围壳提供主题作用域 + 背景色斑玻璃卡的模糊才有内容可透 -->
<AppPageAtmosphere>
<view class="nl-page">
<AppDetailHero
:title="title"
:subtitle="String(info.UNI_SUBTITLE_FIELD || '')"
:status-text="Number(info.status) === 0 ? '正常' : '禁用'"
:status-type="Number(info.status) === 0 ? 'success' : 'danger'"
/>
<AppInfoGroup title="详细信息" :items="rows" />
<AppBottomBar primary-text="编辑" danger-text="删除" @primary="goEdit" @danger="onDelete" />
</view>
</AppPageAtmosphere>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
/**
* 模板名称新建/编辑全屏表单(替代底部抽屉)
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { createupperCamelCase, getupperCamelCaseInfo, updateupperCamelCase } from '../api'
import AppPageForm from '@/components/AppPageForm.vue'
const id = ref(0)
const submitting = ref(false)
const form = ref<Record<string, any>>({
// UNI_FORM_DEFAULTS
})
const isEdit = computed(() => !!id.value)
onLoad(async (q) => {
id.value = Number(q?.id || 0)
if (id.value) {
const info = await getupperCamelCaseInfo(id.value)
form.value = {
// UNI_FORM_ASSIGN
}
// 回填兜底
Object.assign(form.value, info || {})
}
})
async function onSubmit() {
submitting.value = true
try {
if (isEdit.value) await updateupperCamelCase({ id: id.value, ...form.value })
else await createupperCamelCase({ ...form.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} finally {
submitting.value = false
}
}
</script>
<template>
<AppPageForm tip="请填写模板名称信息后保存。" :loading="submitting" @submit="onSubmit">
<!-- UNI_FORM_FIELDS -->
</AppPageForm>
</template>
<style scoped lang="scss">
.form-item {
display: flex; align-items: center; justify-content: space-between;
min-height: 100rpx; border-bottom: 1rpx solid $nl-color-border;
}
.label { width: 160rpx; color: $nl-color-text-secondary; font-size: 28rpx; }
.input { flex: 1; text-align: right; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* 模板名称列表C 端):点击进详情,新增进全屏表单
* 兼容微信小程序 / App / H5
*/
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getupperCamelCaseList } from '../api'
import AppCardList, { type AppCardItem } from '@/components/AppCardList.vue'
import AppEmpty from '@/components/AppEmpty.vue'
import AppFab from '@/components/AppFab.vue'
import AppFilterBar from '@/components/AppFilterBar.vue'
import AppLoading from '@/components/AppLoading.vue'
import AppPageAtmosphere from '@/components/AppPageAtmosphere.vue'
import AppSearchBar from '@/components/AppSearchBar.vue'
import AppSkeleton from '@/components/AppSkeleton.vue'
import { usePullRefresh } from '@/composables/usePullRefresh'
const list = ref<any[]>([])
const keyword = ref('')
const draftKeyword = ref('')
const loading = ref(false)
const page = ref(1)
const finished = ref(false)
const selectedCount = computed(() => (keyword.value ? 1 : 0))
const cards = computed<AppCardItem[]>(() =>
list.value.map((item) => ({
id: item.id,
title: String(item.UNI_TITLE_FIELD || `模板名称#${item.id}`),
subtitle: String(item.UNI_SUBTITLE_FIELD || ''),
iconText: String(item.UNI_TITLE_FIELD || '模').slice(0, 1),
statusText: Number(item.status) === 0 ? '正常' : '禁用',
statusType: Number(item.status) === 0 ? 'success' : 'danger',
})),
)
onShow(() => reload())
// pages.json 该页需 enablePullDownRefresh: true
usePullRefresh(reload)
async function reload() {
page.value = 1
finished.value = false
list.value = []
await loadMore()
}
async function loadMore() {
if (loading.value || finished.value) return
loading.value = true
try {
const res = await getupperCamelCaseList({
page: page.value,
pageSize: 20,
keyword: keyword.value || undefined,
})
const items = res?.items || res?.data || []
list.value = page.value === 1 ? items : list.value.concat(items)
const total = Number(res?.total || 0)
if (!items.length || list.value.length >= total) finished.value = true
else page.value += 1
} finally {
loading.value = false
}
}
function onFilterReset() {
draftKeyword.value = ''
}
function onFilterConfirm() {
keyword.value = draftKeyword.value
reload()
}
function goDetail(item: AppCardItem) {
uni.navigateTo({ url: `/pages-sub/my-gen/dashCase/pages/detail?id=${item.id}` })
}
function goCreate() {
uni.navigateTo({ url: '/pages-sub/my-gen/dashCase/pages/form' })
}
</script>
<template>
<AppPageAtmosphere tone="cool">
<view class="nl-page page">
<AppSearchBar
v-model="keyword"
placeholder="搜索模板名称"
@search="(v) => { keyword = v; reload() }"
@clear="() => { keyword = ''; reload() }"
/>
<AppFilterBar :selected-count="selectedCount" @reset="onFilterReset" @confirm="onFilterConfirm">
<view class="filter-row">
<text>关键词</text>
<input v-model="draftKeyword" class="filter-input" placeholder="关键词" />
</view>
</AppFilterBar>
<AppSkeleton v-if="loading && !cards.length" :rows="5" />
<AppCardList v-else-if="cards.length" :list="cards" @select="goDetail" />
<AppEmpty v-else text="暂无数据" />
<AppLoading v-if="loading && cards.length" text="加载更多…" />
<view v-else-if="cards.length" class="more" @tap="loadMore">
{{ finished ? '没有更多了' : '加载更多' }}
</view>
<AppFab @tap="goCreate" />
</view>
</AppPageAtmosphere>
</template>
<style scoped lang="scss">
.page { padding-bottom: 160rpx; }
.filter-row {
display: flex; align-items: center; justify-content: space-between; min-height: 72rpx; font-size: 26rpx;
}
.filter-input { flex: 1; text-align: right; margin-left: 24rpx; }
.more { text-align: center; color: $nl-color-text-muted; padding: 16rpx; font-size: 24rpx; }
</style>

View File

@@ -6,7 +6,14 @@ const prefix = 'dashCase/';
* @param data
*/
export async function getupperCamelCaseListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 获取模板名称下拉列表
*/
export async function getupperCamelCaseOptionApi() {
return requestClient.get<any>(`${prefix}option`);
}
/**
@@ -14,7 +21,7 @@ export async function getupperCamelCaseListApi(data: any) {
* @param id
*/
export async function getupperCamelCaseInfoApi(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
@@ -22,7 +29,7 @@ export async function getupperCamelCaseInfoApi(id: number) {
* @param data
*/
export async function createupperCamelCaseApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
return requestClient.post<any>(`${prefix}create`, data);
}
/**
@@ -30,7 +37,7 @@ export async function createupperCamelCaseApi(data: Record<string, any>) {
* @param data
*/
export async function updateupperCamelCaseApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
return requestClient.post<any>(`${prefix}update`, data);
}
/**
@@ -38,5 +45,5 @@ export async function updateupperCamelCaseApi(data: Record<string, any>) {
* @param data
*/
export async function deleteupperCamelCaseApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
return requestClient.post<any>(`${prefix}delete`, data);
}

View File

@@ -10,7 +10,7 @@ import {createupperCamelCaseApi, updateupperCamelCaseApi} from '../api';
import { modalFormProps } from '../config/form';
defineOptions({
name: 'upperCamelCaseDemo',
name: 'upperCamelCaseDemo',
});
const isUpdate = ref(false);
@@ -19,44 +19,44 @@ const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateupperCamelCaseApi : createupperCamelCaseApi;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
isUpdate.value = update;
formApi.setValues(values);
}
}
},
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateupperCamelCaseApi : createupperCamelCaseApi;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
isUpdate.value = update;
formApi.setValues(values);
}
}
},
});
</script>
<template>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}模板名称`" class="w-[30%]">
<Form />
</Modal>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}模板名称`" class="w-[30%]">
<Form />
</Modal>
</template>

View File

@@ -1,28 +1,28 @@
import type { VbenFormProps } from '#/adapter/form';
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格,
commonConfig: {
formItemClass: 'col-span-12',
// 所有表单项
componentProps: {
class: 'w-full',
wrapperClass: 'grid-cols-12', // 24栅格,
commonConfig: {
formItemClass: 'col-span-12',
// 所有表单项
componentProps: {
class: 'w-full',
},
},
},
// handleSubmit: onSubmit,
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
// 生成的表单字段
],
showDefaultActions: false,
// handleSubmit: onSubmit,
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
// 生成的表单字段
],
showDefaultActions: false,
};

View File

@@ -3,18 +3,18 @@ import type { VbenFormProps } from '#/adapter/form';
// import dayjs from 'dayjs';
export const formOptions: VbenFormProps = {
// 默认展开
collapsed: false,
schema: [
// 生成的搜索字段
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
// 默认展开
collapsed: false,
schema: [
// 生成的搜索字段
],
// 控制表单是否显示折叠按钮
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
// 是否在字段值改变时提交表单
submitOnChange: true,
// 按下回车时是否提交表单
submitOnEnter: false,
};

View File

@@ -3,64 +3,64 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import { getupperCamelCaseListApi } from '../api';
interface RowType {
id: string;
name: string;
logo: string;
introduce: string;
created_at: string;
id: string;
name: string;
logo: string;
introduce: string;
created_at: string;
}
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
// 生成的列表字段
{ field: 'created_at', title: '创建时间' },
{ field: 'updated_at', title: '编辑时间' },
{ type: 'html', title: '操作', slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
return await getupperCamelCaseListApi({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
checkboxConfig: {
highlight: true,
labelField: '',
},
},
height: 'auto',
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
export: false, // 导出
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
columnConfig: {
useKey: true,
},
custom: {
// 自定义列-图标
icon: 'vxe-icon-menu',
rowConfig: {
useKey: true,
},
},
showOverflow: false,
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 },
// 生成的列表字段
{ field: 'created_at', title: '创建时间' },
{ field: 'updated_at', title: '编辑时间' },
{ type: 'html', title: '操作', slots: { default: 'action' } },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
// 请求后端接口方法
query: async ({ page }, formValues) => {
return await getupperCamelCaseListApi({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// 是否显示搜索表单控制按钮
// @ts-ignore 正式环境时有完整的类型声明
search: true,
refresh: true, // 刷新
print: false, // 打印
export: false, // 导出
// custom: true, // 自定义列
zoom: true, // 最大化最小化
slots: {
buttons: 'toolbar-buttons',
},
custom: {
// 自定义列-图标
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -17,7 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
// 操作日志:按 nl_api_endpoint.is_log 落库
$middleware->api(append: [
\App\Http\Middleware\ApiOpLogMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e) {

View File

@@ -6,11 +6,12 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.3",
"ext-zip": "*",
"firebase/php-jwt": "^6.11",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
"firebase/php-jwt": "^7",
"laravel/framework": "^13.0",
"laravel/tinker": "^3",
"ext-pdo": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -19,7 +20,7 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {

2638
composer.lock generated

File diff suppressed because it is too large Load Diff

20
config/ai.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
/**
* AI 供应商环境回落配置(库内未配置密钥/地址时使用)
*/
return [
'default_provider' => env('AI_DEFAULT_PROVIDER', 'deepseek'),
'spark' => [
'api_url' => env('SPARK_API_URL', 'https://spark-api-open.xf-yun.com/v1/chat/completions'),
'password' => env('SPARK_API_PASSWORD', ''),
'model' => env('SPARK_API_MODEL', 'lite'),
'timeout' => (float) env('SPARK_API_TIMEOUT', 60),
],
'deepseek' => [
'api_url' => env('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'),
'api_key' => env('DEEPSEEK_API_KEY', ''),
'model' => env('DEEPSEEK_API_MODEL', 'deepseek-chat'),
'timeout' => (float) env('DEEPSEEK_API_TIMEOUT', 60),
],
];

View File

@@ -35,7 +35,7 @@ return [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'prefix' => env('DB_PREFIX', ''),
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,

View File

@@ -397,7 +397,7 @@ if(!function_exists('ase_encode')) {
function ase_encode($str)
{
if (empty($str)) return $str;
return base64_encode(\Illuminate\Support\Str::random(config('ase.len')). base64_encode($str));
return base64_encode(\Illuminate\Support\Str::random(config('nl.ase_len', 30)). base64_encode($str));
}
}
@@ -411,7 +411,7 @@ if (!function_exists('ase_decode')) {
{
if (empty($str)) return $str?? '';
$result = base64_decode($str);
$result = base64_decode(substr($result, config('ase.len')));
$result = base64_decode(substr($result, config('nl.ase_len', 30)));
$pattern = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]|[\x{E000}-\x{F8FF}]/u';
if (preg_match($pattern, $result)) return $str;
return $result;

View File

@@ -18,17 +18,34 @@ return [
// 'over_time' => 0 //处方审核自动失效时间
],
],
/*
* JWT 配置HS256 密钥至少 32 字节,否则 firebase/php-jwt Provided key is too short
*/
'jwt' => [
'secret' => env('JWT_SECRET', 'nl_admin_jwt_secret_key_change_me_32b'),
],
/*
* 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_读取兼容历史 ***
* 对应 .envENCRYPT_KEY
*/
'encrypt_key' => env('ENCRYPT_KEY', ''),
/*
* 传输层 ase_encode / ase_decode 随机盐长度(与历史 helpers 兼容)
*/
'ase_len' => (int) env('ASE_LEN', 30),
/*
* redis配置
*/
'redis' => [
'jwt' => 'xk_login_',
'jwt' => 'nl_login_',
'jwt_ttl' => 360000,
// 'jwt_ttl' => 360,
'email' => 'xk_email_',
'phone' => 'xk_phone_',
'login_out_key' => 'xk_login_out_',
'menu_key' => 'xk_menu_',
'email' => 'nl_email_',
'phone' => 'nl_phone_',
'login_out_key' => 'nl_login_out_',
'menu_key' => 'nl_menu_',
// 公众号 stable access_token 缓存前缀key = 前缀 + appidTTL 随微信 expires_in
'wechat_token' => 'nl_wechat_at_',
],
/*
* api白名单
@@ -43,7 +60,7 @@ return [
* 阿里云oss配置
*/
'oss' => [
'redis_key' => 'xk_oss_url_',
'redis_key' => 'nl_oss_url_',
'ali' => [
'accessKeyId' => env('OSS_ACCESS_KEY_ID'),
'accessKeySecret' => env('OSS_ACCESS_KEY_SECRET'),
@@ -52,10 +69,10 @@ return [
'domain' => env('OSS_DOMAIN'),
],
'qiniu' => [
'access_key' => 'RPQlZUd2yrL3kR8CC4BpTGV6FhGr11npBzf6IuCT',
'secret_key' => 'b-796uVY1InOKSVecpY8VBpO7Cdubo1Tv4RcOfMY',
'bucket' => 'ccspa',
'domain' => 'http://o-spa.nailaoyun.cn',
'access_key' => env('QINIU_ACCESS_KEY', ''),
'secret_key' => env('QINIU_SECRET_KEY', ''),
'bucket' => env('QINIU_BUCKET', ''),
'domain' => env('QINIU_DOMAIN', ''),
]
],
/*
@@ -109,17 +126,27 @@ return [
* 日志配置
*/
'log' => [
// 不添加返回记录的api接口
// 不添加返回记录的api接口(路径不含 /api/ 前缀,与中间件规范化后一致)
'no_insert' => [
'api/admin/auth/login',
'api/admin/auth/send-verification-code',
'api/admin/auth/my-info',
'api/admin/auth/codes',
'api/admin/auth/menu',
'api/admin/log/api-list',
'api/admin/log/ledger-list',
'api/admin/log/op-list',
'api/admin/log/prescription-list',
'login',
'register',
'admin/my-info',
'admin/codes',
'admin/menu',
'admin/login-log',
'admin/op-log',
'api-endpoint/list',
'oss-config/runtime-options',
'oss-config/driver-options',
'oss-config/list',
'ai-config/runtime-options',
'ai-config/key-list',
'ai-config/platform-option',
'ai-config/generation-list',
'wechat-account/list',
'wechat-account/option',
'wechat-article/list',
'wechat-article/version-list',
]
],
'app' => [

View File

@@ -1,17 +1,17 @@
/*
Navicat Premium Dump SQL
Source Server : 开发库(本地
Source Server : 开发环境-本地
Source Server Type : MySQL
Source Server Version : 80404 (8.4.4)
Source Host : localhost:3310
Source Schema : nl_admin
Source Server Version : 80407 (8.4.7)
Source Host : localhost:3306
Source Schema : cc_admin
Target Server Type : MySQL
Target Server Version : 80404 (8.4.4)
Target Server Version : 80407 (8.4.7)
File Encoding : 65001
Date: 12/05/2025 17:07:23
Date: 23/01/2026 17:08:01
*/
SET NAMES utf8mb4;
@@ -22,51 +22,51 @@ SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
DROP TABLE IF EXISTS `nl_admin`;
CREATE TABLE `nl_admin` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`open_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'OpenID后期整合萧康服务中心会用到',
`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 '密码',
`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 '角色',
`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',
`last_login_time` int NOT NULL DEFAULT 0 COMMENT '最后登录时间',
`ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '0' COMMENT '最后登录IP',
`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禁用',
`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
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`open_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'OpenID后期整合萧康服务中心会用到',
`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 '密码',
`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 '角色',
`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',
`last_login_time` int NOT NULL DEFAULT 0 COMMENT '最后登录时间',
`ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '0' COMMENT '最后登录IP',
`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禁用',
`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;
-- ----------------------------
-- Records of nl_admin
-- ----------------------------
INSERT INTO `nl_admin` VALUES (1, 'nl_28686ad68682180e26836c721a52', '', '超管', '$2y$12$sMgspfujo/5qnMEFvXH2d.0hYq/7PXhkJREYHbc..4WKt5LH7M8ui', '15100000000', 'workerqi@163.com', '517117', 1, 0, 0, 0, 0, '127.0.0.1', '[\"127.0.0.1\"]', '0', '', 1, 0, 1747026536, 0);
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);
-- ----------------------------
-- Table structure for nl_admin_notice
-- ----------------------------
DROP TABLE IF EXISTS `nl_admin_notice`;
CREATE TABLE `nl_admin_notice` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '消息标题',
`detail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '简介',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '消息内容',
`type` int NOT NULL DEFAULT 0 COMMENT '消息类型',
`user_id` int NOT NULL DEFAULT 0 COMMENT '关联用户ID',
`status` int 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
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '消息标题',
`detail` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '简介',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '消息内容',
`type` int NOT NULL DEFAULT 0 COMMENT '消息类型',
`user_id` int NOT NULL DEFAULT 0 COMMENT '关联用户ID',
`status` int 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 = 84 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '管理员消息列表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
@@ -78,43 +78,88 @@ CREATE TABLE `nl_admin_notice` (
-- ----------------------------
DROP TABLE IF EXISTS `nl_api_op_log`;
CREATE TABLE `nl_api_op_log` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL COMMENT '操作用户ID',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作路由',
`method` tinyint(1) NOT NULL DEFAULT 0 COMMENT '请求方式 0未知 1GET 2POST',
`controller` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作的控制器',
`ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作者IP地址',
`param` json NOT NULL COMMENT '请求携带参数',
`result` json NOT NULL COMMENT '返回json',
`type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '操作状态 0成功 1失败',
`result_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '返回状态码',
`platform_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '平台类型 0总后台 1门店后台 2小程序',
`belong_id` int NOT NULL DEFAULT 0 COMMENT '所属平台ID',
`user_type` int NOT NULL DEFAULT 0 COMMENT '用户类型',
`equipment` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '浏览器',
`created_at` int NOT NULL DEFAULT 0 COMMENT '操作时间',
PRIMARY KEY (`id`) USING BTREE
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL COMMENT '操作用户ID',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作路由',
`method` tinyint(1) NOT NULL DEFAULT 0 COMMENT '请求方式 0未知 1GET 2POST',
`controller` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作的控制器',
`ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作者IP地址',
`param` json NOT NULL COMMENT '请求携带参数',
`result` json NOT NULL COMMENT '返回json',
`type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '操作状态 0成功 1失败',
`result_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '返回状态码',
`platform_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '平台类型 0总后台 1门店后台 2小程序',
`belong_id` int NOT NULL DEFAULT 0 COMMENT '所属平台ID',
`user_type` int NOT NULL DEFAULT 0 COMMENT '用户类型',
`equipment` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '浏览器',
`created_at` int NOT NULL DEFAULT 0 COMMENT '操作时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 16860 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '新的后台API访问日志记录' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of nl_api_op_log
-- ----------------------------
-- ----------------------------
-- Table structure for nl_code_generation
-- ----------------------------
DROP TABLE IF EXISTS `nl_code_generation`;
CREATE TABLE `nl_code_generation` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单标题',
`file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件名称',
`path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '下载链接',
`params` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL 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 = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '代码生成记录' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of nl_code_generation
-- ----------------------------
-- ----------------------------
-- Table structure for nl_database_change_log
-- ----------------------------
DROP TABLE IF EXISTS `nl_database_change_log`;
CREATE TABLE `nl_database_change_log` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL DEFAULT 0 COMMENT '操作用户ID',
`table_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '表名',
`operation_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '操作类型structure_change, data_change, sql_query',
`operation_detail` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '操作详情JSON格式',
`sql_statement` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '执行的SQL语句',
`before_data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '变更前数据JSON可选',
`after_data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '变更后数据JSON可选',
`created_at` int NOT NULL DEFAULT 0 COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_table_name`(`table_name` ASC) USING BTREE,
INDEX `idx_operation_type`(`operation_type` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE,
INDEX `idx_table_created`(`table_name` ASC, `created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '数据库变更记录表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of nl_database_change_log
-- ----------------------------
-- ----------------------------
-- Table structure for nl_file
-- ----------------------------
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音频 3excel 4压缩包 ',
`source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源 0后台 1用户端',
`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
`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音频 3excel 4压缩包 ',
`source` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源 0后台 1用户端',
`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
) ENGINE = InnoDB AUTO_INCREMENT = 84 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '文件表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
@@ -126,33 +171,33 @@ CREATE TABLE `nl_file` (
-- ----------------------------
DROP TABLE IF EXISTS `nl_menu`;
CREATE TABLE `nl_menu` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单标题',
`icon` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单图标',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '页面Name全局唯一',
`path` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '访问路由',
`component` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '组件路径需要去掉 views/ 和 .vue',
`redirect` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '父级菜单重定向的子集菜单',
`keep_alive` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启页面缓存 0开启 1关闭',
`hide_in_menu` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否将页面展示在菜单栏 0展示 1隐藏',
`affix_tab` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否为置顶页 0是 1',
`badge` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单的徽标',
`badge_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '用于配置页面的徽标类型0dot 小红点 1normal 文本',
`badge_variants` tinyint(1) NOT NULL DEFAULT 0 COMMENT '用于配置页面的徽标颜色 \r\n0default 1destructive 2primary 3success 4 warning',
`iframe_src` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '内嵌的页面路径',
`pid` int NOT NULL DEFAULT 0 COMMENT '父级菜单id',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`query` varchar(255) 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 = 10 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '菜单表' ROW_FORMAT = DYNAMIC;
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单标题',
`icon` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单图标',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '页面Name全局唯一',
`path` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '访问路由',
`component` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '组件路径需要去掉 views/ 和 .vue',
`redirect` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '父级菜单重定向的子集菜单',
`keep_alive` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启页面缓存 0开启 1关闭',
`hide_in_menu` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否将页面展示在菜单栏 0展示 1隐藏',
`affix_tab` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否为置顶页 0是 1',
`badge` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '菜单的徽标',
`badge_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '用于配置页面的徽标类型0dot 小红点 1normal 文本',
`badge_variants` tinyint(1) NOT NULL DEFAULT 0 COMMENT '用于配置页面的徽标颜色 \r\n0default 1destructive 2primary 3success 4 warning',
`iframe_src` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '内嵌的页面路径',
`pid` int NOT NULL DEFAULT 0 COMMENT '父级菜单id',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序',
`query` varchar(255) 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 = 12 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '菜单表' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of nl_menu
-- ----------------------------
INSERT INTO `nl_menu` VALUES (1, '概览', 'twemoji:house-with-garden', 'Dashboard', '/', 'BasicLayout', '', 0, 0, 1, '', 0, 0, '', 0, -1, '', 1734485082, 1734486088, 0);
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);
INSERT INTO `nl_menu` VALUES (4, '关于', 'lucide:copyright', 'About', '/about', '/_core/about/index.vue', '', 1, 0, 1, '', 0, 0, '', 0, 999999999, '', 0, 0, 0);
@@ -161,21 +206,23 @@ INSERT INTO `nl_menu` VALUES (6, '管理员管理', 'grommet-icons:user-admin',
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 (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);
-- ----------------------------
-- Table structure for nl_role
-- ----------------------------
DROP TABLE IF EXISTS `nl_role`;
CREATE TABLE `nl_role` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
`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 '角色说明',
`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
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
`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 '角色说明',
`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;
-- ----------------------------
@@ -188,12 +235,12 @@ INSERT INTO `nl_role` VALUES (1, '超级管理员', 'admin', 0, '', 0, 0, 0);
-- ----------------------------
DROP TABLE IF EXISTS `nl_role_menu_relations`;
CREATE TABLE `nl_role_menu_relations` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`role_id` int NOT NULL DEFAULT 0 COMMENT '角色ID',
`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 = 9 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '角色权限绑定表' ROW_FORMAT = DYNAMIC;
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`role_id` int NOT NULL DEFAULT 0 COMMENT '角色ID',
`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;
-- ----------------------------
-- Records of nl_role_menu_relations
@@ -206,5 +253,300 @@ INSERT INTO `nl_role_menu_relations` VALUES (5, 1, 5, 1747033198);
INSERT INTO `nl_role_menu_relations` VALUES (6, 1, 6, 1747033198);
INSERT INTO `nl_role_menu_relations` VALUES (7, 1, 7, 1747033198);
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);
-- ----------------------------
-- Table structure for nl_system_config
-- ----------------------------
DROP TABLE IF EXISTS `nl_system_config`;
CREATE TABLE `nl_system_config` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '配置键,全局唯一',
`value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '配置值(可 JSON 字符串)',
`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` int NOT NULL DEFAULT 0 COMMENT '更新时间',
`deleted_at` int NOT NULL DEFAULT 0 COMMENT '删除时间0表示未删除',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uk_key`(`key` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统配置' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_system_config` (`key`, `value`, `remark`, `created_at`, `updated_at`, `deleted_at`) VALUES
('ai_active_provider', 'deepseek', '当前启用的 AI 平台编码spark/deepseek', 1786329600, 0, 0),
('ai_active_model', 'deepseek-chat', '当前启用的模型编码', 1786329600, 0, 0),
('ai_active_api_key_id', '2', '当前启用的 API 密钥 ID0 表示按平台默认密钥', 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_ai_platform
-- ----------------------------
DROP TABLE IF EXISTS `nl_ai_platform`;
CREATE TABLE `nl_ai_platform` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '平台编码spark/deepseek',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '平台名称',
`api_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Chat Completions 接口地址',
`logo` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Logo URL卡片展示',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '平台说明',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`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,
UNIQUE INDEX `uk_code`(`code` ASC) USING BTREE,
INDEX `idx_status_sort`(`status` ASC, `sort` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI平台' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_ai_platform` VALUES
(1, 'spark', '讯飞星火', 'https://spark-api-open.xf-yun.com/v1/chat/completions', 'https://p9.itc.cn/q_70/images03/20230713/28e961f4b8274101bcaddce110828f5c.png', '讯飞星火 OpenAPIOpenAI 兼容)', 1, 100, 1786329600, 0, 0),
(2, 'deepseek', 'DeepSeek', 'https://api.deepseek.com/v1/chat/completions', 'https://ts3.tc.mm.bing.net/th/id/OIP-C.xc2137qQttRyKpPwq3ooRwAAAA?w=108&h=108&c=1&bgcl=84b7ac&r=0&o=7&pid=ImgRC&rm=3', 'DeepSeek Chat Completions API', 1, 90, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_ai_model
-- ----------------------------
DROP TABLE IF EXISTS `nl_ai_model`;
CREATE TABLE `nl_ai_model` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`platform_id` int NOT NULL DEFAULT 0 COMMENT 'AI平台ID',
`code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '模型编码调用API用如 lite / deepseek-chat',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '展示名称',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '模型说明',
`is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否平台默认模型 0否1是',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`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_platform_id`(`platform_id` ASC) USING BTREE,
INDEX `idx_platform_code`(`platform_id` ASC, `code` ASC) USING BTREE,
INDEX `idx_status_sort`(`status` ASC, `sort` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI厂商模型' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_ai_model` VALUES
(1, 1, 'lite', 'lite', '讯飞星火 默认模型', 1, 1, 100, 1786329600, 0, 0),
(2, 2, 'deepseek-chat', 'deepseek-chat', 'DeepSeek 默认模型', 1, 1, 100, 1786329600, 0, 0),
(4, 2, 'deepseek-reasoner', 'DeepSeek Reasoner', '推理模型', 0, 1, 80, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_ai_api_key
-- ----------------------------
DROP TABLE IF EXISTS `nl_ai_api_key`;
CREATE TABLE `nl_ai_api_key` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`platform_id` int NOT NULL DEFAULT 0 COMMENT 'AI平台ID',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '密钥别名',
`api_key` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'API密钥库内AES加密前缀 nl_ase_256_',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '备注',
`is_default` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否平台默认密钥 0否1是',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`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_platform_id`(`platform_id` ASC) USING BTREE,
INDEX `idx_status_sort`(`status` ASC, `sort` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI平台API密钥' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_ai_api_key` VALUES
(1, 1, '李琦-免费', 'nl_ase_256_3Oy8Dxp82N0PgU2rXLrhVADcfwkqG+rzTwWboUpdYnX9Sd6acEtx95BAXVehSaGHWiDq7Zgr/SRcjHDNhPfg4Q==', '李琦-免费测试key', 1, 1, 0, 1786329600, 0, 0),
(2, 2, 'DeepSeek-v4', 'nl_ase_256_K5ycIstKd4nalXpGqN79TggP7t+NywXffr+gN8uZcLh1T09CWI4nLPZkAgosrUoDaDCOmgIthhLvQsqHxWLtQA==', 'DeepSeek-v4', 1, 1, 0, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_ai_generation
-- ----------------------------
DROP TABLE IF EXISTS `nl_ai_generation`;
CREATE TABLE `nl_ai_generation` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`admin_id` int NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
`scene` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '场景code_generation/requirement_prompt',
`name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '展示名称(如模块名)',
`provider` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'AI供应商spark/deepseek',
`model` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '模型名',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0进行中1成功2失败',
`api_key_id` int NOT NULL DEFAULT 0 COMMENT 'nl_ai_api_key.idenv回落为0',
`prompt_tokens` int NOT NULL DEFAULT 0 COMMENT '输入token数',
`completion_tokens` int NOT NULL DEFAULT 0 COMMENT '输出token数',
`total_tokens` int NOT NULL DEFAULT 0 COMMENT '总token数',
`usage_json` json NULL COMMENT '完整usage原始对象',
`started_at` int NOT NULL DEFAULT 0 COMMENT '开始时间戳',
`finished_at` int NOT NULL DEFAULT 0 COMMENT '结束时间戳',
`duration_ms` int NOT NULL DEFAULT 0 COMMENT '耗时毫秒',
`error_msg` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '失败原因摘要',
`input_snapshot` json NULL COMMENT '输入快照(模块名/描述/pid等',
`result_json` json NULL COMMENT '结构化结果 JSON',
`raw_response` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL 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_admin_scene`(`admin_id` ASC, `scene` ASC, `deleted_at` ASC) USING BTREE,
INDEX `idx_status_created`(`status` ASC, `created_at` ASC) USING BTREE,
INDEX `idx_provider_model_created`(`provider` ASC, `model` ASC, `created_at` ASC) USING BTREE,
INDEX `idx_api_key_created`(`api_key_id` ASC, `created_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AI生成历史' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Table structure for nl_oss_config
-- ----------------------------
DROP TABLE IF EXISTS `nl_oss_config`;
CREATE TABLE `nl_oss_config` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`driver` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'local' COMMENT '驱动local/aliyun/qcloud/qiniu/huawei/aws/minio/baidu',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '配置别名',
`access_key` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'AccessKey库内AES加密',
`secret_key` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'SecretKey库内AES加密',
`endpoint` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Endpoint/服务地址',
`region` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '地域,如 ap-guangzhou',
`bucket` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Bucket 名称',
`domain` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '对外访问域名(含协议)',
`path_prefix` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '对象路径前缀',
`extra_json` json NULL COMMENT '扩展配置 JSON',
`is_active` 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_driver_status`(`driver` ASC, `status` ASC) USING BTREE,
INDEX `idx_is_active`(`is_active` ASC, `deleted_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 9 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'OSS存储配置' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_oss_config` VALUES
(1, 'local', '本地存储', '', '', '', '', '', '', 'uploads', NULL, 1, 1, '默认本地磁盘,文件落到 storage/app', 100, 1786329600, 0, 0),
(2, 'aliyun', '阿里云 OSS', '', '', 'oss-cn-hangzhou.aliyuncs.com', 'cn-hangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、访问域名Endpoint 可按地域修改', 90, 1786329600, 0, 0),
(3, 'qcloud', '腾讯云 COS', '', '', '', 'ap-guangzhou', '', '', 'uploads', NULL, 0, 1, '请填写 SecretId/SecretKey、Bucket、Region、访问域名', 80, 1786329600, 0, 0),
(4, 'qiniu', '七牛云 Kodo', '', '', '', '', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、外链域名含协议', 70, 1786329600, 0, 0),
(5, 'huawei', '华为云 OBS', '', '', 'https://obs.cn-north-4.myhuaweicloud.com', 'cn-north-4', '', '', 'uploads', NULL, 0, 1, '请填写 AK/SK、Bucket、Endpoint、访问域名', 60, 1786329600, 0, 0),
(6, 'aws', 'AWS S3', '', '', 'https://s3.amazonaws.com', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/Secret、Bucket、Region、访问域名', 50, 1786329600, 0, 0),
(7, 'minio', 'MinIO', '', '', 'http://127.0.0.1:9000', 'us-east-1', '', '', 'uploads', NULL, 0, 1, '私有化 S3 兼容;请填写 Key、Bucket、Endpoint', 40, 1786329600, 0, 0),
(8, 'baidu', '百度云 BOS', '', '', 'https://bj.bcebos.com', 'bj', '', '', 'uploads', NULL, 0, 1, '请填写 AccessKey/SecretKey、Bucket、Endpoint、访问域名', 30, 1786329600, 0, 0);
INSERT INTO `nl_system_config` (`key`, `value`, `remark`, `created_at`, `updated_at`, `deleted_at`) VALUES
('oss_active_config_id', '1', '当前启用的 OSS 配置 ID', 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_api_endpoint
-- ----------------------------
DROP TABLE IF EXISTS `nl_api_endpoint`;
CREATE TABLE `nl_api_endpoint` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '接口路径,如 admin/list不含前缀 /api/',
`method` tinyint(1) NOT NULL DEFAULT 0 COMMENT '请求方式 0ANY 1GET 2POST',
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作名',
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '接口说明',
`controller` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '所属控制器',
`is_log` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否记录操作日志 0否1是',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1启用0禁用',
`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,
UNIQUE INDEX `uk_url_method`(`url` ASC, `method` ASC) USING BTREE,
INDEX `idx_is_log`(`is_log` ASC, `status` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '接口注册表' ROW_FORMAT = DYNAMIC;
INSERT INTO `nl_api_endpoint` (`url`, `method`, `name`, `description`, `controller`, `is_log`, `status`, `created_at`, `updated_at`, `deleted_at`) VALUES
('login', 2, '登录', '账号密码登录', 'LoginController', 0, 1, 1786329600, 0, 0),
('register', 2, '注册', '注册管理员账号', 'LoginController', 0, 1, 1786329600, 0, 0),
('logout', 0, '退出登录', '退出登录', 'LoginController', 0, 1, 1786329600, 0, 0),
('codes', 0, '权限码', '获取权限码(登录前)', 'LoginController', 0, 1, 1786329600, 0, 0),
('admin/list', 1, '管理员列表', '分页查询管理员', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/option', 1, '管理员下拉', '管理员下拉选项', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/detail', 1, '管理员详情', '管理员详情', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/create', 2, '创建管理员', '新增管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/update', 2, '更新管理员', '编辑管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/delete', 2, '删除管理员', '删除管理员', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/change-password', 2, '修改密码', '当前用户修改密码', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/reset-password', 2, '重置密码', '管理员重置他人密码', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/my-info', 1, '我的信息', '获取当前登录用户信息', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/update-profile', 2, '更新个人资料', '个人中心编辑资料/头像', 'AdminController', 1, 1, 1786329600, 0, 0),
('admin/menu', 1, '我的菜单', '按角色获取菜单树', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/codes', 1, '我的权限码', '获取当前用户权限码', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/login-log', 1, '我的登录日志', '个人中心登录日志', 'AdminController', 0, 1, 1786329600, 0, 0),
('admin/op-log', 1, '我的操作日志', '个人中心操作日志', 'AdminController', 0, 1, 1786329600, 0, 0),
('role/list', 1, '角色列表', '分页查询角色', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/option', 1, '角色下拉', '角色下拉选项', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/detail', 1, '角色详情', '角色详情', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/create', 2, '创建角色', '新增角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/update', 2, '更新角色', '编辑角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/delete', 2, '删除角色', '删除角色', 'RoleController', 1, 1, 1786329600, 0, 0),
('role/get-menu-ids-by-role-ids', 1, '角色菜单ID', '按角色获取已绑菜单', 'RoleController', 0, 1, 1786329600, 0, 0),
('role/save-role-menu', 2, '保存角色菜单', '绑定角色菜单权限', 'RoleController', 1, 1, 1786329600, 0, 0),
('menu/list', 1, '菜单列表', '分页查询菜单', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/option', 1, '菜单下拉', '菜单下拉选项', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/detail', 1, '菜单详情', '菜单详情', 'MenuController', 0, 1, 1786329600, 0, 0),
('menu/create', 2, '创建菜单', '新增菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/update', 2, '更新菜单', '编辑菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/delete', 2, '删除菜单', '删除菜单', 'MenuController', 1, 1, 1786329600, 0, 0),
('menu/tree-option', 1, '菜单树', '菜单树形选项', 'MenuController', 0, 1, 1786329600, 0, 0),
('upload/image', 2, '上传图片', '上传图片文件', 'UploadController', 1, 1, 1786329600, 0, 0),
('upload/video', 2, '上传视频', '上传视频文件', 'UploadController', 1, 1, 1786329600, 0, 0),
('database/list-tables', 1, '库表列表', '列出数据库表', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/get-table-info', 1, '表结构', '获取表结构信息', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/get-table-data', 2, '表数据查询', '查询表数据', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('database/update-table-data', 2, '更新表数据', '修改表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/delete-table-data', 2, '删除表数据', '删除表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/batch-delete-table-data', 2, '批量删除表数据', '批量删除表行', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/insert-table-data', 2, '插入表数据', '新增表行数据', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-table-comment', 2, '更新表注释', '修改表注释', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-column-comment', 2, '更新字段注释', '修改字段注释', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/add-index', 2, '添加索引', '创建表索引', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/drop-index', 2, '删除索引', '删除表索引', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/update-table-structure', 2, '改表结构', '修改表结构', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/execute-sql-query', 2, '执行SQL', '执行自定义 SQL', 'DatabaseController', 1, 1, 1786329600, 0, 0),
('database/get-change-log', 1, '库变更日志', '数据库变更记录', 'DatabaseController', 0, 1, 1786329600, 0, 0),
('ai-config/runtime-options', 1, 'AI运行时选项', 'AI平台/模型/密钥选项', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/save-runtime', 2, '保存AI运行时', '保存默认AI组合', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/key-list', 1, 'AI密钥列表', 'API Key 卡片列表', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/platform-option', 1, 'AI平台下拉', 'AI平台选项', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/create-key', 2, '创建AI密钥', '新增API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/update-key', 2, '更新AI密钥', '编辑API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/delete-key', 2, '删除AI密钥', '删除API Key', 'AiConfigController', 1, 1, 1786329600, 0, 0),
('ai-config/generation-list', 1, 'AI生成列表', 'AI生成历史列表', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('ai-config/generation-detail', 1, 'AI生成详情', 'AI生成历史详情', 'AiConfigController', 0, 1, 1786329600, 0, 0),
('code/generation', 2, '代码生成', '按模块生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/batch-generation', 2, '批量代码生成', '批量生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/download', 0, '下载代码包', '下载生成结果', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/download-info', 0, '下载信息', '代码包下载信息', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/get-tables', 1, '代码生成表列表', '可选数据表', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/get-table-structure', 1, '代码生成表结构', '表结构供生成', 'CodeGenerationController', 0, 1, 1786329600, 0, 0),
('code/generate-from-table', 2, '从表生成', '按表结构生成代码', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/ai-generate', 2, 'AI代码生成', 'AI辅助代码生成', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('code/ai-requirement-prompt', 2, 'AI需求提示词', '生成需求提示词', 'CodeGenerationController', 1, 1, 1786329600, 0, 0),
('oss-config/driver-options', 1, 'OSS驱动选项', '可选存储驱动列表', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/runtime-options', 1, 'OSS运行时选项', '配置卡片+当前启用', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/save-runtime', 2, '启用OSS配置', '切换当前存储驱动', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/list', 1, 'OSS配置列表', '存储配置列表', 'OssConfigController', 0, 1, 1786329600, 0, 0),
('oss-config/create', 2, '创建OSS配置', '新增存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/update', 2, '更新OSS配置', '编辑存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('oss-config/delete', 2, '删除OSS配置', '删除存储配置', 'OssConfigController', 1, 1, 1786329600, 0, 0),
('api-endpoint/list', 1, '接口列表', '接口注册表分页', 'ApiEndpointController', 0, 1, 1786329600, 0, 0),
('api-endpoint/update', 2, '更新接口', '编辑接口说明/是否记日志', 'ApiEndpointController', 1, 1, 1786329600, 0, 0);
-- ----------------------------
-- Table structure for nl_login_log
-- ----------------------------
DROP TABLE IF EXISTS `nl_login_log`;
CREATE TABLE `nl_login_log` (
`id` int UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int NOT NULL DEFAULT 0 COMMENT '管理员ID失败时可能为0',
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '登录账号/手机号',
`nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '昵称快照',
`ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '登录IP',
`equipment` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '操作系统',
`browser` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '浏览器',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0成功1失败',
`message` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT '结果说明',
`created_at` int NOT NULL DEFAULT 0 COMMENT '登录时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_created`(`user_id` ASC, `created_at` ASC) USING BTREE,
INDEX `idx_created_at`(`created_at` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '登录日志' ROW_FORMAT = DYNAMIC;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,863 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>SQL 安装器 - Laravel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #6366f1;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--dark-color: #1e293b;
--light-color: #f8fafc;
}
body {
background: linear-gradient(135deg, #f0f4f8, #e2e8f0);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
padding: 2rem 0;
min-height: 100vh;
}
.logo-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 1.5rem;
}
.logo-header i {
font-size: 2.5rem;
color: var(--primary-color);
}
.card {
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
border: none;
overflow: hidden;
margin-bottom: 2rem;
}
.card-header {
background: var(--primary-color);
color: white;
font-weight: 600;
font-size: 1.25rem;
padding: 1.25rem 1.5rem;
border-bottom: none;
}
.card-body {
padding: 2rem;
}
.progress-container {
position: relative;
height: 2.5rem;
margin-bottom: 1.5rem;
border-radius: 12px;
background-color: #e2e8f0;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 12px;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
position: absolute;
top: 0;
left: 0;
background: linear-gradient(90deg, var(--primary-color), #4f46e5);
}
.log-container {
background-color: #1e293b;
color: #cbd5e1;
font-family: 'Fira Code', monospace;
border-radius: 12px;
padding: 1.5rem;
height: 320px;
overflow-y: auto;
white-space: pre-wrap;
margin-bottom: 1.5rem;
font-size: 0.9rem;
line-height: 1.6;
}
.log-item {
margin-bottom: 6px;
border-left: 2px solid transparent;
padding-left: 12px;
}
.log-item.info {
border-left-color: #60a5fa;
color: #bfdbfe;
}
.log-item.success {
border-left-color: var(--success-color);
color: #6ee7b7;
}
.log-item.warning {
border-left-color: var(--warning-color);
color: #fcd34d;
}
.log-item.error {
border-left-color: var(--danger-color);
color: #fca5a5;
}
.btn-action {
padding: 0.75rem 1.5rem;
font-weight: 600;
border-radius: 12px;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), #4f46e5);
box-shadow: 0 4px 6px rgba(99, 102, 241, 0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #4f46e5, #4338ca);
box-shadow: 0 6px 8px rgba(99, 102, 241, 0.4);
transform: translateY(-2px);
}
.status-indicator {
width: 18px;
height: 18px;
border-radius: 50%;
display: inline-block;
margin-right: 10px;
box-shadow: 0 0 8px currentColor;
}
.status-idle {
background-color: #94a3b8;
}
.status-running {
background-color: var(--primary-color);
animation: pulse 1.5s infinite;
}
.status-success {
background-color: var(--success-color);
}
.status-error {
background-color: var(--danger-color);
}
.info-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
}
.info-card h5 {
color: var(--primary-color);
margin-bottom: 1rem;
font-weight: 600;
}
.info-card .info-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
}
.info-card .info-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.1rem;
font-weight: 600;
color: var(--dark-color);
margin-bottom: 1.2rem;
}
.config-section {
background: rgba(241, 245, 249, 0.5);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
font-weight: bold;
margin-right: 12px;
}
.highlight {
background: rgba(99, 102, 241, 0.1);
border-left: 4px solid var(--primary-color);
padding: 0.5rem 1rem;
border-radius: 0 8px 8px 0;
margin: 1rem 0;
}
.floating-message {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
padding: 1rem 1.5rem;
border-radius: 12px;
font-weight: 500;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: translateY(20px);
transition: all 0.4s ease;
}
.floating-message.show {
opacity: 1;
transform: translateY(0);
}
.floating-success {
background: var(--success-color);
color: white;
}
.floating-error {
background: var(--danger-color);
color: white;
}
.permission-hint {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 10px;
border-radius: 4px;
margin: 15px 0;
display: none;
}
.permission-hint-title {
color: #dc3545;
font-weight: bold;
}
.permission-solution {
background-color: #d1ecf1;
border-left: 4px solid #0dcaf0;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo-header">
<i class="fas fa-database"></i>
<h1>Laravel SQL 安装器</h1>
</div>
<!-- 权限问题提示 -->
<div class="permission-hint" id="permissionHint">
<div class="permission-hint-title">
<i class="fas fa-exclamation-triangle me-2"></i>检测到权限问题
</div>
<p>您的系统可能遇到了文件权限问题导致无法加载PHP扩展。</p>
<div class="permission-solution">
<h6><i class="fas fa-wrench me-2"></i>解决方法:</h6>
<ol>
<li>检查Web服务器用户如www-data是否有权访问PHP扩展目录</li>
<li>在服务器终端执行:<code>sudo chmod -R a+r /usr/local/lib/php/extensions/</code></li>
<li>如果使用SELinux/AppArmor调整安全策略允许访问</li>
<li>确保PHP进程有权执行<code>artisan</code>命令</li>
</ol>
</div>
</div>
<div class="row">
<!-- 左侧配置面板 -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<i class="fas fa-sliders-h me-2"></i>数据库配置
</div>
<div class="card-body">
<form id="installForm">
<div class="config-section">
<div class="section-title">
<i class="fas fa-server"></i>
<span>数据库连接</span>
</div>
<div class="mb-3">
<label for="host" class="form-label">主机</label>
<input type="text" class="form-control" id="host"
placeholder="数据库主机" value="{{ env('DB_HOST', '127.0.0.1') }}">
</div>
<div class="mb-3">
<label for="port" class="form-label">端口</label>
<input type="number" class="form-control" id="port"
placeholder="端口号" value="{{ env('DB_PORT', 3306) }}">
</div>
<div class="mb-3">
<label for="database" class="form-label">数据库名</label>
<input type="text" class="form-control" id="database"
placeholder="数据库名称" value="{{ env('DB_DATABASE', 'nl_admin') }}">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username"
placeholder="用户名" value="{{ env('DB_USERNAME', 'root') }}">
</div>
<div class="col-md-6 mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password"
placeholder="密码" value="{{ env('DB_PASSWORD', '') }}">
</div>
</div>
<div class="d-grid mt-3">
<button type="button" class="btn btn-primary" id="testConnectionBtn">
<i class="fas fa-plug"></i> 测试连接
</button>
</div>
</div>
<div class="config-section">
<div class="section-title">
<i class="fas fa-file-code"></i>
<span>SQL 文件设置</span>
</div>
<div class="mb-3">
<label for="sqlPath" class="form-label">SQL 文件路径</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-folder"></i></span>
<input type="text" class="form-control" id="sqlPath"
placeholder="输入SQL文件路径" value="public/nl_admin.sql">
</div>
<div class="form-text mt-1">相对于应用根目录的文件路径</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="forceInstall">
<label class="form-check-label" for="forceInstall">
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
强制安装(覆盖现有数据)
</label>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="backupDb">
<label class="form-check-label" for="backupDb">
<i class="fas fa-file-archive text-info me-2"></i>
安装前备份数据库
</label>
</div>
<div class="d-grid gap-2 mt-4">
<button type="button" class="btn btn-primary" id="startInstallBtn">
<i class="fas fa-play-circle"></i> 开始安装
</button>
</div>
</div>
</form>
</div>
</div>
<div class="info-card">
<h5><i class="fas fa-info-circle text-primary me-2"></i>使用说明</h5>
<div class="highlight">
<p>1. 配置数据库连接信息</p>
<p>2. 设置SQL文件路径默认为<code>public/nl_admin.sql</code></p>
<p>3. 测试数据库连接是否成功</p>
<p>4. 点击"开始安装"执行SQL文件</p>
</div>
<div class="info-item">
<span><i class="fas fa-exclamation-triangle text-warning me-2"></i>重要提示:</span>
<span class="text-end">强制安装选项会覆盖现有数据,请谨慎使用</span>
</div>
</div>
</div>
<!-- 右侧监控面板 -->
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-chart-line me-2"></i>安装进度
</div>
<div id="statusDisplay">
<div class="status-indicator status-idle"></div>
<span id="statusText">准备就绪</span>
</div>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span id="progressPercentage">0%</span>
<span id="fileSize" class="badge bg-light text-dark">
<i class="fas fa-file-alt me-1"></i> 文件大小: 0 KB
</span>
</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar" style="width: 0%"></div>
</div>
<div class="log-container" id="logContainer">
<div class="log-item info">== 系统日志 ==</div>
<div class="log-item info">等待操作开始...</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="button" class="btn btn-outline-primary" id="downloadLogBtn" style="display: none;">
<i class="fas fa-download me-2"></i>下载日志报告
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<i class="fas fa-database me-2"></i>数据库信息
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="info-item">
<span>状态:</span>
<span id="dbStatus" class="text-primary">未连接</span>
</div>
<div class="info-item">
<span>驱动:</span>
<span id="dbDriver" class="text-info">{{ env('DB_CONNECTION', '未设置') }}</span>
</div>
<div class="info-item">
<span>字符集:</span>
<span>utf8mb4</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<span>主机:</span>
<span id="dbHost">{{ env('DB_HOST', '未设置') }}</span>
</div>
<div class="info-item">
<span>端口:</span>
<span id="dbPort">{{ env('DB_PORT', '未设置') }}</span>
</div>
<div class="info-item">
<span>数据库名:</span>
<span id="dbName">{{ env('DB_DATABASE', '未设置') }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 操作结果提示 -->
<div id="floatingMessage" class="floating-message">
<i class="fas fa-exclamation-circle me-2"></i> <span id="floatingText"></span>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 获取所有DOM元素引用
const elements = {
logContainer: document.getElementById('logContainer'),
progressBar: document.getElementById('progressBar'),
progressPercentage: document.getElementById('progressPercentage'),
startInstallBtn: document.getElementById('startInstallBtn'),
testConnectionBtn: document.getElementById('testConnectionBtn'),
downloadLogBtn: document.getElementById('downloadLogBtn'),
statusIndicator: document.querySelector('.status-indicator'),
statusText: document.getElementById('statusText'),
dbStatus: document.getElementById('dbStatus'),
dbHost: document.getElementById('dbHost'),
dbPort: document.getElementById('dbPort'),
dbName: document.getElementById('dbName'),
dbDriver: document.getElementById('dbDriver'),
floatingMessage: document.getElementById('floatingMessage'),
floatingText: document.getElementById('floatingText'),
permissionHint: document.getElementById('permissionHint')
};
let installLog = [];
let currentProgress = 0;
// 添加日志函数
function addLog(message, type = 'info') {
const logItem = document.createElement('div');
logItem.className = `log-item ${type}`;
const now = new Date();
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
logItem.textContent = `[${time}] ${message}`;
elements.logContainer.appendChild(logItem);
// 滚动到底部
elements.logContainer.scrollTop = elements.logContainer.scrollHeight;
// 保存日志
installLog.push({
time: new Date().toISOString(),
message,
type
});
// 检测是否是权限错误
if (message.includes('Permission denied')) {
elements.permissionHint.style.display = 'block';
}
// 检测进度信息
detectProgress(message);
}
// 从日志消息中检测进度
function detectProgress(message) {
// 如果消息中包含进度百分比
if (message.includes('%') && /[0-9]{1,3}%/.test(message)) {
const match = message.match(/(\d{1,3})%/);
if (match && match[1]) {
currentProgress = parseInt(match[1]);
updateProgress(currentProgress);
}
}
// 检测文件大小信息
else if (message.includes('文件大小')) {
const match = message.match(/大小: ([0-9.]+)MB/);
if (match && match[1]) {
const size = Math.round(parseFloat(match[1]) * 1024);
elements.fileSize.innerHTML = `<i class="fas fa-file-alt me-1"></i> 文件大小: ${size} KB`;
}
}
}
// 更新状态显示
function updateStatus(status) {
elements.statusIndicator.className = 'status-indicator';
switch(status) {
case 'idle':
elements.statusIndicator.classList.add('status-idle');
elements.statusText.textContent = '准备就绪';
break;
case 'running':
elements.statusIndicator.classList.add('status-running');
elements.statusText.textContent = '安装进行中...';
elements.startInstallBtn.disabled = true;
elements.testConnectionBtn.disabled = true;
break;
case 'success':
elements.statusIndicator.classList.add('status-success');
elements.statusText.textContent = '安装成功';
elements.startInstallBtn.disabled = false;
elements.testConnectionBtn.disabled = false;
break;
case 'error':
elements.statusIndicator.classList.add('status-error');
elements.statusText.textContent = '安装失败';
elements.startInstallBtn.disabled = false;
elements.testConnectionBtn.disabled = false;
elements.downloadLogBtn.style.display = 'block';
break;
}
}
// 更新数据库连接状态
function updateDbStatus(status) {
elements.dbStatus.textContent = status;
elements.dbStatus.className = '';
switch(status) {
case '已连接':
elements.dbStatus.classList.add('text-success');
break;
case '连接失败':
elements.dbStatus.classList.add('text-danger');
break;
default:
elements.dbStatus.classList.add('text-primary');
}
}
// 更新进度条
function updateProgress(percentage, message) {
const percent = Math.min(100, Math.max(0, percentage));
elements.progressBar.style.width = `${percent}%`;
elements.progressPercentage.textContent = `${Math.round(percent)}%`;
if (message) {
addLog(message);
}
}
// 显示浮动消息
function showFloatingMessage(message, type) {
elements.floatingText.textContent = message;
elements.floatingMessage.className = 'floating-message';
elements.floatingMessage.classList.add('show');
if (type === 'success') {
elements.floatingMessage.classList.add('floating-success');
} else {
elements.floatingMessage.classList.add('floating-error');
}
setTimeout(() => {
elements.floatingMessage.classList.remove('show');
setTimeout(() => {
elements.floatingMessage.className = 'floating-message';
}, 300);
}, 5000);
}
// 测试数据库连接
async function testDatabaseConnection() {
const host = document.getElementById('host').value;
const port = document.getElementById('port').value;
const database = document.getElementById('database').value;
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
// 更新数据库信息显示
elements.dbHost.textContent = host || '未设置';
elements.dbPort.textContent = port || '未设置';
elements.dbName.textContent = database || '未设置';
addLog('正在测试数据库连接...', 'info');
try {
const response = await fetch('/api/sql/test-connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
host,
port,
database,
username,
password
})
});
const result = await response.json();
if (result.success) {
addLog('✅ 数据库连接成功!', 'success');
updateDbStatus('已连接');
showFloatingMessage('✅ 数据库连接成功!', 'success');
// 更新数据库信息
elements.dbDriver.textContent = 'MySQL';
} else {
const errorMessage = `❌ ${result.message || '数据库连接失败'}`;
addLog(errorMessage, 'error');
updateDbStatus('连接失败');
showFloatingMessage(errorMessage, 'error');
}
} catch (error) {
const errorMessage = `❌ 连接测试出错: ${error.message}`;
addLog(errorMessage, 'error');
updateDbStatus('连接失败');
showFloatingMessage(errorMessage, 'error');
}
}
// 开始安装过程
async function startInstallation() {
// 重置日志和UI状态
elements.logContainer.innerHTML = '<div class="log-item info">== 安装日志 ==</div>';
installLog = [];
currentProgress = 0;
updateProgress(0);
updateStatus('running');
elements.downloadLogBtn.style.display = 'none';
elements.permissionHint.style.display = 'none';
const sqlPath = document.getElementById('sqlPath').value;
const host = document.getElementById('host').value;
const port = document.getElementById('port').value;
const database = document.getElementById('database').value;
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const forceInstall = document.getElementById('forceInstall').checked;
const backupDb = document.getElementById('backupDb').checked;
addLog('🚀 开始数据库安装流程...', 'info');
addLog(`📁 文件路径: ${sqlPath}`, 'info');
addLog(`🔧 数据库配置: ${username}@${host}:${port}`, 'info');
addLog(`🗃️ 数据库名: ${database}`, 'info');
addLog(`⚡ 强制模式: ${forceInstall ? '是' : '否'}`, forceInstall ? 'warning' : 'info');
try {
const response = await fetch('/api/sql/start-installation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
file: sqlPath,
host,
port,
database,
username,
password,
force: forceInstall,
backup: backupDb
})
});
const result = await response.json();
if (!response.ok) {
// 处理错误情况
const errorMsg = result.message || '安装过程中发生错误';
// 确保日志存在
if (result.logs && Array.isArray(result.logs)) {
result.logs.forEach(log => {
let type = 'info';
if (log.includes('❌') || log.includes('失败')) type = 'error';
if (log.includes('⚠️')) type = 'warning';
if (log.includes('✅')) type = 'success';
addLog(log, type);
});
} else {
addLog(`❌ ${errorMsg}`, 'error');
}
showFloatingMessage(`❌ ${errorMsg}`, 'error');
updateStatus('error');
return;
}
// 处理成功情况
if (result.success && Array.isArray(result.logs)) {
result.logs.forEach(log => {
let type = 'info';
if (log.includes('❌') || log.includes('失败')) type = 'error';
if (log.includes('⚠️')) type = 'warning';
if (log.includes('✅')) type = 'success';
addLog(log, type);
});
// 更新文件大小显示
if (result.file_size) {
const sizeKB = Math.round(result.file_size / 1024);
elements.fileSize.innerHTML =
`<i class="fas fa-file-alt me-1"></i> 文件大小: ${sizeKB} KB`;
}
// 更新进度到100%
updateProgress(100, '✅ 安装完成');
showFloatingMessage('✅ 安装成功!', 'success');
updateStatus('success');
} else {
addLog('❌ 安装完成但返回数据异常', 'error');
showFloatingMessage('❌ 安装完成但返回数据异常', 'error');
updateStatus('error');
}
} catch (error) {
addLog(`❌ 安装过程中出错: ${error.message}`, 'error');
showFloatingMessage(`❌ 安装失败: ${error.message}`, 'error');
updateStatus('error');
}
}
// 下载日志报告
function downloadLogReport() {
const logContent = installLog.map(entry =>
`[${new Date(entry.time).toLocaleTimeString()}] ${entry.message}`
).join('\n');
const blob = new Blob([logContent], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sql_install_log_${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// 绑定事件监听器
elements.startInstallBtn.addEventListener('click', startInstallation);
elements.testConnectionBtn.addEventListener('click', testDatabaseConnection);
elements.downloadLogBtn.addEventListener('click', downloadLogReport);
// 页面加载完成状态
addLog('系统初始化完毕', 'info');
updateStatus('idle');
updateDbStatus('未连接');
// 初始显示数据库信息
elements.dbHost.textContent = "{{ env('DB_HOST', '未设置') }}";
elements.dbPort.textContent = "{{ env('DB_PORT', '未设置') }}";
elements.dbName.textContent = "{{ env('DB_DATABASE', '未设置') }}";
elements.dbDriver.textContent = "{{ env('DB_CONNECTION', '未设置') }}";
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,16 @@
<?php
use App\Http\Controllers\core\SqlInstallController;
use App\Service\common\UtilsService;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::post('/sql/test-connection', [SqlInstallController::class, 'testConnection']);
Route::post('/sql/start-installation', [SqlInstallController::class, 'startInstallation']);
/*
* -----------------------------------以下注释不要删除--------------------------------
* 不需要登录的路由生成地址
@@ -27,6 +32,13 @@ Route::group([], function () {
'role' => \App\Http\Controllers\Api\RoleController::class, // 角色管理
'menu' => \App\Http\Controllers\Api\MenuController::class, // 菜单管理
'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件
'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理
'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥)
'oss-config' => \App\Http\Controllers\Api\OssConfigController::class, // OSS 存储配置
'api-endpoint' => \App\Http\Controllers\Api\ApiEndpointController::class, // 接口注册表
'wechat-account' => \App\Http\Controllers\Api\WechatAccountController::class, // 公众号账号配置
'wechat-article' => \App\Http\Controllers\Api\WechatArticleController::class, // 公众号图文创作
'wechat-theme' => \App\Http\Controllers\Api\WechatThemeController::class, // 自定义排版主题(我的模板)
// 需要登录的路由生成地址
]);
});

View File

@@ -5,3 +5,6 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/init', function () {
return view('init');
});