# 入口模块管理 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 ------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('#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)