签入版本
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
# 8.5 字典数据接口
|
||||
|
||||
:::tip 概述
|
||||
字典数据接口分为两组:字典管理(DictController)和字典项管理(DictItemController)。前端通过字典编码获取字典项列表用于下拉框,后台通过管理接口维护字典数据。
|
||||
:::
|
||||
|
||||
## 接口总览
|
||||
|
||||
### 字典管理(DictController)
|
||||
|
||||
| 方法 | URL | 权限码 | 说明 |
|
||||
|------|-----|--------|------|
|
||||
| GET | `/api/dict/page` | sys:dict:page | 字典分页列表 |
|
||||
| GET | `/api/dict/detail/{id}` | sys:dict:detail | 字典详情 |
|
||||
| POST | `/api/dict/add` | sys:dict:add | 添加字典 |
|
||||
| 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 | 刷新字典缓存 |
|
||||
|
||||
### 字典项管理(DictItemController)
|
||||
|
||||
| 方法 | URL | 权限码 | 说明 |
|
||||
|------|-----|--------|------|
|
||||
| GET | `/api/dict/item/page` | sys:dict:page | 字典项分页列表 |
|
||||
| GET | `/api/dict/item/detail/{id}` | sys:dict:detail | 字典项详情 |
|
||||
| POST | `/api/dict/item/add` | sys:dict:add | 添加字典项 |
|
||||
| PUT | `/api/dict/item/update` | sys:dict:update | 修改字典项 |
|
||||
| DELETE | `/api/dict/item/delete/{id}` | sys:dict:delete | 删除字典项 |
|
||||
| DELETE | `/api/dict/item/batchDelete` | sys:dict:batchDelete | 批量删除字典项 |
|
||||
| GET | `/api/dict/item/getDictItemList/{code}` | — | 按编码获取字典项列表 |
|
||||
|
||||
## 按编码获取字典项(最常用)
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `GET /api/dict/item/getDictItemList/{code}` |
|
||||
| 权限 | 无需额外权限(已登录即可) |
|
||||
| 用途 | 前端下拉框数据源 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```bash
|
||||
GET /api/dict/item/getDictItemList/gender
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": [
|
||||
{ "id": 1, "name": "男", "value": "1", "sort": 1, "note": "" },
|
||||
{ "id": 2, "name": "女", "value": "2", "sort": 2, "note": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | number | 字典项 ID |
|
||||
| `name` | string | 字典项名称(显示标签) |
|
||||
| `value` | string | 字典项值(存储值) |
|
||||
| `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=仅本人 |
|
||||
|
||||
## 刷新字典缓存
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `GET /api/dict/refreshCache` |
|
||||
| 权限 | `sys:dict:update` |
|
||||
| 演示环境 | 允许执行(标注 `#[DemoAllow]`) |
|
||||
|
||||
修改字典数据后需调用此接口刷新缓存,否则新数据不会立即生效。
|
||||
|
||||
```bash
|
||||
GET /api/dict/refreshCache
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "缓存已刷新",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 前端使用
|
||||
|
||||
### 获取字典项
|
||||
|
||||
```typescript
|
||||
// ui/src/api/common/index.ts
|
||||
export function getDictItemList(code: string) {
|
||||
return http.request({
|
||||
url: '/dict/item/getDictItemList/' + code,
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 下拉框使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-select v-model="formData.status" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
|
||||
const statusOptions = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
statusOptions.value = await getDictItemList('example_status');
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 多个字典批量获取
|
||||
|
||||
```typescript
|
||||
const [genderOptions, statusOptions] = await Promise.all([
|
||||
getDictItemList('gender'),
|
||||
getDictItemList('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`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* 枚举显示名映射
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $serializeMaps = [
|
||||
'status' => 'example_status',
|
||||
];
|
||||
```
|
||||
|
||||
```json
|
||||
// API 响应自动包含 statusText
|
||||
{ "id": 1, "name": "测试", "status": 1, "statusText": "启用" }
|
||||
```
|
||||
|
||||
:::tip 缓存机制
|
||||
DictService 采用两级缓存(请求级 + 持久缓存,1小时过期)。修改字典数据后需调用刷新缓存接口,否则新数据不会立即生效。
|
||||
:::
|
||||
|
||||
## 后端 DictService 调用
|
||||
|
||||
```php
|
||||
use app\service\DictService;
|
||||
|
||||
// 根据值获取名称
|
||||
$text = DictService::getText('gender', 1); // '男'
|
||||
|
||||
// 根据名称反向获取值
|
||||
$value = DictService::getValue('gender', '男'); // '1'
|
||||
|
||||
// 获取下拉框选项
|
||||
$options = DictService::getOptions('gender');
|
||||
// [['label'=>'男','value'=>'1'], ['label'=>'女','value'=>'2']]
|
||||
|
||||
// 获取完整字典项(含 id、sort、note)
|
||||
$items = DictService::getFullItems('gender');
|
||||
|
||||
// 清除缓存
|
||||
DictService::clearCache('gender'); // 清除指定字典
|
||||
DictService::clearAllCache(); // 清除所有字典
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# 8.2 错误码对照表
|
||||
|
||||
:::tip 概述
|
||||
所有接口返回统一的 JSON 结构,通过 `code` 字段区分业务状态。前端应根据 `code` 做差异化处理。
|
||||
:::
|
||||
|
||||
## 业务错误码
|
||||
|
||||
| code | ok | 含义 | 使用场景 | 前端处理 |
|
||||
|------|-----|------|----------|----------|
|
||||
| `0` | true | 成功 | 所有成功操作 | 正常处理 data |
|
||||
| `1` | false | 一般失败 | 业务逻辑失败 | 显示 msg 提示 |
|
||||
| `401` | false | 未授权 | Token 缺失/无效/过期 | 跳转登录页 |
|
||||
| `403` | false | 禁止访问 | 权限不足/租户异常/演示环境 | 显示无权限提示 |
|
||||
| `404` | false | 数据不存在 | 查询单条记录为空 | 显示数据不存在 |
|
||||
| `422` | false | 参数验证失败 | 表单验证不通过 | 显示具体字段错误 |
|
||||
|
||||
## 响应示例
|
||||
|
||||
> 响应示例详见 [统一响应格式](/zh/api-spec/response.html)
|
||||
|
||||
## 常见错误消息
|
||||
|
||||
### 认证相关
|
||||
|
||||
| 消息 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `未提供认证令牌` | 请求头缺少 Authorization | 携带 Token |
|
||||
| `无效的认证令牌` | Token 类型不是 access | 使用 access_token |
|
||||
| `token已过期` | Token 超过有效期 | 调用刷新接口或重新登录 |
|
||||
| `用户名或密码错误` | 账号或密码不正确 | 检查输入 |
|
||||
| `账号已被禁用` | 用户 status != 1 | 联系管理员 |
|
||||
|
||||
### 权限相关
|
||||
|
||||
| 消息 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `无访问权限:xxx` | 用户无该接口权限 | 联系管理员分配权限 |
|
||||
| `租户已被禁用` | 租户 status != 1 | 联系平台管理员 |
|
||||
| `租户已过期` | 租户 expire_time 已过 | 续费或联系平台 |
|
||||
| `演示环境,禁止操作` | 演示模式下写操作被拦截 | 非演示环境操作 |
|
||||
|
||||
### 业务相关
|
||||
|
||||
| 消息 | 原因 | 解决方案 |
|
||||
|------|------|----------|
|
||||
| `xxx已存在` | 唯一性校验失败 | 修改重复字段值 |
|
||||
| `数据不存在` | 记录已被删除或不存在 | 刷新列表 |
|
||||
| `请选择要删除的数据` | 批量删除时 ids 为空 | 先选择数据 |
|
||||
| `该部门下存在子部门,不可删除` | 删除前钩子校验 | 先删除子部门 |
|
||||
|
||||
## 前端错误处理
|
||||
|
||||
```typescript
|
||||
// Axios 响应拦截器
|
||||
axios.interceptors.response.use(response => {
|
||||
const { code, msg, data } = response.data;
|
||||
|
||||
if (code === 0) {
|
||||
return data; // 成功
|
||||
}
|
||||
|
||||
if (code === 401) {
|
||||
// Token 过期,跳转登录
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 403) {
|
||||
ElMessage.error(msg || '无权限');
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
ElMessage.error(msg || '操作失败');
|
||||
return Promise.reject(new Error(msg));
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,211 @@
|
||||
# 8.7 API 接口总览
|
||||
|
||||
:::tip 概述
|
||||
本节按模块分组列出所有 API 接口的概述,详细路由清单见[附录 E](/zh/appendix/api-cheatsheet.html)。
|
||||
:::
|
||||
|
||||
## API 设计规范
|
||||
|
||||
### 命名规范
|
||||
|
||||
| 规范 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| URL 风格 | RESTful,小写下划线 | `/api/user/page` |
|
||||
| 模块名 | 与 Controller 对应 | `user`、`article`、`dict` |
|
||||
| 操作名 | 标准 CRUD 动词 | `page`、`list`、`detail`、`add`、`update`、`delete`、`batchDelete` |
|
||||
| 扩展操作 | 业务动词 | `import`、`export`、`resetPwd`、`refreshCache` |
|
||||
|
||||
### HTTP 方法规范
|
||||
|
||||
| 操作 | HTTP 方法 | URL 示例 | 说明 |
|
||||
|------|----------|---------|------|
|
||||
| 分页查询 | GET | `/api/user/page` | 参数在 URL query string |
|
||||
| 全量列表 | GET | `/api/user/list` | 参数在 URL query string |
|
||||
| 详情 | 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 请求体 |
|
||||
|
||||
### 统一响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0, // 0=成功,其他=失败
|
||||
"ok": true, // 是否成功
|
||||
"msg": "操作成功", // 提示信息
|
||||
"data": {} // 响应数据
|
||||
}
|
||||
```
|
||||
|
||||
### 认证方式
|
||||
|
||||
```text
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
## 公开接口(无需认证)
|
||||
|
||||
| 模块 | 接口 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 登录 | `/api/login` | POST | 用户登录,返回双令牌 |
|
||||
| 登出 | `/api/logout` | GET | 用户登出(记录日志) |
|
||||
| 验证码 | `/api/captcha` | GET | 获取图片验证码 |
|
||||
| Token | `/api/oauth2/token` | POST | 获取/刷新 Token |
|
||||
| Token | `/api/oauth2/check/token` | GET | 检查 Token 有效性 |
|
||||
| Token | `/api/oauth2/remove/{token}` | DELETE | 吊销 Token |
|
||||
|
||||
## 系统管理
|
||||
|
||||
| 模块 | 主要接口 | 方法 | 说明 |
|
||||
|------|---------|------|------|
|
||||
| 首页 | `/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/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/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/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/job/log/batchDelete` | 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/upload/uploadFile` | POST | 上传文件(type=file/image) |
|
||||
| 代码生成 | `/api/generator/page` | GET | 可生成的表列表 |
|
||||
| 代码生成 | `/api/generator/preview` | POST | 预览生成代码 |
|
||||
| 代码生成 | `/api/generator/generate` | POST | 执行代码生成 |
|
||||
|
||||
## 接口统计
|
||||
|
||||
| 模块 | 接口数量 | 说明 |
|
||||
|------|---------|------|
|
||||
| 公开接口 | 6 | 登录/登出/验证码/Token |
|
||||
| 系统管理 | ~35 | 用户/角色/菜单/部门/岗位/职级/租户 |
|
||||
| 数据管理 | ~25 | 字典/字典项/配置/配置项/参数/公告/城市 |
|
||||
| 内容管理 | ~12 | 文章/分类/链接 |
|
||||
| 监控管理 | ~10 | 定时任务/任务日志 |
|
||||
| 日志审计 | ~8 | 操作日志/登录日志 |
|
||||
| 公共接口 | ~4 | 文件上传/代码生成 |
|
||||
| **合计** | **~100** | |
|
||||
|
||||
详细路由清单(HTTP 方法 + 路径 + 权限码)见[附录 E](/zh/appendix/api-cheatsheet.html)。
|
||||
@@ -0,0 +1,371 @@
|
||||
# 8.3 分页查询规范
|
||||
|
||||
:::tip 概述
|
||||
所有分页接口遵循统一的参数命名和返回结构。分页由 `BaseController::_index()` → `BaseLogic::pageList()` 实现,前端只需传标准参数即可获得分页数据。
|
||||
:::
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `pageNo` | number | 1 | 页码(最小为 1) |
|
||||
| `pageSize` | number | 20 | 每页条数(1~100) |
|
||||
| `orderField` | string | - | 排序字段(camelCase,如 `createTime`) |
|
||||
| `orderType` | string | `desc` | 排序方向(`asc` / `desc`) |
|
||||
| `fields` | string | - | 返回字段(逗号分隔,如 `id,username,realname`) |
|
||||
| 其他参数 | - | - | 按 Logic 中配置的 `pageLikeFields` / `pageEqFields` 自动匹配 |
|
||||
|
||||
### 分页参数边界限制
|
||||
|
||||
| 参数 | 最小值 | 最大值 | 默认值 | 超出处理 |
|
||||
|------|--------|--------|--------|---------|
|
||||
| `pageNo` | 1 | - | 1 | 小于 1 时强制为 1 |
|
||||
| `pageSize` | 1 | 100 | 20 | 超出范围时限制在 1~100 |
|
||||
|
||||
## 请求示例
|
||||
|
||||
```bash
|
||||
# 基本分页
|
||||
GET /api/example/page?pageNo=1&pageSize=10
|
||||
|
||||
# 带模糊搜索(LIKE)
|
||||
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&name=测试&status=1
|
||||
|
||||
# 自定义排序
|
||||
GET /api/example/page?pageNo=1&pageSize=10&orderField=sort&orderType=asc
|
||||
|
||||
# 指定返回字段
|
||||
GET /api/example/page?pageNo=1&pageSize=10&fields=id,name,status
|
||||
|
||||
# 多条件组合
|
||||
GET /api/user/page?pageNo=1&pageSize=20&username=admin&status=1&deptId=3&orderField=id&orderType=desc
|
||||
```
|
||||
|
||||
## 返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"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" }
|
||||
],
|
||||
"total": 100,
|
||||
"size": 10,
|
||||
"current": 1,
|
||||
"pages": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 返回字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `records` | array | 当前页数据列表 |
|
||||
| `total` | number | 总记录数 |
|
||||
| `size` | number | 每页条数 |
|
||||
| `current` | number | 当前页码 |
|
||||
| `pages` | number | 总页数(`ceil(total / size)`) |
|
||||
|
||||
## 查询条件匹配
|
||||
|
||||
### pageLikeFields — 模糊匹配(LIKE)
|
||||
|
||||
在 Logic 中配置的字段,前端传参时自动使用 `LIKE '%值%'` 查询:
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
protected array $pageLikeFields = ['name', 'title'];
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# name 字段自动使用 LIKE 查询
|
||||
GET /api/example/page?name=测试
|
||||
# 生成: WHERE name LIKE '%测试%'
|
||||
```
|
||||
|
||||
### pageEqFields — 精确匹配(=)
|
||||
|
||||
在 Logic 中配置的字段,前端传参时自动使用 `= 值` 查询:
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
protected array $pageEqFields = ['status', 'type', 'category_id'];
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# status 和 type 字段自动使用 = 查询
|
||||
GET /api/example/page?status=1&type=2
|
||||
# 生成: WHERE status = 1 AND type = 2
|
||||
```
|
||||
|
||||
### 条件组合
|
||||
|
||||
```bash
|
||||
# 模糊 + 精确 + 排序 + 分页
|
||||
GET /api/example/page?pageNo=1&pageSize=20&name=测试&status=1&orderField=sort&orderType=asc
|
||||
|
||||
# 生成 SQL:
|
||||
# SELECT * FROM think_example
|
||||
# WHERE is_delete = 0
|
||||
# AND name LIKE '%测试%'
|
||||
# AND status = 1
|
||||
# ORDER BY sort ASC
|
||||
# LIMIT 20 OFFSET 0
|
||||
```
|
||||
|
||||
## 排序
|
||||
|
||||
### 前端传参排序
|
||||
|
||||
```bash
|
||||
# 按创建时间倒序
|
||||
GET /api/example/page?orderField=createTime&orderType=desc
|
||||
|
||||
# 按排序字段正序
|
||||
GET /api/example/page?orderField=sort&orderType=asc
|
||||
```
|
||||
|
||||
### Logic 默认排序
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 单字段排序
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = ['field' => 'sort', 'type' => 'asc'];
|
||||
|
||||
/**
|
||||
* 多字段排序
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected array $pageOrderBy = [
|
||||
['field' => 'sort', 'type' => 'asc'],
|
||||
['field' => 'id', 'type' => 'desc'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**优先级:** 前端传参 > Logic 默认配置
|
||||
|
||||
## 字段过滤
|
||||
|
||||
### fields 参数
|
||||
|
||||
前端可通过 `fields` 参数指定只需要的字段,减少数据传输量:
|
||||
|
||||
```bash
|
||||
# 只返回 id、name、status 三个字段
|
||||
GET /api/example/page?fields=id,name,status
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"records": [
|
||||
{ "id": 1, "name": "测试", "status": 1 },
|
||||
{ "id": 2, "name": "示例", "status": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** `id` 字段始终保留,即使未在 `fields` 中指定。
|
||||
|
||||
## 参数转换
|
||||
|
||||
前端使用 camelCase,后端自动转换为 snake_case:
|
||||
|
||||
| 前端参数 | 转换后 | 说明 |
|
||||
|---------|--------|------|
|
||||
| `pageNo` | `pageNo` | 不转换(排除项) |
|
||||
| `pageSize` | `pageSize` | 不转换(排除项) |
|
||||
| `orderField` | `orderField` | 不转换(排除项) |
|
||||
| `orderType` | `orderType` | 不转换(排除项) |
|
||||
| `fields` | `fields` | 不转换(排除项) |
|
||||
| `categoryId` | `category_id` | 自动转换 |
|
||||
| `createTime` | `create_time` | 自动转换 |
|
||||
| `userName` | `user_name` | 自动转换 |
|
||||
|
||||
### 排序字段值也自动转换
|
||||
|
||||
```bash
|
||||
# 前端传入 camelCase
|
||||
GET /api/example/page?orderField=createTime
|
||||
|
||||
# 后端自动转换为 snake_case
|
||||
ORDER BY create_time DESC
|
||||
```
|
||||
|
||||
## Controller 层实现
|
||||
|
||||
### 使用 _index 快捷方法(推荐)
|
||||
|
||||
```php
|
||||
class ExampleController extends BaseController
|
||||
{
|
||||
protected ExampleLogic $logic;
|
||||
|
||||
protected function initialize(): void
|
||||
{
|
||||
$this->logic = new ExampleLogic();
|
||||
}
|
||||
|
||||
#[Permission('sys:example:page', '案例分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
return parent::_index($this->logic);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 带额外查询条件
|
||||
|
||||
```php
|
||||
#[Permission('sys:user:page', '用户分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
// 额外条件:只查启用状态的用户
|
||||
return parent::_index($this->logic, ['status' => 1]);
|
||||
}
|
||||
```
|
||||
|
||||
### 带关联查询
|
||||
|
||||
```php
|
||||
#[Permission('sys:article:page', '文章分页')]
|
||||
public function page(): Json
|
||||
{
|
||||
// 预加载分类关联
|
||||
return parent::_index($this->logic, [], ['category']);
|
||||
}
|
||||
```
|
||||
|
||||
## Logic 层实现
|
||||
|
||||
```php
|
||||
class ExampleLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* 关联的模型类
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
### API 函数
|
||||
|
||||
```typescript
|
||||
// src/api/tool/example.ts
|
||||
export function getExamplePage(params?) {
|
||||
return http.request({ url: '/example/page', method: 'GET', params });
|
||||
}
|
||||
```
|
||||
|
||||
### 组件使用
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { getExamplePage } from '@/api/tool/example';
|
||||
|
||||
const tableData = ref([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const searchForm = ref({ name: '', status: '' });
|
||||
|
||||
const fetchData = async () => {
|
||||
const res = await getExamplePage({
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
...searchForm.value,
|
||||
});
|
||||
tableData.value = res.records;
|
||||
total.value = res.total;
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 翻页
|
||||
const handlePageChange = (page) => {
|
||||
pageNo.value = page;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
onMounted(() => fetchData());
|
||||
</script>
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:搜索条件不生效
|
||||
|
||||
**原因:** 字段名未在 `pageLikeFields` 或 `pageEqFields` 中配置。
|
||||
|
||||
**解决:** 在 Logic 中添加对应字段配置。
|
||||
|
||||
### 问题 2:排序不生效
|
||||
|
||||
**原因:** `orderField` 传了数据库不存在的字段名。
|
||||
|
||||
**解决:** 确保 `orderField` 传的是数据库字段名的 camelCase 形式。
|
||||
|
||||
### 问题 3:分页数据重复
|
||||
|
||||
**原因:** 排序字段值相同,导致数据库返回顺序不确定。
|
||||
|
||||
**解决:** 排序字段加上唯一字段(如 id)作为第二排序条件。
|
||||
@@ -0,0 +1,328 @@
|
||||
# 8.1 统一响应格式
|
||||
|
||||
:::tip 概述
|
||||
所有接口返回统一的 JSON 结构,前端可据此判断请求结果。响应由 `response\Result` 类构造,保证全系统格式一致。
|
||||
:::
|
||||
|
||||
## 响应结构
|
||||
|
||||
```typescript
|
||||
interface ApiResponse {
|
||||
code: number; // 状态码:0=成功,其他=失败
|
||||
ok: boolean; // 是否成功
|
||||
msg: string; // 提示信息
|
||||
data: any; // 响应数据
|
||||
}
|
||||
```
|
||||
|
||||
## 状态码定义
|
||||
|
||||
| code | 含义 | ok | 说明 |
|
||||
|------|------|-----|------|
|
||||
| `0` | 成功 | `true` | 业务成功 |
|
||||
| `1` | 业务失败 | `false` | 通用业务错误(参数错误、业务校验失败等) |
|
||||
| `401` | 未授权 | `false` | Token 缺失/无效/过期 |
|
||||
| `403` | 禁止访问 | `false` | 权限不足 |
|
||||
| `404` | 数据不存在 | `false` | 查询的记录不存在 |
|
||||
| `422` | 参数验证失败 | `false` | Validate 校验不通过 |
|
||||
|
||||
## 成功响应
|
||||
|
||||
### 普通成功
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"realname": "管理员",
|
||||
"createTime": "2026-01-01 00:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 带自定义消息
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "添加成功",
|
||||
"data": { "id": 42 }
|
||||
}
|
||||
```
|
||||
|
||||
### data 为 null
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "修改成功",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### data 为数组
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": [
|
||||
{ "id": 1, "name": "选项一" },
|
||||
{ "id": 2, "name": "选项二" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 分页响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"records": [
|
||||
{ "id": 1, "username": "admin", "createTime": "2026-01-01 00:00:00" },
|
||||
{ "id": 2, "username": "user1", "createTime": "2026-01-02 00:00:00" }
|
||||
],
|
||||
"total": 100,
|
||||
"size": 20,
|
||||
"current": 1,
|
||||
"pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 分页参数
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `records` | array | 当前页数据列表 |
|
||||
| `total` | number | 总记录数 |
|
||||
| `size` | number | 每页条数 |
|
||||
| `current` | number | 当前页码 |
|
||||
| `pages` | number | 总页数(`ceil(total / size)`) |
|
||||
|
||||
## 失败响应
|
||||
|
||||
### 通用业务失败
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"ok": false,
|
||||
"msg": "用户名已存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 参数验证失败
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"ok": false,
|
||||
"msg": "参数验证失败",
|
||||
"data": "用户名不能为空"
|
||||
}
|
||||
```
|
||||
|
||||
### 批量验证失败
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"ok": false,
|
||||
"msg": "参数验证失败",
|
||||
"data": [
|
||||
"用户名不能为空",
|
||||
"姓名不能为空"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 数据不存在
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"ok": false,
|
||||
"msg": "数据不存在",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 未授权(Token 问题)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"ok": false,
|
||||
"msg": "token已过期",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 权限不足
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 403,
|
||||
"ok": false,
|
||||
"msg": "无访问权限",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `code` | number | 状态码,0=成功 |
|
||||
| `ok` | boolean | 是否成功 |
|
||||
| `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`
|
||||
|
||||
### 方法清单
|
||||
|
||||
| 方法 | 说明 | 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 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
// Controller 中通过 BaseController 调用
|
||||
$this->success($data, '操作成功');
|
||||
$this->success(['id' => $id], '添加成功');
|
||||
$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('用户名不能为空');
|
||||
```
|
||||
|
||||
## 驼峰自动转换
|
||||
|
||||
响应数据自动从 snake_case 转为 camelCase:
|
||||
|
||||
| 数据库字段 | 响应字段 |
|
||||
|-----------|---------|
|
||||
| `create_time` | `createTime` |
|
||||
| `user_name` | `userName` |
|
||||
| `is_delete` | `isDelete` |
|
||||
| `dept_id` | `deptId` |
|
||||
|
||||
可通过 `config/api.php` 的 `camel_snake_convert` 配置关闭。
|
||||
|
||||
## 前端响应处理
|
||||
|
||||
### Axios 响应拦截器
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
const { code, data, msg } = response.data;
|
||||
|
||||
// 成功
|
||||
if (code === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Token 过期
|
||||
if (code === 401) {
|
||||
localStorage.clear();
|
||||
router.push('/login');
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 权限不足
|
||||
if (code === 403) {
|
||||
ElMessage.error(msg || '无访问权限');
|
||||
return Promise.reject(new Error(msg));
|
||||
}
|
||||
|
||||
// 其他业务错误
|
||||
ElMessage.error(msg || '操作失败');
|
||||
return Promise.reject(new Error(msg));
|
||||
},
|
||||
error => {
|
||||
ElMessage.error('网络错误');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### 处理分页数据
|
||||
|
||||
```typescript
|
||||
const fetchData = async () => {
|
||||
const res = await getUserPage({ pageNo: 1, pageSize: 20 });
|
||||
// res 已经是 data 部分(拦截器已解包)
|
||||
tableData.value = res.records;
|
||||
total.value = res.total;
|
||||
};
|
||||
```
|
||||
|
||||
### 处理导入结果
|
||||
|
||||
```typescript
|
||||
const handleImport = async (file) => {
|
||||
const res = await importUsers(file);
|
||||
// res = { count: 8, errors: [{ row: 3, reason: "..." }] }
|
||||
if (res.errors.length === 0) {
|
||||
ElMessage.success(`成功导入 ${res.count} 条`);
|
||||
} else {
|
||||
ElMessage.warning(`成功 ${res.count} 条,失败 ${res.errors.length} 条`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 常见响应场景汇总
|
||||
|
||||
| 场景 | code | msg | data |
|
||||
|------|------|-----|------|
|
||||
| 查询成功 | 0 | 操作成功 | 数据对象/数组 |
|
||||
| 分页成功 | 0 | 操作成功 | {records, total, size, current, pages} |
|
||||
| 新增成功 | 0 | 添加成功 | {id: 42} |
|
||||
| 修改成功 | 0 | 修改成功 | null |
|
||||
| 删除成功 | 0 | 删除成功 | null |
|
||||
| 业务失败 | 1 | 具体错误信息 | null |
|
||||
| 参数验证失败 | 1 | 参数验证失败 | 错误字符串或数组 |
|
||||
| Token 过期 | 401 | token已过期 | null |
|
||||
| 无权限 | 403 | 无访问权限 | null |
|
||||
| 数据不存在 | 404 | 数据不存在 | null |
|
||||
@@ -0,0 +1,531 @@
|
||||
# 8.6 Token 使用说明
|
||||
|
||||
:::tip 概述
|
||||
系统采用 JWT 双令牌认证,access_token 用于接口认证(2小时),refresh_token 用于刷新令牌(7天)。所有需认证的接口必须在请求头中携带 access_token。
|
||||
:::
|
||||
|
||||
## Token 完整生命周期
|
||||
|
||||
### 登录获取 Token
|
||||
|
||||
```text
|
||||
用户输入账号密码
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端:POST /api/login │
|
||||
│ { username, password, code, key } │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端:LoginController → LoginLogic → JwtService │
|
||||
│ │
|
||||
│ 1. CaptchaService::check() 验证码校验 │
|
||||
│ 2. User::where('username') 查询用户 │
|
||||
│ 3. PasswordService::verify() 密码校验 │
|
||||
│ 4. Jwt::createAccessToken() 签发 access_token(2小时) │
|
||||
│ Jwt::createRefreshToken() 签发 refresh_token(7天) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 响应: │
|
||||
│ { │
|
||||
│ "accessToken": "eyJ...", ← 用于接口认证 │
|
||||
│ "refreshToken": "eyJ...", ← 用于刷新令牌 │
|
||||
│ "tokenType": "Bearer", │
|
||||
│ "expiresIn": 7200, ← 2小时 = 7200秒 │
|
||||
│ "user": { "id":1, "username":"admin" } │
|
||||
│ } │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端:存储 Token │
|
||||
│ │
|
||||
│ localStorage.setItem('access_token', accessToken) │
|
||||
│ localStorage.setItem('refresh_token', refreshToken) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 使用 Token 请求 API
|
||||
|
||||
```text
|
||||
用户操作(如点击"用户列表")
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端:请求拦截器 │
|
||||
│ │
|
||||
│ const token = localStorage.getItem('access_token') │
|
||||
│ config.headers['Authorization'] = `Bearer ${token}` │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ GET /api/user/page?pageNo=1&pageSize=10 │
|
||||
│ Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 后端:AuthMiddleware │
|
||||
│ │
|
||||
│ 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 → 响应 │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端:响应拦截器 │
|
||||
│ │
|
||||
│ code === 0 → 返回 data(成功) │
|
||||
│ code === 401 → Token 过期,尝试刷新 │
|
||||
│ code === 403 → 提示"无访问权限" │
|
||||
│ 其他 → 提示 msg │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Token 过期自动刷新
|
||||
|
||||
```text
|
||||
前端请求 API → 后端返回 code=401 "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) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Token 刷新失败(重新登录)
|
||||
|
||||
```text
|
||||
前端请求 API → 后端返回 code=401
|
||||
│
|
||||
▼
|
||||
尝试用 refresh_token 刷新
|
||||
│
|
||||
▼
|
||||
刷新失败(refresh_token 也过期/无效)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 前端:清除登录态,跳转登录页 │
|
||||
│ │
|
||||
│ localStorage.removeItem('access_token') │
|
||||
│ localStorage.removeItem('refresh_token') │
|
||||
│ window.location.href = '/login' │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 两种 Token 的区别
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ access_token │
|
||||
│ │
|
||||
│ 用途:接口认证(每个 API 请求携带) │
|
||||
│ 有效期:2 小时(7200 秒) │
|
||||
│ payload.type:access │
|
||||
│ 传递方式:Authorization: Bearer <token> │
|
||||
│ 校验方:AuthMiddleware │
|
||||
│ 特点:短期有效,泄露影响范围有限 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ refresh_token │
|
||||
│ │
|
||||
│ 用途:刷新令牌(仅在 access_token 过期时使用) │
|
||||
│ 有效期:7 天(604800 秒) │
|
||||
│ payload.type:refresh │
|
||||
│ 传递方式:POST /api/oauth2/token 请求体 │
|
||||
│ 校验方:JwtService::refreshToken() │
|
||||
│ 特点:长期有效,仅用于换取新令牌,不用于接口认证 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 请求头格式
|
||||
|
||||
```text
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# curl
|
||||
curl http://localhost:8000/api/user/page \
|
||||
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
|
||||
# curl(带分页参数)
|
||||
curl "http://localhost:8000/api/user/page?pageNo=1&pageSize=10" \
|
||||
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
```
|
||||
|
||||
### 请求头配置
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `jwt.header_name` | `Authorization` | 请求头名称 |
|
||||
| `jwt.header_prefix` | `Bearer ` | 请求头前缀(注意有空格) |
|
||||
|
||||
## Token 获取
|
||||
|
||||
### 登录获取
|
||||
|
||||
```bash
|
||||
POST /api/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"code": "a3Bx",
|
||||
"key": "captcha_key_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**成功响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "登录成功",
|
||||
"data": {
|
||||
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"refreshToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"realname": "管理员",
|
||||
"avatar": "/uploads/user/avatar/avatar.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth2 密码模式获取
|
||||
|
||||
```bash
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=password&username=admin&password=123456
|
||||
```
|
||||
|
||||
### 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `accessToken` | string | 访问令牌(用于接口认证) |
|
||||
| `refreshToken` | string | 刷新令牌(用于换取新令牌) |
|
||||
| `tokenType` | string | 令牌类型(固定 `Bearer`) |
|
||||
| `expiresIn` | number | access_token 有效期(秒,默认 7200) |
|
||||
| `user` | object | 用户基础信息(id、username、realname、avatar) |
|
||||
|
||||
## Token 刷新
|
||||
|
||||
access_token 过期后(响应 code=401),使用 refresh_token 换取新令牌对:
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `POST /api/oauth2/token` |
|
||||
| Content-Type | `application/json` 或 `application/x-www-form-urlencoded` |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```bash
|
||||
# JSON 格式
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
}
|
||||
|
||||
# 表单格式
|
||||
POST /api/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=refresh_token&refresh_token=eyJ0eXAiOi...
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"accessToken": "eyJ...(新access_token)",
|
||||
"refreshToken": "eyJ...(新refresh_token)",
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": 7200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应
|
||||
|
||||
| 场景 | code | msg |
|
||||
|------|------|-----|
|
||||
| 缺少 refresh_token | 1 | 缺少refresh_token参数 |
|
||||
| refresh_token 无效/过期 | 1 | 无效的refresh_token |
|
||||
| 用户不存在/禁用 | 1 | 用户不存在或已被禁用 |
|
||||
|
||||
## Token 有效性检查
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `GET /api/oauth2/check/token` |
|
||||
| 权限 | 无需认证 |
|
||||
|
||||
### 请求示例
|
||||
|
||||
```bash
|
||||
# 通过请求头传递
|
||||
GET /api/oauth2/check/token
|
||||
Authorization: Bearer <access_token>
|
||||
|
||||
# 通过 URL 参数传递
|
||||
GET /api/oauth2/check/token?token=<access_token>
|
||||
```
|
||||
|
||||
### 成功响应(有效)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"valid": true,
|
||||
"uid": 1,
|
||||
"exp": 1727007200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 失败响应(无效)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "操作成功",
|
||||
"data": {
|
||||
"valid": false,
|
||||
"msg": "token已过期"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Token 吊销
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `DELETE /api/oauth2/remove/{token}` |
|
||||
|
||||
```bash
|
||||
DELETE /api/oauth2/remove/eyJ0eXAiOi...
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
:::warning 简化实现
|
||||
当前 Token 吊销为简化实现(直接返回 true),JWT 本身无状态,服务端无法直接使其失效。生产环境建议结合 Redis 黑名单机制实现真正的吊销。
|
||||
:::
|
||||
|
||||
## URL 参数传递(SSO 场景)
|
||||
|
||||
路由守卫支持通过 URL 参数传递 Token,适用于 SSO 单点登录场景:
|
||||
|
||||
```html
|
||||
http://localhost:8001/?token=<access_token>
|
||||
```
|
||||
|
||||
**前端处理逻辑:**
|
||||
|
||||
```text
|
||||
1. 路由守卫检测 URL 中的 token 参数
|
||||
2. 将 token 存入 localStorage
|
||||
3. 清除 URL 中的 token 参数(防止泄露)
|
||||
4. 后续请求自动携带该 token
|
||||
```
|
||||
|
||||
## 前端存储与使用
|
||||
|
||||
### 存储
|
||||
|
||||
```typescript
|
||||
// 登录后存储
|
||||
localStorage.setItem('access_token', data.accessToken);
|
||||
localStorage.setItem('refresh_token', data.refreshToken);
|
||||
|
||||
// 登出时清除
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
```
|
||||
|
||||
### 请求拦截器(自动携带)
|
||||
|
||||
```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;
|
||||
});
|
||||
```
|
||||
|
||||
### 响应拦截器(自动刷新)
|
||||
|
||||
```typescript
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
async error => {
|
||||
const { config, response } = error;
|
||||
|
||||
if (response?.status === 401 && !config._retry) {
|
||||
config._retry = true;
|
||||
|
||||
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 重试原请求
|
||||
config.headers['Authorization'] = `Bearer ${res.data.accessToken}`;
|
||||
return axios(config);
|
||||
} catch (e) {
|
||||
// 刷新失败,跳转登录
|
||||
localStorage.clear();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Token Payload 结构
|
||||
|
||||
### access_token
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "access",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727007200
|
||||
}
|
||||
```
|
||||
|
||||
### refresh_token
|
||||
|
||||
```json
|
||||
{
|
||||
"uid": 1,
|
||||
"username": "admin",
|
||||
"type": "refresh",
|
||||
"iss": "rxthinkcmf",
|
||||
"iat": 1727000000,
|
||||
"exp": 1727604800
|
||||
}
|
||||
```
|
||||
|
||||
## 错误响应汇总
|
||||
|
||||
| 场景 | HTTP 状态 | code | msg |
|
||||
|------|----------|------|-----|
|
||||
| 未提供 Token | 200 | 401 | 未提供认证令牌 |
|
||||
| Token 过期 | 200 | 401 | token已过期 |
|
||||
| Token 签名无效 | 200 | 401 | token签名无效 |
|
||||
| Token 类型错误 | 200 | 401 | 无效的认证令牌 |
|
||||
| 无权限 | 200 | 403 | 无访问权限 |
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置键 | 环境变量 | 默认值 | 说明 |
|
||||
|--------|---------|--------|------|
|
||||
| `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.header_prefix` | — | `Bearer ` | 请求头前缀 |
|
||||
|
||||
:::warning 生产环境安全
|
||||
生产环境务必通过 `.env` 设置 `JWT.SECRET` 为强随机密钥:
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32));"
|
||||
```
|
||||
:::
|
||||
@@ -0,0 +1,293 @@
|
||||
# 8.4 文件上传接口
|
||||
|
||||
:::tip 概述
|
||||
文件上传接口将文件保存到临时目录,返回文件 URL。前端拿到 URL 后提交表单时传给后端,BaseLogic 在 add/update 时自动迁移到正式目录。通过 `type` 参数可区分上传类型(文件/图片)。
|
||||
:::
|
||||
|
||||
## 接口信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| URL | `POST /api/upload/uploadFile` |
|
||||
| Content-Type | `multipart/form-data` |
|
||||
| 权限 | 无需认证(公开接口) |
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `file` | File | 是 | - | 文件二进制数据 |
|
||||
| `type` | string | 否 | `file` | 上传类型:`file`(所有合法文件)/ `image`(仅图片) |
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"ok": true,
|
||||
"msg": "上传成功",
|
||||
"data": {
|
||||
"fileUrl": "https://cdn.example.com/temp/20260920/a1b2c3d4e5f6.jpg",
|
||||
"filePath": "temp/20260920/a1b2c3d4e5f6.jpg",
|
||||
"originalName": "photo.jpg",
|
||||
"fileSize": 102400,
|
||||
"fileExtension": "jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileName": "a1b2c3d4e5f6.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 响应字段
|
||||
|
||||
| 字段 | 类型 | 说明 | 示例 |
|
||||
|------|------|------|------|
|
||||
| `fileUrl` | string | 完整 URL(含域名) | `https://cdn.example.com/temp/...` |
|
||||
| `filePath` | string | 相对路径 | `temp/20260920/a1b2c3d4e5f6.jpg` |
|
||||
| `originalName` | string | 原始文件名 | `photo.jpg` |
|
||||
| `fileSize` | number | 文件大小(字节) | `102400` |
|
||||
| `fileExtension` | string | 扩展名 | `jpg` |
|
||||
| `fileType` | string | MIME 类型 | `image/jpeg` |
|
||||
| `fileName` | string | 服务器文件名(随机) | `a1b2c3d4e5f6.jpg` |
|
||||
|
||||
### type 参数说明
|
||||
|
||||
| type 值 | 说明 | 允许的后缀 | 适用场景 |
|
||||
|---------|------|-----------|---------|
|
||||
| `file`(默认) | 所有合法文件 | 由 `config/file.php` 的 `file_ext` 控制 | 文档、压缩包等 |
|
||||
| `image` | 仅图片 | 由 `config/file.php` 的 `image_ext` 控制 | 头像、封面等 |
|
||||
|
||||
**file_ext 默认值:**
|
||||
|
||||
```text
|
||||
gif, jpg, jpeg, png, bmp, webp, svg,
|
||||
doc, docx, xls, xlsx, ppt, pptx,
|
||||
pdf, zip, rar, txt, csv,
|
||||
mp3, mp4, sql, js, css
|
||||
```
|
||||
|
||||
**image_ext 默认值:**
|
||||
|
||||
```text
|
||||
gif, jpg, jpeg, png, bmp, webp, svg
|
||||
```
|
||||
|
||||
## 失败响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"ok": false,
|
||||
"msg": "不允许的文件类型: .exe",
|
||||
"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` |
|
||||
|
||||
## 前端使用
|
||||
|
||||
### ElementPlus Upload 组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 上传文件(默认 type=file) -->
|
||||
<el-upload
|
||||
:action="'/api/upload/uploadFile'"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
:on-success="handleUploadSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<el-button type="primary">上传文件</el-button>
|
||||
</el-upload>
|
||||
|
||||
<!-- 上传图片(指定 type=image) -->
|
||||
<el-upload
|
||||
:action="'/api/upload/uploadFile?type=image'"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
:on-success="handleImageSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button type="primary">上传图片</el-button>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
ElMessage.error('文件大小不能超过 10MB');
|
||||
}
|
||||
return isLt10M;
|
||||
};
|
||||
|
||||
const handleUploadSuccess = (response) => {
|
||||
if (response.code === 0) {
|
||||
form.value.file = response.data.fileUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageSuccess = (response) => {
|
||||
if (response.code === 0) {
|
||||
form.value.avatar = response.data.fileUrl;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 手动上传
|
||||
|
||||
```typescript
|
||||
// 上传文件
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await http.request({
|
||||
url: '/upload/uploadFile',
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
// res.fileUrl = "https://cdn.example.com/temp/20260920/abc.jpg"
|
||||
|
||||
// 上传图片(指定 type=image)
|
||||
const res2 = await http.request({
|
||||
url: '/upload/uploadFile?type=image',
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
```
|
||||
|
||||
### 提交表单时传 URL
|
||||
|
||||
```typescript
|
||||
// 前端上传后拿到 fileUrl,提交表单时传给后端
|
||||
await exampleAdd({
|
||||
name: '测试',
|
||||
avatar: 'https://cdn.example.com/temp/20260920/abc.jpg',
|
||||
});
|
||||
|
||||
// 后端 BaseLogic::add() 自动:
|
||||
// 1. 检测到 avatar 是临时文件 URL
|
||||
// 2. 调用 save_file() 迁移到正式目录
|
||||
// 3. 去掉域名,只存相对路径入库
|
||||
```
|
||||
|
||||
### 头像上传示例
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="avatar-upload">
|
||||
<el-avatar :src="form.avatar" :size="80" />
|
||||
<el-upload
|
||||
:action="'/api/upload/uploadFile?type=image'"
|
||||
:headers="{ Authorization: `Bearer ${token}` }"
|
||||
:show-file-list="false"
|
||||
:on-success="handleAvatarSuccess"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button size="small">更换头像</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const form = ref({ avatar: '' });
|
||||
|
||||
const handleAvatarSuccess = (response) => {
|
||||
form.value.avatar = response.data.fileUrl;
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 上传后处理流程
|
||||
|
||||
```text
|
||||
前端上传 → temp/日期/xxx.jpg(临时目录)
|
||||
│
|
||||
▼ 提交表单(携带 URL)
|
||||
BaseLogic::add()
|
||||
│
|
||||
├─ processFileFieldsOnSave()
|
||||
│ └─ save_file() → 正式目录/日期/xxx.jpg
|
||||
│
|
||||
└─ 数据库存储相对路径
|
||||
│
|
||||
▼ 读取时
|
||||
processFileFieldsOnRead()
|
||||
└─ get_file_url() → 拼接域名返回完整 URL
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
```ini
|
||||
; .env
|
||||
[FILE]
|
||||
UPLOAD_DIR = D:\xampp8.2\htdocs\RXThinkCMF_ThinkPhp8_EleVue\uploads ; 上传根目录
|
||||
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
|
||||
```
|
||||
|
||||
## 安全设计
|
||||
|
||||
| 安全措施 | 说明 |
|
||||
|---------|------|
|
||||
| 文件类型校验 | 同时检查后缀名和 MIME 类型(finfo),防止伪造扩展名 |
|
||||
| 文件大小限制 | 通过 `config/file.php` 的 `max_size` 配置 |
|
||||
| 图片内容校验 | 图片类型额外用 `getimagesize` 验证,防止非图片伪装 |
|
||||
| 随机文件名 | 使用 `bin2hex(random_bytes(16))` 防止文件名冲突和路径遍历 |
|
||||
| 临时目录隔离 | 上传文件先存入 `temp/` 目录,提交表单后才迁移到正式目录 |
|
||||
| Nginx 防护 | 上传目录禁止执行 PHP 文件 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:上传返回 401
|
||||
|
||||
**原因:** 上传接口需要认证,但请求头未携带 Token。
|
||||
|
||||
**解决:** 确保请求头包含 `Authorization: Bearer <token>`。
|
||||
|
||||
### 问题 2:上传返回"不允许的文件类型"
|
||||
|
||||
**原因:** 文件扩展名不在白名单中。
|
||||
|
||||
**解决:** 检查 `.env` 中 `FILE.FILE_EXT` 或 `FILE.IMAGE_EXT` 配置。
|
||||
|
||||
### 问题 3:上传后图片不显示
|
||||
|
||||
**原因:** `DOMAIN_URL` 配置错误或 Nginx 未配置静态文件处理。
|
||||
|
||||
**解决:** 检查 `.env` 中 `FILE.DOMAIN_URL` 是否正确,Nginx 是否配置了 `/uploads/` 的 alias。
|
||||
|
||||
### 问题 4:大文件上传失败
|
||||
|
||||
**原因:** PHP 或 Nginx 限制了上传大小。
|
||||
|
||||
**解决:**
|
||||
|
||||
```ini
|
||||
; php.ini
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 25M
|
||||
|
||||
; .env
|
||||
FILE.MAX_SIZE = 20
|
||||
```
|
||||
|
||||
```nginx
|
||||
# nginx.conf
|
||||
client_max_body_size 20M;
|
||||
```
|
||||
Reference in New Issue
Block a user