签入版本
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user