Compare commits

...

5 Commits

Author SHA1 Message Date
f745651370 Merge remote-tracking branch 'origin/main' 2026-06-12 10:51:02 +08:00
a4f479454f 修复流式审核点击后无输出(静默失败)
原因:旧实现在返回 SseEmitter 之前同步做文档解析/渲染,一旦抛异常会被全局
异常处理器包成 JSON(HTTP 200 + {code,msg})返回;前端按 SSE 流读取,找不到
\n\n 分隔帧便静默结束——表现为“闪一下后无输出、也无报错”。

后端:
- analyzeStream 拆分 prepareSync(仅校验+读字节,必须在请求线程内)与
  buildPrompt(解析/渲染/构建提示词)。buildPrompt 移入异步线程,任何异常都
  转为 SSE error 事件返回,不再走 JSON 静默路径
- 线程启动即推送 start 事件,确认通道已打开
- 流式接口去掉 @Log(操作日志切面会尝试序列化 SseEmitter 返回值)

前端 add.vue:
- 校验响应 content-type:非 text/event-stream(鉴权失败/异常JSON/HTML)时读出
  正文并弹出错误,避免静默
- 统计收到的事件数,全程零事件时提示“未收到流式数据,请检查后端能否访问AI服务”
- 处理 start 事件

注:服务端为 Undertow、未开 gzip 压缩,dev 代理默认透传,排除缓冲导致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:37:18 +08:00
7a2603e1f9 AI审核新增改为独立流式页面:左侧实时输出+右侧文档预览
解决上传后长时间无反馈的问题——改为流式(SSE)边生成边展示。

后端:
- MiMoClient.chatStream:HttpURLConnection 读 SSE,分别回调 reasoning(思考)
  与 content(正文) 增量;支持多模态(扫描件PDF)
- IOaAiReviewService.analyzeStream + 实现:同步校验/解析文档后,异步线程调用
  流式接口,通过 SseEmitter 推送 {type:reasoning|content|done|error},
  结束后落库(含结论解析、摘要、原件OSS留存);createBy 显式回填(异步线程无登录上下文)
  · 抽出 prepare()/persist() 复用,analyze() 与 analyzeStream() 共用
- Controller 新增 POST /oa/aiReview/analyzeStream(multipart→text/event-stream)

前端:
- 新增独立二级页面 views/oa/aiReview/add.vue(路由 /hint/aiReview/add):
  · 顶部:类型/岗位/选文件/开始审核
  · 左侧:用原生 fetch 读流,实时渲染——思考过程(可折叠)+正文 Markdown 实时输出
  · 右侧:选中文件即用本地 objectURL 预览(PDF 内嵌 iframe,Word 占位提示)
  · 完成后显示匹配度/风险标签 + 查看详情
- 列表页「新增审核」由弹窗改为跳转该页面,移除弹窗相关逻辑
- router 增加 /hint/aiReview/add 隐藏路由

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:19:52 +08:00
d46754ede8 AI审核改为 列表页+详情页 结构,列表带审核摘要
- 表 oa_ai_review 增加 summary 列(审核结论摘要,纯文本,列表展示用),
  已应用到生产库;分析时由结果 Markdown 提炼前160字纯文本写入
- 列表查询清空大字段 result_md 减小响应体,详情接口仍返回完整结果
- 前端拆分:
  · index.vue 重写为列表页:搜索(类型/关键字)+表格(类型/文件名/岗位/结论标签/
    审核摘要/时间)+分页,「新增审核」改为弹窗上传(类型/岗位/文件),
    审核完成后跳转详情;行可删除
  · 新增 detail.vue 详情页:元信息(文件名+下载原件/岗位/模型/时间/审核人)
    + 结论标签 + 完整 Markdown 结果,返回列表按钮
  · router 增加 /hint/aiReview/detail/:id 隐藏路由
- 原件已通过 OSS 留存,详情页可下载,下次可直接查看

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:04:16 +08:00
faca2f85eb 新增 AI 合同/简历审核功能(小米 MiMo 多模态大模型)
核心诉求:合同审核站在我方(德睿福)立场,找出不利条款并给出利好我方的
修改/补充建议;简历审核评估候选人与目标岗位的匹配度。

后端(ruoyi-oa):
- 接入小米 MiMo(OpenAI 兼容 /chat/completions),mimo-v2.5 多模态模型
  · MiMoProperties 绑定 application.yml mimo: 配置(base-url/api-key/model/...)
  · MiMoClient:text + multimodal(image_url base64) 两种调用,独立长超时
    RestTemplate;mimo-v2.5 是推理模型,max-tokens 配 8192 留足思考额度
- DocumentParseUtil:PDF 文字(PDFBox)、Word(POI: docx XWPF / doc HWPF),
  扫描版 PDF(提取文字过短)用 PDFRenderer 转 PNG 走多模态
- OaAiReview 实体 + BO/VO/Mapper/Service/Controller(/oa/aiReview)
  · analyze 上传解析→构建提示词→调用大模型→留存原件(OSS)→落库
  · 合同/简历两套提示词;正则解析风险评级:高/中/低与匹配度评分:NN入库
  · 提供 list/detail/delete
- ruoyi-oa/pom.xml 增加 poi-ooxml、poi-scratchpad(Word 解析)
- application.yml 增加 mimo: 配置块

前端(ruoyi-ui):
- views/oa/aiReview/index.vue:类型切换(合同/简历)、拖拽上传(pdf/word)、
  简历目标岗位输入、审核(loading)、Markdown 结果渲染、历史记录列表
- api/oa/aiReview.js:analyze 用 FormData,超时放宽到 5 分钟

SQL(已应用到生产库):
- oa_ai_review 表;菜单挂信息下(menu_id 2063910000000000001),授权10个角色

已用真实接口端到端验证:合同审核输出利好我方意见、风险评级可正确解析。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:00:09 +08:00
18 changed files with 1618 additions and 0 deletions

View File

@@ -328,3 +328,19 @@ fad:
webKey: 34bf20d1db5b183558b9bb85d6eed783
securityKey: 6f9171724396deb5f8c42ef256b3cbc5
--- # 小米 MiMo 大模型AI 合同/简历审核)
mimo:
# OpenAI 兼容接口地址(不含 /chat/completions
base-url: https://api.xiaomimimo.com/v1
# API Key
api-key: sk-cgdkhgch2w1cg37dl12scuckyzbnrkj37ih3b6f0k13dcgwp
# 多模态模型
model: mimo-v2.5
# 最大生成 tokenmimo-v2.5 为推理模型,会先消耗 token 思考,需留足额度)
max-tokens: 8192
temperature: 0.3
# 单次请求读超时(秒)。推理+长文档审核较慢,给足时间
timeout: 180
# PDF 无法提取文字时(扫描件)转图片走多模态,最多渲染页数
max-image-pages: 8

View File

@@ -83,6 +83,17 @@
<version>2.0.29</version>
</dependency>
<!-- 解析 Word(.docx/.doc) 简历/合同 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>${poi.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,92 @@
package com.ruoyi.oa.aireview;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.hwpf.extractor.WordExtractor;
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* 文档解析:从 PDF / Word 提取文字;扫描版 PDF 渲染为图片走多模态。
*
* @author wangyu
*/
@Slf4j
public final class DocumentParseUtil {
private DocumentParseUtil() {}
/** 提取文字内容PDF / docx / doc。提取不到返回空串不抛异常。 */
public static String extractText(String fileName, byte[] bytes) {
String lower = fileName == null ? "" : fileName.toLowerCase();
try {
if (lower.endsWith(".pdf")) {
return extractPdfText(bytes);
} else if (lower.endsWith(".docx")) {
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes));
XWPFWordExtractor ex = new XWPFWordExtractor(doc)) {
return StringUtils.trimToEmpty(ex.getText());
}
} else if (lower.endsWith(".doc")) {
try (WordExtractor ex = new WordExtractor(new ByteArrayInputStream(bytes))) {
return StringUtils.trimToEmpty(ex.getText());
}
}
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("解析文档失败:{}", fileName, e);
return "";
}
}
private static String extractPdfText(byte[] bytes) throws Exception {
try (PDDocument doc = PDDocument.load(bytes)) {
PDFTextStripper stripper = new PDFTextStripper();
return StringUtils.trimToEmpty(stripper.getText(doc));
}
}
/** 是否为 PDF */
public static boolean isPdf(String fileName) {
return fileName != null && fileName.toLowerCase().endsWith(".pdf");
}
/**
* 将 PDF 渲染为 PNG 图片的 data URI 列表(用于扫描件走多模态)。
*
* @param maxPages 最多渲染页数
*/
public static List<String> renderPdfImages(byte[] bytes, int maxPages) {
List<String> images = new ArrayList<>();
try (PDDocument doc = PDDocument.load(bytes)) {
PDFRenderer renderer = new PDFRenderer(doc);
int pages = Math.min(doc.getNumberOfPages(), Math.max(1, maxPages));
for (int i = 0; i < pages; i++) {
BufferedImage img = renderer.renderImageWithDPI(i, 120, ImageType.RGB);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "png", baos);
String b64 = Base64.getEncoder().encodeToString(baos.toByteArray());
images.add("data:image/png;base64," + b64);
}
} catch (Exception e) {
log.error("PDF 转图片失败", e);
throw new ServiceException("PDF 转图片失败:" + e.getMessage());
}
return images;
}
}

View File

@@ -0,0 +1,238 @@
package com.ruoyi.oa.aireview;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.function.BiConsumer;
/**
* 小米 MiMo 大模型调用客户端OpenAI 兼容 /chat/completions
*
* 注意mimo-v2.5 是推理模型,响应里 message.content 是最终答案,
* message.reasoning_content 是思考过程max_completion_tokens 要给足,
* 否则 token 被思考耗尽会出现 content 为空、finish_reason=length。
*
* @author wangyu
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MiMoClient {
private final MiMoProperties props;
private final ObjectMapper json = new ObjectMapper();
private RestTemplate restTemplate;
@PostConstruct
public void init() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10_000);
factory.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000);
this.restTemplate = new RestTemplate(factory);
}
/**
* 纯文本对话
*/
public String chatText(String systemPrompt, String userText) {
ObjectNode body = baseBody();
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
messages.addObject().put("role", "user").put("content", userText);
return send(body);
}
/**
* 多模态对话:一段文字 + 若干图片data URI形如 data:image/png;base64,xxx
*/
public String chatMultimodal(String systemPrompt, String userText, List<String> imageDataUris) {
ObjectNode body = baseBody();
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", userText);
if (imageDataUris != null) {
for (String uri : imageDataUris) {
if (StringUtils.isBlank(uri)) continue;
ObjectNode img = content.addObject();
img.put("type", "image_url");
img.putObject("image_url").put("url", uri);
}
}
return send(body);
}
/**
* 流式对话SSE。一边接收一边回调返回拼接好的最终答案 content。
*
* @param imageDataUris 多模态图片(可空)
* @param onDelta 回调kind = "reasoning"(思考) / "content"(正文)text = 增量片段
*/
public String chatStream(String systemPrompt, String userText, List<String> imageDataUris,
BiConsumer<String, String> onDelta) {
if (StringUtils.isBlank(props.getApiKey())) {
throw new ServiceException("未配置 MiMo API Keyapplication.yml: mimo.api-key");
}
ObjectNode body = baseBody();
body.put("stream", true);
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
boolean multimodal = imageDataUris != null && !imageDataUris.isEmpty();
if (multimodal) {
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", userText);
for (String uri : imageDataUris) {
if (StringUtils.isBlank(uri)) continue;
ObjectNode img = content.addObject();
img.put("type", "image_url");
img.putObject("image_url").put("url", uri);
}
} else {
messages.addObject().put("role", "user").put("content", userText);
}
HttpURLConnection conn = null;
try {
URL url = new URL(props.getBaseUrl() + "/chat/completions");
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setConnectTimeout(10_000);
conn.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("api-key", props.getApiKey());
conn.setRequestProperty("Authorization", "Bearer " + props.getApiKey());
try (OutputStream os = conn.getOutputStream()) {
os.write(json.writeValueAsString(body).getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
if (code >= 400) {
String err = readAll(conn.getErrorStream());
throw new ServiceException("AI 服务返回 " + code + "" + StringUtils.substring(err, 0, 300));
}
StringBuilder full = new StringBuilder();
try (BufferedReader r = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = r.readLine()) != null) {
if (line.isEmpty() || !line.startsWith("data:")) continue;
String payload = line.substring(5).trim();
if ("[DONE]".equals(payload)) break;
JsonNode delta = json.readTree(payload).path("choices").path(0).path("delta");
JsonNode rc = delta.get("reasoning_content");
if (rc != null && !rc.isNull() && !rc.asText().isEmpty()) {
onDelta.accept("reasoning", rc.asText());
}
JsonNode c = delta.get("content");
if (c != null && !c.isNull() && !c.asText().isEmpty()) {
full.append(c.asText());
onDelta.accept("content", c.asText());
}
}
}
if (full.length() == 0) {
throw new ServiceException("AI 未返回有效内容(文档过长或额度不足)");
}
return full.toString();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("调用 MiMo 流式接口失败", e);
throw new ServiceException("AI 服务调用失败:" + e.getMessage());
} finally {
if (conn != null) conn.disconnect();
}
}
private String readAll(InputStream in) {
if (in == null) return "";
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String l;
while ((l = r.readLine()) != null) sb.append(l);
return sb.toString();
} catch (Exception e) {
return "";
}
}
private ObjectNode baseBody() {
ObjectNode body = json.createObjectNode();
body.put("model", props.getModel());
// OpenAI 兼容新参数名MiMo 同时也接受 max_tokens这里两者都给以防万一
body.put("max_completion_tokens", props.getMaxTokens());
body.put("max_tokens", props.getMaxTokens());
body.put("temperature", props.getTemperature());
body.put("stream", false);
return body;
}
private String send(ObjectNode body) {
if (StringUtils.isBlank(props.getApiKey())) {
throw new ServiceException("未配置 MiMo API Keyapplication.yml: mimo.api-key");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// 文档要求 api-key 头;同时带 Bearer 以兼容 OpenAI 风格
headers.set("api-key", props.getApiKey());
headers.set("Authorization", "Bearer " + props.getApiKey());
String url = props.getBaseUrl() + "/chat/completions";
try {
HttpEntity<String> entity = new HttpEntity<>(json.writeValueAsString(body), headers);
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) {
throw new ServiceException("AI 服务返回异常:" + resp.getStatusCode());
}
JsonNode root = json.readTree(resp.getBody());
JsonNode message = root.path("choices").path(0).path("message");
String content = message.path("content").asText("");
String finish = root.path("choices").path(0).path("finish_reason").asText("");
if (StringUtils.isBlank(content)) {
if ("length".equals(finish)) {
throw new ServiceException("文档过长AI 输出被截断,请精简文档或提高 mimo.max-tokens 后重试");
}
throw new ServiceException("AI 未返回有效内容");
}
int total = root.path("usage").path("total_tokens").asInt(0);
log.info("MiMo 审核完成model={}, 消耗 token={}", props.getModel(), total);
return content;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("调用 MiMo 失败", e);
throw new ServiceException("AI 服务调用失败:" + e.getMessage());
}
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.oa.aireview;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 小米 MiMo 大模型配置AI 合同/简历审核)
*
* @author wangyu
*/
@Data
@Component
@ConfigurationProperties(prefix = "mimo")
public class MiMoProperties {
/** OpenAI 兼容接口地址(不含 /chat/completions */
private String baseUrl = "https://api.xiaomimimo.com/v1";
/** API Key */
private String apiKey;
/** 多模态模型名 */
private String model = "mimo-v2.5";
/** 最大生成 tokenmimo-v2.5 为推理模型,需留足额度给思考+输出) */
private Integer maxTokens = 8192;
/** 采样温度 */
private Double temperature = 0.3;
/** 单次请求读超时(秒) */
private Integer timeout = 180;
/** PDF 转图片走多模态时最多渲染页数 */
private Integer maxImagePages = 8;
}

View File

@@ -0,0 +1,70 @@
package com.ruoyi.oa.controller;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import com.ruoyi.oa.service.IOaAiReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.Arrays;
/**
* AI 审核(合同 / 简历)
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/oa/aiReview")
public class OaAiReviewController extends BaseController {
private final IOaAiReviewService service;
/**
* 上传并审核
*/
@Log(title = "AI审核", businessType = BusinessType.OTHER)
@PostMapping(value = "/analyze", consumes = "multipart/form-data")
public R<OaAiReviewVo> analyze(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,
@RequestParam(value = "position", required = false) String position) {
return R.ok("审核完成", service.analyze(file, reviewType, position));
}
/**
* 上传并流式审核SSE边生成边推送结束后落库
* 不加 @Log操作日志切面会尝试序列化返回值SseEmitter 不适合被序列化。
*/
@PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8")
public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,
@RequestParam(value = "position", required = false) String position) {
return service.analyzeStream(file, reviewType, position);
}
@GetMapping("/list")
public TableDataInfo<OaAiReviewVo> list(OaAiReviewBo bo, PageQuery pageQuery) {
return service.queryPageList(bo, pageQuery);
}
@GetMapping("/{id}")
public R<OaAiReviewVo> getInfo(@NotNull @PathVariable Long id) {
return R.ok(service.queryById(id));
}
@Log(title = "AI审核", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@NotEmpty @PathVariable Long[] ids) {
return toAjax(service.deleteByIds(Arrays.asList(ids)));
}
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.oa.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* AI 审核记录(合同 / 简历)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("oa_ai_review")
public class OaAiReview extends BaseEntity {
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
/** 审核类型contract 合同 / resume 简历 */
private String reviewType;
/** 原始文件名 */
private String fileName;
/** OSS 文件ID原件留存可空 */
private Long ossId;
/** OSS 文件地址 */
private String fileUrl;
/** 简历审核的目标岗位 */
private String position;
/** 简历匹配度评分 0-100合同为空 */
private Integer matchScore;
/** 合同总体风险评级:高/中/低(简历为空) */
private String riskLevel;
/** AI 审核结论摘要(列表展示,纯文本) */
private String summary;
/** AI 审核结果Markdown */
private String resultMd;
/** 使用的模型 */
private String model;
/** 消耗 token */
private Integer tokens;
@TableLogic
private String delFlag;
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.oa.domain.bo;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* AI 审核记录 查询 BO
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OaAiReviewBo extends BaseEntity {
private Long id;
/** 审核类型contract / resume */
private String reviewType;
/** 关键字(文件名 / 岗位) */
private String keyword;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.oa.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* AI 审核记录 VO
*/
@Data
public class OaAiReviewVo implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String reviewType;
private String fileName;
private Long ossId;
private String fileUrl;
private String position;
private Integer matchScore;
private String riskLevel;
private String summary;
private String resultMd;
private String model;
private Integer tokens;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.oa.mapper;
import com.ruoyi.common.core.mapper.BaseMapperPlus;
import com.ruoyi.oa.domain.OaAiReview;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
public interface OaAiReviewMapper extends BaseMapperPlus<OaAiReviewMapper, OaAiReview, OaAiReviewVo> {
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.oa.service;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
public interface IOaAiReviewService {
/**
* 上传合同/简历并进行 AI 审核,落库并返回结果
*
* @param file PDF / Word 文件
* @param reviewType contract / resume
* @param position 简历审核的目标岗位(合同可空)
*/
OaAiReviewVo analyze(MultipartFile file, String reviewType, String position);
/**
* 流式审核边生成边推送SSE结束后落库
*/
SseEmitter analyzeStream(MultipartFile file, String reviewType, String position);
TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery);
OaAiReviewVo queryById(Long id);
Boolean deleteByIds(Collection<Long> ids);
}

View File

@@ -0,0 +1,342 @@
package com.ruoyi.oa.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.oa.aireview.DocumentParseUtil;
import com.ruoyi.oa.aireview.MiMoClient;
import com.ruoyi.oa.aireview.MiMoProperties;
import com.ruoyi.oa.domain.OaAiReview;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import com.ruoyi.oa.mapper.OaAiReviewMapper;
import com.ruoyi.oa.service.IOaAiReviewService;
import com.ruoyi.system.domain.vo.SysOssVo;
import com.ruoyi.system.service.ISysOssService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.File;
import java.nio.file.Files;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AI 审核(合同 / 简历)
*
* @author wangyu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OaAiReviewServiceImpl implements IOaAiReviewService {
private final OaAiReviewMapper baseMapper;
private final MiMoClient miMoClient;
private final MiMoProperties miMoProps;
private final ISysOssService ossService;
/** 文字内容少于该长度,认为是扫描件/图片版PDF 走多模态 */
private static final int MIN_TEXT_LEN = 30;
/** 文档过长时截断,避免 prompt 过大 */
private static final int MAX_TEXT_LEN = 24000;
private static final Pattern SCORE_PATTERN = Pattern.compile("匹配度评分[:\\s]*([0-9]{1,3})");
private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[:\\s]*([高中低])");
@Override
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) {
Prepared p = prepareSync(file, reviewType, position);
buildPrompt(p);
String result = p.images != null
? miMoClient.chatMultimodal(p.system, p.userText, p.images)
: miMoClient.chatText(p.system, p.userText);
// 留存原件
SysOssVo oss = uploadQuietly(file);
OaAiReview entity = persist(p, result, oss, currentUsername());
return baseMapper.selectVoById(entity.getId());
}
@Override
public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) {
// 只在同步阶段做轻量校验 + 读取字节multipart 必须在请求线程内消费);
// 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回,
// 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON前端按流读取会“静默失败”
Prepared p = prepareSync(file, reviewType, position);
String username = currentUsername();
long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L;
SseEmitter emitter = new SseEmitter(timeoutMs);
Thread worker = new Thread(() -> {
try {
// 立即推送一个 start 事件,确认通道已打开(前端据此显示“已连接”)
emitter.send(event("start", null));
// 解析文档 + 构建提示词(可能抛异常,此处会转成 SSE error
buildPrompt(p);
String result = miMoClient.chatStream(p.system, p.userText, p.images, (kind, chunk) -> {
try {
Map<String, Object> ev = new HashMap<>();
ev.put("type", kind); // reasoning / content
ev.put("c", chunk);
emitter.send(ev);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
SysOssVo oss = uploadQuietly(p.fileName, p.bytes);
OaAiReview entity = persist(p, result, oss, username);
Map<String, Object> done = new HashMap<>();
done.put("type", "done");
done.put("id", entity.getId());
done.put("matchScore", entity.getMatchScore());
done.put("riskLevel", entity.getRiskLevel());
emitter.send(done);
emitter.complete();
} catch (Exception e) {
log.error("流式审核失败", e);
try {
Throwable cause = e instanceof RuntimeException && e.getCause() != null ? e.getCause() : e;
emitter.send(event("error", cause.getMessage()));
} catch (Exception ignored) {}
emitter.complete();
}
});
worker.setDaemon(true);
worker.setName("ai-review-stream");
worker.start();
return emitter;
}
private Map<String, Object> event(String type, String msg) {
Map<String, Object> ev = new HashMap<>();
ev.put("type", type);
if (msg != null) ev.put("msg", msg);
return ev;
}
/** 同步阶段:校验 + 读取字节(不解析,快) */
private Prepared prepareSync(MultipartFile file, String reviewType, String position) {
if (file == null || file.isEmpty()) {
throw new ServiceException("请上传文件");
}
if (!"contract".equals(reviewType) && !"resume".equals(reviewType)) {
throw new ServiceException("审核类型不正确");
}
String fileName = file.getOriginalFilename();
if (StringUtils.isBlank(fileName)) {
throw new ServiceException("文件名为空");
}
String lower = fileName.toLowerCase();
if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) {
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
}
byte[] bytes;
try {
bytes = file.getBytes();
} catch (Exception e) {
throw new ServiceException("读取文件失败");
}
Prepared p = new Prepared();
p.reviewType = reviewType;
p.position = position;
p.fileName = fileName;
p.bytes = bytes;
return p;
}
/** 解析文档 + 构建提示词(耗时/可能抛异常的部分) */
private void buildPrompt(Prepared p) {
p.system = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position);
String text = truncate(DocumentParseUtil.extractText(p.fileName, p.bytes));
if (StringUtils.length(text) >= MIN_TEXT_LEN) {
p.userText = "contract".equals(p.reviewType)
? "以下是待审核的合同全文:\n\n" + text
: "以下是待评估的简历内容:"
+ (StringUtils.isNotBlank(p.position) ? "(目标岗位:" + p.position + "" : "") + "\n\n" + text;
p.images = null;
} else if (DocumentParseUtil.isPdf(p.fileName)) {
p.images = DocumentParseUtil.renderPdfImages(p.bytes, miMoProps.getMaxImagePages());
p.userText = "contract".equals(p.reviewType)
? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。"
: "请评估以下图片中的简历(扫描件)。"
+ (StringUtils.isNotBlank(p.position) ? "目标岗位:" + p.position + "" : "");
} else {
throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF");
}
}
/** 落库(结论解析、摘要、创建人) */
private OaAiReview persist(Prepared p, String result, SysOssVo oss, String username) {
OaAiReview entity = new OaAiReview();
entity.setReviewType(p.reviewType);
entity.setFileName(p.fileName);
if (oss != null) {
entity.setOssId(oss.getOssId());
entity.setFileUrl(oss.getUrl());
}
entity.setPosition(p.position);
entity.setResultMd(result);
entity.setSummary(buildSummary(result));
entity.setModel(miMoProps.getModel());
if ("resume".equals(p.reviewType)) {
entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100));
} else {
entity.setRiskLevel(parseStr(RISK_PATTERN, result));
}
if (StringUtils.isNotBlank(username)) {
entity.setCreateBy(username);
}
baseMapper.insert(entity);
return entity;
}
private String currentUsername() {
try {
return LoginHelper.getLoginUser() != null ? LoginHelper.getLoginUser().getUsername() : null;
} catch (Exception e) {
return null;
}
}
private SysOssVo uploadQuietly(MultipartFile file) {
try {
return ossService.upload(file, 0L);
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", file.getOriginalFilename(), e);
return null;
}
}
private SysOssVo uploadQuietly(String fileName, byte[] bytes) {
File tmp = null;
try {
String suffix = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.')) : "";
tmp = File.createTempFile("aireview", suffix);
Files.write(tmp.toPath(), bytes);
return ossService.upload(tmp, fileName, 0L);
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", fileName, e);
return null;
} finally {
if (tmp != null && tmp.exists()) {
try { tmp.delete(); } catch (Exception ignored) {}
}
}
}
/** 解析后的待审核数据 */
private static class Prepared {
String reviewType;
String position;
String fileName;
byte[] bytes;
String system;
String userText;
List<String> images;
}
private String truncate(String text) {
if (text == null) return "";
return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text;
}
/** 从 Markdown 结果里提炼一段纯文本摘要,供列表展示 */
private String buildSummary(String md) {
if (StringUtils.isBlank(md)) return null;
String text = md.replaceAll("(?m)^#+\\s*", "") // 标题符号
.replaceAll("[*`>#\\-]", " ") // markdown 符号
.replaceAll("\\s+", " ") // 折叠空白
.trim();
return text.length() > 160 ? text.substring(0, 160) : text;
}
private Integer parseInt(Pattern p, String text, int max) {
Matcher m = p.matcher(text == null ? "" : text);
if (m.find()) {
try {
int v = Integer.parseInt(m.group(1));
if (v >= 0 && v <= max) return v;
} catch (NumberFormatException ignored) {}
}
return null;
}
private String parseStr(Pattern p, String text) {
Matcher m = p.matcher(text == null ? "" : text);
return m.find() ? m.group(1) : null;
}
private String contractSystemPrompt() {
return "你是德睿福成套设备有限公司聘请的资深合同法务与商务谈判顾问。"
+ "你的立场是【最大化保护并争取“我方”(德睿福)的利益】——审查时始终站在我方角度,"
+ "识别对我方不利的安排,并提出利好我方的修改与补充建议。\n"
+ "请用简体中文、Markdown 格式输出审核报告,包含以下小节(用二级标题):\n"
+ "## 一、合同概要\n合同类型、双方主体、标的、金额、期限等关键信息\n"
+ "## 二、总体风险评级\n必须单独一行明确写出`风险评级:高` 或 `风险评级:中` 或 `风险评级:低`,再附简要理由)\n"
+ "## 三、对我方不利/存在风险的条款\n逐条列出① 原文摘录 ② 风险说明 ③ 利好我方的修改建议)\n"
+ "## 四、建议补充的利好我方条款\n如违约金、付款节点与比例、质保与验收、知识产权归属、保密、不可抗力、争议解决地与管辖等\n"
+ "## 五、关键条款审查\n付款、交付、验收、违约责任、责任限制/赔偿上限、解除权等)\n"
+ "## 六、谈判要点与优先级\n按重要性排序给出可直接用于谈判的要点\n"
+ "注意:若合同中我方实为乙方/供方,请据实判断我方身份后仍以争取我方利益为目标。";
}
private String resumeSystemPrompt(String position) {
String posLine = StringUtils.isNotBlank(position)
? "目标岗位为【" + position + "】,请重点评估候选人与该岗位的匹配度。\n"
: "未指定目标岗位,请综合判断其最适合的岗位方向并据此评估。\n";
return "你是德睿福成套设备有限公司的资深招聘官与技术面试官。" + posLine
+ "请用简体中文、Markdown 格式输出评估报告,包含以下小节(用二级标题):\n"
+ "## 一、候选人概要\n姓名、工作年限、学历、当前/最近岗位、期望等,能提取则填)\n"
+ "## 二、岗位匹配度\n必须单独一行明确写出`匹配度评分NN`NN 为 0-100 的整数;随后给出评分理由,"
+ "结合岗位所需的技能、经验、行业背景逐点对照)\n"
+ "## 三、核心优势\n"
+ "## 四、短板与风险点\n经历空窗、跳槽频繁、技能缺口等\n"
+ "## 五、建议重点考察的面试问题\n针对其经历与岗位要求给出 5 个左右有针对性的问题)\n"
+ "## 六、录用建议\n明确给出建议录用 / 谨慎考虑 / 不建议,并说明理由)";
}
@Override
public TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<OaAiReview> lqw = Wrappers.lambdaQuery();
if (bo != null) {
lqw.eq(StringUtils.isNotBlank(bo.getReviewType()), OaAiReview::getReviewType, bo.getReviewType());
if (StringUtils.isNotBlank(bo.getKeyword())) {
String kw = bo.getKeyword().trim();
lqw.and(w -> w.like(OaAiReview::getFileName, kw).or().like(OaAiReview::getPosition, kw));
}
}
lqw.orderByDesc(OaAiReview::getCreateTime);
Page<OaAiReviewVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
// 列表只需 summary清空大字段 resultMd 减小响应体
if (page.getRecords() != null) {
page.getRecords().forEach(v -> v.setResultMd(null));
}
return TableDataInfo.build(page);
}
@Override
public OaAiReviewVo queryById(Long id) {
OaAiReviewVo vo = baseMapper.selectVoById(id);
if (vo == null) throw new ServiceException("记录不存在或已删除");
return vo;
}
@Override
public Boolean deleteByIds(Collection<Long> ids) {
return baseMapper.deleteBatchIds(ids) > 0;
}
}

View File

@@ -0,0 +1,28 @@
import request from '@/utils/request'
/**
* 上传合同/简历进行 AI 审核
* @param {FormData} data 包含 file, reviewType(contract|resume), position(可选)
*/
export function analyzeAiReview (data) {
return request({
url: '/oa/aiReview/analyze',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
// 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟
timeout: 300000
})
}
export function listAiReview (query) {
return request({ url: '/oa/aiReview/list', method: 'get', params: query })
}
export function getAiReview (id) {
return request({ url: '/oa/aiReview/' + id, method: 'get' })
}
export function delAiReview (ids) {
return request({ url: '/oa/aiReview/' + ids, method: 'delete' })
}

View File

@@ -177,6 +177,18 @@ export const constantRoutes = [
name: "editMeetingMinutes",
meta: { title: "编辑会议纪要", activeMenu: "/hint/meeting" },
},
{
path: "aiReview/add",
component: () => import("@/views/oa/aiReview/add"),
name: "aiReviewAdd",
meta: { title: "新增审核", activeMenu: "/hint/aiReview" },
},
{
path: "aiReview/detail/:id(\\d+)",
component: () => import("@/views/oa/aiReview/detail"),
name: "aiReviewDetail",
meta: { title: "审核详情", activeMenu: "/hint/aiReview" },
},
],
},
{

View File

@@ -0,0 +1,312 @@
<template>
<div class="app-container ai-review-add">
<!-- 顶部操作栏 -->
<el-card shadow="never" class="topbar">
<div class="bar">
<div class="left">
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
<span class="title"><i class="el-icon-magic-stick" /> 新增 AI 审核</span>
</div>
<div class="right">
<el-radio-group v-model="reviewType" size="small" :disabled="streaming">
<el-radio-button label="contract">合同审核</el-radio-button>
<el-radio-button label="resume">简历审核</el-radio-button>
</el-radio-group>
<el-input v-if="reviewType === 'resume'" v-model="position" size="small"
placeholder="目标岗位(选填)" clearable :disabled="streaming" style="width: 200px" />
<el-upload action="#" :auto-upload="false" :show-file-list="false" :limit="1"
:on-change="onFileChange" accept=".pdf,.doc,.docx" :disabled="streaming">
<el-button size="small" icon="el-icon-paperclip" :disabled="streaming">
{{ fileName || '选择文件' }}
</el-button>
</el-upload>
<el-button size="small" type="primary" icon="el-icon-cpu" :loading="streaming"
@click="start">{{ streaming ? '审核中' : '开始审核' }}</el-button>
</div>
</div>
<div class="bar-hint">
{{ reviewType === 'contract'
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改/补充建议。'
: '评估候选人,分析与目标岗位的匹配度、优势、短板与面试建议。' }}
支持 PDF / Word(.doc/.docx) 20MB
</div>
</el-card>
<el-row :gutter="12" class="body">
<!-- 流式输出 -->
<el-col :span="12" :xs="24">
<el-card shadow="never" class="panel out-panel">
<div slot="header" class="hd">
<span><i class="el-icon-document" /> 审核结果</span>
<div class="hd-tags">
<span v-if="streaming" class="streaming-dot"> 实时生成中</span>
<el-tag v-if="done && reviewType === 'resume' && matchScore != null" size="mini"
type="success" effect="dark">匹配度 {{ matchScore }}</el-tag>
<el-tag v-if="done && reviewType === 'contract' && riskLevel" size="mini"
:type="riskTagType(riskLevel)" effect="dark">{{ riskLevel }}风险</el-tag>
<el-button v-if="done && savedId" type="text" size="mini" icon="el-icon-view"
@click="goDetail">查看详情</el-button>
</div>
</div>
<div ref="outBody" class="out-body">
<div v-if="!streaming && !content && !reasoning" class="placeholder">
<i class="el-icon-cpu" />
<div>选择文件后点击开始审核结果将实时流式输出</div>
</div>
<!-- 思考过程 -->
<div v-if="reasoning" class="reasoning">
<div class="reasoning-hd" @click="showReasoning = !showReasoning">
<i :class="showReasoning ? 'el-icon-arrow-down' : 'el-icon-arrow-right'" />
<i class="el-icon-loading" v-if="streaming && !content" />
思考过程
</div>
<pre v-show="showReasoning" class="reasoning-body">{{ reasoning }}</pre>
</div>
<!-- 正文markdown 实时渲染 -->
<div v-if="content" class="md-body" v-html="renderedMd" />
<div v-else-if="streaming && reasoning" class="thinking-tip">AI 正在分析文档马上输出结论</div>
</div>
</el-card>
</el-col>
<!-- 文档预览 -->
<el-col :span="12" :xs="24">
<el-card shadow="never" class="panel preview-panel">
<div slot="header" class="hd"><span><i class="el-icon-view" /> 文档预览</span></div>
<div class="preview-body">
<iframe v-if="previewUrl && isPdf" :src="previewUrl" class="pdf-frame" />
<div v-else-if="previewUrl && !isPdf" class="preview-placeholder">
<i class="el-icon-document" />
<div>{{ fileName }}</div>
<div class="sub">Word 文档暂不支持在线预览审核结果见左侧</div>
</div>
<div v-else class="preview-placeholder">
<i class="el-icon-picture-outline" />
<div>选择 PDF 文件可在此预览</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { getToken } from '@/utils/auth'
const marked = require('marked')
export default {
name: 'OaAiReviewAdd',
data () {
return {
reviewType: 'contract',
position: '',
rawFile: null,
fileName: '',
streaming: false,
done: false,
reasoning: '',
content: '',
showReasoning: true,
savedId: null,
matchScore: null,
riskLevel: null,
eventCount: 0,
previewUrl: '',
isPdf: false
}
},
computed: {
renderedMd () {
try { return marked(this.content) } catch (e) { return this.content }
}
},
created () {
marked.setOptions({ breaks: true })
},
beforeDestroy () {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
},
methods: {
goBack () { this.$router.push('/hint/aiReview') },
goDetail () { if (this.savedId) this.$router.push('/hint/aiReview/detail/' + this.savedId) },
riskTagType (r) { return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success') },
onFileChange (file) {
if (!/\.(pdf|doc|docx)$/i.test(file.name)) {
return this.$modal.msgError('仅支持 PDF / Word(.doc/.docx)')
}
if (file.size > 20 * 1024 * 1024) {
return this.$modal.msgError('文件不能超过 20MB')
}
this.rawFile = file.raw
this.fileName = file.name
// 立即生成本地预览
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
this.previewUrl = URL.createObjectURL(file.raw)
this.isPdf = /\.pdf$/i.test(file.name)
},
async start () {
if (!this.rawFile) return this.$modal.msgError('请先选择文件')
this.reasoning = ''
this.content = ''
this.done = false
this.savedId = null
this.matchScore = null
this.riskLevel = null
this.showReasoning = true
this.streaming = true
const fd = new FormData()
fd.append('file', this.rawFile)
fd.append('reviewType', this.reviewType)
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
method: 'POST',
headers: { Authorization: 'Bearer ' + getToken(), Accept: 'text/event-stream' },
body: fd
})
// 非流式响应(鉴权失败 / 后端异常被包成 JSON / HTML→ 读出来报错,避免静默
const ct = resp.headers.get('content-type') || ''
if (!resp.ok || !resp.body || ct.indexOf('text/event-stream') === -1) {
let msg = '审核失败HTTP ' + resp.status + ''
try {
const txt = await resp.text()
try { const j = JSON.parse(txt); if (j && (j.msg || j.message)) msg = j.msg || j.message }
catch (e) { if (txt) msg = txt.slice(0, 200) }
} catch (e) {}
this.$modal.msgError(msg)
this.eventCount = 0
this.streaming = false
return
}
const reader = resp.body.getReader()
const decoder = new TextDecoder('utf-8')
let buf = ''
this.eventCount = 0
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx
while ((idx = buf.indexOf('\n\n')) >= 0) {
const frame = buf.slice(0, idx)
buf = buf.slice(idx + 2)
this.handleFrame(frame)
}
}
if (this.eventCount === 0) {
this.$modal.msgError('未收到任何流式数据,请检查后端是否可访问 AI 服务')
}
} catch (e) {
this.$modal.msgError('连接中断:' + (e.message || e))
} finally {
this.streaming = false
}
},
handleFrame (frame) {
let data = ''
for (const line of frame.split('\n')) {
if (line.startsWith('data:')) data += line.slice(5).trim()
}
if (!data) return
let ev
try { ev = JSON.parse(data) } catch (e) { return }
this.eventCount++
if (ev.type === 'start') {
// 通道已打开,等待解析/生成
} else if (ev.type === 'reasoning') {
this.reasoning += ev.c
this.scrollBottom()
} else if (ev.type === 'content') {
if (this.content === '') this.showReasoning = false // 正文开始时折叠思考过程
this.content += ev.c
this.scrollBottom()
} else if (ev.type === 'done') {
this.done = true
this.savedId = ev.id
this.matchScore = ev.matchScore
this.riskLevel = ev.riskLevel
this.$modal.msgSuccess('审核完成')
} else if (ev.type === 'error') {
this.$modal.msgError(ev.msg || '审核失败')
}
},
scrollBottom () {
this.$nextTick(() => {
const el = this.$refs.outBody
if (el) el.scrollTop = el.scrollHeight
})
}
}
}
</script>
<style scoped lang="scss">
.ai-review-add { padding: 10px; }
.topbar { margin-bottom: 8px; ::v-deep .el-card__body { padding: 10px 14px; } }
.bar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
.left { display: flex; align-items: center; gap: 12px;
.title { font-weight: 600; font-size: 15px; color: #303133; i { color: #409eff; margin-right: 4px; } }
}
.right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
}
.bar-hint { font-size: 12px; color: #909399; margin-top: 8px; }
.body { margin-top: 0; }
.panel { ::v-deep .el-card__header { padding: 9px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;
font-size: 13px; font-weight: 600; color: #303133; i { color: #409eff; margin-right: 4px; }
.hd-tags { display: flex; align-items: center; gap: 8px; }
.streaming-dot { color: #67c23a; font-size: 12px; animation: blink 1s infinite; }
}
.out-panel, .preview-panel { height: calc(100vh - 180px); ::v-deep .el-card__body { height: calc(100% - 40px); padding: 0; } }
.out-body { height: 100%; overflow-y: auto; padding: 12px 16px; }
.placeholder, .preview-placeholder {
text-align: center; color: #c0c4cc; padding: 90px 20px;
i { font-size: 52px; opacity: .4; display: block; margin-bottom: 14px; }
.sub { font-size: 12px; margin-top: 6px; }
}
.thinking-tip { color: #909399; font-size: 13px; padding: 8px 0; }
.reasoning { margin-bottom: 12px; border: 1px dashed #e0e3e9; border-radius: 6px; background: #fafbfc; }
.reasoning-hd { cursor: pointer; user-select: none; padding: 6px 10px; font-size: 12px; color: #909399;
i { margin-right: 4px; } }
.reasoning-body { margin: 0; padding: 0 12px 10px; font-size: 12px; color: #b0b3b8; line-height: 1.6;
white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; }
.preview-body { height: 100%; }
.pdf-frame { width: 100%; height: 100%; border: none; }
.md-body {
font-size: 14px; color: #2c3e50; line-height: 1.8;
::v-deep {
h1, h2, h3 { color: #303133; margin: 16px 0 9px; font-weight: 600; }
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
h3 { font-size: 14px; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 4px 0; }
strong { color: #c0392b; }
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
th { background: #f5f7fa; }
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
}
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="app-container ai-review-detail" v-loading="loading">
<el-card shadow="never" class="panel">
<div slot="header" class="hd">
<div class="hd-left">
<el-button size="small" icon="el-icon-back" @click="goBack">返回列表</el-button>
<span class="title"><i class="el-icon-document-checked" /> 审核详情</span>
</div>
<div v-if="info" class="hd-right">
<el-tag size="small" :type="info.reviewType === 'contract' ? 'warning' : 'success'">
{{ info.reviewType === 'contract' ? '合同审核' : '简历审核' }}
</el-tag>
<el-tag v-if="info.reviewType === 'resume' && info.matchScore != null"
size="small" type="success" effect="dark">匹配度 {{ info.matchScore }}</el-tag>
<el-tag v-if="info.reviewType === 'contract' && info.riskLevel"
size="small" :type="riskTagType(info.riskLevel)" effect="dark">{{ info.riskLevel }}风险</el-tag>
</div>
</div>
<el-descriptions v-if="info" :column="3" size="small" border class="meta">
<el-descriptions-item label="文件名">
<span>{{ info.fileName }}</span>
<el-button v-if="info.fileUrl" type="text" size="mini" icon="el-icon-download"
style="margin-left:8px" @click="downloadFile">下载原件</el-button>
</el-descriptions-item>
<el-descriptions-item v-if="info.reviewType === 'resume'" label="目标岗位">
{{ info.position || '' }}
</el-descriptions-item>
<el-descriptions-item label="模型">{{ info.model || '—' }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ info.createTime }}</el-descriptions-item>
<el-descriptions-item label="审核人">{{ info.createBy || '—' }}</el-descriptions-item>
</el-descriptions>
<div v-if="info" class="md-body" v-html="renderedMd" />
</el-card>
</div>
</template>
<script>
import { getAiReview } from '@/api/oa/aiReview'
const marked = require('marked')
export default {
name: 'OaAiReviewDetail',
data () {
return {
loading: true,
info: null
}
},
computed: {
renderedMd () {
if (!this.info || !this.info.resultMd) return ''
try { return marked(this.info.resultMd) } catch (e) { return this.info.resultMd }
}
},
created () {
marked.setOptions({ breaks: true })
const id = this.$route.params.id
if (id) this.load(id)
else this.loading = false
},
methods: {
load (id) {
this.loading = true
getAiReview(id).then(res => { this.info = res.data })
.finally(() => { this.loading = false })
},
goBack () {
this.$router.push('/hint/aiReview')
},
downloadFile () {
if (this.info && this.info.fileUrl) window.open(this.info.fileUrl, '_blank')
},
riskTagType (r) {
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
}
}
}
</script>
<style scoped lang="scss">
.ai-review-detail { padding: 10px; }
.panel { ::v-deep .el-card__header { padding: 10px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;
.hd-left { display: flex; align-items: center; gap: 12px;
.title { font-weight: 600; font-size: 14px; color: #303133; i { color: #409eff; margin-right: 4px; } }
}
.hd-right { display: flex; align-items: center; gap: 8px; }
}
.meta { margin-bottom: 16px; }
.md-body {
font-size: 14px; color: #2c3e50; line-height: 1.8; padding: 4px 6px;
::v-deep {
h1, h2, h3 { color: #303133; margin: 18px 0 10px; font-weight: 600; }
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
h3 { font-size: 14px; }
p { margin: 8px 0; }
ul, ol { padding-left: 22px; margin: 8px 0; }
li { margin: 4px 0; }
strong { color: #c0392b; }
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
th { background: #f5f7fa; }
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
}
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="app-container">
<!-- 搜索 -->
<el-form :model="query" ref="queryForm" size="small" :inline="true" label-width="68px">
<el-form-item label="类型" prop="reviewType">
<el-select v-model="query.reviewType" placeholder="全部" clearable style="width: 120px" @change="handleQuery">
<el-option label="合同" value="contract" />
<el-option label="简历" value="resume" />
</el-select>
</el-form-item>
<el-form-item label="关键字" prop="keyword">
<el-input v-model="query.keyword" placeholder="文件名 / 岗位" clearable style="width: 220px"
@keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-magic-stick" size="mini" @click="openUpload">新增审核</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<!-- 列表 -->
<el-table v-loading="loading" :data="list" @row-dblclick="goDetail">
<el-table-column label="类型" align="center" width="80">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.reviewType === 'contract' ? 'warning' : 'success'">
{{ scope.row.reviewType === 'contract' ? '合同' : '简历' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="文件名" align="left" prop="fileName" min-width="180" show-overflow-tooltip />
<el-table-column label="岗位" align="center" prop="position" width="130" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.position">{{ scope.row.position }}</span>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="结论" align="center" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.reviewType === 'resume' && scope.row.matchScore != null"
size="mini" type="success" effect="dark">匹配 {{ scope.row.matchScore }}</el-tag>
<el-tag v-else-if="scope.row.reviewType === 'contract' && scope.row.riskLevel"
size="mini" :type="riskTagType(scope.row.riskLevel)" effect="dark">
{{ scope.row.riskLevel }}风险</el-tag>
<span v-else style="color:#c0c4cc"></span>
</template>
</el-table-column>
<el-table-column label="审核摘要" align="left" prop="summary" min-width="280" show-overflow-tooltip>
<template slot-scope="scope">
<span class="summary-cell">{{ scope.row.summary || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="审核时间" align="center" prop="createTime" width="150" />
<el-table-column label="操作" align="center" width="130" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-view" @click="goDetail(scope.row)">详情</el-button>
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total"
:page.sync="query.pageNum" :limit.sync="query.pageSize" @pagination="getList" />
</div>
</template>
<script>
import { listAiReview, delAiReview } from '@/api/oa/aiReview'
export default {
name: 'OaAiReview',
data () {
return {
loading: true,
showSearch: true,
total: 0,
list: [],
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
}
},
created () {
this.getList()
},
activated () {
this.getList()
},
methods: {
getList () {
this.loading = true
listAiReview(this.query).then(res => {
this.list = res.rows || []
this.total = res.total || 0
}).finally(() => { this.loading = false })
},
handleQuery () {
this.query.pageNum = 1
this.getList()
},
resetQuery () {
this.resetForm('queryForm')
this.query.reviewType = ''
this.handleQuery()
},
goDetail (row) {
this.$router.push('/hint/aiReview/detail/' + row.id)
},
handleDelete (row) {
this.$modal.confirm(`确认删除「${row.fileName}」的审核记录?`).then(() => {
return delAiReview(row.id)
}).then(() => {
this.$modal.msgSuccess('已删除')
this.getList()
}).catch(() => {})
},
openUpload () {
this.$router.push('/hint/aiReview/add')
},
riskTagType (r) {
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
}
}
}
</script>
<style scoped lang="scss">
.summary-cell { color: #606266; font-size: 12px; }
</style>

61
sql/oa_ai_review.sql Normal file
View File

@@ -0,0 +1,61 @@
-- =====================================================
-- AI 智能审核(合同 / 简历)
-- - 使用小米 MiMo 多模态大模型mimo-v2.5
-- - 合同:站在“我方”立场审查,找出不利条款 + 利好我方的修改建议
-- - 简历:评估候选人与目标岗位匹配度
-- 本脚本可重复执行(幂等)。
-- 注意sys_menu 主键为雪花ID非自增必须显式指定。
-- =====================================================
-- ---------------- 审核记录表 ----------------
CREATE TABLE IF NOT EXISTS `oa_ai_review` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`review_type` varchar(20) NOT NULL COMMENT '类型 contract合同 / resume简历',
`file_name` varchar(255) DEFAULT NULL COMMENT '原始文件名',
`oss_id` bigint(20) DEFAULT NULL COMMENT 'OSS文件ID原件留存',
`file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件地址',
`position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位',
`match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100',
`risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低',
`summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要列表展示',
`result_md` longtext COMMENT 'AI 审核结果Markdown',
`model` varchar(50) DEFAULT NULL COMMENT '使用的模型',
`tokens` int(11) DEFAULT NULL COMMENT '消耗 token',
`create_by` varchar(64) DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_by` varchar(64) DEFAULT NULL,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`del_flag` char(1) DEFAULT '0' COMMENT '删除标志0正常 2删除mybatis-plus logicDeleteValue=2',
PRIMARY KEY (`id`),
KEY `idx_type` (`review_type`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)';
-- 若表已存在(旧版本),补加 summary 列MySQL 不支持 ADD COLUMN IF NOT EXISTS重复执行报错可忽略
-- ALTER TABLE `oa_ai_review` ADD COLUMN `summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要列表展示' AFTER `risk_level`;
-- ---------------- 菜单:信息 > AI审核 ----------------
-- 父菜单 1774989374680858626 = 「信息」
INSERT IGNORE INTO `sys_menu`
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`)
VALUES
(2063910000000000001, 'AI审核', 1774989374680858626, 4,
'aiReview', 'oa/aiReview/index', 'C', '0', '0',
NULL, 'eye-open', 'admin', NOW());
-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ----------------
INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`)
VALUES
(1743186990678077442, 2063910000000000001), -- 总经理
(1743204526291349506, 2063910000000000001), -- 技术总监
(1743205028123045890, 2063910000000000001), -- 信息化部
(1852970465740505090, 2063910000000000001), -- 普通员工
(1859257980152692738, 2063910000000000001), -- 职工
(1859548445766717441, 2063910000000000001), -- 后勤
(1893987128812761089, 2063910000000000001), -- 新员工临时身份
(1914212623781187585, 2063910000000000001), -- 技术总工
(1914213026883162113, 2063910000000000001), -- 设计主任
(1925062159919448065, 2063910000000000001); -- 外贸专责
-- ---------------- 校验 ----------------
SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063910000000000001;