一、引言

在上一篇博客中,我们设计了文档管理子系统的四层架构、数据模型、状态机、异步任务队列和 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(), "系统重启,任务已重置,请重新分析");
    }
}

系统崩溃可能导致正在执行的任务状态停留在 PENDINGPROCESSING。启动时将这类任务统一标记为 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 参数

十、未来计划

目前为止项目基本功能已经完成,接下来进行功能补全和添加,例如对话导出功能,更多前端交互优化与无障碍改造等。之后进行软件项目测试。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐