46 KiB
入口模块管理 API 完整技术文档
供前端对接使用 | 更新时间:2025-11-28 | 版本:v2.0
📋 目录
概述
入口模块管理系统负责管理系统首页的功能入口模块,支持多地区独立配置、图标管理和动态展示控制。
基本信息
| 项目 | 说明 |
|---|---|
| 基础路径 | /api/v3/entry-modules |
| 认证方式 | JWT Bearer Token |
| 后端框架 | FastAPI + asyncpg + PostgreSQL |
| 存储服务 | MinIO (对象存储) |
| 权限系统 | RBAC v2 (基于装饰器) |
核心功能
- ✅ 入口模块的增删改查(CRUD)
- ✅ 分页查询 + 模糊搜索 + 地区筛选
- ✅ 多地区独立配置(启用/禁用、排序)
- ✅ 图标上传到MinIO(自动覆盖策略)
- ✅ RBAC权限控制(基于
entry_module:*:*权限代码)
数据库设计
entry_modules 表结构
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
);
索引设计
-- 主键索引(自动创建)
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)
触发器
-- 自动更新 updated_at 字段的触发器
CREATE TRIGGER update_entry_modules_updated_at
BEFORE UPDATE ON entry_modules
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
触发器逻辑:
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 格式
[
{
"area": "梅州", // 地区名称(字符串)
"enabled": true, // 是否启用(布尔值)
"sort_order": 1 // 排序号(整数,越小越靠前)
},
{
"area": "云浮",
"enabled": true,
"sort_order": 2
},
{
"area": "揭阳",
"enabled": false, // 禁用状态
"sort_order": 3
}
]
实际数据示例
-- 实际数据查询结果
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驱动)
@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
连接生命周期:
- 每个服务方法独立获取连接
- 使用
try...finally确保连接关闭 - 不使用连接池(使用asyncpg自身的连接管理)
MinIO存储配置
配置来源:core/config.py 的 MINIO_CONFIG
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前缀
}
客户端初始化:
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. 获取入口模块列表
接口定义
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:无筛选条件
-- 查询总数
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:名称模糊搜索
-- 查询总数
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:地区筛选
-- 查询总数
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:复合筛选
-- 查询总数
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)
{
"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)
{
"detail": "查询入口模块列表失败: connection to server failed"
}
2. 获取入口模块详情
接口定义
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执行
SELECT id, name, description, path, areas, created_at, updated_at
FROM entry_modules
WHERE id = 1; -- $1 参数化查询
响应示例
成功响应(200)
{
"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)
{
"detail": "入口模块不存在: id=999"
}
3. 创建入口模块
接口定义
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 结构:
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执行示例
唯一性检查:
SELECT id FROM entry_modules WHERE name = '合同管理';
-- 结果:如果存在返回id,不存在返回空
插入数据:
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)
{
"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)- 名称重复
{
"detail": "模块名称已存在: name=合同管理"
}
4. 更新入口模块
接口定义
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
-- 存在性检查
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:更新多个字段
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(完全替换)
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)
{
"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)- 模块不存在
{
"detail": "入口模块不存在: id=999"
}
错误响应(400)- 名称已存在
{
"detail": "模块名称已存在: name=案卷智能评查"
}
错误响应(400)- 没有更新字段
{
"detail": "没有要更新的字段"
}
5. 删除入口模块
接口定义
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执行
-- 存在性检查
SELECT id, name FROM entry_modules WHERE id = 1;
-- 返回: {"id": 1, "name": "合同管理"}
-- 执行删除
DELETE FROM entry_modules WHERE id = 1;
-- 影响行数: 1
响应示例
成功响应(200)
{
"code": 0,
"msg": "成功",
"data": {
"message": "入口模块删除成功: 合同管理"
}
}
错误响应(404)
{
"detail": "入口模块不存在: id=999"
}
注意事项:
- ⚠️ 删除操作是物理删除,数据库记录直接删除,不可恢复
- ⚠️ MinIO中的图标文件不会自动删除,需要手动清理或通过定时任务清理孤立文件
- ⚠️ 如果其他表有外键引用,可能会触发级联删除或报错(取决于外键约束设置)
6. 上传入口模块图标
接口定义
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 方法实现:
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 方法实现:
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执行示例
存在性检查:
SELECT id, name, path FROM entry_modules WHERE id = 1;
-- 返回: {"id": 1, "name": "合同管理", "path": "entryModule/old.png"}
更新路径:
UPDATE entry_modules
SET path = 'documents/mz/static/img/entry_module_1.png',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1;
-- 影响行数: 1
响应示例
成功响应(200)
{
"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)- 不支持的格式
{
"detail": "不支持的图片格式: .pdf,支持的格式: .jpg, .jpeg, .png, .gif, .webp, .svg"
}
错误响应(400)- 文件为空
{
"detail": "文件内容为空"
}
错误响应(404)- 模块不存在
{
"detail": "入口模块不存在: id=999"
}
错误响应(500)- MinIO上传失败
{
"detail": "上传入口模块图片失败: S3 error: Access Denied"
}
业务逻辑详解
唯一性约束处理
数据库层面:
name字段有UNIQUE约束- 插入重复值时PostgreSQL会抛出异常:
duplicate key value violates unique constraint "entry_modules_name_key"
应用层面:
- 创建前主动查询检查
- 更新前检查名称是否与其他模块冲突(排除自身)
# 创建时的检查
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列表转换为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):
# 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
- 用法:检查数组中是否存在某个元素
查询示例:
-- 查找包含"梅州"地区配置的模块
SELECT * FROM entry_modules
WHERE areas @> '[{"area": "梅州"}]'::jsonb;
-- 等价于:areas数组中存在至少一个对象,其 area 字段为 "梅州"
实际执行计划:
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(但加上也无害)
触发器代码:
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构建
更新接口的动态字段处理:
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示例:
-- 只更新 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)
服务层异常处理模式
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服务 |
日志记录
日志级别:
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 类型定义
/**
* 地区配置项
*/
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 封装示例
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;
switch (status) {
case 401:
// Token过期,跳转登录
window.location.href = '/login';
break;
case 403:
// 无权限
console.error('权限不足');
break;
case 404:
// 资源不存在
console.error(data.detail || '资源不存在');
break;
case 500:
// 服务器错误
console.error(data.detail || '服务器错误');
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}`);
},
/**
* 上传图标
*/
uploadImage: (id: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post<{
code: number;
msg: string;
data: ImageUploadResponse;
}>(`/entry-modules/${id}/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
}
};
使用示例
// 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);
}
};
完整创建流程示例
/**
* 创建入口模块的完整流程
*/
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 约束,保证名称唯一性。这是业务规则,避免前端展示混淆。
性能优化建议
前端:
- 列表页使用虚拟滚动,减少DOM渲染压力
- 图标使用懒加载,按需加载图片
- 使用防抖优化搜索输入
- 缓存列表数据,避免频繁请求
后端:
- 数据库已建立 GIN 索引,地区筛选性能良好
- asyncpg 异步驱动,支持高并发
- MinIO 分布式存储,读写性能优秀
- 建议启用 Redis 缓存热门数据
权限配置参考
数据库权限表:
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 | 删除入口模块 |
角色分配示例:
-- 给管理员角色分配所有权限
INSERT INTO role_permissions (role_id, permission_id, grant_type, data_scope)
SELECT 1, id, 'GRANT', NULL
FROM permissions
WHERE module = 'entry_module';
文档版本:v2.0 最后更新:2025-11-28 维护者:Backend Team 反馈渠道:提交Issue