1932 lines
50 KiB
Markdown
1932 lines
50 KiB
Markdown
# 入口模块管理 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. HTTPException(FastAPI标准异常)
|
||
├─ 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-Type,axios会自动设置
|
||
// 如果手动设置会缺少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)
|