签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -1,7 +1,7 @@
# 8.5 字典数据接口
:::tip 概述
字典数据接口分为两组:字典管理(DictController)和字典项管理(DictItemController)。前端通过字典编码获取字典项列表用于下拉框,后台通过管理接口维护字典数据。
字典数据接口分为两组:字典管理(DictController)和字典项管理(DictItemController)。前端通过字典编码获取字典项列表用于下拉框,后台通过管理接口维护字典数据。字典项变更时 `DictItemObserver` **自动失效缓存**。
:::
## 接口总览
@@ -16,7 +16,7 @@
| PUT | `/api/dict/update` | sys:dict:update | 修改字典 |
| DELETE | `/api/dict/delete/{id}` | sys:dict:delete | 删除字典 |
| DELETE | `/api/dict/batchDelete` | sys:dict:batchDelete | 批量删除字典 |
| GET | `/api/dict/refreshCache` | sys:dict:update | 刷新字典缓存 |
| GET | `/api/dict/refreshCache` | sys:dict:update | 刷新字典缓存(`#[DemoAllow]` 演示环境放行) |
### 字典项管理(DictItemController)
@@ -30,6 +30,10 @@
| DELETE | `/api/dict/item/batchDelete` | sys:dict:batchDelete | 批量删除字典项 |
| GET | `/api/dict/item/getDictItemList/{code}` | — | 按编码获取字典项列表 |
:::tip 批量删除为 DELETE
本项目 `batchDelete` 系列接口统一为 **DELETE** 方法(请求体传 `ids` 数组——DELETE 方法携带 body)。
:::
## 按编码获取字典项(最常用)
### 接口信息
@@ -37,7 +41,7 @@
| 项目 | 说明 |
|------|------|
| URL | `GET /api/dict/item/getDictItemList/{code}` |
| 权限 | 无需额外权限(已登录即可) |
| 权限 | 无需额外权限注解(已登录即可,位于 `auth.jwt` 组内) |
| 用途 | 前端下拉框数据源 |
### 请求示例
@@ -56,7 +60,8 @@ Authorization: Bearer <token>
"msg": "操作成功",
"data": [
{ "id": 1, "name": "男", "value": "1", "sort": 1, "note": "" },
{ "id": 2, "name": "女", "value": "2", "sort": 2, "note": "" }
{ "id": 2, "name": "女", "value": "2", "sort": 2, "note": "" },
{ "id": 3, "name": "保密", "value": "3", "sort": 3, "note": "" }
]
}
```
@@ -71,18 +76,17 @@ Authorization: Bearer <token>
| `sort` | number | 排序 |
| `note` | string | 备注 |
### 常用字典编码
### 常用字典编码(以初始化脚本为准)
| 编码 | 说明 | 字典项 |
|------|------|--------|
| `gender` | 性别 | 1=男, 2=女 |
| `user_status` | 用户状态 | 0=禁用, 1=启用 |
| `article_status` | 文章状态 | 0=草稿, 1=已发布 |
| `tenant_status` | 租户状态 | 0=禁用, 1=启用 |
| `notice_type` | 通知类型 | 1=通知, 2=公告 |
| `example_type` | 案例类型 | 1=基础, 2=进阶, 3=高级 |
| `example_status` | 案例状态 | 0=禁用, 1=启用 |
| `data_scope` | 数据权限 | 1=全部, 2=本部门, 3=仅本人 |
| `gender` | 性别 | 1=男, 2=女, 3=保密 |
| `user_status` | 用户状态 | 1=正常, 2=禁用 |
| `tenant_status` | 租户状态 | 1=启用, 0=禁用 |
| `example_type` | 案例类型 | 1=类型1, 2=类型2, 3=类型3, 4=类型4 |
| `example_status` | 案例状态 | 1=在用, 2=停用 |
(完整字典可在「数据管理 → 字典管理」中查看。)
## 刷新字典缓存
@@ -92,7 +96,9 @@ Authorization: Bearer <token>
| 权限 | `sys:dict:update` |
| 演示环境 | 允许执行(标注 `#[DemoAllow]`) |
修改字典数据后需调用此接口刷新缓存,否则新数据不会立即生效。
:::tip 正常情况无需手动刷新
字典项通过后台接口增删改时,`DictItemObserver` **自动清除**对应字典缓存。本接口用于**绕过模型直接改库**后的手动兜底,或运维批量刷新。
:::
```bash
GET /api/dict/refreshCache
@@ -105,18 +111,25 @@ Authorization: Bearer <token>
{
"code": 0,
"ok": true,
"msg": "缓存已刷新",
"msg": "缓存刷新成功",
"data": null
}
```
命令行兜底:`php artisan dict:clear-cache`。
## 前端使用
### 获取字典项
```typescript
// ui/src/api/common/index.ts
export function getDictItemList(code: string) {
// ui/src/api/common/index.ts(实际代码)
/**
* 根据字典编码查询字典项数据
* @param code 字典编码
* @returns 返回结果
*/
export function getDictItemByCode(code) {
return http.request({
url: '/dict/item/getDictItemList/' + code,
method: 'GET',
@@ -140,12 +153,12 @@ export function getDictItemList(code: string) {
<script setup>
import { ref, onMounted } from 'vue';
import { getDictItemList } from '@/api/common/index';
import { getDictItemByCode } from '@/api/common/index';
const statusOptions = ref([]);
onMounted(async () => {
statusOptions.value = await getDictItemList('example_status');
statusOptions.value = await getDictItemByCode('example_status');
});
</script>
```
@@ -154,51 +167,16 @@ onMounted(async () => {
```typescript
const [genderOptions, statusOptions] = await Promise.all([
getDictItemList('gender'),
getDictItemList('user_status'),
getDictItemByCode('gender'),
getDictItemByCode('user_status'),
]);
```
### 封装为通用 Hook
```typescript
// ui/src/hooks/web/useDict.ts
import { ref, onMounted } from 'vue';
import { getDictItemList } from '@/api/common/index';
export function useDict(code: string) {
const options = ref([]);
const loading = ref(false);
const load = async () => {
loading.value = true;
try {
options.value = await getDictItemList(code);
} finally {
loading.value = false;
}
};
onMounted(() => load());
return { options, loading, reload: load };
}
// 使用
const { options: genderOptions } = useDict('gender');
const { options: statusOptions } = useDict('user_status');
```
### 枚举显示名自动翻译
Logic 中配置 `serializeMaps` 后,查询数据自动补全 `{字段名}Text`:
Service 中配置 `serializeMaps`(需 `use HasSerializeMaps`)后,查询数据自动补全 `{字段名}Text`:
```php
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = [
'status' => 'example_status',
];
@@ -206,32 +184,32 @@ protected array $serializeMaps = [
```json
// API 响应自动包含 statusText
{ "id": 1, "name": "测试", "status": 1, "statusText": "启用" }
{ "id": 1, "name": "测试", "status": 1, "statusText": "在用" }
```
:::tip 缓存机制
DictService 采用两级缓存(请求级 + 持久缓存,1小时过期)。修改字典数据后需调用刷新缓存接口,否则新数据不会立即生效。
`DictCacheService` 采用两级缓存(请求级 + Redis 持久缓存)。变更一致性由 Observer 保证;详见 [4.8 缓存架构设计](/zh/architecture/cache.html) 与 [5.10 字典模块](/zh/core/dict.html)。
:::
## 后端 DictService 调用
## 后端 DictCacheService 调用
```php
use app\service\DictService;
use App\Services\DictCacheService;
// 根据值获取名称
$text = DictService::getText('gender', 1); // '男'
$text = DictCacheService::getText('gender', 1); // '男'
// 根据名称反向获取值
$value = DictService::getValue('gender', '男'); // '1'
$value = DictCacheService::getValue('gender', '男'); // '1'
// 获取下拉框选项
$options = DictService::getOptions('gender');
// [['label'=>'男','value'=>'1'], ['label'=>'女','value'=>'2']]
$options = DictCacheService::getOptions('gender');
// [['label'=>'男','value'=>'1'], ['label'=>'女','value'=>'2'], ...]
// 获取完整字典项(含 id、sort、note)
$items = DictService::getFullItems('gender');
// 批量获取多个字典映射(列表翻译一次取词)
$maps = DictCacheService::getBatchTextMaps(['gender', 'user_status']);
// 清除缓存
DictService::clearCache('gender'); // 清除指定字典
DictService::clearAllCache(); // 清除所有字典
DictCacheService::clearCache('gender'); // 清除指定字典
DictCacheService::clearAllCache(); // 清除所有字典
```
@@ -1,19 +1,20 @@
# 8.2 错误码对照表
:::tip 概述
所有接口返回统一的 JSON 结构,通过 `code` 字段区分业务状态。前端应根据 `code` 做差异化处理。
所有接口返回统一的 JSON 结构,通过 `code` 字段区分业务状态。前端应根据 `code` 做差异化处理。本表汇总系统全部错误码与常见错误消息(消息均来自源码中的真实文案)。
:::
## 业务错误码
| code | ok | 含义 | 使用场景 | 前端处理 |
|------|-----|------|----------|----------|
| `0` | true | 成功 | 所有成功操作 | 正常处理 data |
| `1` | false | 一般失败 | 业务逻辑失败 | 显示 msg 提示 |
| `401` | false | 未授权 | Token 缺失/无效/过期 | 跳转登录页 |
| `403` | false | 禁止访问 | 权限不足/租户异常/演示环境 | 显示无权限提示 |
| `404` | false | 数据不存在 | 查询单条记录为空 | 显示数据不存在 |
| `422` | false | 参数验证失败 | 表单验证不通过 | 显示具体字段错误 |
| code | ok | HTTP | 含义 | 使用场景 | 前端处理 |
|------|-----|------|------|----------|----------|
| `0` | true | 200 | 成功 | 所有成功操作 | 正常处理 data |
| `1` | false | 200 | 一般失败 | 业务逻辑失败、FormRequest 格式错误 | 显示 msg 提示 |
| `401` | false | 200 | 未授权 | Token 缺失/无效/过期 | 跳转登录页 |
| `403` | false | **403** | 禁止访问 | 权限不足/租户异常/演示环境 | 显示无权限提示 |
| `404` | false | 200 | 数据不存在 | 查询单条记录为空 / 路由不存在 | 显示数据不存在 |
| `422` | false | 200 | 参数验证失败 | 代码内手动 `Validator::validate()` 不通过 | 显示具体字段错误 |
| `500` | false | 200 | 服务器异常 | 未捕获异常 | 显示通用错误 + 记录日志 |
## 响应示例
@@ -21,33 +22,66 @@
## 常见错误消息
### 认证相关
### 认证相关(Authenticate 中间件 / AuthController)
| 消息 | code/HTTP | 原因 | 解决方案 |
|------|------|------|----------|
| `未提供认证令牌` | 401/200 | 请求头缺少 Authorization | 携带 token(Bearer 前缀可选) |
| `Expired token` | 401/200 | Token 超过有效期 | 调用 `/api/refresh` 刷新或重新登录 |
| `Signature verification failed` | 401/200 | 密钥不匹配或令牌被篡改 | 检查 `JWT_SECRET` 是否一致 |
| `无效的access_token` | 401/200 | 拿 refresh_token 访问业务接口 | 使用 access_token |
| `用户名或密码错误` | 1/200 | 账号或密码不正确(统一提示) | 检查输入 |
| `账号已被禁用` | 1/200 | 用户 status != 1 | 联系管理员 |
| `验证码错误` / `请输入验证码` | 1/200 | 验证码过期/不匹配/缺失 | 重新获取验证码 |
| `缺少refresh_token` | 1/200 | 刷新未传参 | 检查请求体 |
| `refresh_token已失效` | 1/200 | 旧令牌已用过(一次性)或被吊销 | 用最新的 refresh_token |
| `用户不存在或已被禁用` | 1/200 | 刷新时用户状态异常 | 重新登录 |
| `旧密码错误` | 1/200 | 修改密码时旧密码不匹配 | 核对旧密码 |
### 权限与租户相关(Authenticate / ResolveTenant / DemoMiddleware)
| 消息 | code/HTTP | 原因 | 解决方案 |
|------|------|------|----------|
| `无访问权限:{权限码}` | 403/403 | 用户无该接口权限 | 联系管理员分配权限节点 |
| `用户不存在,请重新登录` | 401/200 | token 有效但用户被删除 | 重新登录 |
| `租户不存在` | 403/403 | 租户数据被删除 | 检查租户数据 |
| `租户已被禁用` | 403/403 | 租户 status != 1 | 联系平台管理员启用 |
| `租户已过期` | 403/403 | 租户 expire_time 已过 | 续费或联系平台 |
| `演示模式下禁止操作` | 403/403 | 演示模式下写操作被拦截 | 非演示环境操作 / `#[DemoAllow]` 放行 |
### 业务相关(Service 层 BizException)
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `未提供认证令牌` | 请求头缺少 Authorization | 携带 Token |
| `无效的认证令牌` | Token 类型不是 access | 使用 access_token |
| `token已过期` | Token 超过有效期 | 调用刷新接口或重新登录 |
| `用户名或密码错误` | 账号或密码不正确 | 检查输入 |
| `账号已被禁用` | 用户 status != 1 | 联系管理员 |
### 权限相关
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `无访问权限:xxx` | 用户无该接口权限 | 联系管理员分配权限 |
| `租户已被禁用` | 租户 status != 1 | 联系平台管理员 |
| `租户已过期` | 租户 expire_time 已过 | 续费或联系平台 |
| `演示环境,禁止操作` | 演示模式下写操作被拦截 | 非演示环境操作 |
### 业务相关
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `xxx已存在` | 唯一性校验失败 | 修改重复字段值 |
| `数据不存在` | 记录已被删除或不存在 | 刷新列表 |
| `{字段中文名}已存在` | 唯一性校验失败(uniqueFields) | 修改重复字段值 |
| `数据不存在` | 记录已被删除或不存在(_detail 404) | 刷新列表 |
| `缺少id参数` | update/delete 未传 id | 检查请求参数 |
| `请选择要删除的数据` | 批量删除时 ids 为空 | 先选择数据 |
| `该部门下存在子部门,不可删除` | 删除前钩子校验 | 先删除子部门 |
| `存在子部门,不可删除` | beforeDelete 钩子校验(DeptService) | 先删除子部门 |
| `存在子菜单,不可删除` | 菜单删除保护(MenuService) | 先删除子菜单 |
| `存在子分类,不可删除` | 分类删除保护(CategoryService,仅校验子级) | 先删除子分类 |
| `该租户下存在用户,不可删除` | 租户删除保护 | 先迁移或删除租户用户 |
| `已达到最大用户数限制` | 租户用户数达到 `max_user_num` | 调大租户用户上限或清理用户 |
| `旧密码错误` | 个人中心改密 | 核对旧密码 |
### 参数与上传相关(FormRequest / UploadController / CaptchaService)
| 消息 | code | 原因 | 解决方案 |
|------|------|------|----------|
| `参数验证失败` | 1 | FormRequest rules 不通过(data 为字段明细) | 按 data 修正入参 |
| `请选择文件` | 1 | 上传未带 file | 检查表单文件字段 |
| `不支持的文件格式,允许:...` | 1 | 扩展名不在白名单 | 转换格式或调整 `FILE_EXT` |
| `文件大小不能超过 NMB` | 1 | 超过 `FILE_MAX_SIZE` | 压缩文件或调大限制 |
| `不支持的上传类型,允许:image,file` | 1 | type 参数非法 | 传 `image` 或 `file` |
| `请求过于频繁,请 N 秒后重试` | 1 | 验证码获取触发 IP 限流 | 等待窗口期 |
| `模板不存在` | 1 | 按编码查文件模板为空 | 检查 code |
### 服务器相关
| 消息 | code | 原因 | 解决方案 |
|------|------|------|----------|
| `操作失败`(生产) | 500 | 未知异常(详情在服务端日志) | 查 `storage/logs/` 日志定位 |
| 异常原文(仅 `APP_DEBUG=true`) | 500 | 调试模式返回真实异常 | **生产必须关闭 APP_DEBUG** |
## 前端错误处理
@@ -60,13 +94,13 @@ axios.interceptors.response.use(response => {
return data; // 成功
}
if (code === 401) {
if (code === 401 || response.status === 401) {
// Token 过期,跳转登录
router.push('/login');
return;
}
if (code === 403) {
if (code === 403 || response.status === 403) {
ElMessage.error(msg || '无权限');
return;
}
@@ -75,4 +109,8 @@ axios.interceptors.response.use(response => {
ElMessage.error(msg || '操作失败');
return Promise.reject(new Error(msg));
});
```
```
:::tip 排查顺序
接口异常时按序确认:`HTTP 状态`(403 直接处理)→ `code`(401 跳转登录,其余按分支)→ `msg`(展示给用户)→ `data`(验证错误明细 / 服务端日志)。
:::
@@ -1,7 +1,7 @@
# 8.7 API 接口总览
:::tip 概述
本节按模块分组列出所有 API 接口的概述,详细路由清单见[附录 E](/zh/appendix/api-cheatsheet.html)。
本节按模块分组列出所有 API 接口的概述(全部来自 `routes/api.php` 实际路由),详细路由清单见 [附录 E API 接口速查表](/zh/appendix/api-cheatsheet.html)。
:::
## API 设计规范
@@ -10,22 +10,22 @@
| 规范 | 说明 | 示例 |
|------|------|------|
| URL 风格 | RESTful,小写下划线 | `/api/user/page` |
| URL 风格 | 小驼峰/小写下划线混合,前缀 `/api`(`bootstrap/app.php` 统一添加) | `/api/user/page` |
| 模块名 | 与 Controller 对应 | `user`、`article`、`dict` |
| 操作名 | 标准 CRUD 动词 | `page`、`list`、`detail`、`add`、`update`、`delete`、`batchDelete` |
| 扩展操作 | 业务动词 | `import`、`export`、`resetPwd`、`refreshCache` |
| 扩展操作 | 业务动词 | `import`、`export`、`resetPwd`、`refreshCache`、`runOnce` |
### HTTP 方法规范
| 操作 | HTTP 方法 | URL 示例 | 说明 |
|------|----------|---------|------|
| 分页查询 | GET | `/api/user/page` | 参数在 URL query string |
| 全量列表 | GET | `/api/user/list` | 参数在 URL query string |
| 全量列表 | GET | `/api/role/list` | 参数在 URL query string(`list` 路由按模块提供,如 role/dept/menu;user 模块仅分页) |
| 详情 | GET | `/api/user/detail/{id}` | ID 在 URL 路径 |
| 新增 | POST | `/api/user/add` | 参数在 JSON 请求体 |
| 修改 | PUT | `/api/user/update` | 参数在 JSON 请求体(含 id) |
| 删除 | DELETE | `/api/user/delete/{id}` | ID 在 URL 路径 |
| 批量删除 | DELETE | `/api/user/batchDelete` | ids 在 JSON 请求体 |
| 批量删除 | **DELETE** | `/api/user/batchDelete` | ids 在 JSON 请求体(**本项目统一 DELETE + body**) |
### 统一响应格式
@@ -44,168 +44,193 @@
Authorization: Bearer <access_token>
```
公开接口放在 `routes/api.php` 中间件组**外**,认证接口统一在 `['auth.jwt', 'tenant', 'demo', 'log.operation']` 组内。
## 公开接口(无需认证)
| 模块 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 登录 | `/api/login` | POST | 用户登录,返回双令牌 |
| 登出 | `/api/logout` | GET | 用户登出(记录日志) |
| 刷新 | `/api/refresh` | POST | refresh_token 换取新令牌对 |
| 验证码 | `/api/captcha` | GET | 获取图片验证码 |
| Token | `/api/oauth2/token` | POST | 获取/刷新 Token |
| Token | `/api/oauth2/token` | POST | OAuth2 兼容获取/刷新 Token |
| Token | `/api/oauth2/check/token` | GET | 检查 Token 有效性 |
| Token | `/api/oauth2/remove/{token}` | DELETE | 吊销 Token |
## 系统管理
## 认证接口(组内,其余全部)
以下模块接口均位于认证中间件组内,需要 `Authorization` 头。
### 个人中心与公共
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 首页 | `/api/index/getUserInfo` | GET | 获取当前用户信息 |
| 首页 | `/api/index/getMenus` | GET | 获取菜单树(动态路由) |
| 首页 | `/api/index/updatePwd` | PUT | 修改密码 |
| 用户 | `/api/user/page` | GET | 用户分页列表 |
| 用户 | `/api/user/detail/{id}` | GET | 用户详情 |
| 用户 | `/api/user/add` | POST | 新增用户 |
| 用户 | `/api/user/update` | PUT | 修改用户 |
| 用户 | `/api/user/delete/{id}` | DELETE | 删除用户 |
| 用户 | `/api/user/batchDelete` | DELETE | 批量删除用户 |
| 用户 | `/api/user/resetPwd` | POST | 重置密码 |
| 用户 | `/api/user/import` | POST | Excel 导入用户 |
| 用户 | `/api/user/export` | GET | Excel 导出用户 |
| 角色 | `/api/role/page` | GET | 角色分页列表 |
| 角色 | `/api/role/add` | POST | 新增角色(含菜单权限) |
| 角色 | `/api/role/update` | PUT | 修改角色(含菜单权限) |
| 角色 | `/api/role/delete/{id}` | DELETE | 删除角色 |
| 角色 | `/api/role/menus/{id}` | GET | 获取角色的菜单 ID 列表 |
| 菜单 | `/api/menu/treeList` | GET | 菜单树形列表 |
| 菜单 | `/api/menu/add` | POST | 新增菜单 |
| 菜单 | `/api/menu/update` | PUT | 修改菜单 |
| 菜单 | `/api/menu/delete/{id}` | DELETE | 删除菜单 |
| 部门 | `/api/dept/treeList` | GET | 部门树形列表 |
| 部门 | `/api/dept/add` | POST | 新增部门 |
| 部门 | `/api/dept/update` | PUT | 修改部门 |
| 部门 | `/api/dept/delete/{id}` | DELETE | 删除部门 |
| 岗位 | `/api/position/page` | GET | 岗位分页列表 |
| 岗位 | `/api/position/add` | POST | 新增岗位 |
| 岗位 | `/api/position/update` | PUT | 修改岗位 |
| 岗位 | `/api/position/delete/{id}` | DELETE | 删除岗位 |
| 职级 | `/api/level/page` | GET | 职级分页列表 |
| 职级 | `/api/level/add` | POST | 新增职级 |
| 职级 | `/api/level/update` | PUT | 修改职级 |
| 职级 | `/api/level/delete/{id}` | DELETE | 删除职级 |
| 职级 | `/api/level/import` | POST | Excel 导入职级 |
| 职级 | `/api/level/export` | GET | Excel 导出职级 |
| 租户 | `/api/tenant/page` | GET | 租户分页列表 |
| 租户 | `/api/tenant/detail/{id}` | GET | 租户详情 |
| 租户 | `/api/tenant/add` | POST | 新增租户 |
| 租户 | `/api/tenant/update` | PUT | 修改租户 |
| 租户 | `/api/tenant/delete/{id}` | DELETE | 删除租户 |
| 租户 | `/api/tenant/account` | POST | 创建租户管理员账号 |
| 租户 | `/api/tenant/users/{tenantId}` | GET | 获取租户用户列表 |
| 登出 | `/api/logout` | POST | 登出(吊销 refresh_token + 记录日志) |
| Token | `/api/oauth2/remove/{token}` | DELETE | 吊销指定令牌 |
| 首页 | `/api/index/getUser` | GET | 获取当前用户信息(含角色与权限) |
| 首页 | `/api/index/getMenus` | GET | 获取菜单树(动态路由数据源) |
| 首页 | `/api/index/updatePassword` | PUT | 修改密码(改密后全端下线) |
| 首页 | `/api/index/updateProfile` | PUT | 更新个人信息 |
| 上传 | `/api/upload/uploadFile` | POST | 上传文件(需 `sys:upload:file` 权限) |
## 数据管理
### 系统管理
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 字典 | `/api/dict/page` | GET | 字典分页列表 |
| 字典 | `/api/dict/add` | POST | 新增字典 |
| 字典 | `/api/dict/update` | PUT | 修改字典 |
| 字典 | `/api/dict/delete/{id}` | DELETE | 删除字典 |
| 字典 | `/api/dict/refreshCache` | GET | 刷新字典缓存 |
| 字典项 | `/api/dict/item/page` | GET | 字典项分页列表 |
| 字典项 | `/api/dict/item/add` | POST | 新增字典项 |
| 字典项 | `/api/dict/item/update` | PUT | 修改字典项 |
| 字典项 | `/api/dict/item/delete/{id}` | DELETE | 删除字典项 |
| 字典项 | `/api/dict/item/getDictItemList/{code}` | GET | 按编码获取字典项列表 |
| 配置 | `/api/config/page` | GET | 配置分页列表 |
| 配置 | `/api/config/add` | POST | 新增配置组 |
| 配置 | `/api/config/update` | PUT | 修改配置组 |
| 配置 | `/api/config/delete/{id}` | DELETE | 删除配置组 |
| 配置项 | `/api/config/item/page` | GET | 配置项分页列表 |
| 配置项 | `/api/config/item/add` | POST | 新增配置项 |
| 配置项 | `/api/config/item/update` | PUT | 修改配置项 |
| 配置项 | `/api/config/item/delete/{id}` | DELETE | 删除配置项 |
| 参数 | `/api/param/page` | GET | 参数分页列表 |
| 参数 | `/api/param/add` | POST | 新增参数 |
| 参数 | `/api/param/update` | PUT | 修改参数 |
| 参数 | `/api/param/delete/{id}` | DELETE | 删除参数 |
| 公告 | `/api/notice/page` | GET | 公告分页列表 |
| 公告 | `/api/notice/add` | POST | 新增公告 |
| 公告 | `/api/notice/update` | PUT | 修改公告 |
| 公告 | `/api/notice/delete/{id}` | DELETE | 删除公告 |
| 城市 | `/api/city/list` | GET | 城市列表 |
| 城市 | `/api/city/add` | POST | 新增城市 |
| 城市 | `/api/city/update` | PUT | 修改城市 |
| 城市 | `/api/city/delete/{id}` | DELETE | 删除城市 |
| 城市 | `/api/city/getCityList/{parentCode}` | GET | 按父编码查询下级城市 |
| 城市 | `/api/city/getParent` | GET | 获取省份列表 |
| 用户 | `/api/user/page` `detail/{id}` | GET | 分页 / 详情 |
| 用户 | `/api/user/add` `import` | POST | 新增 / Excel 导入 |
| 用户 | `/api/user/batchDelete` | DELETE | 批量删除 |
| 用户 | `/api/user/update` | PUT | 修改 |
| 用户 | `/api/user/resetPwd` | PUT | 重置密码 |
| 用户 | `/api/user/delete/{id}` `export` | DELETE / GET | 删除 / Excel 导出 |
| 角色 | `/api/role/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 角色 | `/api/role/add` `menu/save` | POST | 新增 / 保存菜单权限 |
| 角色 | `/api/role/batchDelete` | DELETE | 批量删除 |
| 角色 | `/api/role/update` | PUT | 修改 |
| 角色 | `/api/role/delete/{id}` | DELETE | 删除 |
| 角色 | `/api/role/menu/list/{id}` | GET | 获取角色菜单(含选中状态) |
| 菜单 | `/api/menu/list` `detail/{id}` | GET | 树形列表 / 详情 |
| 菜单 | `/api/menu/add` | POST | 新增 |
| 菜单 | `/api/menu/update` | PUT | 修改 |
| 菜单 | `/api/menu/delete/{id}` | DELETE | 删除 |
| 部门 | `/api/dept/list` `detail/{id}` | GET | 树形列表 / 详情 |
| 部门 | `/api/dept/add` | POST | 新增 |
| 部门 | `/api/dept/update` | PUT | 修改 |
| 部门 | `/api/dept/delete/{id}` | DELETE | 删除 |
| 职级 | `/api/level/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 职级 | `/api/level/add` `import` | POST | 新增 / Excel 导入 |
| 职级 | `/api/level/batchDelete` | DELETE | 批量删除 |
| 职级 | `/api/level/update` | PUT | 修改 |
| 职级 | `/api/level/delete/{id}` `export` | DELETE / GET | 删除 / Excel 导出 |
| 岗位 | `/api/position/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 岗位 | `/api/position/add` | POST | 新增 |
| 岗位 | `/api/position/batchDelete` | DELETE | 批量删除 |
| 岗位 | `/api/position/update` | PUT | 修改 |
| 岗位 | `/api/position/delete/{id}` | DELETE | 删除 |
| 租户 | `/api/tenant/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 租户 | `/api/tenant/add` `account` | POST | 新增 / 创建租户账号 |
| 租户 | `/api/tenant/batchDelete` | DELETE | 批量删除 |
| 租户 | `/api/tenant/update` | PUT | 修改 |
| 租户 | `/api/tenant/delete/{id}` | DELETE | 删除 |
| 租户 | `/api/tenant/users/{tenantId}` | GET | 租户用户列表 |
## 内容管理
### 数据管理
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 文章 | `/api/article/page` | GET | 文章分页列表 |
| 文章 | `/api/article/detail/{id}` | GET | 文章详情 |
| 文章 | `/api/article/add` | POST | 新增文章 |
| 文章 | `/api/article/update` | PUT | 修改文章 |
| 文章 | `/api/article/delete/{id}` | DELETE | 删除文章 |
| 文章 | `/api/article/batchDelete` | DELETE | 批量删除文章 |
| 分类 | `/api/category/treeList` | GET | 分类树形列表 |
| 分类 | `/api/category/add` | POST | 新增分类 |
| 分类 | `/api/category/update` | PUT | 修改分类 |
| 分类 | `/api/category/delete/{id}` | DELETE | 删除分类 |
| 链接 | `/api/link/page` | GET | 链接分页列表 |
| 链接 | `/api/link/add` | POST | 新增链接 |
| 链接 | `/api/link/update` | PUT | 修改链接 |
| 链接 | `/api/link/delete/{id}` | DELETE | 删除链接 |
| 字典 | `/api/dict/page` `detail/{id}` | GET | 分页 / 详情 |
| 字典 | `/api/dict/add` | POST | 新增 |
| 字典 | `/api/dict/batchDelete` | DELETE | 批量删除 |
| 字典 | `/api/dict/update` | PUT | 修改 |
| 字典 | `/api/dict/delete/{id}` | DELETE | 删除 |
| 字典 | `/api/dict/refreshCache` | GET | 刷新字典缓存(`#[DemoAllow]`) |
| 字典项 | `/api/dict/item/page` `detail/{id}` `getDictItemList/{code}` | GET | 分页 / 详情 / 按编码获取 |
| 字典项 | `/api/dict/item/add` | POST | 新增 |
| 字典项 | `/api/dict/item/batchDelete` | DELETE | 批量删除 |
| 字典项 | `/api/dict/item/update` | PUT | 修改 |
| 字典项 | `/api/dict/item/delete/{id}` | DELETE | 删除 |
| 配置 | `/api/config/page` `list` `detail/{id}` `web/index` | GET | 分页 / 全量 / 详情 / 网站配置 |
| 配置 | `/api/config/add` `web/save` | POST | 新增 / 保存网站配置 |
| 配置 | `/api/config/batchDelete` | DELETE | 批量删除 |
| 配置 | `/api/config/update` | PUT | 修改 |
| 配置 | `/api/config/delete/{id}` | DELETE | 删除 |
| 配置项 | `/api/config/item/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 配置项 | `/api/config/item/add` | POST | 新增 |
| 配置项 | `/api/config/item/batchDelete` | DELETE | 批量删除 |
| 配置项 | `/api/config/item/update` | PUT | 修改 |
| 配置项 | `/api/config/item/delete/{id}` | DELETE | 删除 |
| 参数 | `/api/param/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 参数 | `/api/param/add` | POST | 新增 |
| 参数 | `/api/param/batchDelete` | DELETE | 批量删除 |
| 参数 | `/api/param/update` | PUT | 修改 |
| 参数 | `/api/param/delete/{id}` | DELETE | 删除 |
| 城市 | `/api/city/list` `detail/{id}` `getCityList/{code}` `getParent/{id}` `findByParentCode` | GET | 列表 / 详情 / 级联查询 |
| 城市 | `/api/city/add` | POST | 新增 |
| 城市 | `/api/city/batchDelete` | DELETE | 批量删除 |
| 城市 | `/api/city/update` | PUT | 修改 |
| 城市 | `/api/city/delete/{id}` | DELETE | 删除 |
## 监控管理
### 内容管理
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 定时任务 | `/api/job/page` | GET | 任务分页列表 |
| 定时任务 | `/api/job/add` | POST | 新增任务 |
| 定时任务 | `/api/job/update` | PUT | 修改任务 |
| 定时任务 | `/api/job/delete/{id}` | DELETE | 删除任务 |
| 定时任务 | `/api/job/run/{id}` | POST | 手动执行任务 |
| 定时任务 | `/api/job/pause/{id}` | POST | 暂停任务 |
| 定时任务 | `/api/job/resume/{id}` | POST | 恢复任务 |
| 任务日志 | `/api/job/log/page` | GET | 任务日志分页列表 |
| 任务日志 | `/api/job/log/delete/{id}` | DELETE | 删除日志 |
| 文章 | `/api/article/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 文章 | `/api/article/add` | POST | 新增 |
| 文章 | `/api/article/batchDelete` | DELETE | 批量删除 |
| 文章 | `/api/article/update` | PUT | 修改 |
| 文章 | `/api/article/delete/{id}` | DELETE | 删除 |
| 分类 | `/api/category/list` `page` `detail/{id}` | GET | 树形 / 分页 / 详情 |
| 分类 | `/api/category/add` | POST | 新增 |
| 分类 | `/api/category/batchDelete` | DELETE | 批量删除 |
| 分类 | `/api/category/update` | PUT | 修改 |
| 分类 | `/api/category/delete/{id}` | DELETE | 删除 |
| 公告 | `/api/notice/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 公告 | `/api/notice/add` | POST | 新增 |
| 公告 | `/api/notice/batchDelete` | DELETE | 批量删除 |
| 公告 | `/api/notice/update` | PUT | 修改 |
| 公告 | `/api/notice/delete/{id}` | DELETE | 删除 |
| 友链 | `/api/link/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 友链 | `/api/link/add` | POST | 新增 |
| 友链 | `/api/link/batchDelete` | DELETE | 批量删除 |
| 友链 | `/api/link/update` | PUT | 修改 |
| 友链 | `/api/link/delete/{id}` | DELETE | 删除 |
### 监控管理
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 定时任务 | `/api/job/page` `list` `detail/{id}` | GET | 分页 / 全量 / 详情 |
| 定时任务 | `/api/job/runOnce/{id}` | GET | 手动执行一次 |
| 定时任务 | `/api/job/pause/{id}` `resume/{id}` `daemonStatus` | GET | 暂停 / 恢复 / 守护进程状态 |
| 定时任务 | `/api/job/add` `status` | POST | 新增 / 设置状态 |
| 定时任务 | `/api/job/batchDelete` | DELETE | 批量删除 |
| 定时任务 | `/api/job/update` | PUT | 修改 |
| 定时任务 | `/api/job/delete/{id}` | DELETE | 删除 |
| 任务日志 | `/api/job/log/page` `detail/{id}` | GET | 日志分页 / 详情 |
| 任务日志 | `/api/job/log/batchDelete` | DELETE | 批量删除日志 |
| 任务日志 | `/api/job/log/delete/{id}` | DELETE | 删除日志 |
## 日志审计
### 日志审计
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 操作日志 | `/api/oper/log/page` | GET | 操作日志分页列表 |
| 操作日志 | `/api/oper/log/detail/{id}` | GET | 日志详情 |
| 操作日志 | `/api/oper/log/delete/{id}` | DELETE | 删除日志 |
| 操作日志 | `/api/oper/log/batchDelete` | DELETE | 批量删除日志 |
| 登录日志 | `/api/login/log/page` | GET | 登录日志分页列表 |
| 登录日志 | `/api/login/log/detail/{id}` | GET | 日志详情 |
| 登录日志 | `/api/login/log/delete/{id}` | DELETE | 删除日志 |
| 登录日志 | `/api/login/log/batchDelete` | DELETE | 批量删除日志 |
| 操作日志 | `/api/oper/log/page` `detail/{id}` | GET | 分页 / 详情 |
| 操作日志 | `/api/oper/log/batchDelete` | DELETE | 批量删除 |
| 操作日志 | `/api/oper/log/delete/{id}` | DELETE | 删除 |
| 登录日志 | `/api/login/log/page` `detail/{id}` | GET | 分页 / 详情 |
| 登录日志 | `/api/login/log/batchDelete` | DELETE | 批量删除 |
| 登录日志 | `/api/login/log/delete/{id}` | DELETE | 删除 |
## 公共接口
### 示例与开发工具
| 模块 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 文件上传 | `/api/upload/uploadFile` | POST | 上传文件(type=file/image) |
| 代码生成 | `/api/generator/page` | GET | 可生成的表列表 |
| 代码生成 | `/api/generator/preview` | POST | 预览生成代码 |
| 代码生成 | `/api/generator/generate` | POST | 执行代码生成 |
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 示例 | `/api/example/page` `list` `detail/{id}` `export` | GET | 分页 / 全量 / 详情 / 导出 |
| 示例 | `/api/example/add` `import` | POST | 新增 / 导入 |
| 示例 | `/api/example/batchDelete` | DELETE | 批量删除 |
| 示例 | `/api/example/update` | PUT | 修改 |
| 示例 | `/api/example/delete/{id}` | DELETE | 删除 |
| 文件模板 | `/api/file/template/page` `detail/{id}` `getTemplateByCode/{code}` | GET | 分页 / 详情 / 按编码获取 |
| 文件模板 | `/api/file/template/add` | POST | 新增 |
| 文件模板 | `/api/file/template/batchDelete` | DELETE | 批量删除 |
| 文件模板 | `/api/file/template/update` | PUT | 修改 |
| 文件模板 | `/api/file/template/delete/{id}` | DELETE | 删除 |
| 代码生成 | `/api/generator/page` `preview` | GET | 可生成表列表 / 预览代码 |
| 代码生成 | `/api/generator/generate` `batchGenerate` | POST | 生成 / 批量生成 |
## 接口统计
| 模块 | 接口数量 | 说明 |
| 分组 | 接口数量 | 说明 |
|------|---------|------|
| 公开接口 | 6 | 登录/登出/验证码/Token |
| 系统管理 | ~35 | 用户/角色/菜单/部门/岗位/职级/租户 |
| 数据管理 | ~25 | 字典/字典项/配置/配置项/参数/公告/城市 |
| 内容管理 | ~12 | 文章/分类/链接 |
| 监控管理 | ~10 | 定时任务/任务日志 |
| 日志审计 | ~8 | 操作日志/登录日志 |
| 公共接口 | ~4 | 文件上传/代码生成 |
| **合计** | **~100** | |
| 公开接口 | 5 | 登录 / 刷新 / 验证码 / OAuth2 |
| 个人中心与公共 | 7 | 登出 / 用户信息 / 菜单 / 改密 / 改资料 / 上传 / 吊销 |
| 系统管理 | ~52 | 用户 / 角色 / 菜单 / 部门 / 岗位 / 职级 / 租户 |
| 数据管理 | ~50 | 字典 / 字典项 / 配置 / 配置项 / 参数 / 城市 |
| 内容管理 | ~28 | 文章 / 分类 / 公告 / 友链 |
| 监控管理 | 16 | 定时任务 / 任务日志 |
| 日志审计 | 8 | 操作日志 / 登录日志 |
| 示例与开发工具 | ~20 | 示例 / 文件模板 / 代码生成 |
| **合计** | **≈180** | 以 `php artisan route:list` 为准 |
详细路由清单(HTTP 方法 + 路径 + 权限码)见[附录 E](/zh/appendix/api-cheatsheet.html)。
:::tip 动态获取
执行 `php artisan route:list` 可输出全部路由(方法、URI、中间件、控制器动作),是核对接口清单的最权威来源。详细清单见 [附录 E](/zh/appendix/api-cheatsheet.html)。
:::
@@ -1,7 +1,7 @@
# 8.3 分页查询规范
:::tip 概述
所有分页接口遵循统一的参数命名和返回结构。分页由 `BaseController::_index()` → `BaseLogic::pageList()` 实现,前端只需传标准参数即可获得分页数据。
所有分页接口遵循统一的参数命名和返回结构。分页由 `Controller::_page()` → `Service::pageList()` 实现,前端只需传标准参数即可获得分页数据。
:::
## 请求参数
@@ -13,14 +13,16 @@
| `orderField` | string | - | 排序字段(camelCase,如 `createTime`) |
| `orderType` | string | `desc` | 排序方向(`asc` / `desc`) |
| `fields` | string | - | 返回字段(逗号分隔,如 `id,username,realname`) |
| 其他参数 | - | - | 按 Logic 中配置的 `pageLikeFields` / `pageEqFields` 自动匹配 |
| 其他参数 | - | - | 按 Service 中配置的 `pageLikeFields` / `pageEqFields` 自动匹配 |
### 分页参数边界限制
| 参数 | 最小值 | 最大值 | 默认值 | 超出处理 |
|------|--------|--------|--------|---------|
| `pageNo` | 1 | - | 1 | 小于 1 时强制为 1 |
| `pageSize` | 1 | 100 | 20 | 超出范围时限制在 1~100 |
| `pageSize` | 1 | 100 | 20 | 超出范围时限制在 1~100(防一次拉取过多) |
(边界由 `Controller::getPageParams()` 统一处理。)
## 请求示例
@@ -32,13 +34,13 @@ GET /api/example/page?pageNo=1&pageSize=10
GET /api/example/page?pageNo=1&pageSize=10&name=测试
# 带精确过滤(=)
GET /api/example/page?pageNo=1&pageSize=10&status=1&type=2
GET /api/example/page?pageNo=1&pageSize=10&status=1
# 模糊 + 精确组合
GET /api/example/page?pageNo=1&pageSize=10&name=测试&status=1
# 自定义排序
GET /api/example/page?pageNo=1&pageSize=10&orderField=sort&orderType=asc
GET /api/example/page?pageNo=1&pageSize=10&orderField=createTime&orderType=desc
# 指定返回字段
GET /api/example/page?pageNo=1&pageSize=10&fields=id,name,status
@@ -56,12 +58,12 @@ GET /api/user/page?pageNo=1&pageSize=20&username=admin&status=1&deptId=3&orderFi
"msg": "操作成功",
"data": {
"records": [
{ "id": 1, "name": "测试", "status": 1, "statusText": "启用", "createTime": "2026-01-01 00:00:00" },
{ "id": 2, "name": "示例", "status": 0, "statusText": "禁用", "createTime": "2026-01-02 00:00:00" }
{ "id": 1, "name": "测试", "status": 1, "statusText": "在用", "createTime": "2026-01-01 00:00:00" },
{ "id": 2, "name": "示例", "status": 2, "statusText": "停用", "createTime": "2026-01-02 00:00:00" }
],
"total": 100,
"size": 10,
"current": 1,
"size": 10,
"pages": 10
}
}
@@ -73,18 +75,18 @@ GET /api/user/page?pageNo=1&pageSize=20&username=admin&status=1&deptId=3&orderFi
|------|------|------|
| `records` | array | 当前页数据列表 |
| `total` | number | 总记录数 |
| `size` | number | 每页条数 |
| `current` | number | 当前页码 |
| `pages` | number | 总页数(`ceil(total / size)`) |
| `size` | number | 每页条数 |
| `pages` | number | 总页数 |
## 查询条件匹配
### pageLikeFields — 模糊匹配(LIKE)
在 Logic 中配置的字段,前端传参时自动使用 `LIKE '%值%'` 查询:
在 Service 中配置的字段,前端传参时自动使用 `LIKE '%值%'` 查询:
```php
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
protected array $pageLikeFields = ['name', 'title'];
}
@@ -98,10 +100,10 @@ GET /api/example/page?name=测试
### pageEqFields — 精确匹配(=)
在 Logic 中配置的字段,前端传参时自动使用 `= 值` 查询:
在 Service 中配置的字段,前端传参时自动使用 `= 值` 查询:
```php
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
protected array $pageEqFields = ['status', 'type', 'category_id'];
}
@@ -119,15 +121,18 @@ GET /api/example/page?status=1&type=2
# 模糊 + 精确 + 排序 + 分页
GET /api/example/page?pageNo=1&pageSize=20&name=测试&status=1&orderField=sort&orderType=asc
# 生成 SQL:
# SELECT * FROM think_example
# 生成 SQL(buildQuery 组装):
# SELECT * FROM laravel_example
# WHERE is_delete = 0
# AND name LIKE '%测试%'
# AND status = 1
# AND tenant_id = 1 ← 租户隔离(启用时)
# ORDER BY sort ASC
# LIMIT 20 OFFSET 0
```
`buildQuery` 叠加顺序(见 [4.7 横切机制](/zh/architecture/cross-cutting.html)):额外固定条件 → LIKE → 精确匹配 → 数据权限 → 租户隔离。
## 排序
### 前端传参排序
@@ -140,31 +145,24 @@ GET /api/example/page?orderField=createTime&orderType=desc
GET /api/example/page?orderField=sort&orderType=asc
```
### Logic 默认排序
### Service 默认排序
```php
class ExampleLogic extends BaseLogic
{
/**
* 单字段排序
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
// 单字段排序
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 多字段排序
*
* @var array
*/
protected array $pageOrderBy = [
['field' => 'sort', 'type' => 'asc'],
['field' => 'id', 'type' => 'desc'],
];
}
// 多字段排序
protected array $pageOrderBy = [
['field' => 'sort', 'type' => 'asc'],
['field' => 'id', 'type' => 'desc'],
];
```
**优先级:** 前端传参 > Logic 默认配置
**优先级:** 前端传参 > Service 默认配置。
:::warning 排序白名单
前端 `orderField` 会先做 camel→snake 转换,再与 **`pageOrderBy` 默认字段 ∪ `orderFieldAllow`** 的并集比对——不在白名单内的字段**直接忽略**(防 SQL 注入)。需要开放更多排序字段请配置 `orderFieldAllow`。见 [3.5 Service 层开发](/zh/guide/logic.html)。
:::
## 字段过滤
@@ -183,16 +181,16 @@ GET /api/example/page?fields=id,name,status
{
"records": [
{ "id": 1, "name": "测试", "status": 1 },
{ "id": 2, "name": "示例", "status": 0 }
{ "id": 2, "name": "示例", "status": 2 }
]
}
```
**注意:** `id` 字段始终保留,即使未在 `fields` 中指定。
(由 `BaseService::filterFields()` 处理,`id` 字段始终保留。)
## 参数转换
前端使用 camelCase,后端自动转换为 snake_case:
前端使用 camelCase,后端自动转换为 snake_case(`convertQueryParams`):
| 前端参数 | 转换后 | 说明 |
|---------|--------|------|
@@ -200,11 +198,12 @@ GET /api/example/page?fields=id,name,status
| `pageSize` | `pageSize` | 不转换(排除项) |
| `orderField` | `orderField` | 不转换(排除项) |
| `orderType` | `orderType` | 不转换(排除项) |
| `fields` | `fields` | 不转换(排除项) |
| `categoryId` | `category_id` | 自动转换 |
| `createTime` | `create_time` | 自动转换 |
| `userName` | `user_name` | 自动转换 |
(排除项由 `config/api.php` 的 `convert_except_params` 配置,默认四项。)
### 排序字段值也自动转换
```bash
@@ -217,23 +216,14 @@ ORDER BY create_time DESC
## Controller 层实现
### 使用 _index 快捷方法(推荐)
### 使用 _page 快捷方法(推荐)
```php
class ExampleController extends BaseController
#[OperationLog('示例管理-查询分页', OperationLog::TYPE_QUERY)]
#[Permission('sys:example:page', '示例分页')]
public function page(): JsonResponse
{
protected ExampleLogic $logic;
protected function initialize(): void
{
$this->logic = new ExampleLogic();
}
#[Permission('sys:example:page', '案例分页')]
public function page(): Json
{
return parent::_index($this->logic);
}
return $this->_page($this->exampleService);
}
```
@@ -241,10 +231,10 @@ class ExampleController extends BaseController
```php
#[Permission('sys:user:page', '用户分页')]
public function page(): Json
public function page(): JsonResponse
{
// 额外条件:只查启用状态的用户
return parent::_index($this->logic, ['status' => 1]);
// 额外固定条件:只查启用状态的用户
return $this->_page($this->userService, ['status' => 1]);
}
```
@@ -252,51 +242,25 @@ public function page(): Json
```php
#[Permission('sys:article:page', '文章分页')]
public function page(): Json
public function page(): JsonResponse
{
// 预加载分类关联
return parent::_index($this->logic, [], ['category']);
// 预加载分类关联(defaultWith 属性也可全局配置)
return $this->_page($this->articleService, [], ['category']);
}
```
## Logic 层实现
## Service 层配置
```php
class ExampleLogic extends BaseLogic
class ExampleService extends BaseService
{
/**
* 关联的模型类
*
* @var string
*/
use HasCrudOperations, HasQueryScopes, HasSerializeMaps, HasUniqueCheck;
protected string $modelClass = Example::class;
/**
* LIKE 模糊匹配字段
*
* @var array
*/
protected array $pageLikeFields = ['name', 'title'];
/**
* 精确匹配字段
*
* @var array
*/
protected array $pageEqFields = ['status', 'type', 'category_id'];
/**
* 默认排序规则
*
* @var array
*/
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
/**
* 枚举显示名映射
*
* @var array
*/
protected array $serializeMaps = ['status' => 'example_status'];
}
```
@@ -306,7 +270,7 @@ class ExampleLogic extends BaseLogic
### API 函数
```typescript
// src/api/tool/example.ts
// ui/src/api/tool/example.ts
export function getExamplePage(params?) {
return http.request({ url: '/example/page', method: 'GET', params });
}
@@ -356,16 +320,22 @@ onMounted(() => fetchData());
**原因:** 字段名未在 `pageLikeFields` 或 `pageEqFields` 中配置。
**解决:** 在 Logic 中添加对应字段配置。
**解决:** 在 Service 中添加对应字段配置。
### 问题 2:排序不生效
**原因:** `orderField` 传了数据库不存在的字段名。
**原因:** `orderField` 不在排序白名单(`pageOrderBy` ∪ `orderFieldAllow`)内,或字段名转换后与数据库列不匹配。
**解决:** 确保 `orderField` 传的是数据库字段名的 camelCase 形式。
**解决:** 确认传 camelCase 形式且字段在白名单内;开放新字段配置 `orderFieldAllow`。
### 问题 3:分页数据重复
**原因:** 排序字段值相同,导致数据库返回顺序不确定。
**解决:** 排序字段加上唯一字段(如 id)作为第二排序条件。
**解决:** 排序字段加上唯一字段(如 id)作为第二排序条件(多字段 `pageOrderBy`)。
### 问题 4:pageSize 传 1000 无效
**原因:** `getPageParams()` 将 pageSize 限制在 1~100(防一次拉取过多数据)。
**解决:** 如需导出大量数据,使用导出接口(`chunkExport` 分批)而非拉大分页。
@@ -1,7 +1,7 @@
# 8.1 统一响应格式
:::tip 概述
所有接口返回统一的 JSON 结构,前端可据此判断请求结果。响应由 `response\Result` 类构造,保证全系统格式一致。
所有接口返回统一的 JSON 结构,前端可据此判断请求结果。响应由 `App\Support\Result` 类构造,保证全系统格式一致;HTTP 状态码默认 200(仅 403 例外),结果一律以 body 的 `code` 判断。
:::
## 响应结构
@@ -17,14 +17,18 @@ interface ApiResponse {
## 状态码定义
| code | 含义 | ok | 说明 |
|------|------|-----|------|
| `0` | 成功 | `true` | 业务成功 |
| `1` | 业务失败 | `false` | 通用业务错误(参数错误、业务校验失败等) |
| `401` | 未授权 | `false` | Token 缺失/无效/过期 |
| `403` | 禁止访问 | `false` | 权限不足 |
| `404` | 数据不存在 | `false` | 查询的记录不存在 |
| `422` | 参数验证失败 | `false` | Validate 校验不通过 |
| code | 含义 | ok | HTTP 状态 | 说明 |
|------|------|-----|-----|------|
| `0` | 成功 | `true` | 200 | 业务成功 |
| `1` | 业务失败 | `false` | 200 | 通用业务错误(业务校验失败、FormRequest 格式错误等) |
| `401` | 未授权 | `false` | 200 | Token 缺失/无效/过期(前端据 body code=401 跳转登录) |
| `403` | 禁止访问 | `false` | **403** | 权限不足 / 租户异常 / 演示环境拦截 |
| `404` | 数据不存在 | `false` | 200 | 查询的记录不存在 / 路由不存在 |
| `422` | 参数验证失败 | `false` | 200 | 代码内手动 `Validator::validate()` 校验不通过 |
:::tip HTTP 状态码约定
仅 `forbidden()` 同时设置 HTTP 403(便于网关按 HTTP 状态处理),其余响应 HTTP 恒为 200——前端以 `code` 为准,`code=401` 时跳转登录。
:::
## 成功响应
@@ -93,93 +97,110 @@ interface ApiResponse {
{ "id": 2, "username": "user1", "createTime": "2026-01-02 00:00:00" }
],
"total": 100,
"size": 20,
"current": 1,
"size": 20,
"pages": 5
}
}
```
### 分页参数
### 分页字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `records` | array | 当前页数据列表 |
| `total` | number | 总记录数 |
| `size` | number | 每页条数 |
| `current` | number | 当前页码 |
| `pages` | number | 总页数(`ceil(total / size)`) |
| `size` | number | 每页条数 |
| `pages` | number | 总页数 |
## 失败响应
### 通用业务失败
### 通用业务失败(BizException)
```json
{
"code": 1,
"ok": false,
"msg": "用户名已存在",
"msg": "名称已存在",
"data": null
}
```
### 参数验证失败
### FormRequest 参数验证失败
```json
{
"code": 1,
"ok": false,
"msg": "参数验证失败",
"data": "用户名不能为空"
"data": {
"username": ["用户名不能为空"],
"password": ["密码不能为空"]
}
}
```
### 批量验证失败
:::info data 为字段明细对象
FormRequest 失败时 `data` 为 `{字段名: [错误消息数组]}`(`BaseRequest::failedValidation()`),前端可按字段回显。
:::
### 代码内手动校验失败(ValidationException)
```json
{
"code": 1,
"code": 422,
"ok": false,
"msg": "参数验证失败",
"data": [
"用户名不能为空",
"姓名不能为空"
]
"data": { "name": ["案例名称不能为空"] }
}
```
### 数据不存在
### 未授权(Token 问题,code 401)
```json
{
"code": 1,
"code": 401,
"ok": false,
"msg": "未提供认证令牌",
"data": null
}
```
### 权限不足(HTTP 403)
```json
{
"code": 403,
"ok": false,
"msg": "无访问权限:sys:user:add",
"data": null
}
```
### 数据不存在(404)
```json
{
"code": 404,
"ok": false,
"msg": "数据不存在",
"data": null
}
```
### 未授权(Token 问题)
### 未知异常(500)
```json
{
"code": 401,
"code": 500,
"ok": false,
"msg": "token已过期",
"msg": "操作失败",
"data": null
}
```
### 权限不足
```json
{
"code": 403,
"ok": false,
"msg": "无访问权限",
"data": null
}
```
(调试模式 `APP_DEBUG=true` 时 `msg` 为异常原文,生产环境固定"操作失败"。)
## 字段说明
@@ -190,53 +211,46 @@ interface ApiResponse {
| `msg` | string | 提示信息 |
| `data` | any | 响应数据,失败时通常为 null 或错误详情 |
### 分页字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.records` | array | 分页数据列表 |
| `data.total` | number | 总记录数 |
| `data.size` | number | 每页条数 |
| `data.current` | number | 当前页码 |
| `data.pages` | number | 总页数 |
## 后端 Result 类
**文件:** `extend/response/Result.php`
**文件:** `app/Support/Result.php`
### 方法清单
| 方法 | 说明 | code | ok |
|------|------|------|-----|
| `success($data, $msg)` | 成功响应 | 0 | true |
| `page($records, $total, $current, $size)` | 分页响应 | 0 | true |
| `fail($msg, $code, $data)` | 失败响应 | 1 | false |
| `unauthorized($msg)` | 未授权 | 401 | false |
| `forbidden($msg)` | 禁止访问 | 403 | false |
| `notFound($msg)` | 数据不存在 | 404 | false |
| `validateError($errors)` | 验证失败 | 422 | false |
| 方法 | 说明 | code | HTTP |
|------|------|------|------|
| `success($data, $msg, $raw)` | 成功响应(`$raw=true` 跳过驼峰转换) | 0 | 200 |
| `page($records, $total, $current, $size, $pages = null)` | 分页响应 | 0 | 200 |
| `fail($msg, $code, $data)` | 失败响应(code 默认 1) | 自定义 | 200 |
| `unauthorized($msg)` | 未授权 | 401 | 200 |
| `forbidden($msg)` | 禁止访问 | 403 | **403** |
| `notFound($msg)` | 数据不存在 | 404 | 200 |
| `validateError($errors)` | 验证失败 | 422 | 200 |
### 使用示例
```php
// Controller 中通过 BaseController 调用
// Controller 中通过基类调用(推荐)
$this->success($data, '操作成功');
$this->success(['id' => $id], '添加成功');
$this->fail('用户名已存在');
$this->paginate($pageListResult); // 分页(records/total/current/size/pages)
$this->fail('名称已存在');
$this->fail('缺少id参数', 1);
// 直接调用 Result 类
\response\Result::success($data);
\response\Result::fail('错误信息');
\response\Result::unauthorized('未授权');
\response\Result::forbidden('无权限');
\response\Result::notFound('数据不存在');
\response\Result::validateError('用户名不能为空');
Result::success($data);
Result::fail('错误信息');
Result::unauthorized('未授权');
Result::forbidden('无权限');
Result::notFound('数据不存在');
Result::validateError(['name' => ['名称不能为空']]);
```
响应构造与异常渲染的完整机制见 [5.16 统一响应与异常抛出](/zh/core/exception.html)。
## 驼峰自动转换
响应数据自动从 snake_case 转为 camelCase:
响应数据自动从 snake_case 转为 camelCase(`API_CAMEL_SNAKE_CONVERT=true` 时):
| 数据库字段 | 响应字段 |
|-----------|---------|
@@ -245,14 +259,14 @@ $this->fail('缺少id参数', 1);
| `is_delete` | `isDelete` |
| `dept_id` | `deptId` |
可通过 `config/api.php` 的 `camel_snake_convert` 配置关闭。
请求方向同时自动转换(排除 `pageNo/pageSize/orderField/orderType`),可通过 `config/api.php` 的 `camel_snake_convert` 关闭。需要原样返回时用 `Result::success($data, $msg, true)`。
## 前端响应处理
### Axios 响应拦截器
```typescript
// src/utils/http/axios/index.ts
// ui/src/utils/http/axios/index.ts
axios.interceptors.response.use(
response => {
@@ -263,15 +277,15 @@ axios.interceptors.response.use(
return data;
}
// Token 过期
if (code === 401) {
// Token 过期(code 401)
if (code === 401 || response.status === 401) {
localStorage.clear();
router.push('/login');
return Promise.reject(new Error(msg));
}
// 权限不足
if (code === 403) {
if (code === 403 || response.status === 403) {
ElMessage.error(msg || '无访问权限');
return Promise.reject(new Error(msg));
}
@@ -295,6 +309,7 @@ const fetchData = async () => {
// res 已经是 data 部分(拦截器已解包)
tableData.value = res.records;
total.value = res.total;
// 总页数:res.pages
};
```
@@ -303,7 +318,7 @@ const fetchData = async () => {
```typescript
const handleImport = async (file) => {
const res = await importUsers(file);
// res = { count: 8, errors: [{ row: 3, reason: "..." }] }
// res = { count: 8, errors: [{ row: 3, name: "...", reason: "..." }] }
if (res.errors.length === 0) {
ElMessage.success(`成功导入 ${res.count} 条`);
} else {
@@ -317,12 +332,14 @@ const handleImport = async (file) => {
| 场景 | code | msg | data |
|------|------|-----|------|
| 查询成功 | 0 | 操作成功 | 数据对象/数组 |
| 分页成功 | 0 | 操作成功 | {records, total, size, current, pages} |
| 分页成功 | 0 | 操作成功 | {records, total, current, size, pages} |
| 新增成功 | 0 | 添加成功 | {id: 42} |
| 修改成功 | 0 | 修改成功 | null |
| 删除成功 | 0 | 删除成功 | null |
| 业务失败 | 1 | 具体错误信息 | null |
| 参数验证失败 | 1 | 参数验证失败 | 错误字符串或数组 |
| Token 过期 | 401 | token已过期 | null |
| 无权限 | 403 | 无访问权限 | null |
| FormRequest 验证失败 | 1 | 参数验证失败 | {字段: [错误数组]} |
| 手动校验失败 | 422 | 参数验证失败 | {字段: [错误数组]} |
| Token 过期 | 401 | 未提供认证令牌 / Expired token | null |
| 无权限 | 403(HTTP 403) | 无访问权限:xxx | null |
| 数据不存在 | 404 | 数据不存在 | null |
| 未知异常 | 500 | 操作失败(生产) | null |
+160 -199
View File
@@ -1,7 +1,7 @@
# 8.6 Token 使用说明
:::tip 概述
系统采用 JWT 双令牌认证,access_token 用于接口认证(2小时),refresh_token 用于刷新令牌(7天)。所有需认证的接口必须在请求头中携带 access_token。
系统采用 JWT 双令牌认证,access_token 用于接口认证(2 小时),refresh_token 用于刷新令牌(7 天)。refresh_token 存储于 Redis(jti 有状态),支持**一次性刷新、登出吊销、踢下线**。所有需认证的接口必须在请求头中携带 access_token。
:::
## Token 完整生命周期
@@ -19,33 +19,35 @@
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端:LoginController → LoginLogic → JwtService │
│ 后端:AuthController → JwtService │
│ │
│ 1. CaptchaService::check() 验证码校验 │
│ 2. User::where('username') 查询用户 │
│ 3. PasswordService::verify() 密码校验 │
│ 4. Jwt::createAccessToken() 签发 access_token(2小时) │
│ Jwt::createRefreshToken() 签发 refresh_token(7天) │
│ 1. LoginRequest 参数格式校验 │
│ 2. CaptchaService::verify() 验证码校验(key/code) │
│ 3. User::where('username') 查询用户 │
│ 4. PasswordService::verify() 密码校验(双重加盐) │
│ 5. createTokenPair() 签发双令牌 + refresh 存 Redis │
│ (同一 jti,Redis 键 refresh:{uid}:{jti},TTL 7 天) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 响应: │
│ 响应(登录/刷新接口 raw 返回,保持 snake_case): │
│ { │
│ "accessToken": "eyJ...", ← 用于接口认证 │
│ "refreshToken": "eyJ...", ← 用于刷新令牌 │
│ "tokenType": "Bearer", │
│ "expiresIn": 7200, ← 2小时 = 7200秒 │
│ "user": { "id":1, "username":"admin" } │
│ "access_token": "eyJ...", ← 用于接口认证 │
│ "refresh_token": "eyJ...", ← 用于刷新令牌 │
│ "token_type": "Bearer", │
│ "expires_in": 7200, ← 2小时 = 7200秒 │
│ "user": { "id":1, "username":"admin", ... } │
│ } │
│ (AuthController 调用 success(..., true) 跳过驼峰转换, │
│ 与前端 store 的 data.access_token 读取方式保持一致) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:存储 Token │
│ 前端:存储 Token(仅存 access_token) │
│ │
│ localStorage.setItem('access_token', accessToken) │
│ localStorage.setItem('refresh_token', refreshToken) │
│ storage.set('ACCESS-TOKEN', data.access_token) │
└─────────────────────────────────────────────────────────────┘
```
@@ -57,116 +59,75 @@
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:请求拦截器 │
│ │
│ const token = localStorage.getItem('access_token') │
│ const token = storage.get('ACCESS-TOKEN') │
│ config.headers['Authorization'] = `Bearer ${token}` │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ GET /api/user/page?pageNo=1&pageSize=10 │
│ Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端:AuthMiddleware │
│ 后端:Authenticate 中间件(auth.jwt) │
│ │
│ 1. Jwt::getTokenFromHeader() 从请求头提取 Token │
│ │ 空 → 返回 401 "未提供认证令牌" │
│ ▼ │
│ 2. Jwt::decode($token) 解码并校验签名+有效期 │
│ │ 过期 → 返回 401 "token已过期" │
│ │ 签名无效 → 返回 401 "token签名无效" │
│ ▼ │
│ 3. $decoded->type === 'access' 校验 Token 类型 │
│ │ 非 access → 返回 401 "无效的认证令牌" │
│ ▼ │
│ 4. $request->userInfo = $decoded 注入用户信息 │
│ ▼ │
│ 5. checkPermission() 权限校验 │
│ │ 无权限 → 返回 403 "无访问权限" │
│ ▼ │
│ 6. 放行 → Controller → Logic → Model → 响应 │
│ 1. 提取 Authorization 头(带 Bearer 前缀则剥离,裸 token 亦可)│
│ │ 头缺失 → HTTP 200 "未提供认证令牌" │
│ 2. JwtService::parseToken()(校验签名+有效期+type=access) │
│ │ 过期/签名错/类型错 → HTTP 200 │
│ 3. 注入 attributes['userInfo'] │
│ 4. 反射 #[Permission] → checkPermission() │
│ │ 无权限 → HTTP 403 "无访问权限:{权限码}" │
│ 5. 放行 → Controller → Service → Model → 响应 │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:响应拦截器 │
│ │
│ code === 0 → 返回 data(成功) │
│ code === 401 → Token 过期,尝试刷新 │
│ code === 403 → 提示"无访问权限" │
│ code/status 401 → 弹窗"登录身份已失效" → 清存储 → 跳登录页 │
│ code/status 403 → 提示"无访问权限" │
│ 其他 → 提示 msg │
└─────────────────────────────────────────────────────────────┘
```
### Token 过期自动刷新
### Token 刷新(后端能力)
access_token 过期后,客户端可用 refresh_token 换取新令牌对:
```text
前端请求 API → 后端返回 code=401 "token已过期"
POST /api/refresh
{ "refresh_token": "eyJ..." } ← 主入口(公开接口)
(或 POST /api/oauth2/token,grant_type=refresh_token)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:响应拦截器检测到 401 │
│ │
│ if (code === 401 && !config._retry) { │
│ // 尝试用 refresh_token 刷新 │
│ } │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:POST /api/oauth2/token │
│ { grant_type: "refresh_token", refresh_token: "eyJ..." } │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端:JwtService::refreshToken() │
│ │
│ 1. Jwt::decode(refreshToken) 解码 refresh_token │
│ │ 无效/过期 → 返回错误 │
│ ▼ │
│ 2. $decoded->type === 'refresh' 校验必须为 refresh 类型 │
│ │ 非 refresh → 返回错误 │
│ ▼ │
│ 3. User::find($decoded->uid) 校验用户仍存在且状态正常 │
│ │ 不存在/禁用 → 返回错误 │
│ ▼ │
│ 4. 签发新 access_token + 新 refresh_token │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:更新存储 + 重试原请求 │
│ │
│ localStorage.setItem('access_token', newAccessToken) │
│ localStorage.setItem('refresh_token', newRefreshToken) │
│ │
│ // 用新 Token 重试原失败请求 │
│ config.headers['Authorization'] = `Bearer ${newAccessToken}`│
│ return axios(config) │
└─────────────────────────────────────────────────────────────┘
JwtService::refreshToken()
│ 1. 解析 refresh_token → 失败 → "无效的refresh_token"
│ 2. type 必须为 refresh
│ 3. Lua 原子校验并删除 Redis refresh:{uid}:{jti}(一次性)
│ 已用过/已吊销 → "refresh_token已失效"
│ 4. 用户状态校验 → "用户不存在或已被禁用"
│ 5. createTokenPair() → 新 jti 签发新令牌对
▼
返回新令牌对(access_token / refresh_token 均为新值)
```
### Token 刷新失败(重新登录)
:::warning 一次性刷新(防重放)
旧 refresh_token 刷新后立即从 Redis 删除——**必须保存返回的新 refresh_token**,重放旧令牌会得到 `refresh_token已失效`。
:::
:::info 管理后台前端的现状
`ui/` 管理后台当前**未内置自动续签**:access_token 过期(401)时直接弹窗提示重新登录(见下文「响应拦截器」)。`POST /api/refresh` 面向第三方接入客户端与后续前端升级使用。
:::
### Token 失效(重新登录)
```text
前端请求 API → 后端返回 code=401
│
▼
尝试用 refresh_token 刷新
│
▼
刷新失败(refresh_token 也过期/无效)
access_token 过期 / refresh_token 刷新失败(过期/已吊销/网络异常)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:清除登录态,跳转登录页 │
│ │
│ localStorage.removeItem('access_token') │
│ localStorage.removeItem('refresh_token') │
│ storage.clear() │
│ window.location.href = '/login' │
└─────────────────────────────────────────────────────────────┘
```
@@ -176,22 +137,20 @@
```text
┌─────────────────────────────────────────────────────────────┐
│ access_token │
│ │
│ 用途:接口认证(每个 API 请求携带) │
│ 有效期:2 小时(7200 秒) │
│ payload.type:access │
│ 有效期:2 小时(JWT_ACCESS_TTL=7200 秒) │
│ payload:type=access,含 jti │
│ 传递方式:Authorization: Bearer <token> │
│ 校验方:AuthMiddleware │
│ 特点:短期有效,泄露影响范围有限 │
│ 校验方:Authenticate 中间件 │
│ 特点:短期有效、无状态,泄露影响范围有限 │
├─────────────────────────────────────────────────────────────┤
│ refresh_token │
│ │
│ 用途:刷新令牌(仅在 access_token 过期时使用) │
│ 有效期:7 天(604800 秒) │
│ payload.type:refresh │
│ 传递方式:POST /api/oauth2/token 请求体 │
│ 校验方:JwtService::refreshToken() │
│ 特点:长期有效,仅用于换取新令牌,不用于接口认证 │
│ 有效期:7 天(JWT_REFRESH_TTL=604800 秒) │
│ payload:type=refresh,含与 access 相同的 jti │
│ 传递方式:POST /api/refresh 请求体 │
│ 校验方:JwtService::refreshToken()(Redis jti 一次性校验) │
│ 特点:有状态(Redis)、可吊销、支持踢下线 │
└─────────────────────────────────────────────────────────────┘
```
@@ -217,12 +176,11 @@ curl "http://localhost:8000/api/user/page?pageNo=1&pageSize=10" \
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `jwt.header_name` | `Authorization` | 请求头名称 |
| `jwt.header_prefix` | `Bearer ` | 请求头前缀(注意有空格) |
| `jwt.header_prefix` | `Bearer ` | 请求头前缀(注意有尾部空格,`config/jwt.php`) |
## Token 获取
### 登录获取
### 登录获取(主入口)
```bash
POST /api/login
@@ -244,15 +202,15 @@ Content-Type: application/json
"ok": true,
"msg": "登录成功",
"data": {
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"expiresIn": 7200,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 7200,
"user": {
"id": 1,
"username": "admin",
"realname": "管理员",
"avatar": "/uploads/user/avatar/avatar.jpg"
"avatar": "user/avatar/20260923/100429_e8FAKf9r.png"
}
}
}
@@ -271,36 +229,38 @@ grant_type=password&username=admin&password=123456
| 字段 | 类型 | 说明 |
|------|------|------|
| `accessToken` | string | 访问令牌(用于接口认证) |
| `refreshToken` | string | 刷新令牌(用于换取新令牌) |
| `tokenType` | string | 令牌类型(固定 `Bearer`) |
| `expiresIn` | number | access_token 有效期(秒,默认 7200) |
| `access_token` | string | 访问令牌(用于接口认证) |
| `refresh_token` | string | 刷新令牌(用于换取新令牌) |
| `token_type` | string | 令牌类型(固定 `Bearer`) |
| `expires_in` | number | access_token 有效期(秒,默认 7200) |
| `user` | object | 用户基础信息(id、username、realname、avatar) |
:::tip 登录/刷新接口为 snake_case 特例
一般接口响应经驼峰自动转换(`create_time` → `createTime`);登录、刷新等令牌接口由 `AuthController` 以 `success(..., true)` raw 返回,键名保持 snake_case,与前端 `data.access_token` 读取方式一致。
:::
## Token 刷新
access_token 过期后(响应 code=401),使用 refresh_token 换取新令牌对:
access_token 过期后(code=401),使用 refresh_token 换取新令牌对:
### 接口信息
| 项目 | 说明 |
|------|------|
| URL | `POST /api/oauth2/token` |
| URL(主入口) | `POST /api/refresh`(公开接口) |
| URL(OAuth2 兼容) | `POST /api/oauth2/token`(grant_type=refresh_token) |
| Content-Type | `application/json` 或 `application/x-www-form-urlencoded` |
### 请求示例
```bash
# JSON 格式
POST /api/oauth2/token
# 推荐:/api/refresh(JSON)
POST /api/refresh
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}
{ "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." }
# 表单格式
# OAuth2 兼容(表单)
POST /api/oauth2/token
Content-Type: application/x-www-form-urlencoded
@@ -313,12 +273,12 @@ grant_type=refresh_token&refresh_token=eyJ0eXAiOi...
{
"code": 0,
"ok": true,
"msg": "success",
"msg": "刷新成功",
"data": {
"accessToken": "eyJ...(新access_token)",
"refreshToken": "eyJ...(新refresh_token)",
"tokenType": "Bearer",
"expiresIn": 7200
"access_token": "eyJ...(新access_token)",
"refresh_token": "eyJ...(新refresh_token)",
"token_type": "Bearer",
"expires_in": 7200
}
}
```
@@ -327,8 +287,9 @@ grant_type=refresh_token&refresh_token=eyJ0eXAiOi...
| 场景 | code | msg |
|------|------|-----|
| 缺少 refresh_token | 1 | 缺少refresh_token参数 |
| refresh_token 无效/过期 | 1 | 无效的refresh_token |
| 缺少 refresh_token | 1 | 缺少refresh_token |
| refresh_token 无效/类型错 | 1 | 无效的refresh_token |
| 旧令牌已用过(重放)/ 已吊销 | 1 | refresh_token已失效 |
| 用户不存在/禁用 | 1 | 用户不存在或已被禁用 |
## Token 有效性检查
@@ -338,7 +299,7 @@ grant_type=refresh_token&refresh_token=eyJ0eXAiOi...
| 项目 | 说明 |
|------|------|
| URL | `GET /api/oauth2/check/token` |
| 权限 | 无需认证 |
| 权限 | 无需认证(公开接口) |
### 请求示例
@@ -375,31 +336,33 @@ GET /api/oauth2/check/token?token=<access_token>
"msg": "操作成功",
"data": {
"valid": false,
"msg": "token已过期"
"msg": "Expired token"
}
}
```
## Token 吊销
| 项目 | 说明 |
|------|------|
| URL | `DELETE /api/oauth2/remove/{token}` |
| 接口 | 方法 | 认证 | 说明 |
|------|------|------|------|
| `/api/logout` | POST | 认证组 | 登出:吊销当前 refresh_token(`revokeToken` 删除 Redis jti) |
| `/api/oauth2/remove/{token}` | DELETE | 认证组 | 吊销指定令牌 |
```bash
DELETE /api/oauth2/remove/eyJ0eXAiOi...
# 登出(携带 access_token)
POST /api/logout
Authorization: Bearer <access_token>
```
:::warning 简化实现
当前 Token 吊销为简化实现(直接返回 true),JWT 本身无状态,服务端无法直接使其失效。生产环境建议结合 Redis 黑名单机制实现真正的吊销。
:::tip 真实吊销(Redis 有状态)
本项目 refresh_token 以 `refresh:{uid}:{jti}` 存于 Redis——登出即删除该键,旧 access_token 到期后**无法续签**;`JwtService::revokeAllTokens($userId)` 可一次性吊销用户全部令牌(踢下线、改密全端失效场景)。
:::
## URL 参数传递(SSO 场景)
路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景:
前端路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景:
```html
```text
http://localhost:8001/?token=<access_token>
```
@@ -414,64 +377,53 @@ http://localhost:8001/?token=<access_token>
## 前端存储与使用
### 存储
### 存储(摘自 `ui/src/store/modules/user.ts` 实际代码)
```typescript
// 登录后存储
localStorage.setItem('access_token', data.accessToken);
localStorage.setItem('refresh_token', data.refreshToken);
// 登录成功后存储(仅 access_token)
const ex = 7 * 24 * 60 * 60 * 1000;
storage.set(ACCESS_TOKEN, data.access_token, ex); // 键名 'ACCESS-TOKEN'
// 登出时清除
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// 登出时清除(userStore.logout)
storage.remove(ACCESS_TOKEN);
storage.remove(CURRENT_USER);
storage.clear();
```
### 请求拦截器(自动携带)
### 请求拦截器(自动携带,摘自 `ui/src/utils/http/axios/index.ts`)
```typescript
// src/utils/http/axios/index.ts
axios.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
const token = storage.get('ACCESS-TOKEN', '');
config.headers['Authorization'] = `Bearer ${token}`;
```
### 响应拦截器(自动刷新)
### 响应拦截器(401 处理,摘自 `ui/src/utils/http/axios/index.ts`)
```typescript
axios.interceptors.response.use(
response => response,
async error => {
const { config, response } = error;
// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', {
type: 'warning',
})
.then(() => {
storage.clear();
window.location.href = PageEnum.BASE_LOGIN; // '/login'
})
.catch(() => {});
break;
```
if (response?.status === 401 && !config._retry) {
config._retry = true;
### SSO 场景(URL 参数传递 Token)
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
// 尝试刷新 Token
const res = await refreshTokenApi(refreshToken);
localStorage.setItem('access_token', res.data.accessToken);
localStorage.setItem('refresh_token', res.data.refreshToken);
路由守卫检测 `?token=` 参数并写入存储(摘自 `ui/src/router/router-guards.ts` 实际代码):
// 用新 Token 重试原请求
config.headers['Authorization'] = `Bearer ${res.data.accessToken}`;
return axios(config);
} catch (e) {
// 刷新失败,跳转登录
localStorage.clear();
window.location.href = '/login';
}
}
}
return Promise.reject(error);
}
);
```typescript
if (to.query && 'token' in to.query) {
const tokenVal = String(to.query.token);
storage.set(ACCESS_TOKEN, tokenVal);
userStore.setToken(tokenVal);
}
```
## Token Payload 结构
@@ -483,6 +435,7 @@ axios.interceptors.response.use(
"uid": 1,
"username": "admin",
"type": "access",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727007200
@@ -496,36 +449,44 @@ axios.interceptors.response.use(
"uid": 1,
"username": "admin",
"type": "refresh",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iss": "rxthinkcmf",
"iat": 1727000000,
"exp": 1727604800
}
```
(同一登录的两个令牌共享 `jti`,Redis 键 `refresh:{uid}:{jti}`。)
## 错误响应汇总
| 场景 | HTTP 状态 | code | msg |
|------|----------|------|-----|
| 未提供 Token | 200 | 401 | 未提供认证令牌 |
| Token 过期 | 200 | 401 | token已过期 |
| Token 签名无效 | 200 | 401 | token签名无效 |
| Token 类型错误 | 200 | 401 | 无效的认证令牌 |
| 无权限 | 200 | 403 | 无访问权限 |
| Token 过期 | 200 | 401 | Expired token |
| Token 签名无效 | 200 | 401 | Signature verification failed |
| Token 类型错误(refresh 当 access 用) | 200 | 401 | 无效的access_token |
| 无权限 | **403** | 403 | 无访问权限:{权限码} |
| refresh_token 无效/类型错 | 200 | 1 | 无效的refresh_token |
| refresh_token 已用/已吊销 | 200 | 1 | refresh_token已失效 |
## 配置项
| 配置键 | 环境变量 | 默认值 | 说明 |
| 配置键(`config/jwt.php`) | 环境变量 | 默认值 | 说明 |
|--------|---------|--------|------|
| `jwt.secret` | `JWT.SECRET` | 内置默认密钥 | JWT 签名密钥,**生产环境务必修改** |
| `jwt.access_ttl` | `JWT.ACCESS_TTL` | `7200` | access_token 有效期(秒) |
| `jwt.refresh_ttl` | `JWT.REFRESH_TTL` | `604800` | refresh_token 有效期(秒) |
| `jwt.issuer` | — | `rxthinkcmf` | 签发者标识 |
| `jwt.header_name` | — | `Authorization` | 请求头名称 |
| `jwt.secret` | `JWT_SECRET` | (必填) | JWT 签名密钥,**必须 ≥ 32 字符,否则拒绝启动** |
| `jwt.access_ttl` | `JWT_ACCESS_TTL` | `7200` | access_token 有效期(秒) |
| `jwt.refresh_ttl` | `JWT_REFRESH_TTL` | `604800` | refresh_token 有效期(秒) |
| `jwt.perms_ttl` | `JWT_PERMS_TTL` | `7200` | 权限缓存 TTL(秒) |
| `jwt.issuer` | `JWT_ISSUER` | `rxthinkcmf` | 签发者标识 |
| `jwt.header_prefix` | — | `Bearer ` | 请求头前缀 |
:::warning 生产环境安全
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
生产环境务必通过 `.env` 设置 `JWT_SECRET` 为 ≥32 字符的强随机密钥(**不满足应用拒绝启动**):
```bash
openssl rand -base64 32
# 或
php -r "echo bin2hex(random_bytes(32));"
```
:::
@@ -1,7 +1,7 @@
# 8.4 文件上传接口
:::tip 概述
文件上传接口将文件保存到临时目录,返回文件 URL。前端拿到 URL 后提交表单时传给后端,BaseLogic 在 add/update 时自动迁移到正式目录。通过 `type` 参数可区分上传类型(文件/图片)。
文件上传接口将文件保存到 `temp/` 临时目录,返回文件相对路径与 URL。前端拿到 `filePath` 后提交表单,Service 的 `fileFields` 经 `save_file()` 把临时文件迁移到正式目录并入库,读取时自动补全域名。通过 `type` 参数区分后缀白名单(文件/图片)。
:::
## 接口信息
@@ -10,13 +10,18 @@
|------|------|
| URL | `POST /api/upload/uploadFile` |
| Content-Type | `multipart/form-data` |
| 权限 | 无需认证(公开接口) |
| 权限 | **需认证**(`auth.jwt` 组内)+ `sys:upload:file` 权限 |
| 日志 | `#[OperationLog]` TYPE_UPLOAD(上传记录进操作日志) |
:::warning 不是公开接口
上传接口位于认证中间件组内且标注 `#[Permission('sys:upload:file', '上传文件')]`——请求头必须携带 `Authorization: Bearer <token>`,且角色需分配该权限节点。
:::
## 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `file` | File | 是 | - | 文件二进制数据 |
| `file` | File | 是 | - | 文件二进制数据(FormData 字段名必须为 `file`) |
| `type` | string | 否 | `file` | 上传类型:`file`(所有合法文件)/ `image`(仅图片) |
### 成功响应
@@ -27,13 +32,13 @@
"ok": true,
"msg": "上传成功",
"data": {
"fileUrl": "https://cdn.example.com/temp/20260920/a1b2c3d4e5f6.jpg",
"filePath": "temp/20260920/a1b2c3d4e5f6.jpg",
"fileUrl": "http://localhost:8000/uploads/temp/20261002/123456_a1b2c3d4e5f6.jpg",
"filePath": "temp/20261002/123456_a1b2c3d4e5f6.jpg",
"originalName": "photo.jpg",
"fileSize": 102400,
"fileExtension": "jpg",
"fileType": "image/jpeg",
"fileName": "a1b2c3d4e5f6.jpg"
"fileName": "123456_a1b2c3d4e5f6.jpg"
}
}
```
@@ -42,34 +47,35 @@
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `fileUrl` | string | 完整 URL(含域名) | `https://cdn.example.com/temp/...` |
| `filePath` | string | 相对路径 | `temp/20260920/a1b2c3d4e5f6.jpg` |
| `fileUrl` | string | 可访问 URL(`FILE_DOMAIN_URL` 为空时等于相对路径) | `http://localhost:8000/uploads/temp/...` |
| `filePath` | string | 相对存储路径(**提交表单建议用此字段**) | `temp/20261002/123456_a1b2....jpg` |
| `originalName` | string | 原始文件名 | `photo.jpg` |
| `fileSize` | number | 文件大小(字节) | `102400` |
| `fileExtension` | string | 扩展名 | `jpg` |
| `fileType` | string | MIME 类型 | `image/jpeg` |
| `fileName` | string | 服务器文件名(随机) | `a1b2c3d4e5f6.jpg` |
| `fileType` | string | MIME 类型(`getMimeType()`) | `image/jpeg` |
| `fileName` | string | 服务器文件名(时间+随机串) | `123456_a1b2c3d4e5f6.jpg` |
### type 参数说明
| type 值 | 说明 | 允许的后缀 | 适用场景 |
| type 值 | 说明 | 允许的后缀 | 存储目录 |
|---------|------|-----------|---------|
| `file`(默认) | 所有合法文件 | 由 `config/file.php` 的 `file_ext` 控制 | 文档、压缩包等 |
| `image` | 仅图片 | 由 `config/file.php` 的 `image_ext` 控制 | 头像、封面等 |
| `file`(默认) | 所有合法文件 | 由 `config/file.php` 的 `file_ext` 控制 | `uploads/temp/Ymd/` |
| `image` | 仅图片 | 由 `config/file.php` 的 `image_ext` 控制 | `uploads/temp/Ymd/` |
两种 `type` 的存储目录相同,均为 `temp/` 临时目录;正式目录在表单提交时由 `save_file()` 按 Service 的 `fileSaveDir` 生成。
**file_ext 默认值:**
```text
gif, jpg, jpeg, png, bmp, webp, svg,
gif, jpg, jpeg, png, bmp, webp,
doc, docx, xls, xlsx, ppt, pptx,
pdf, zip, rar, txt, csv,
mp3, mp4, sql, js, css
pdf, zip, rar, txt, csv
```
**image_ext 默认值:**
**image_ext 默认值(无 svg——内嵌 JS 有 XSS 风险):**
```text
gif, jpg, jpeg, png, bmp, webp, svg
gif, jpg, jpeg, png, bmp, webp
```
## 失败响应
@@ -78,20 +84,21 @@ gif, jpg, jpeg, png, bmp, webp, svg
{
"code": 1,
"ok": false,
"msg": "不允许的文件类型: .exe",
"msg": "不支持的文件格式,允许:gif, jpg, jpeg, png, bmp, webp",
"data": null
}
```
## 常见错误
## 常见错误(真实文案)
| 错误信息 | 原因 | 解决方案 |
|---------|------|----------|
| `未接收到上传文件` | 表单字段名不是 `file` | 检查 FormData 的 key 是否为 `file` |
| `不允许的文件类型: .xxx` | 后缀不在白名单 | 检查 `config/file.php` 的 `image_ext`/`file_ext` |
| `文件大小超过限制` | 超过 `max_size` | 调整 `.env` 中 `FILE.MAX_SIZE` 配置 |
| `图片文件内容无效` | 图片损坏或非图片伪装 | 重新选择文件 |
| `文件上传失败` | PHP 上传错误 | 检查 `php.ini` 的 `upload_max_filesize` |
| `未提供认证令牌`(code 401) | 请求头未携带 Token | 携带 `Authorization: Bearer <token>` |
| `无访问权限:sys:upload:file`(HTTP 403) | 角色无上传权限 | 分配权限节点 |
| `请选择文件` | 表单字段名不是 `file` 或上传无效 | 检查 FormData 的 key 是否为 `file` |
| `不支持的上传类型,允许:image,file` | type 参数非法 | 传 `image` 或 `file` |
| `不支持的文件格式,允许:...` | 后缀不在白名单 | 转换格式或调整 `FILE_IMAGE_EXT`/`FILE_EXT` |
| `文件大小不能超过 NMB` | 超过 `FILE_MAX_SIZE` | 压缩文件或调大限制 |
## 前端使用
@@ -112,6 +119,7 @@ gif, jpg, jpeg, png, bmp, webp, svg
<!-- 上传图片(指定 type=image) -->
<el-upload
:action="'/api/upload/uploadFile?type=image'"
:data="{ type: 'image' }"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImageSuccess"
accept="image/*"
@@ -133,13 +141,13 @@ const beforeUpload = (file) => {
const handleUploadSuccess = (response) => {
if (response.code === 0) {
form.value.file = response.data.fileUrl;
form.value.file = response.data.filePath; // 建议存相对路径
}
};
const handleImageSuccess = (response) => {
if (response.code === 0) {
form.value.avatar = response.data.fileUrl;
form.value.avatar = response.data.filePath;
}
};
</script>
@@ -158,7 +166,8 @@ const res = await http.request({
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
});
// res.fileUrl = "https://cdn.example.com/temp/20260920/abc.jpg"
// res.filePath = "temp/20261002/123456_a1b2....jpg"
// res.fileUrl = "http://localhost:8000/uploads/temp/20261002/..."
// 上传图片(指定 type=image)
const res2 = await http.request({
@@ -169,19 +178,20 @@ const res2 = await http.request({
});
```
### 提交表单时传 URL
### 提交表单时传路径
```typescript
// 前端上传后拿到 fileUrl,提交表单时传给后端
// 推荐提交 filePath(相对路径),Service 层归一化后入库
await exampleAdd({
name: '测试',
avatar: 'https://cdn.example.com/temp/20260920/abc.jpg',
avatar: 'temp/20261002/123456_a1b2c3d4e5f6.jpg',
});
// 后端 BaseLogic::add() 自动:
// 1. 检测到 avatar 是临时文件 URL
// 2. 调用 save_file() 迁移到正式目录
// 3. 去掉域名,只存相对路径入库
// 后端 ExampleService(use HasFileProcessing)自动:
// 1. 识别 avatar 为 fileFields 字段
// 2. save_file() 归一化(去域名 / 迁移 temp 到 fileSaveDir / 下载外部 URL)
// 3. 正式目录相对路径入库(如 example/20261002/...)
// 读取时 processFileFieldsOnRead 补全 FILE_DOMAIN_URL 域名
```
### 头像上传示例
@@ -206,7 +216,9 @@ await exampleAdd({
const form = ref({ avatar: '' });
const handleAvatarSuccess = (response) => {
form.value.avatar = response.data.fileUrl;
if (response.code === 0) {
form.value.avatar = response.data.filePath;
}
};
</script>
```
@@ -214,43 +226,46 @@ const handleAvatarSuccess = (response) => {
## 上传后处理流程
```text
前端上传 → temp/日期/xxx.jpg(临时目录)
前端上传 → public/uploads/temp/Ymd/xxx.jpg(临时目录)
│
▼ 提交表单(携带 URL)
BaseLogic::add()
▼ 提交表单(携带 filePath)
ExampleService::add()
│
├─ processFileFieldsOnSave()
│ └─ save_file() → 正式目录/日期/xxx.jpg
│ └─ save_file() → temp/ 文件迁移到 example/,并归一化(去域名 / 外部 URL 下载)
│
└─ 数据库存储相对路径
└─ 数据库存储正式目录相对路径(example/Ymd/xxx.jpg)
│
▼ 读取时
processFileFieldsOnRead()
└─ get_file_url() → 拼接域名返回完整 URL
└─ get_file_url() → 拼接 FILE_DOMAIN_URL 返回完整 URL
```
详见 [7.1 文件上传全流程](/zh/tools/upload.html)。
## 配置项
```ini
; .env
[FILE]
UPLOAD_DIR = D:/uploads/rxthinkcmf ; 上传根目录
DOMAIN_URL = https://cdn.example.com ; 文件访问域名
MAX_SIZE = 10 ; 单文件最大 10MB
IMAGE_EXT = jpg,jpeg,png,gif,bmp,webp,svg
FILE_EXT = jpg,jpeg,png,gif,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv,mp3,mp4,sql,js,css
# .env(config/file.php 读取)
FILE_UPLOAD_DIR=uploads # 上传根目录(相对 public/)
FILE_DOMAIN_URL=https://cdn.example.com # 文件访问域名
FILE_MAX_SIZE=10 # 单文件最大 10MB
FILE_IMAGE_EXT=gif,jpg,jpeg,png,bmp,webp
FILE_EXT=gif,jpg,jpeg,png,bmp,webp,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,txt,csv
```
## 安全设计
| 安全措施 | 说明 |
|---------|------|
| 文件类型校验 | 同时检查后缀名和 MIME 类型(finfo),防止伪造扩展名 |
| 文件大小限制 | 通过 `config/file.php` 的 `max_size` 配置 |
| 图片内容校验 | 图片类型额外用 `getimagesize` 验证,防止非图片伪装 |
| 随机文件名 | 使用 `bin2hex(random_bytes(16))` 防止文件名冲突和路径遍历 |
| 临时目录隔离 | 上传文件先存入 `temp/` 目录,提交表单后才迁移到正式目录 |
| Nginx 防护 | 上传目录禁止执行 PHP 文件 |
| 认证 + 权限 | `auth.jwt` 组内 + `#[Permission('sys:upload:file')]` |
| 上传有效性校验 | `UploadedFile::isValid()` 捕获 PHP 上传错误 |
| 文件类型白名单 | 按 `type` 从 `config/file.php` 读取后缀白名单 |
| 文件大小限制 | `FILE_MAX_SIZE`(默认 10MB),且受 `php.ini` 双层限制 |
| 随机文件名 | `His + bin2hex(random_bytes(8))` 防冲突与路径遍历 |
| 移除高危后缀 | `image_ext` 不含 svg(内嵌 JS 的 XSS 风险) |
| Nginx 防护 | 仅入口 `index.php` 可执行,上传的 `.php` 一律 404 |
| 操作审计 | `#[OperationLog(TYPE_UPLOAD)]` 记录上传行为 |
## 常见问题
@@ -264,13 +279,13 @@ FILE_EXT = jpg,jpeg,png,gif,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,
**原因:** 文件扩展名不在白名单中。
**解决:** 检查 `.env` 中 `FILE.FILE_EXT` 或 `FILE.IMAGE_EXT` 配置。
**解决:** 检查 `.env` 中 `FILE_EXT` / `FILE_IMAGE_EXT` 配置(改后 `php artisan config:clear`)。
### 问题 3:上传后图片不显示
**原因:** `DOMAIN_URL` 配置错误或 Nginx 未配置静态文件处理。
**原因:** `FILE_DOMAIN_URL` 配置错误或与前端不同源。
**解决:** 检查 `.env` 中 `FILE.DOMAIN_URL` 是否正确,Nginx 是否配置了 `/uploads/` 的 alias。
**解决:** 检查 `.env` 中 `FILE_DOMAIN_URL` 是否为前端可访问地址;留空时用相对路径(同源/代理)。
### 问题 4:大文件上传失败
@@ -279,15 +294,14 @@ FILE_EXT = jpg,jpeg,png,gif,bmp,webp,svg,doc,docx,xls,xlsx,ppt,pptx,pdf,zip,rar,
**解决:**
```ini
; php.ini
upload_max_filesize = 20M
post_max_size = 25M
; .env
FILE.MAX_SIZE = 20
; php.ini(修改后重启)
upload_max_filesize = 50M
post_max_size = 50M
```
```nginx
# nginx.conf
client_max_body_size 20M;
# nginx.conf(生产按需调整)
client_max_body_size 50M;
```
同时调大 `.env` 的 `FILE_MAX_SIZE`(业务层限制)。