签入版本

This commit is contained in:
2026-09-29 09:49:04 +08:00
commit 04eec2664c
210 changed files with 37515 additions and 0 deletions
@@ -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` |
## 前端使用
### Element Plus 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:/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
```
## 安全设计
| 安全措施 | 说明 |
|---------|------|
| 文件类型校验 | 同时检查后缀名和 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;
```