山东大学软件学院创新实训--“智愈医院自助服务系统“-(6)-文档管理设计实现
一、引言
在上一篇博客中,我们设计了文档管理子系统的四层架构、数据模型、状态机、异步任务队列和 API 接口规范。本文将从设计图走向代码,详细阐述每个核心模块的编码实现过程。
二、数据库迁移实现
2.1 表结构调整
原 analysis_history 表仅包含基础字段(id、user_id、file_name、image_url、analysis_result、create_time),无法支撑异步任务的生命周期管理。我们通过迁移脚本为现有表新增了 6 个字段:
ALTER TABLE `analysis_history`
ADD COLUMN `file_size` BIGINT DEFAULT NULL COMMENT '文件大小(字节)' AFTER `file_type`,
ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING/PROCESSING/SUCCESS/WARNING/FAILED' AFTER `image_url`,
ADD COLUMN `progress` INT NOT NULL DEFAULT 0 COMMENT '分析进度(0-100)' AFTER `status`,
ADD COLUMN `error_message` VARCHAR(1000) DEFAULT NULL COMMENT '失败原因' AFTER `progress`,
ADD COLUMN `confidence` DOUBLE DEFAULT NULL COMMENT 'AI置信度' AFTER `primary_diagnosis`,
ADD INDEX `idx_status` (`status`);
2.2 字段设计说明
| 字段 | 类型 | 默认值 | 作用 |
|---|---|---|---|
file_size |
BIGINT | NULL | 记录文件大小,支持存储统计 |
status |
VARCHAR(20) | ‘PENDING’ | 驱动状态机流转的核心字段 |
progress |
INT | 0 | 前端进度条展示 |
error_message |
VARCHAR(1000) | NULL | 失败原因持久化 |
confidence |
DOUBLE | NULL | AI 模型置信度评分 |
2.3 完整建表 SQL
最终的表结构包含完整的索引设计:
CREATE TABLE `analysis_history` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_type` VARCHAR(50) NOT NULL,
`file_size` BIGINT DEFAULT NULL,
`image_url` VARCHAR(1024) DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`progress` INT NOT NULL DEFAULT 0,
`error_message` VARCHAR(1000) DEFAULT NULL,
`analysis_result` LONGTEXT DEFAULT NULL,
`patient_name` VARCHAR(100) DEFAULT NULL,
`primary_diagnosis` VARCHAR(500) DEFAULT NULL,
`confidence` DOUBLE DEFAULT NULL,
`create_time` DATETIME NOT NULL,
`update_time` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='诊断书分析历史记录表';
三、线程池配置实现
3.1 AsyncConfig 配置类
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("analysisExecutor")
public ThreadPoolTaskExecutor analysisExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("analysis-");
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
3.2 参数选择依据
| 参数 | 值 | 理由 |
|---|---|---|
corePoolSize |
2 | AI 分析为 IO 密集型,2 个常驻线程即可维持基础吞吐 |
maxPoolSize |
4 | 允许流量短时突增,4 线程足以应对并发上传场景 |
queueCapacity |
50 | 缓冲队列防止任务丢弃,同时避免内存溢出 |
CallerRunsPolicy |
— | 满队列时由主线程同步执行,降级而非丢任务 |
awaitTerminationSeconds |
30 | 应用关闭时等待正在执行的任务完成 |
设计考量:为何不用更大的线程池?因为通义千问 API 的并发限制约为 5-10 QPS,更大的线程池只会增加排队等待时间而非提升吞吐。4 线程足以压满 API 配额上限。
四、任务状态机实现
4.1 状态常量定义
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_PROCESSING = "PROCESSING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_WARNING = "WARNING";
public static final String STATUS_FAILED = "FAILED";
4.2 状态转换方法
每个状态的变更都对应一个独立的 Service 方法,职责清晰:
// 创建任务 → PENDING
public AnalysisHistory createTask(...) {
// 写入数据库,status = PENDING, progress = 0
}
// 开始处理 → PROCESSING
public void markProcessing(Integer taskId) {
// status = PROCESSING, progress = 30
}
// 分析成功 → SUCCESS
public void markSuccess(Integer taskId, JSONObject result) {
// status = SUCCESS, progress = 100
// 同时保存 patient_name、primary_diagnosis、confidence
}
// 部分识别 → WARNING
public void markWarning(Integer taskId, JSONObject partialResult, String warningMsg) {
// status = WARNING, progress = 100
// 保留部分提取结果供用户参考
}
// 分析失败 → FAILED
public void markFailed(Integer taskId, String errorMessage) {
// status = FAILED, progress = 0
// 记录完整错误信息
}
4.3 重试与恢复
失败重试(指数退避):
int maxRetries = 3;
while (retryCount <= maxRetries) {
try {
// AI 分析
result = analysisService.analyzeMedicalRecord(imageData);
break;
} catch (Exception e) {
retryCount++;
long backoff = (long) Math.pow(10, retryCount) * 1000;
Thread.sleep(Math.min(backoff, 60000));
// 10s → 30s → 60s
}
}
系统启动恢复(@PostConstruct):
@PostConstruct
public void recoverPendingTasks() {
List<AnalysisHistory> pendingTasks = analysisHistoryService.getPendingTasks();
for (AnalysisHistory task : pendingTasks) {
analysisHistoryService.markFailed(task.getId(), "系统重启,任务已重置,请重新分析");
}
}
系统崩溃可能导致正在执行的任务状态停留在 PENDING 或 PROCESSING。启动时将这类任务统一标记为 FAILED,用户可以手动点击"重新分析"来重试。
五、核心业务流程图
用户上传 ──> ① MedicalScanController
│
├── 校验文件类型(jpg/png/jpeg/pdf)+ 大小(≤10MB)
│
├── ② ossClient.upload() → Presigned URL
│ 文件路径: /medical_records/{userId}/{uuid}.ext
│
├── ③ analysisHistoryService.createTask()
│ status = PENDING, 写入数据库
│
├── ④ taskService.submitAnalysis()
│ 提交到 ThreadPoolTaskExecutor 线程池
│
└── ⑤ 返回 { taskId, status: "PENDING" } 立即响应
│
┌────────┴────────┐
│ 前端每 2s 轮询 │
│ /api/analysis- │
│ history/status │
└────────┬────────┘
│
┌────────▼────────┐
│ ⑥ 线程池消费任务 │
│ markProcessing()│
│ → PROCESSING │
└────────┬────────┘
│
┌────────▼────────┐
│ ⑦ 通义千问VL 分析│
│ analyzeMedical │
│ Record(URL) │
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
完整结构化结果 部分识别结果 API 异常
│ │ │
▼ ▼ ▼
markSuccess() markWarning() 重试3次失败
→ SUCCESS → WARNING markFailed()
→ FAILED
│ │ │
└──────┬───────┘ │
▼ ▼
前端轮询到完成 前端轮询到失败
加载详情展示 展示错误信息+重试按钮
六、RESTful API 控制器实现
6.1 接口总览
所有接口统一集中在 MedicalScanController 中,使用 @ResponseBody + RespResult 统一响应格式:
| 方法 | 路径 | 功能 | 实现行 |
|---|---|---|---|
POST |
/api/medical-record/analyze |
上传诊断书并触发异步分析 | 异步提交,立即返回 taskId |
GET |
/api/analysis-history/status |
轮询任务状态 | 返回 status/progress/errorMessage |
POST |
/api/analysis-history/retry |
重新分析失败/成功的任务 | 重置状态为 PENDING 后重新提交 |
GET |
/api/analysis-history/list |
查询历史分析记录(分页) | pageNum/pageSize 分页参数 |
GET |
/api/analysis-history/detail |
查询单条分析详情 | 完整分析结果 + 诊断信息 |
POST |
/api/analysis-history/delete |
删除单条历史记录 | 校验用户归属 |
POST |
/api/analysis-history/batch-delete |
批量删除历史记录 | 支持多 ID 同时删除 |
POST |
/api/analysis-history/clear |
清空所有历史记录 | 一次性清空当前用户所有记录 |
GET |
/api/analysis-history/stats |
获取存储统计 | 记录总数 + 文件总大小 |
POST |
/api/medical-record/export-pdf |
导出诊断报告 PDF | iText 7 生成并下载 |
6.2 核心接口代码
上传分析接口(异步提交流程):
@PostMapping("/api/medical-record/analyze")
public RespResult analyzeMedicalRecord(@RequestParam("file") MultipartFile file,
HttpSession session) {
// 1. 校验文件类型(白名单)和大小(10MB上限)
// 2. 上传 OSS(路径含 userId 隔离)
String imageUrl = ossClient.upload(file, "medical_records", loginUser.getId());
// 3. 创建 PENDING 任务
AnalysisHistory history = analysisHistoryService.createTask(...);
// 4. 提交到线程池异步执行
taskService.submitAnalysis(task);
// 5. 立即返回 taskId
return RespResult.success("分析任务已提交", data);
}
任务状态轮询接口:
@GetMapping("/api/analysis-history/status")
public RespResult getTaskStatus(@RequestParam Integer id, HttpSession session) {
AnalysisHistory task = analysisHistoryService.getTaskStatus(id, loginUser.getId());
// 返回 { id, status, progress, errorMessage, createTime, updateTime }
return RespResult.success("获取成功", data);
}
批量删除接口:
@PostMapping("/api/analysis-history/batch-delete")
public RespResult batchDelete(@RequestBody Map<String, Object> params, HttpSession session) {
List<Integer> ids = (List<Integer>) params.get("ids");
int count = analysisHistoryService.batchDelete(ids, loginUser.getId());
return RespResult.success("成功删除 " + count + " 条记录");
}
6.3 统一响应格式
所有接口采用 { code, message, data } 结构:
{
"code": "SUCCESS",
"message": "分析任务已提交",
"data": {
"taskId": 10042,
"status": "PENDING",
"estimatedWait": "约 10-30 秒完成"
}
}
RespResult 提供静态工厂方法,避免每个 Controller 方法重复构造:
RespResult.success() // 成功,无数据
RespResult.success("msg", data) // 成功,带消息和数据
RespResult.fail("错误描述") // 失败
七、文件存储策略实现
7.1 OSS 客户端组件
@Component
public class OssClient {
public String upload(MultipartFile file, String path, Integer userId) throws IOException {
// 1. 自动检测/创建 Bucket
// 2. 生成路径: /{业务前缀}/{userId}/{UUID}.{ext}
// 3. 上传文件
// 4. 生成 Presigned URL(有效期 30 分钟)
// 5. 返回签名 URL
}
}
7.2 文件路径规范
{业务前缀}/{用户ID}/{唯一标识符}.{扩展名}
示例:
medical_records/42/a1b2c3d4e5f6.jpg
设计要点:
- 使用
IdUtil.simpleUUID()生成文件名,避免中文文件名乱码和冲突 - 按
{userId}隔离目录,方便权限管理和数据清理 - 扩展名保留原始格式,便于 OSS 自动识别 Content-Type
7.3 安全访问控制
// 生成 30 分钟有效的 Presigned URL
Date expiration = new Date(System.currentTimeMillis() + 30 * 60 * 1000);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucketName, fileUrl);
request.setExpiration(expiration);
URL signedUrl = ossClient.generatePresignedUrl(request);
安全策略:
- OSS Bucket 设置为私有读写,不开放公网直接访问
- 所有文件访问通过签名 URL 进行,有效期 30 分钟
- 服务端对上传文件进行类型校验(白名单)和大小校验(≤10MB)
- 过期 URL 即使泄露也无法访问原始文件
八、前端轮询与进度展示
8.1 轮询机制
前端在上传成功后,立即开始每 2 秒轮询任务状态:
function startPolling(taskId) {
pollTaskStatus(taskId); // 立即查一次
pollingTimer = setInterval(function() {
pollTaskStatus(taskId);
}, 2000);
}
function pollTaskStatus(taskId) {
$.ajax({
url: '/api/analysis-history/status',
data: { id: taskId },
success: function(response) {
var status = response.data.status;
var progress = response.data.progress;
updateProgressDisplay(progress, status);
if (status === 'SUCCESS' || status === 'WARNING') {
clearInterval(pollingTimer);
loadTaskDetail(taskId); // 加载完整结果
} else if (status === 'FAILED') {
clearInterval(pollingTimer);
showFailedResult(task.errorMessage); // 显示错误
}
// PENDING/PROCESSING 继续轮询
}
});
}
8.2 进度显示
PENDING → 排队等待中... (0%)
PROCESSING → AI 正在分析诊断书... (0-99%)
SUCCESS → 分析完成!(100%)
WARNING → 分析完成(部分识别)(100%)
FAILED → 分析失败(点击重试)
8.3 异步 vs 同步体验对比
| 维度 | 同步方式(旧) | 异步方式(新) |
|---|---|---|
| 用户操作 | 等待页面转圈直到超时 | 上传即返回,边分析边查看历史 |
| 响应时间 | 5-30 秒,期间页面无响应 | 1 秒内返回 taskId |
| 失败处理 | 整个请求断开连接 | 失败后保留错误信息,可一键重试 |
| 并发能力 | HTTP 连接池被长时间占用 | 2 个线程即可处理海量请求 |
九、(新增内容)多会话管理 — 对话历史持久化与边栏切换
原有的对话记忆仅存储在 HttpSession 中,刷新页面即丢失。本节引入多会话机制:每个会话独立存储、独立构建 AI 上下文,前端通过边栏切换。
9.1 实体层(Entity)
// ChatConversation.java — 会话元信息
@Data
@Builder
@TableName("chat_conversation")
public class ChatConversation implements Serializable {
private String id; // UUID
private Integer userId; // 用户ID
private String title; // 自动生成(首条消息前50字)
private Date createdAt;
private Date updatedAt;
}
// ChatHistory.java — 单条消息
@Data
@Builder
@TableName("chat_history")
public class ChatHistory implements Serializable {
private Integer id;
private Integer userId;
private String conversationId; // 归属会话
private String role; // "user" / "assistant"
private String content;
private Date createTime;
}
9.2 服务层(Service)
@Service
public class ChatConversationService {
@Resource
private ChatConversationDao chatConversationDao;
/** 创建新会话,使用 Hutool 生成 UUID */
public ChatConversation createConversation(Integer userId) {
ChatConversation conversation = ChatConversation.builder()
.id(IdUtil.simpleUUID())
.userId(userId)
.title("新对话")
.createdAt(new Date())
.updatedAt(new Date())
.build();
chatConversationDao.insert(conversation);
return conversation;
}
/** 自动取首条用户消息的前50字作为标题 */
public void updateTitle(String conversationId, String firstMessage) {
if (firstMessage == null || firstMessage.isEmpty()) return;
String title = firstMessage.length() > 50
? firstMessage.substring(0, 50) + "…"
: firstMessage;
LambdaUpdateWrapper<ChatConversation> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(ChatConversation::getId, conversationId)
.set(ChatConversation::getTitle, title)
.set(ChatConversation::getUpdatedAt, new Date());
chatConversationDao.update(null, wrapper);
}
/** 获取用户所有会话,按最后活动时间倒序 */
public List<ChatConversation> getUserConversations(Integer userId) {
LambdaQueryWrapper<ChatConversation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ChatConversation::getUserId, userId)
.orderByDesc(ChatConversation::getUpdatedAt);
return chatConversationDao.selectList(wrapper);
}
/** 删除会话(仅限当前用户) */
public int deleteById(String conversationId, Integer userId) {
LambdaQueryWrapper<ChatConversation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ChatConversation::getId, conversationId)
.eq(ChatConversation::getUserId, userId);
return chatConversationDao.delete(wrapper);
}
}
9.3 控制层(Controller)
@PostMapping("/conversation/new")
public RespResult newConversation(HttpSession session) {
User loginUser = getLoginUser(session);
ChatConversation conversation =
chatConversationService.createConversation(loginUser.getId());
Map<String, Object> data = new HashMap<>();
data.put("conversationId", conversation.getId());
return RespResult.success("创建成功", data);
}
@GetMapping("/conversation/list")
public RespResult listConversations(HttpSession session) {
User loginUser = getLoginUser(session);
List<ChatConversation> list =
chatConversationService.getUserConversations(loginUser.getId());
return RespResult.success("获取成功", list);
}
@GetMapping("/conversation/{id}/messages")
public RespResult getConversationMessages(
@PathVariable("id") String conversationId, HttpSession session) {
User loginUser = getLoginUser(session);
List<ChatHistory> messages = chatHistoryService
.getConversationMessages(loginUser.getId(), conversationId);
return RespResult.success("获取成功", messages);
}
@PostMapping("/conversation/{id}/delete")
public RespResult deleteConversation(
@PathVariable("id") String conversationId, HttpSession session) {
User loginUser = getLoginUser(session);
chatHistoryService.clearConversationMessages(loginUser.getId(), conversationId);
chatConversationService.deleteById(conversationId, loginUser.getId());
return RespResult.success("删除成功");
}
AI 上下文构建(核心改动)——从数据库读取指定会话的全部历史,不同会话严格隔离:
private List<Message> buildAIMessages(
String conversationId, Integer userId, String newContent) {
List<Message> messages = new ArrayList<>();
messages.add(Message.builder()
.role(Role.SYSTEM.getValue())
.content(ApiService.SYSTEM_PROMPT).build());
List<ChatHistory> history = chatHistoryService
.getConversationMessages(userId, conversationId);
for (ChatHistory h : history) {
messages.add(Message.builder()
.role(h.getRole()).content(h.getContent()).build());
}
if (newContent != null && !newContent.isEmpty()) {
messages.add(Message.builder()
.role(Role.USER.getValue()).content(newContent).build());
}
return messages;
}
流式端点中,用户消息先入库,AI 回复流结束后整段入库,同时触发标题自动更新:
@GetMapping("/query/stream")
public SseEmitter queryStream(
@RequestParam String content,
@RequestParam String conversationId,
HttpSession session) {
User loginUser = getLoginUser(session);
// 用户消息写入数据库
chatHistoryService.saveMessage(
loginUser.getId(), conversationId,
Role.USER.getValue(), content);
chatConversationService.touchConversation(conversationId);
updateTitleIfNeeded(conversationId, loginUser.getId(), content);
List<Message> messages = buildAIMessages(
conversationId, loginUser.getId(), null);
// ... SSE 流式处理 ...
// 流结束后: chatHistoryService.saveMessage(..., Role.ASSISTANT, reply);
}
9.4 前端边栏实现
HTML 布局(doctor.html):
<div class="row">
<!-- 左侧:对话边栏 -->
<div class="col-lg-3" style="padding-right:0;">
<div class="conv-sidebar">
<div class="conv-sidebar-header">
<button class="btn-new-chat" onclick="newConversation()">+ 新对话</button>
</div>
<div class="conv-sidebar-list" id="conversationList"></div>
</div>
</div>
<!-- 右侧:聊天区域 -->
<div class="col-lg-9" style="padding-left:0;">
<div class="message">
<div class="message-header">...</div>
<div id="messages" style="height:260px;overflow-y:auto"></div>
<div class="msg-reply">
<textarea id="message"></textarea>
<button onclick="send()">发送</button>
</div>
</div>
</div>
</div>
JavaScript 会话管理:
let currentConversationId = null;
$(document).ready(function() {
loadConversationList();
});
function newConversation() {
$.post('/message/conversation/new', function(resp) {
currentConversationId = resp.data.conversationId;
resetChatArea();
loadConversationList();
});
}
function switchConversation(convId) {
currentConversationId = convId;
$.get('/message/conversation/' + convId + '/messages', function(resp) {
renderMessages(resp.data);
$('.conv-item').removeClass('active');
$('#conv-' + convId).addClass('active');
});
}
function loadConversationList() {
$.get('/message/conversation/list', function(resp) {
if (resp.data && resp.data.length > 0) {
if (!currentConversationId) switchConversation(resp.data[0].id);
} else {
newConversation();
}
});
}
发送消息时携带 conversationId(custom.js):
var streamUrl = 'message/query/stream?content=' + encodeURIComponent(message)
+ '&conversationId=' + encodeURIComponent(currentConversationId);
var es = new EventSource(streamUrl);
9.5 实现要点
| 关注点 | 所在文件 | 关键实现 |
|---|---|---|
| 会话实体 | ChatConversation.java |
UUID 主键、@TableName、Lombok @Data |
| 会话 Service | ChatConversationService.java |
UUID 生成、标题自动截取、按时间倒序 |
| 消息 Service | ChatHistoryService.java |
按 userId + conversationId 查询、级联删除 |
| 会话 API | MessageController.java |
4 个端点 + buildAIMessages() 上下文构建 |
| 流式端点 | MessageController.java |
queryStream() 新增 @RequestParam conversationId |
| 前端边栏 | doctor.html |
新对话/切换/删除/高亮的完整交互 |
| EventSource URL | custom.js |
send() 中拼接 conversationId 参数 |
十、未来计划
目前为止项目基本功能已经完成,接下来进行功能补全和添加,例如对话导出功能,更多前端交互优化与无障碍改造等。之后进行软件项目测试。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)