签入版本
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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`(业务层限制)。
|
||||
|
||||
Reference in New Issue
Block a user