Files
2026-05-06 09:42:29 +08:00

1932 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 入口模块管理 API 完整技术文档
> **供前端对接使用** | 更新时间:2025-01-29 | 版本:v2.1
## 📋 目录
1. [概述](#概述)
2. [数据库设计](#数据库设计)
3. [技术架构](#技术架构)
4. [API接口详细说明](#api接口详细说明)
5. [业务逻辑详解](#业务逻辑详解)
6. [错误处理机制](#错误处理机制)
7. [前端对接指南](#前端对接指南)
---
## 概述
**入口模块管理系统**负责管理系统首页的功能入口模块,支持多地区独立配置、图标管理和动态展示控制。
### 基本信息
| 项目 | 说明 |
|------|------|
| **基础路径** | `/api/v3/entry-modules` |
| **认证方式** | JWT Bearer Token |
| **后端框架** | FastAPI + asyncpg + PostgreSQL |
| **存储服务** | MinIO (对象存储) |
| **权限系统** | RBAC v2 (基于装饰器) |
### 核心功能
- ✅ 入口模块的增删改查(CRUD
- ✅ 分页查询 + 模糊搜索 + 地区筛选
- ✅ 多地区独立配置(启用/禁用、排序)
- ✅ 图标上传到MinIO(自动覆盖策略)
- ✅ RBAC权限控制(基于 `entry_module:*:*` 权限代码)
---
## 数据库设计
### entry_modules 表结构
```sql
CREATE TABLE entry_modules (
-- 主键,自增
id SERIAL PRIMARY KEY,
-- 入口模块名称,唯一且非空
name VARCHAR(255) NOT NULL UNIQUE,
-- 类型描述,可为空
description TEXT,
-- 更新时间(自动维护)
updated_at TIMESTAMP(6) WITH TIME ZONE DEFAULT NOW(),
-- 创建时间(自动维护)
created_at TIMESTAMP(6) WITH TIME ZONE DEFAULT NOW(),
-- 入口菜单的图片存储路径(MinIO路径)
path VARCHAR(255),
-- 地区配置 JSONB数组
-- 格式: [{"area": "地区名", "enabled": true/false, "sort_order": 排序号}]
areas JSONB
);
```
### 索引设计
```sql
-- 主键索引(自动创建)
CREATE UNIQUE INDEX entry_modules_pkey ON entry_modules USING btree (id);
-- 名称唯一索引(保证模块名称不重复)
CREATE UNIQUE INDEX entry_modules_name_key ON entry_modules USING btree (name);
-- JSONB GIN索引(加速 areas 字段的 JSONB 查询)
CREATE INDEX idx_entry_modules_areas ON entry_modules USING GIN (areas);
```
**GIN索引作用**
- 支持 `@>` 操作符进行高效的 JSONB 包含查询
- 加速地区筛选查询(`WHERE areas @> '[{"area": "梅州"}]'::jsonb`
### 触发器
```sql
-- 自动更新 updated_at 字段的触发器
CREATE TRIGGER update_entry_modules_updated_at
BEFORE UPDATE ON entry_modules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
```
**触发器逻辑**
```sql
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```
**说明**:每次 `UPDATE` 操作时,自动将 `updated_at` 字段更新为当前时间戳,无需在应用层手动设置。
### areas 字段 JSONB 格式
```json
[
{
"area": "梅州", // 地区名称(字符串)
"enabled": true, // 是否启用(布尔值)
"sort_order": 1 // 排序号(整数,越小越靠前)
},
{
"area": "云浮",
"enabled": true,
"sort_order": 2
},
{
"area": "揭阳",
"enabled": false, // 禁用状态
"sort_order": 3
}
]
```
### 实际数据示例
```sql
-- 实际数据查询结果
id | name | description | path | areas | created_at | updated_at
----+--------------+-------------------------+---------------------------------------------------+---------------------------------------------------------------------------+--------------------------------+--------------------------------
1 | | | documents/mz/static/img/entry_module_1.png | [{"area":"梅州","enabled":true,"sort_order":1},{"area":"云浮","enabled":true,"sort_order":2}] | 2025-11-18 21:33:33.857417+08 | 2025-11-28 11:05:38.421705+08
2 | | | entryModule/aj | [{"area":"梅州","enabled":true,"sort_order":1},{"area":"揭阳","enabled":true,"sort_order":2}] | 2025-11-18 21:34:48.467438+08 | 2025-11-26 20:44:31.438596+08
3 | | | entryModule/nw | [{"area":"云浮","enabled":true,"sort_order":1},{"area":"梅州","enabled":true,"sort_order":2}] | 2025-11-18 21:35:09.977711+08 | 2025-11-28 09:44:18.20009+08
```
---
## 技术架构
### 三层架构设计
```
┌─────────────────────────────────────────────────────────────┐
│ 路由层 (app/routes/entry_modules.py) │
│ - FastAPI路由注册 │
│ - 请求参数验证(Pydantic) │
│ - JWT权限校验(verify_token + require_permission_v2
│ - 统一响应封装(@unified_resp
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 服务层 (app/services/entry_module_service.py) │
│ - 业务逻辑处理 │
│ - 数据库操作(asyncpg直连) │
│ - MinIO文件操作(core/storage/minio_client.py
│ - 数据转换(JSONB ↔ Python对象) │
│ - 异常处理和日志记录 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 数据层 │
│ ├─ PostgreSQL (entry_modules表) │
│ └─ MinIO (图片文件存储) │
└─────────────────────────────────────────────────────────────┘
```
### 数据库连接管理
**连接方式**asyncpg(异步PostgreSQL驱动)
```python
@staticmethod
async def _get_db_connection():
"""
获取数据库连接
环境变量:
- DB_HOST: 数据库主机
- DB_PORT: 数据库端口
- DB_NAME: 数据库名称
- DB_USER: 数据库用户
- DB_PASSWORD: 数据库密码
"""
conn = await asyncpg.connect(
host=os.getenv("DB_HOST"),
port=int(os.getenv("DB_PORT")),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=os.getenv("DB_NAME")
)
return conn
```
**连接生命周期**
1. 每个服务方法独立获取连接
2. 使用 `try...finally` 确保连接关闭
3. 不使用连接池(使用asyncpg自身的连接管理)
### MinIO存储配置
**配置来源**`core/config.py``MINIO_CONFIG`
```python
MINIO_CONFIG = {
"endpoint": "172.16.0.81:9000", # MinIO服务地址
"access_key": "minioadmin", # 访问密钥
"secret_key": "minioadmin", # 秘密密钥
"bucket_name": "docauditai", # 存储桶名称
"secure": False, # 是否使用HTTPS
"base_url": "http://172.16.0.81:9000/docauditai/" # 访问URL前缀
}
```
**客户端初始化**
```python
def _get_minio_client() -> MinioStorage:
return MinioStorage(
endpoint=MINIO_CONFIG["endpoint"],
access_key=MINIO_CONFIG["access_key"],
secret_key=MINIO_CONFIG["secret_key"],
bucket_name=MINIO_CONFIG["bucket_name"],
secure=MINIO_CONFIG.get("secure", False),
base_url=MINIO_CONFIG.get("base_url", ""),
)
```
---
## API接口详细说明
### 1. 获取入口模块列表
#### 接口定义
```http
GET /api/v3/entry-modules?page=1&page_size=20&name=&area=
Authorization: Bearer {JWT_TOKEN}
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:list:read` | 查看入口模块列表 |
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 校验规则 | 说明 |
|------|------|------|--------|---------|------|
| page | int | 否 | 1 | `>= 1` | 页码,从1开始 |
| page_size | int | 否 | 20 | `>= 1`, `<= 100` | 每页数量,最大100条 |
| name | string | 否 | - | - | 模块名称模糊搜索(ILIKE) |
| area | string | 否 | - | - | 地区筛选(精确匹配) |
#### 业务逻辑流程
```
1. 参数验证
├─ page: 自动校正为 >=1
├─ page_size: 限制在 1~100 之间
├─ name: 可选字符串(支持中文)
└─ area: 可选字符串(地区名称)
2. 数据库连接
└─ 调用 _get_db_connection() 获取 asyncpg 连接
3. 构建查询条件
├─ name 参数 → "name ILIKE $1" with "%{name}%"
├─ area 参数 → "areas @> $2::jsonb" with '[{"area": "{area}"}]'
└─ 无参数时 → WHERE 1=1
4. 执行查询
├─ 步骤1: SELECT COUNT(*) 获取总数
└─ 步骤2: SELECT * LIMIT/OFFSET 获取分页数据
5. 数据转换
└─ JSONB字段 → Python字典(如果是字符串则JSON解析)
6. 返回响应
└─ { total, page, page_size, items }
```
#### SQL执行示例
**场景1:无筛选条件**
```sql
-- 查询总数
SELECT COUNT(*) FROM entry_modules WHERE 1=1;
-- 查询数据(第1页,每页20条)
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE 1=1
ORDER BY id ASC
LIMIT 20 OFFSET 0;
```
**场景2:名称模糊搜索**
```sql
-- 查询总数
SELECT COUNT(*) FROM entry_modules WHERE name ILIKE '%合同%';
-- 查询数据
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE name ILIKE '%合同%'
ORDER BY id ASC
LIMIT 20 OFFSET 0;
```
**场景3:地区筛选**
```sql
-- 查询总数
SELECT COUNT(*) FROM entry_modules
WHERE areas @> '[{"area": "梅州"}]'::jsonb;
-- 查询数据
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE areas @> '[{"area": "梅州"}]'::jsonb
ORDER BY id ASC
LIMIT 20 OFFSET 0;
```
**场景4:复合筛选**
```sql
-- 查询总数
SELECT COUNT(*) FROM entry_modules
WHERE name ILIKE '%合同%'
AND areas @> '[{"area": "梅州"}]'::jsonb;
-- 查询数据
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE name ILIKE '%合同%'
AND areas @> '[{"area": "梅州"}]'::jsonb
ORDER BY id ASC
LIMIT 20 OFFSET 0;
```
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"total": 3,
"page": 1,
"page_size": 20,
"items": [
{
"id": 1,
"name": "合同管理",
"description": "合同管理入口模块",
"path": "documents/mz/static/img/entry_module_1.png",
"areas": [
{"area": "梅州", "enabled": true, "sort_order": 1},
{"area": "云浮", "enabled": true, "sort_order": 2}
],
"created_at": "2025-11-18T21:33:33.857417+08:00",
"updated_at": "2025-11-28T11:05:38.421705+08:00"
},
{
"id": 2,
"name": "案卷智能评查",
"description": "案卷类型的入口模块",
"path": "entryModule/aj",
"areas": [
{"area": "梅州", "enabled": true, "sort_order": 1},
{"area": "揭阳", "enabled": true, "sort_order": 2}
],
"created_at": "2025-11-18T21:34:48.467438+08:00",
"updated_at": "2025-11-26T20:44:31.438596+08:00"
}
]
}
}
```
**错误响应(500**
```json
{
"code": 500,
"msg": "查询入口模块列表失败: connection to server failed",
"data": []
}
```
---
### 2. 获取入口模块详情
#### 接口定义
```http
GET /api/v3/entry-modules/{module_id}
Authorization: Bearer {JWT_TOKEN}
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:detail:read` | 查看入口模块详情 |
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| module_id | int | 是 | 模块ID(必须为正整数) |
#### 业务逻辑流程
```
1. 参数验证
└─ module_id: FastAPI自动校验为int类型
2. 数据库查询
└─ SELECT * FROM entry_modules WHERE id = $1
3. 存在性检查
├─ 查询结果为空 → 抛出 HTTPException(404)
└─ 查询结果存在 → 继续处理
4. 数据转换
└─ JSONB字段 → Python字典
5. 返回响应
└─ 模块详情字典
```
#### SQL执行
```sql
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE id = 1; -- $1 参数化查询
```
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"id": 1,
"name": "合同管理",
"description": "合同管理入口模块",
"path": "documents/mz/static/img/entry_module_1.png",
"areas": [
{"area": "梅州", "enabled": true, "sort_order": 1},
{"area": "云浮", "enabled": true, "sort_order": 2}
],
"created_at": "2025-11-18T21:33:33.857417+08:00",
"updated_at": "2025-11-28T11:05:38.421705+08:00"
}
}
```
**错误响应(404**
```json
{
"code": 404,
"msg": "入口模块不存在: id=999",
"data": []
}
```
---
### 3. 创建入口模块
#### 接口定义
```http
POST /api/v3/entry-modules
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
{
"name": "",
"description": "",
"path": null,
"areas": [
{"area": "", "enabled": true, "sort_order": 1},
{"area": "", "enabled": true, "sort_order": 2}
]
}
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:create:write` | 创建入口模块 |
#### 请求体字段
| 字段 | 类型 | 必填 | 校验规则 | 说明 |
|------|------|------|---------|------|
| name | string | 是 | `max_length=255`, 不能重复 | 模块名称 |
| description | string | 否 | - | 模块描述 |
| path | string | 否 | `max_length=255` | 图标路径(通常为null,后续通过上传接口设置) |
| areas | AreaConfig[] | 否 | 每项需包含 area, enabled, sort_order | 地区配置数组 |
**AreaConfig 结构:**
```typescript
interface AreaConfig {
area: string; // 地区名称(必填)
enabled: boolean; // 是否启用(必填)
sort_order: number; // 排序号(必填)
}
```
#### 业务逻辑流程
```
1. 请求体验证
├─ Pydantic Schema 自动验证
├─ name: 非空,最大255字符
├─ description: 可选字符串
├─ path: 可选字符串,最大255字符
└─ areas: 可选数组,每项包含 area/enabled/sort_order
2. 数据库连接
└─ 调用 _get_db_connection()
3. 唯一性检查
├─ SQL: SELECT id FROM entry_modules WHERE name = $1
├─ 结果存在 → 抛出 HTTPException(400, "模块名称已存在")
└─ 结果不存在 → 继续处理
4. 数据转换
└─ areas 数组 → JSON字符串(json.dumps
5. 插入数据
└─ SQL: INSERT INTO entry_modules (name, description, path, areas)
VALUES ($1, $2, $3, $4)
RETURNING *
6. 数据转换
└─ JSONB字段 → Python字典
7. 日志记录
└─ logger.info(f"入口模块创建成功: id={item['id']}, name={name}")
8. 返回响应
└─ 新创建的模块详情
```
#### SQL执行示例
**唯一性检查:**
```sql
SELECT id FROM entry_modules WHERE name = '合同管理';
-- 结果:如果存在返回id,不存在返回空
```
**插入数据:**
```sql
INSERT INTO entry_modules (name, description, path, areas)
VALUES (
'合同管理',
'合同管理入口模块',
NULL,
'[{"area":"梅州","enabled":true,"sort_order":1},{"area":"云浮","enabled":true,"sort_order":2}]'::jsonb
)
RETURNING id, name, description, path, areas, created_at, updated_at;
```
**插入后触发器自动设置:**
- `created_at`: NOW()(由DEFAULT值设置)
- `updated_at`: NOW()(由DEFAULT值设置)
- `id`: SERIAL自增
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"id": 4,
"name": "合同管理",
"description": "合同管理入口模块",
"path": null,
"areas": [
{"area": "梅州", "enabled": true, "sort_order": 1},
{"area": "云浮", "enabled": true, "sort_order": 2}
],
"created_at": "2025-11-28T16:30:00.123456+08:00",
"updated_at": "2025-11-28T16:30:00.123456+08:00"
}
}
```
**错误响应(400- 名称重复**
```json
{
"code": 400,
"msg": "模块名称已存在: name=合同管理",
"data": []
}
```
---
### 4. 更新入口模块
#### 接口定义
```http
PUT /api/v3/entry-modules/{module_id}
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
{
"name": "v2",
"description": "",
"areas": [
{"area": "", "enabled": true, "sort_order": 1}
]
}
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:update:write` | 更新入口模块 |
#### 请求参数
**路径参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| module_id | int | 是 | 模块ID |
**请求体字段(所有可选):**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 否 | 模块名称(如修改需验证唯一性) |
| description | string | 否 | 模块描述 |
| path | string | 否 | 图标路径 |
| areas | AreaConfig[] | 否 | 地区配置(**完全替换**) |
#### 业务逻辑流程
```
1. 参数验证
├─ module_id: FastAPI自动验证
└─ 请求体: Pydantic Schema 验证(所有字段可选)
2. 数据库连接
└─ 调用 _get_db_connection()
3. 存在性检查
├─ SQL: SELECT id, name FROM entry_modules WHERE id = $1
├─ 结果为空 → 抛出 HTTPException(404)
└─ 结果存在 → 继续处理
4. 名称唯一性检查(如果更新name)
├─ 判断: name != existing['name']
├─ SQL: SELECT id FROM entry_modules WHERE name = $1 AND id != $2
├─ 结果存在 → 抛出 HTTPException(400, "模块名称已存在")
└─ 结果不存在 → 继续处理
5. 动态构建UPDATE语句
├─ 收集传入的字段(非None)
├─ 构建 SET 子句: "name = $1, description = $2, ..."
├─ 没有字段传入 → 抛出 HTTPException(400, "没有要更新的字段")
└─ 自动添加: updated_at = CURRENT_TIMESTAMP
6. 执行更新
└─ SQL: UPDATE entry_modules SET ... WHERE id = $n RETURNING *
7. 数据转换
└─ JSONB字段 → Python字典
8. 日志记录
└─ logger.info(f"入口模块更新成功: id={module_id}")
9. 返回响应
└─ 更新后的模块详情
```
#### SQL执行示例
**场景1:只更新 name**
```sql
-- 存在性检查
SELECT id, name FROM entry_modules WHERE id = 1;
-- 返回: {"id": 1, "name": "合同管理"}
-- 名称唯一性检查
SELECT id FROM entry_modules WHERE name = '合同管理v2' AND id != 1;
-- 返回: 空(不存在重复)
-- 执行更新
UPDATE entry_modules
SET name = '合同管理v2', updated_at = CURRENT_TIMESTAMP
WHERE id = 1
RETURNING id, name, description, path, areas, created_at, updated_at;
```
**场景2:更新多个字段**
```sql
UPDATE entry_modules
SET
name = '合同管理v2',
description = '更新后的描述',
areas = '[{"area":"梅州","enabled":true,"sort_order":1}]'::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
RETURNING id, name, description, path, areas, created_at, updated_at;
```
**场景3:只更新 areas(完全替换)**
```sql
UPDATE entry_modules
SET
areas = '[{"area":"云浮","enabled":false,"sort_order":1}]'::jsonb,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
RETURNING id, name, description, path, areas, created_at, updated_at;
```
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"id": 1,
"name": "合同管理v2",
"description": "更新后的描述",
"path": "documents/mz/static/img/entry_module_1.png",
"areas": [
{"area": "梅州", "enabled": true, "sort_order": 1}
],
"created_at": "2025-11-18T21:33:33.857417+08:00",
"updated_at": "2025-11-28T16:45:00.123456+08:00"
}
}
```
**错误响应(404- 模块不存在**
```json
{
"code": 404,
"msg": "入口模块不存在: id=999",
"data": []
}
```
**错误响应(400- 名称已存在**
```json
{
"code": 400,
"msg": "模块名称已存在: name=案卷智能评查",
"data": []
}
```
**错误响应(400- 没有更新字段**
```json
{
"code": 400,
"msg": "没有要更新的字段",
"data": []
}
```
---
### 5. 删除入口模块
#### 接口定义
```http
DELETE /api/v3/entry-modules/{module_id}
Authorization: Bearer {JWT_TOKEN}
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:delete:delete` | 删除入口模块 |
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| module_id | int | 是 | 模块ID |
#### 业务逻辑流程
```
1. 参数验证
└─ module_id: FastAPI自动验证
2. 数据库连接
└─ 调用 _get_db_connection()
3. 存在性检查
├─ SQL: SELECT id, name FROM entry_modules WHERE id = $1
├─ 结果为空 → 抛出 HTTPException(404)
└─ 结果存在 → 记录模块名称,继续处理
4. 执行删除
└─ SQL: DELETE FROM entry_modules WHERE id = $1
5. 日志记录
└─ logger.info(f"入口模块删除成功: id={module_id}, name={module_name}")
6. 返回响应
└─ {"message": "入口模块删除成功: {module_name}"}
```
#### SQL执行
```sql
-- 存在性检查
SELECT id, name FROM entry_modules WHERE id = 1;
-- 返回: {"id": 1, "name": "合同管理"}
-- 执行删除
DELETE FROM entry_modules WHERE id = 1;
-- 影响行数: 1
```
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"message": "入口模块删除成功: 合同管理"
}
}
```
**错误响应(404**
```json
{
"code": 404,
"msg": "入口模块不存在: id=999",
"data": []
}
```
**注意事项:**
- ⚠️ 删除操作是**物理删除**,数据库记录直接删除,不可恢复
- ⚠️ MinIO中的图标文件**不会自动删除**,需要手动清理或通过定时任务清理孤立文件
- ⚠️ 如果其他表有外键引用,可能会触发级联删除或报错(取决于外键约束设置)
---
### 6. 上传入口模块图标
> ⚠️ **重要更新 (v2.1)**:图片上传功能已修复JWT拦截问题,静态资源现在可以无需Token访问。
#### 🔥 前端常见问题及解决方案
**问题1:上传时报错 "Field required - body.file"**
```json
{
"code": 3000,
"msg": "参数校验错误",
"data": [
{
"type": "missing",
"loc": ["body", "file"],
"msg": "Field required"
}
]
}
```
**原因**:字段名错误或没有使用FormData上传
**正确做法**
```javascript
// ✅ 正确
const formData = new FormData();
formData.append('file', imageFile); // 字段名必须是 "file"
await fetch(`/api/v3/entry-modules/${moduleId}/image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
// ✅ 不要设置 Content-Type,让浏览器自动添加boundary
},
body: formData
});
// ❌ 错误示例
formData.append('image', imageFile); // 字段名错误
formData.append('picture', imageFile); // 字段名错误
body: JSON.stringify({file: base64}); // 不能用JSON
headers: {'Content-Type': 'multipart/form-data'} // 不要手动设置
```
**问题2:图片显示401 Unauthorized**
这是因为旧版本JWT中间件拦截了MinIO静态资源。
**已修复** ✅:
- MinIO路径(`/docauditai/`)已加入JWT白名单
- 图片现在可以**无需Token**直接访问
- 测试:`curl http://your-server/docauditai/documents/mz/static/img/entry_module_1.png`
**问题3:错误的图片访问路径**
```javascript
// ❌ 错误 - 硬编码路径
const imageUrl = `/docauditai/entryModule/${module.code}`;
// ✅ 正确 - 使用数据库path字段
const imageUrl = `http://172.16.0.81:9000/docauditai/${module.path}`;
// ✅ 或者使用上传接口返回的url字段(推荐)
const result = await uploadImage(moduleId, file);
const imageUrl = result.data.url;
```
#### 接口定义
```http
POST /api/v3/entry-modules/{module_id}/image
Content-Type: multipart/form-data
Authorization: Bearer {JWT_TOKEN}
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="icon.png"
Content-Type: image/png
<binary data>
------WebKitFormBoundary--
```
#### 权限要求
| 权限代码 | 说明 |
|---------|------|
| `entry_module:update:write` | 上传图标(复用更新权限) |
#### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| module_id | int | 是 | 模块ID |
#### 表单字段
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| file | File | 是 | 图片文件 |
**支持的文件格式:**
- 扩展名:`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.svg`
- Content-Type`image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/svg+xml`
#### 业务逻辑流程
```
1. 文件格式验证
├─ 检查文件名非空
├─ 获取扩展名: os.path.splitext(file.filename)[1].lower()
├─ 扩展名不在允许列表 → 抛出 HTTPException(400, "不支持的图片格式")
├─ 检查 Content-Type
└─ 不匹配时根据扩展名推断
2. 数据库连接
└─ 调用 _get_db_connection()
3. 模块存在性检查
├─ SQL: SELECT id, name, path FROM entry_modules WHERE id = $1
├─ 结果为空 → 抛出 HTTPException(404)
└─ 结果存在 → 记录旧路径 old_path
4. 构建存储路径
├─ 获取实例名称: os.environ.get("INSTANCE_NAME", "default")
├─ 固定文件名: entry_module_{module_id}{ext}
└─ 完整路径: documents/{instance_name}/static/img/{filename}
示例: documents/mz/static/img/entry_module_1.png
5. 读取文件内容
├─ file_content = await file.read()
└─ 内容为空 → 抛出 HTTPException(400, "文件内容为空")
6. MinIO上传
├─ 获取客户端: _get_minio_client()
├─ 旧文件清理(如果路径不同):
│ ├─ 检查: old_path and old_path != storage_path
│ ├─ 存在性检查: minio_client.file_exists(old_path)
│ └─ 删除: minio_client.delete_file(old_path)
├─ 上传新文件:
│ └─ file_url = minio_client.upload_bytes(
│ data=file_content,
│ destination_path=storage_path,
│ content_type=content_type
│ )
└─ 返回访问URL
7. 更新数据库
└─ SQL: UPDATE entry_modules
SET path = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
8. 日志记录
└─ logger.info(f"入口模块图片上传成功: module_id={module_id}, path={storage_path}")
9. 返回响应
└─ {module_id, path, url, message}
```
#### 文件路径规则
**格式**`documents/{instance_name}/static/img/entry_module_{module_id}.{ext}`
**示例**
```
梅州实例(INSTANCE_NAME=mz:
documents/mz/static/img/entry_module_1.png
documents/mz/static/img/entry_module_2.jpg
云浮实例(INSTANCE_NAME=yf:
documents/yf/static/img/entry_module_1.png
documents/yf/static/img/entry_module_3.gif
```
**覆盖策略**
- 使用**固定文件名** `entry_module_{module_id}`
- 每次上传**覆盖**同一模块的旧图标
- 如果扩展名改变(如 `.png``.jpg`),旧文件会被**删除**
#### MinIO 上传详细流程
**upload_bytes 方法实现:**
```python
def upload_bytes(
self,
data: bytes,
destination_path: str,
content_type: Optional[str] = None
) -> str:
"""
上传字节数据到MinIO
内部流程:
1. 规范化路径(移除前导斜杠,统一使用正斜杠)
2. 创建BytesIO对象包装字节数据
3. 调用MinIO客户端 put_object() 方法
4. 自动设置Content-Type头
5. 返回完整访问URL
"""
object_name = self._normalize_path(destination_path)
file_data = BytesIO(data)
file_size = len(data)
self.client.put_object(
bucket_name=self.bucket_name,
object_name=object_name,
data=file_data,
length=file_size,
content_type=content_type or "application/octet-stream"
)
return self.get_file_url(destination_path)
```
**get_file_url 方法实现:**
```python
def get_file_url(self, file_path: str) -> str:
"""
生成文件访问URL
逻辑:
1. 如果配置了 base_url,直接拼接
http://172.16.0.81:9000/docauditai/ + file_path
2. 否则使用MinIO SDK生成预签名URL(默认7天有效期)
"""
if self.base_url:
object_name = self._normalize_path(file_path)
return urljoin(self.base_url, object_name)
else:
return self.client.presigned_get_object(
bucket_name=self.bucket_name,
object_name=self._normalize_path(file_path),
expires=timedelta(days=7)
)
```
#### SQL执行示例
**存在性检查:**
```sql
SELECT id, name, path FROM entry_modules WHERE id = 1;
-- 返回: {"id": 1, "name": "合同管理", "path": "entryModule/old.png"}
```
**更新路径:**
```sql
UPDATE entry_modules
SET path = 'documents/mz/static/img/entry_module_1.png',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1;
-- 影响行数: 1
```
#### 响应示例
**成功响应(200**
```json
{
"code": 0,
"msg": "成功",
"data": {
"module_id": 1,
"path": "documents/mz/static/img/entry_module_1.png",
"url": "http://172.16.0.81:9000/docauditai/documents/mz/static/img/entry_module_1.png",
"message": "图片上传成功"
}
}
```
**错误响应(400- 不支持的格式**
```json
{
"code": 400,
"msg": "不支持的图片格式: .pdf,支持的格式: .jpg, .jpeg, .png, .gif, .webp, .svg",
"data": []
}
```
**错误响应(400- 文件为空**
```json
{
"code": 400,
"msg": "文件内容为空",
"data": []
}
```
**错误响应(404- 模块不存在**
```json
{
"code": 404,
"msg": "入口模块不存在: id=999",
"data": []
}
```
**错误响应(422)- 缺少file字段** ⚠️ **常见错误**
```json
{
"code": 3000,
"msg": "参数校验错误",
"data": [
{
"type": "missing",
"loc": ["body", "file"],
"msg": "Field required",
"input": null
}
]
}
```
**错误响应(500- MinIO上传失败**
```json
{
"code": 500,
"msg": "上传入口模块图片失败: S3 error: Access Denied",
"data": []
}
```
---
## 业务逻辑详解
### 唯一性约束处理
**数据库层面**
- `name` 字段有 `UNIQUE` 约束
- 插入重复值时PostgreSQL会抛出异常:`duplicate key value violates unique constraint "entry_modules_name_key"`
**应用层面**
- 创建前主动查询检查
- 更新前检查名称是否与其他模块冲突(排除自身)
```python
# 创建时的检查
existing = await conn.fetchval(
"SELECT id FROM entry_modules WHERE name = $1",
name
)
if existing:
raise HTTPException(status_code=400, detail=f"模块名称已存在: name={name}")
# 更新时的检查(排除自身)
if name and name != existing['name']:
duplicate = await conn.fetchval(
"SELECT id FROM entry_modules WHERE name = $1 AND id != $2",
name, module_id
)
if duplicate:
raise HTTPException(status_code=400, detail=f"模块名称已存在: name={name}")
```
### JSONB 数据处理
**存储时(Python → PostgreSQL**
```python
# 将Python列表转换为JSON字符串
areas_list = [
{"area": "梅州", "enabled": True, "sort_order": 1},
{"area": "云浮", "enabled": True, "sort_order": 2}
]
areas_json = json.dumps(areas_list) # 转为字符串
# 插入时PostgreSQL自动转为JSONB类型
INSERT INTO entry_modules (..., areas) VALUES (..., $1) -- $1 = areas_json
```
**读取时(PostgreSQL → Python**
```python
# asyncpg返回的JSONB已自动解析为Python对象
row = await conn.fetchrow("SELECT areas FROM entry_modules WHERE id = 1")
areas = row['areas'] # 已经是 list[dict]
# 兼容处理(如果是字符串则手动解析)
if isinstance(areas, str):
areas = json.loads(areas)
```
### 地区筛选查询原理
**JSONB `@>` 操作符**
- 含义:左侧JSONB**包含**右侧JSONB
- 用法:检查数组中是否存在某个元素
**查询示例**
```sql
-- 查找包含"梅州"地区配置的模块
SELECT * FROM entry_modules
WHERE areas @> '[{"area": "梅州"}]'::jsonb;
-- 等价于:areas数组中存在至少一个对象,其 area 字段为 "梅州"
```
**实际执行计划**
```sql
EXPLAIN ANALYZE
SELECT * FROM entry_modules
WHERE areas @> '[{"area": "梅州"}]'::jsonb;
-- 输出(使用GIN索引):
-- Bitmap Index Scan on idx_entry_modules_areas (cost=0.00..4.50 rows=1)
-- Index Cond: (areas @> '[{"area":"梅州"}]'::jsonb)
```
### 触发器自动维护时间戳
**创建时**
- `created_at`: 由 `DEFAULT NOW()` 自动设置
- `updated_at`: 由 `DEFAULT NOW()` 自动设置
**更新时**
- `updated_at`: 由触发器 `update_entry_modules_updated_at` 自动更新
- 应用层无需手动设置 `updated_at = CURRENT_TIMESTAMP`(但加上也无害)
**触发器代码**
```sql
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW(); -- 自动将新记录的 updated_at 设置为当前时间
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```
### MinIO文件覆盖策略
**场景1:首次上传**
```
数据库 path: NULL
MinIO文件: 不存在
操作:
1. 上传文件到 documents/mz/static/img/entry_module_1.png
2. 更新数据库 path = 'documents/mz/static/img/entry_module_1.png'
结果: ✅ 文件上传成功
```
**场景2:覆盖相同格式**
```
数据库 path: documents/mz/static/img/entry_module_1.png
MinIO文件: entry_module_1.png 存在
新文件: icon.png (将重命名为 entry_module_1.png)
操作:
1. old_path == storage_path → 不删除旧文件
2. 直接上传,MinIO自动覆盖
3. 数据库 path 不变
结果: ✅ 文件覆盖成功
```
**场景3:更换文件格式**
```
数据库 path: entryModule/aj (旧路径,无扩展名或其他格式)
MinIO文件: entryModule/aj 存在
新文件: icon.png (将保存为 entry_module_2.png)
操作:
1. old_path != storage_path → 删除旧文件 entryModule/aj
2. 上传新文件到 documents/mz/static/img/entry_module_2.png
3. 更新数据库 path = 'documents/mz/static/img/entry_module_2.png'
结果: ✅ 旧文件删除,新文件上传成功
```
### 动态SQL构建
**更新接口的动态字段处理**
```python
update_fields = []
params = []
param_index = 1
if name is not None:
update_fields.append(f"name = ${param_index}")
params.append(name)
param_index += 1
if description is not None:
update_fields.append(f"description = ${param_index}")
params.append(description)
param_index += 1
# ...其他字段同理
if not update_fields:
raise HTTPException(status_code=400, detail="没有要更新的字段")
# 构建最终SQL
update_query = f"""
UPDATE entry_modules
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${param_index}
RETURNING *
"""
params.append(module_id)
# 执行
result = await conn.fetchrow(update_query, *params)
```
**生成的SQL示例**
```sql
-- 只更新 name
UPDATE entry_modules
SET name = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING *;
-- 参数: ['新名称', 1]
-- 更新 name 和 areas
UPDATE entry_modules
SET name = $1, areas = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
RETURNING *;
-- 参数: ['新名称', '[...]'::jsonb, 1]
```
---
## 错误处理机制
### 异常层级
```
1. HTTPExceptionFastAPI标准异常)
├─ 400 Bad Request: 参数错误、业务规则错误
├─ 404 Not Found: 资源不存在
└─ 500 Internal Server Error: 服务器内部错误
2. 数据库异常(asyncpg
└─ 捕获后转换为 HTTPException(500)
3. MinIO异常(S3Error
└─ 捕获后转换为 HTTPException(500)
```
### 服务层异常处理模式
```python
try:
conn = await EntryModuleService._get_db_connection()
try:
# 业务逻辑...
result = await conn.fetchrow(query, *params)
if not result:
raise HTTPException(status_code=404, detail="资源不存在")
return result
finally:
await conn.close() # 确保连接关闭
except HTTPException:
raise # 直接抛出HTTP异常
except Exception as e:
logger.error(f"操作失败: {e}")
raise HTTPException(status_code=500, detail=f"操作失败: {str(e)}")
```
### 常见错误及处理
| HTTP状态码 | 错误原因 | 错误消息示例 | 处理建议 |
|-----------|---------|-------------|---------|
| 400 | 模块名称已存在 | `模块名称已存在: name=合同管理` | 修改模块名称 |
| 400 | 没有要更新的字段 | `没有要更新的字段` | 至少传入一个字段 |
| 400 | 不支持的图片格式 | `不支持的图片格式: .pdf` | 使用支持的格式 |
| 400 | 文件内容为空 | `文件内容为空` | 检查文件是否损坏 |
| 404 | 模块不存在 | `入口模块不存在: id=999` | 检查模块ID |
| 500 | 数据库连接失败 | `connection to server failed` | 检查数据库服务 |
| 500 | MinIO上传失败 | `上传入口模块图片失败: S3 error` | 检查MinIO服务 |
### 日志记录
**日志级别**
```python
logger.info() # 成功操作
logger.warning() # 警告(如删除旧文件失败,但可忽略)
logger.error() # 错误操作
```
**日志示例**
```
2025-11-28 16:30:00 INFO 查询入口模块列表成功: total=3, page=1, page_size=20
2025-11-28 16:31:00 INFO 入口模块创建成功: id=4, name=合同管理
2025-11-28 16:32:00 INFO 删除旧图片: entryModule/aj
2025-11-28 16:32:00 WARN 删除旧图片失败(可忽略): file not found
2025-11-28 16:33:00 INFO 入口模块图片上传成功: module_id=1, path=documents/mz/static/img/entry_module_1.png
2025-11-28 16:34:00 ERROR 查询入口模块列表失败: connection to server failed
```
---
## 前端对接指南
### TypeScript 类型定义
```typescript
/**
* 地区配置项
*/
interface AreaConfig {
area: string; // 地区名称
enabled: boolean; // 是否启用
sort_order: number; // 排序号
}
/**
* 入口模块
*/
interface EntryModule {
id: number;
name: string;
description: string | null;
path: string | null;
areas: AreaConfig[] | null;
created_at: string; // ISO 8601格式
updated_at: string;
}
/**
* 列表响应
*/
interface EntryModuleListResponse {
total: number;
page: number;
page_size: number;
items: EntryModule[];
}
/**
* 创建请求
*/
interface EntryModuleCreateRequest {
name: string;
description?: string;
path?: string;
areas?: AreaConfig[];
}
/**
* 更新请求(所有字段可选)
*/
interface EntryModuleUpdateRequest {
name?: string;
description?: string;
path?: string;
areas?: AreaConfig[];
}
/**
* 图片上传响应
*/
interface ImageUploadResponse {
module_id: number;
path: string;
url: string;
message: string;
}
```
### API 封装示例
```typescript
import axios, { AxiosInstance } from 'axios';
// 创建axios实例
const api: AxiosInstance = axios.create({
baseURL: '/api/v3',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器 - 添加Token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('jwt_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器 - 统一错误处理
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
const { status, data } = error.response;
// ⭐ v2.1更新:错误响应统一格式 {code, msg, data}
const errorMsg = data.msg || data.detail || '请求失败';
switch (status) {
case 401:
// Token过期,跳转登录
console.error('未授权:', errorMsg);
window.location.href = '/login';
break;
case 403:
// 无权限
console.error('权限不足:', errorMsg);
break;
case 404:
// 资源不存在
console.error('资源不存在:', errorMsg);
break;
case 422:
// 参数校验错误
console.error('参数错误:', errorMsg, data.data);
break;
case 500:
// 服务器错误
console.error('服务器错误:', errorMsg);
break;
}
}
return Promise.reject(error);
}
);
/**
* 入口模块 API
*/
export const entryModuleApi = {
/**
* 获取列表
*/
list: (params?: {
page?: number;
page_size?: number;
name?: string;
area?: string;
}) => {
return api.get<{
code: number;
msg: string;
data: EntryModuleListResponse;
}>('/entry-modules', { params });
},
/**
* 获取详情
*/
get: (id: number) => {
return api.get<{
code: number;
msg: string;
data: EntryModule;
}>(`/entry-modules/${id}`);
},
/**
* 创建模块
*/
create: (data: EntryModuleCreateRequest) => {
return api.post<{
code: number;
msg: string;
data: EntryModule;
}>('/entry-modules', data);
},
/**
* 更新模块
*/
update: (id: number, data: EntryModuleUpdateRequest) => {
return api.put<{
code: number;
msg: string;
data: EntryModule;
}>(`/entry-modules/${id}`, data);
},
/**
* 删除模块
*/
delete: (id: number) => {
return api.delete<{
code: number;
msg: string;
data: { message: string };
}>(`/entry-modules/${id}`);
},
/**
* 上传图标
* ⚠️ 注意:字段名必须是 "file",不要手动设置Content-Type
*/
uploadImage: (id: number, file: File) => {
const formData = new FormData();
formData.append('file', file); // ✅ 字段名必须是 "file"
return api.post<{
code: number;
msg: string;
data: ImageUploadResponse;
}>(`/entry-modules/${id}/image`, formData
// ✅ 不要设置Content-Typeaxios会自动设置
// 如果手动设置会缺少boundary参数导致上传失败
);
}
};
```
### 使用示例
```typescript
// 1. 获取列表(带筛选)
const fetchModules = async () => {
try {
const { data } = await entryModuleApi.list({
page: 1,
page_size: 20,
area: '梅州'
});
console.log('总数:', data.data.total);
console.log('模块列表:', data.data.items);
} catch (error) {
console.error('获取列表失败:', error);
}
};
// 2. 创建模块
const createModule = async () => {
try {
const { data } = await entryModuleApi.create({
name: '合同管理',
description: '合同管理入口模块',
areas: [
{ area: '梅州', enabled: true, sort_order: 1 },
{ area: '云浮', enabled: true, sort_order: 2 }
]
});
console.log('创建成功:', data.data);
return data.data.id;
} catch (error) {
console.error('创建失败:', error);
}
};
// 3. 上传图标
const uploadIcon = async (moduleId: number, file: File) => {
try {
const { data } = await entryModuleApi.uploadImage(moduleId, file);
console.log('上传成功,访问URL:', data.data.url);
return data.data.url;
} catch (error) {
console.error('上传失败:', error);
}
};
// 4. 更新地区配置(完全替换)
const updateAreas = async (moduleId: number) => {
try {
const { data } = await entryModuleApi.update(moduleId, {
areas: [
{ area: '梅州', enabled: false, sort_order: 1 } // 只保留梅州,且禁用
]
});
console.log('更新成功:', data.data);
} catch (error) {
console.error('更新失败:', error);
}
};
// 5. 删除模块
const deleteModule = async (moduleId: number) => {
if (!confirm('确定要删除此模块吗?')) return;
try {
const { data } = await entryModuleApi.delete(moduleId);
console.log(data.data.message);
} catch (error) {
console.error('删除失败:', error);
}
};
```
### 完整创建流程示例
```typescript
/**
* 创建入口模块的完整流程
*/
const createModuleWithIcon = async (
moduleData: EntryModuleCreateRequest,
iconFile: File
) => {
try {
// 步骤1: 创建模块(path为null
const { data: createResult } = await entryModuleApi.create({
...moduleData,
path: null
});
const moduleId = createResult.data.id;
console.log(`模块创建成功,ID: ${moduleId}`);
// 步骤2: 上传图标
const { data: uploadResult } = await entryModuleApi.uploadImage(
moduleId,
iconFile
);
console.log(`图标上传成功,URL: ${uploadResult.data.url}`);
// 步骤3: 获取完整信息(包含最新的path)
const { data: detailResult } = await entryModuleApi.get(moduleId);
return detailResult.data;
} catch (error) {
console.error('创建流程失败:', error);
throw error;
}
};
// 使用
const iconFile = document.querySelector<HTMLInputElement>('#icon-upload')!.files![0];
const newModule = await createModuleWithIcon(
{
name: '合同管理',
description: '合同管理入口模块',
areas: [
{ area: '梅州', enabled: true, sort_order: 1 }
]
},
iconFile
);
console.log('最终模块信息:', newModule);
```
---
## 附录
### 常见问题 FAQ
**Q1: areas 配置会增量更新吗?**
A: 不会。`areas` 字段是**完全替换**。如果只想修改某个地区的 `enabled` 状态,需要先获取当前完整的 `areas` 数组,在前端修改后,再将完整数组传给更新接口。
**Q2: 删除模块后图标会自动删除吗?**
A: 不会。删除模块只删除数据库记录,MinIO中的图标文件不会自动删除。建议定期清理孤立文件。
**Q3: 如何拼接图标的完整访问URL?**
A: 上传接口返回的 `url` 字段已经是完整URL,直接使用即可。格式:`http://172.16.0.81:9000/docauditai/{path}`
**Q4: 支持批量上传吗?**
A: 当前不支持。每次只能上传一个模块的图标。
**Q5: 图标文件大小有限制吗?**
A: 代码中未设置文件大小限制,但建议控制在 5MB 以内以保证上传速度。
**Q6: 如何获取某个地区启用的所有模块?**
A: 调用列表接口 `?area=梅州` 筛选,前端再过滤 `enabled=true` 的记录。
**Q7: 为什么模块名称不能重复?**
A: 数据库表有 `UNIQUE` 约束,保证名称唯一性。这是业务规则,避免前端展示混淆。
### 性能优化建议
**前端**
1. 列表页使用虚拟滚动,减少DOM渲染压力
2. 图标使用懒加载,按需加载图片
3. 使用防抖优化搜索输入
4. 缓存列表数据,避免频繁请求
**后端**
1. 数据库已建立 GIN 索引,地区筛选性能良好
2. asyncpg 异步驱动,支持高并发
3. MinIO 分布式存储,读写性能优秀
4. 建议启用 Redis 缓存热门数据
### 权限配置参考
**数据库权限表**
```sql
SELECT permission_key, display_name
FROM permissions
WHERE module = 'entry_module';
```
**结果**
| permission_key | display_name |
|----------------|--------------|
| entry_module:list:read | 查看入口模块列表 |
| entry_module:detail:read | 查看入口模块详情 |
| entry_module:create:write | 创建入口模块 |
| entry_module:update:write | 更新入口模块 |
| entry_module:delete:delete | 删除入口模块 |
**角色分配示例**
```sql
-- 给管理员角色分配所有权限
INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope)
SELECT 1, id, 'GRANT', NULL
FROM permissions
WHERE module = 'entry_module';
```
---
## 📝 版本更新记录
### v2.1 (2025-01-29)
**重要更新**
1. ✅ 修复JWT拦截MinIO静态资源问题
- MinIO路径(`/docauditai/`)已加入JWT白名单
- 图片现在可以无需Token访问
2. ✅ 统一错误响应格式
- 旧格式:`{"detail": "错误信息"}`
- 新格式:`{"code": xxx, "msg": "错误信息", "data": []}`
3. ✅ 新增图片上传常见问题解决方案
- 详细说明了"Field required - body.file"错误的解决方法
- 提供了正确的FormData使用示例
- 强调字段名必须是 "file"
4. ✅ 创建测试工具
- 新增HTML测试工具:`scripts/test_entry_module_image_upload.html`
- 可视化测试界面,帮助调试图片上传功能
**前端重要修改**
- ⚠️ 删除手动设置 `Content-Type: multipart/form-data`
- ⚠️ 使用 `data.msg` 替代 `data.detail` 获取错误信息
- ⚠️ 图片访问不要硬编码路径,使用数据库path字段
### v2.0 (2025-11-28)
- 完整的API文档
- 数据库设计详解
- 前端对接指南
- TypeScript类型定义
---
**当前版本**v2.1
**最后更新**2025-01-29
**维护者**Backend Team
**反馈渠道**[提交Issue](http://git.7bm.co:1024/leke/docauditai/-/issues)