签入版本

This commit is contained in:
小蚂蚁云
2026-10-25 17:24:27 +08:00
parent 48911cc08f
commit fdbeae092a
1010 changed files with 30682 additions and 95274 deletions
@@ -0,0 +1,63 @@
# 8.4 请求绑定与参数校验
:::tip 概述
请求绑定由反射路由层统一完成:写入顺序 query → body → path(后写覆盖),校验读 binding 标签、错误消息取 msg 标签。文件类请求经 UnmarshalBody 逃生舱接管。
:::
## 绑定优先级
```text
写入顺序:query(GET 参数)→ body(JSON)→ path(路径参数)
最终优先级:path > body > query
```
字段带 `json`(body)与 `form`(query)双标签即可参与两种来源;路径参数(`:id`)按名覆盖同名字段。
## 校验标签
```go
// internal/model/dto/user.go(真实契约)
Username string `json:"username" binding:"required,min=2,max=30" msg:"请输入用户名;用户名长度需在2~30位"`
Mobile string `json:"mobile" binding:"omitempty,phone" msg:"手机号格式不正确"`
```
| 标签 | 作用 |
|---|---|
| binding | 规则(required/min/max/oneof/gte/lte/omitempty/phone/email…) |
| msg | 失败提示语(直接作为信封 msg);`;` 分段与多规则一一对应,单段适用全部规则 |
| dc | 字段说明(OpenAPI 文档生成) |
校验在 handler 前完成,**快速失败**(返回第一条错误);内嵌 PageReq 递归校验。失败即信封返回,业务代码零参与。
## 各类载荷示例
| 载荷 | 方式 |
|---|---|
| GET 查询参数 | form 标签 + query |
| JSON body | json 标签 |
| 路径参数 | path 模板 `:id` + 同名字段 |
| multipart 文件 | UnmarshalBody 逃生舱 |
| 批量删除 | `Ids []int64` + `min=1` |
## UnmarshalBody 逃生舱
结构体实现以下接口即接管解析(文件上传标准姿势):
```go
type BodyUnmarshaler interface {
UnmarshalBody(c *gin.Context) error
}
```
返回 error = 校验失败,同样走统一信封。
## 接口调试要点
- 分页参数走 query:`?pageNo=1&pageSize=10`
- 修改类(PUT)建议全量提交业务字段(Update 全量表单语义)
- 路径参数与 body 同名冲突时以路径为准
## 相关
- [3.8 参数校验与错误上抛](/zh/guide/validation.html)(开发视角)
- [3.3 契约层开发(dto)](/zh/guide/dto.html)
+27 -220
View File
@@ -1,237 +1,44 @@
# 8.5 字典数据接口
# 8.6 字典数据接口
:::tip 概述
字典数据接口分为两组:字典管理(DictController)和字典项管理(DictItemController)。前端通过字典编码获取字典项列表用于下拉框,后台通过管理接口维护字典数据。
字典数据分两套入口:**别名接口**(/api/dictionary/*,登录即可)供业务侧读字典定义与项;**字典项快捷接口**(getDictItemList)供前端下拉取选项。管理类接口需管理权限码。
:::
## 接口总览
## 业务读取(登录即可)
### 字典管理(DictController)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/dictionary/list | 字典列表(含字典项) |
| GET | /api/dictionary/info | 字典详情 |
| 方法 | 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 | 刷新字典缓存 |
未声明 Permission ——登录即可访问,供页面初始化取字典数据。
### 字典项管理(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}` | — | 按编码获取字典项列表 |
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/dict/item/getDictItemList/{code} | 按字典编码取全部字典项 |
## 按编码获取字典项(最常用)
前端下拉数据源标准入口:传字典编码(如 `example_status`),返回该项集合(value/label/排序)。
### 接口信息
## 管理接口(需权限码)
| 项目 | 说明 |
|------|------|
| URL | `GET /api/dict/item/getDictItemList/{code}` |
| 权限 | 无需额外权限(已登录即可) |
| 用途 | 前端下拉框数据源 |
| 路径 | 权限码前缀 |
|---|---|
| /api/dict/*(字典 CRUD、refreshCache) | sys:dict:* |
| /api/dict/item/*(字典项 CRUD) | sys:dictitem:* |
### 请求示例
## 缓存行为
```bash
GET /api/dict/item/getDictItemList/gender
Authorization: Bearer <token>
```
字典项集合按编码缓存(变更主动失效)。业务读接口命中缓存毫秒级返回;`GET /api/dict/refreshCache` 为运维手动兜底(需 sys:dict:cache)。
### 成功响应
## 前后端同源约定
```json
{
"code": 0,
"ok": true,
"msg": "操作成功",
"data": [
{ "id": 1, "name": "男", "value": "1", "sort": 1, "note": "" },
{ "id": 2, "name": "女", "value": "2", "sort": 2, "note": "" }
]
}
```
- 后端 SerializeMaps 回填 `{field}Text` 用的编码 = 前端下拉取选项的编码
- 枚举存储整型、字典值字符串——基座负责转换
- 显示名改动只需改字典项,两端同步生效
### 响应字段
## 相关
| 字段 | 类型 | 说明 |
|------|------|------|
| `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(); // 清除所有字典
```
- [5.11 字典模块](/zh/core/dict.html)
- [6.12 字典管理](/zh/modules/dict.html)
@@ -1,78 +1,70 @@
# 8.2 错误码对照表
:::tip 概述
所有接口返回统一的 JSON 结构,通过 `code` 字段区分业务状态。前端应根据 `code` 做差异化处理。
业务错误码定义在 `utility/apperr`,经 `internal/consts` 别名转出(internal 层只依赖 consts 一个入口)。对外信封除 401 外统一显示为 1,本表用于理解 msg 背后的原因分类。
:::
## 业务错误码
## 通用码
| code | ok | 含义 | 使用场景 | 前端处理 |
|------|-----|------|----------|----------|
| `0` | true | 成功 | 所有成功操作 | 正常处理 data |
| `1` | false | 一般失败 | 业务逻辑失败 | 显示 msg 提示 |
| `401` | false | 未授权 | Token 缺失/无效/过期 | 跳转登录页 |
| `403` | false | 禁止访问 | 权限不足/租户异常/演示环境 | 显示无权限提示 |
| `404` | false | 数据不存在 | 查询单条记录为空 | 显示数据不存在 |
| `422` | false | 参数验证失败 | 表单验证不通过 | 显示具体字段错误 |
| 码 | 常量 | 消息 |
|---|---|---|
| 1 | CodeFailed | 操作失败 |
| 400 | CodeParamError | 参数校验失败 |
| 401 | CodeUnauthorized | 登录已过期,请重新登录 |
| 403 | CodeForbidden | 没有该操作权限 |
| 429 | CodeTooManyRequests | 请求过于频繁,请稍后再试 |
| 500 | CodeServerError | 服务器内部错误 |
| 1009 | CodeRecordExists | 记录已存在 |
| 1010 | CodeRecordNotFound | 记录不存在 |
## 响应示例
## 认证与账户
> 响应示例详见 [统一响应格式](/zh/api-spec/response.html)
| 码 | 常量 | 消息 |
|---|---|---|
| 1001 | CodeUserNotFound | 用户不存在 |
| 1002 | CodeUserDisabled | 账号已被禁用 |
| 1003 | CodePasswordWrong | 密码错误 |
| 1004 | CodeCaptchaError | 验证码错误或已过期 |
| 1008 | CodeTokenInvalid | Token无效或已失效 |
| 1012 | CodeOldPwdWrong | 原密码错误 |
## 常见错误消息
## 租户
### 认证相关
| 码 | 常量 | 消息 |
|---|---|---|
| 1005 | CodeTenantExpired | 租户已过期 |
| 1006 | CodeTenantDisabled | 租户已禁用 |
| 1007 | CodeTenantUserLimit | 租户用户数已达上限 |
| 1013 | CodeTenantNotFound | 租户不存在 |
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `未提供认证令牌` | 请求头缺少 Authorization | 携带 Token |
| `无效的认证令牌` | Token 类型不是 access | 使用 access_token |
| `token已过期` | Token 超过有效期 | 调用刷新接口或重新登录 |
| `用户名或密码错误` | 账号或密码不正确 | 检查输入 |
| `账号已被禁用` | 用户 status != 1 | 联系管理员 |
## 业务约束(删除守卫等)
### 权限相关
| 码 | 常量 | 消息 | 场景 |
|---|---|---|---|
| 1011 | CodeHasChildren | 存在下级或关联记录,不能删除 | 树形删除守卫 |
| 1014 | CodeInUse | 记录已被引用,不能删除 | 引用删除守卫 |
| 1015 | CodeDemoMode | 演示环境,禁止操作! | demo 中间件命中 |
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `无访问权限:xxx` | 用户无该接口权限 | 联系管理员分配权限 |
| `租户已被禁用` | 租户 status != 1 | 联系平台管理员 |
| `租户已过期` | 租户 expire_time 已过 | 续费或联系平台 |
| `演示环境,禁止操作` | 演示模式下写操作被拦截 | 非演示环境操作 |
## 角色常量(非错误码)
### 业务相关
| 常量 | 值 | 说明 |
|---|---|---|
| RoleCodeSuperAdmin | ROLE_SUPER_ADMIN | 超管角色编码(保留) |
| PermWildcard | 通配权限码 | 超管通配(保留) |
| 消息 | 原因 | 解决方案 |
|------|------|----------|
| `xxx已存在` | 唯一性校验失败 | 修改重复字段值 |
| `数据不存在` | 记录已被删除或不存在 | 刷新列表 |
| `请选择要删除的数据` | 批量删除时 ids 为空 | 先选择数据 |
| `该部门下存在子部门,不可删除` | 删除前钩子校验 | 先删除子部门 |
保留编码仅平台初始化数据可用,租户自建角色禁止采用(防同名提权;超管判定另有 IsPlatform 门控双重防线)。
## 前端错误处理
## 新增错误码规约
```typescript
// Axios 响应拦截器
axios.interceptors.response.use(response => {
const { code, msg, data } = response.data;
1. 定义在 `utility/apperr`(码值 + 中文默认消息)
2. 在 `internal/consts/biz_err.go` 别名转出
3. 编号段规划:1xxx 认证租户业务类 / 10xx 守卫类
4. 业务侧经 `consts.CodeXxx` 哨兵或 `apperr.Newf(consts.CodeXxx.Code(), "fmt", a...)` 上抛
if (code === 0) {
return data; // 成功
}
## 对外语义提醒
if (code === 401) {
// Token 过期,跳转登录
router.push('/login');
return;
}
前端能稳定分支的只有 0 / 1 / 401 三值;细分码用于**服务端日志、排障与文档**。前端如需细分处理,以 msg 关键字或后续扩展专用字段为准,不建议直接依赖内部码。
if (code === 403) {
ElMessage.error(msg || '无权限');
return;
}
## 下一步
// 其他错误
ElMessage.error(msg || '操作失败');
return Promise.reject(new Error(msg));
});
```
→ [8.3 分页查询规范](/zh/api-spec/pagination.html)
+37 -191
View File
@@ -1,211 +1,57 @@
# 8.7 API 接口总览
# 8.8 API 接口总览
:::tip 概述
本节按模块分组列出所有 API 接口的概述,详细路由清单见[附录 E](/zh/appendix/api-cheatsheet.html)。
全部接口由 RouteSpec 声明派生,可经 **OpenAPI 端点**(/api.json)与 **Swagger UI**(/swagger)在线查看——文档与运行时元数据同源,永不漂移。
:::
## API 设计规范
## 在线文档端点
### 命名规范
| 端点 | 内容 |
|---|---|
| GET /api.json | OpenAPI JSON(接口清单、schema、权限码、公开标记) |
| /swagger | Swagger UI(浏览与调试) |
| 规范 | 说明 | 示例 |
|------|------|------|
| URL 风格 | RESTful,小写下划线 | `/api/user/page` |
| 模块名 | 与 Controller 对应 | `user`、`article`、`dict` |
| 操作名 | 标准 CRUD 动词 | `page`、`list`、`detail`、`add`、`update`、`delete`、`batchDelete` |
| 扩展操作 | 业务动词 | `import`、`export`、`resetPwd`、`refreshCache` |
开关:`api.docs`(生产建议关闭,防暴露接口清单与权限码元数据)。端点免登录(基础设施白名单)。
### 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>
RouteSpec 声明(Method/Path/Summary/Permission/Public)
+ 方法签名反射(ReqType/ResType → 请求/响应 schema,binding/msg/dc 标签)
→ OpenAPI 生成
```
## 公开接口(无需认证)
改声明即改文档,无需维护第二份接口清单。
| 模块 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 登录 | `/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/login、/api/captcha、/api/oauth2/* | 登录/验证码/令牌 |
| 个人中心 | /api/index/* | 用户信息/菜单/改密 |
| 组织权限 | /api/user、/api/role、/api/menu、/api/dept、/api/level、/api/position、/api/tenant | 系统管理 |
| 内容 | /api/article、/api/category、/api/notice、/api/link | 内容管理 |
| 数据配置 | /api/dict、/api/dict/item、/api/dictionary/*、/api/param、/api/config、/api/city | 数据管理 |
| 任务监控 | /api/job、/api/jobLog、/api/onlineUser、/api/serverMonitor、/api/cacheMonitor | 监控中心 |
| 日志 | /api/operationLog、/api/loginLog | 审计 |
| 文件 | /api/upload/*、/api/file/template/* | 文件与模板 |
| 工具 | /api/example/*、/api/generator/* | 示例与生成器 |
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 首页 | `/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 | 获取省份列表 |
- 请求/响应字段:小驼峰(dto json 标签)
- 分页:pageNo/pageSize → PageData 五字段([8.3](/zh/api-spec/pagination.html))
- 信封:0/1/401 三值语义([8.1](/zh/api-spec/response.html))
- 认证:Bearer access token([8.7](/zh/api-spec/token.html))
## 内容管理
## 第三方对接建议
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 文章 | `/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 | 删除链接 |
1. 从 /api.json 拉取清单生成客户端桩
2. 公开接口仅 /api/login、/api/captcha、/api/oauth2/token(其余需令牌)
3. 分页拉取用 Page 接口;字典取值用 /api/dict/item/getDictItemList/{code}
## 监控管理
## 下一步
| 模块 | 主要接口 | 方法 | 说明 |
|------|---------|------|------|
| 定时任务 | `/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)。
→ [第 9 章 前端开发](/zh/frontend/overview.html)
@@ -1,371 +1,70 @@
# 8.3 分页查询规范
:::tip 概述
所有分页接口遵循统一的参数命名和返回结构。分页由 `BaseController::_index()` → `BaseLogic::pageList()` 实现,前端只需传标准参数即可获得分页数据。
分页契约三件套:请求内嵌 `PageReq`、响应为 `PageData[T]`、默认与上限有兜底。前后端与生成器共享同一结构,无每个模块重定义。
:::
## 请求参数
## 请求(PageReq)
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `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
```go
// internal/model/dto/common.go
type PageReq struct {
PageNo int `json:"pageNo" form:"pageNo" dc:"页码,从1开始,默认1"`
PageSize int `json:"pageSize" form:"pageSize" dc:"每页条数,默认10,最大500"`
}
```
## 返回结构
| 参数 | 默认 | 边界 |
|---|---|---|
| pageNo | 1 | <=0 时取 1(GetPageNo 兜底) |
| pageSize | 10 | 上限 500(GetPageSize 兜底,防超大页拖库) |
GET 请求经 form 标签绑定 query 参数。
## 响应(PageData[T])
```go
type PageData[T any] struct {
Records []T `json:"records"` // 当前页记录
Total int `json:"total"` // 总记录数
Size int `json:"size"` // 每页条数(回显实际生效值)
Current int `json:"current"` // 页码(回显实际生效值)
Pages int `json:"pages"` // 总页数
}
```
## 请求响应示例
```json
// GET /api/example/page?pageNo=1&pageSize=10&name=测试
{
"code": 0,
"ok": true,
"msg": "操作成功",
"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": [],
"total": 0, "size": 10, "current": 1, "pages": 0
}
}
```
### 返回字段
## 服务端流水线
| 字段 | 类型 | 说明 |
|------|------|------|
| `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'];
}
```text
PageReq 兜底(GetPageNo/GetPageSize)
→ ModuleSpec.applyFilters(LikeFields/EqFields/Keyword/Filter/DataScope)
→ Count 总数 + Offset/Limit 查询
→ NewPageData 组壳(records 空输出 [])
```
```bash
# name 字段自动使用 LIKE 查询
GET /api/example/page?name=测试
# 生成: WHERE name LIKE '%测试%'
```
## 前端约定
### pageEqFields — 精确匹配(=)
- 分页组件绑定 pageNo/pageSize,变更即重查
- 列表为空渲染 `[]`(不判 null)
- 筛选条件与分页参数同请求发送(重置筛选时回到第 1 页)
在 Logic 中配置的字段,前端传参时自动使用 `= 值` 查询:
## List(不分页)的对应物
```php
class ExampleLogic extends BaseLogic
{
protected array $pageEqFields = ['status', 'type', 'category_id'];
}
```
全量列表用 List 接口:`limit` 参数可选限制条数(0=不限),响应为平铺数组。下拉数据源等小数据量场景用 List,列表页用 Page。
```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)作为第二排序条件。
→ [8.4 请求绑定与参数校验](/zh/api-spec/binding.html)
+36 -270
View File
@@ -1,34 +1,21 @@
# 8.1 统一响应格式
:::tip 概述
所有接口返回统一的 JSON 结构,前端可据此判断请求结果。响应由 `response\Result` 类构造,保证全系统格式一致。
所有接口返回统一 JSON 信封。对外业务码只有三种:**0 成功、1 失败(msg 说明原因)、401 未认证**——前端判断零歧义,详细原因经 msg 透出。
:::
## 响应结构
## 信封结构
```typescript
interface ApiResponse {
code: number; // 状态码:0=成功,其他=失败
interface Res {
code: number; // 0=成功,1=失败,401=登录过期
ok: boolean; // 是否成功
msg: string; // 提示信息
data: any; // 响应数据
data: any; // 数据(无数据为 null 或 {})
}
```
## 状态码定义
| code | 含义 | ok | 说明 |
|------|------|-----|------|
| `0` | 成功 | `true` | 业务成功 |
| `1` | 业务失败 | `false` | 通用业务错误(参数错误、业务校验失败等) |
| `401` | 未授权 | `false` | Token 缺失/无效/过期 |
| `403` | 禁止访问 | `false` | 权限不足 |
| `404` | 数据不存在 | `false` | 查询的记录不存在 |
| `422` | 参数验证失败 | `false` | Validate 校验不通过 |
## 成功响应
### 普通成功
## 成功
```json
{
@@ -37,50 +24,14 @@ interface ApiResponse {
"msg": "操作成功",
"data": {
"id": 1,
"username": "admin",
"realname": "管理员",
"createTime": "2026-01-01 00:00:00"
"username": "admin"
}
}
```
### 带自定义消息
自定义成功文案用 `OkMsg(msg, data)`(如导入结果提示)。
```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
{
@@ -88,10 +39,7 @@ interface ApiResponse {
"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" }
],
"records": [ { "id": 1, "name": "示例" } ],
"total": 100,
"size": 20,
"current": 1,
@@ -100,229 +48,47 @@ interface ApiResponse {
}
```
### 分页参数
records 为空时输出 `[]` 而非 null(NewPageData 保证)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `records` | array | 当前页数据列表 |
| `total` | number | 总记录数 |
| `size` | number | 每页条数 |
| `current` | number | 当前页码 |
| `pages` | number | 总页数(`ceil(total / size)`) |
## 失败响应
### 通用业务失败
## 失败
```json
{
"code": 1,
"ok": false,
"msg": "用户名已存在",
"data": null
}
{ "code": 1, "ok": false, "msg": "案例名称已存在", "data": {} }
```
### 参数验证失败
## 未认证
```json
{
"code": 1,
"ok": false,
"msg": "参数验证失败",
"data": "用户名不能为空"
}
{ "code": 401, "ok": false, "msg": "登录已过期,请重新登录", "data": {} }
```
### 批量验证失败
401 **原码透出**(不映射为 1),前端据此跳登录页。
```json
{
"code": 1,
"ok": false,
"msg": "参数验证失败",
"data": [
"用户名不能为空",
"姓名不能为空"
]
}
## 码映射规则(后端内部 → 对外)
后端内部错误码丰富(400/403/429/1001…,见 [8.2 错误码对照表](/zh/api-spec/error-code.html)),经 response.Fail 统一收敛:
```text
内部 401 → 对外 401(原样,触发前端跳登录)
其余非 0 → 对外 1(msg 取内部码的中文消息)
```
### 数据不存在
这样前端只需三分支:`code===0` 成功 / `code===401` 跳登录 / 其余弹 msg。
```json
{
"code": 1,
"ok": false,
"msg": "数据不存在",
"data": null
}
```
## HTTP 状态码
### 未授权(Token 问题)
响应体 code 与 HTTP 状态码同步(写信封时按 code 定状态),非 2xx 时响应体仍是 JSON 信封——不存在裸错误页。
```json
{
"code": 401,
"ok": false,
"msg": "token已过期",
"data": null
}
```
## 构造 API(后端)
### 权限不足
| 函数 | 用途 |
|---|---|
| response.Ok(data) | 成功 |
| response.OkMsg(msg, data) | 成功 + 自定义文案 |
| response.Page(list, total) | 分页 |
| response.Fail(err) | 失败(apperr 按码,其余通用 1) |
| response.FailCode(base, format, args) | 指定错误码 + 格式化消息 |
```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 |
→ [8.2 错误码对照表](/zh/api-spec/error-code.html)
+61 -508
View File
@@ -1,531 +1,84 @@
# 8.6 Token 使用说明
# 8.7 Token 使用说明
:::tip 概述
系统采用 JWT 双令牌认证,access_token 用于接口认证(2小时),refresh_token 用于刷新令牌(7天)。所有需认证的接口必须在请求头中携带 access_token。
客户端持有 access_token + refresh_token 双令牌:请求携带 access,过期后用 refresh 兑换新令牌。401 是唯一的"令牌失效"信号。本页字段以 `internal/model/dto/auth.go` 为准。
:::
## 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) │
└─────────────────────────────────────────────────────────────┘
POST /api/login(公开,参数支持 query/body 混合提交)
{
"username": "...",
"password": "...",
"key": "<captcha 接口返回的 key>",
"code": "<验证码>",
"tenantCode": "" // 留空按用户名全局定位
}
响应 data(LoginRes):
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"expires_in": 7200,
"token_type": "Bearer"
}
```
### 使用 Token 请求 API
## 携带
```http
Authorization: Bearer eyJ...
```
前端请求拦截器自动附加;业务代码不手工处理。
## 刷新
```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 │
└─────────────────────────────────────────────────────────────┘
POST /api/oauth2/token(公开)
grant_type=refresh_token 模式:
{ "grant_type": "refresh_token", "refresh_token": "<refresh_token>" }
grant_type=password 模式(兑换):
{ "grant_type": "password", "username": "...", "password": "...", "code": "...", "key": "..." }
```
### Token 过期自动刷新
响应 RefreshRes 结构与登录一致(access_token / refresh_token / expires_in / token_type)。旧 refresh 拉黑(防重放)。
## 校验
```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) │
└─────────────────────────────────────────────────────────────┘
GET /api/oauth2/check/token(携带 Authorization)
响应(CheckTokenRes):{ "valid": true, "userId": 1 }
```
### Token 刷新失败(重新登录)
valid=false 时 userId 按 0 返回。
## 失效场景
| 场景 | 表现 |
|---|---|
| access 过期 | 401 → 前端刷新或跳登录 |
| 登出 | 双令牌入黑名单,立即失效 |
| 强制下线 | 指定会话双令牌拉黑 |
| 撤销 | DELETE /api/oauth2/remove/:token(管理员) |
黑名单 TTL = 令牌剩余有效期,到期自然失效,无需清理。
## 客户端处理建议
```text
前端请求 API → 后端返回 code=401
│
▼
尝试用 refresh_token 刷新
│
▼
刷新失败(refresh_token 也过期/无效)
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 前端:清除登录态,跳转登录页 │
│ │
│ localStorage.removeItem('access_token') │
│ localStorage.removeItem('refresh_token') │
│ window.location.href = '/login' │
└─────────────────────────────────────────────────────────────┘
收到 401 →
有 refresh_token 且未尝试过 → 调 /api/oauth2/token 刷新 → 重放原请求(刷新只尝试一次,防死循环)
否则 → 清本地令牌 → 跳登录页
```
### 两种 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() │
│ 特点:长期有效,仅用于换取新令牌,不用于接口认证 │
└─────────────────────────────────────────────────────────────┘
```
Swagger 调试:先登录拿 access_token,在文档页填入 Authorization 后调用受保护接口。
## 请求头格式
## 相关
```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));"
```
:::
- [5.1 用户认证(JWT)](/zh/core/jwt.html)
- [5.2 认证与授权流程](/zh/core/auth-flow.html)
+37 -267
View File
@@ -1,293 +1,63 @@
# 8.4 文件上传接口
# 8.5 文件上传接口
:::tip 概述
文件上传接口将文件保存到临时目录,返回文件 URL。前端拿到 URL 后提交表单时传给后端,BaseLogic 在 add/update 时自动迁移到正式目录。通过 `type` 参数可区分上传类型(文件/图片)。
上传接口执行两段式的第一段(落临时目录):multipart 提交文件,返回临时访问 URL;该 URL 随业务表单提交,保存记录时由后端转正。
:::
## 接口信息
## 接口清单
| 项目 | 说明 |
|------|------|
| URL | `POST /api/upload/uploadFile` |
| Content-Type | `multipart/form-data` |
| 权限 | 无需认证(公开接口) |
| 方法 | 路径 | 权限码 | 说明 |
|---|---|---|---|
| POST | /api/upload/uploadFile | sys:upload:file | 单文件上传 |
| POST | /api/upload/uploadFiles | sys:upload:files | 批量上传 |
## 请求参数
## 请求
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `file` | File | 是 | - | 文件二进制数据 |
| `type` | string | 否 | `file` | 上传类型:`file`(所有合法文件)/ `image`(仅图片) |
```text
Content-Type: multipart/form-data
file: 二进制文件(单文件字段名 file)
type: 可选,"image" 走图片白名单+内容嗅探,其余按通用文件白名单(internal/model/dto/upload.go UploadFileReq)
```
### 成功响应
大小与扩展名受服务端配置约束(file.maxSize / imageExt / fileExt),超限返回失败信封。批量接口字段名为 `files`(min=1)。
## 响应
单文件(UploadFileRes):
```json
{
"code": 0,
"ok": true,
"msg": "上传成功",
"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"
"originalName": "原始文件名.png",
"fileExtension": "png",
"fileType": "image/png",
"fileSize": 10240,
"fileName": "ab12cd....png",
"filePath": "temp/20261025/ab12cd....png",
"fileUrl": "http://file.example.com/temp/20261025/ab12cd....png"
}
}
```
### 响应字段
批量(UploadFilesRes):`{ "list": [UploadFileRes, ...] }`。
| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `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` |
注意 `filePath`/`fileUrl` 指向的仍是 **temp 临时路径形态**——表单提交保存记录时才经 saveTransform 转正,它们只是中间值,不是最终文件地址。
### 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
① POST /api/upload/uploadFile(multipart)→ data.url(temp)
② 业务表单携带该 url 提交(如新增文章的 cover 字段)
③ 后端 Add → ModuleSpec saveTransform → 转正到 {模块目录}/{yyyymmdd}/
④ 入库为转正后的相对路径;读侧补域名返回完整 URL
```
**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 组件
公共 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;
```
- [7.1 文件上传全流程](/zh/tools/upload.html)
- [7.2 上传配置详解](/zh/tools/upload-config.html)