|
|
@@ -0,0 +1,611 @@
|
|
|
+package com.emoon.tongue.controller;
|
|
|
+
|
|
|
+import cn.hutool.crypto.SecureUtil;
|
|
|
+import cn.hutool.json.JSONUtil;
|
|
|
+import com.emoon.common.core.constant.HttpStatus;
|
|
|
+import com.emoon.common.core.domain.R;
|
|
|
+import com.emoon.common.core.utils.StringUtils;
|
|
|
+import com.emoon.tongue.domain.vo.TongueDiagnosisDetailVo;
|
|
|
+import com.emoon.tongue.domain.vo.TongueDiagnosisInfo;
|
|
|
+import com.emoon.tongue.domain.vo.TongueImageCheckVo;
|
|
|
+import com.emoon.tongue.domain.vo.TongueImageInfo;
|
|
|
+import com.emoon.tongue.service.ISaveFileService;
|
|
|
+import com.emoon.tongue.service.ITongueAiDiagnosisService;
|
|
|
+import com.emoon.tongue.service.ITongueDiagnosisService;
|
|
|
+import com.emoon.tongue.service.ITongueImageCheckService;
|
|
|
+import com.emoon.tongue.service.MinioService;
|
|
|
+import com.emoon.tongue.service.impl.TongueAiLocalDiagnosisService;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.web.bind.annotation.CrossOrigin;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestHeader;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestParam;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.net.URI;
|
|
|
+import java.net.URLEncoder;
|
|
|
+import java.net.http.HttpClient;
|
|
|
+import java.net.http.HttpRequest;
|
|
|
+import java.net.http.HttpResponse;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.time.Duration;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.ThreadLocalRandom;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 舌象测试接口控制器
|
|
|
+ * <p>
|
|
|
+ * 用于快速验证:
|
|
|
+ * 1) 图片接收
|
|
|
+ * 2) 按 DiagnosisRobotController 319-322 同步落盘并转换本地模型路径
|
|
|
+ * 3) 调用舌象模型返回结果
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/v1/diagnosis/testtongue")
|
|
|
+@CrossOrigin(origins = "*", maxAge = 3600, allowedHeaders = "*")
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class TesttongueController {
|
|
|
+
|
|
|
+ /** 测试页固定账号(仅用于舌象测试接口,勿用于生产) */
|
|
|
+ public static final String TEST_TONGUE_USERNAME = "testtongue";
|
|
|
+
|
|
|
+ /** 测试页固定密码(仅用于舌象测试接口,勿用于生产) */
|
|
|
+ public static final String TEST_TONGUE_PASSWORD = "Emoon@Tongue2026";
|
|
|
+
|
|
|
+ /** 登录成功后下发的访问令牌(与前端 sessionStorage 中保存的 token 一致) */
|
|
|
+ private static final String TEST_TONGUE_ACCESS_TOKEN = "emoon-testtongue-access-token";
|
|
|
+
|
|
|
+ @Value("${ai.local.url:http://192.168.10.122:8000/v1/chat/completions}")
|
|
|
+ private String localApiUrl;
|
|
|
+
|
|
|
+ @Value("${ai.local.model:/home/gansu/output_sft_qwen3_2B}")
|
|
|
+ private String localModel;
|
|
|
+
|
|
|
+ @Value("${ai.local.system-prompt:我是AI助手.}")
|
|
|
+ private String localSystemPrompt;
|
|
|
+
|
|
|
+ @Value("${ai.local.auth-header:}")
|
|
|
+ private String localAuthHeader;
|
|
|
+
|
|
|
+ @Value("${ai.local.auth-token:}")
|
|
|
+ private String localAuthToken;
|
|
|
+
|
|
|
+ @Value("${ai.local.timeout-ms:15000}")
|
|
|
+ private int localTimeoutMs;
|
|
|
+
|
|
|
+ @Value("${tongue.robot.forward.base-url:https://tongue.emoon.com/api/v1/forward/diagnosis/robot}")
|
|
|
+ private String robotForwardBaseUrl;
|
|
|
+
|
|
|
+ @Value("${tongue.robot.forward.timeout-ms:30000}")
|
|
|
+ private int robotForwardTimeoutMs;
|
|
|
+
|
|
|
+ @Value("${tongue.robot.forward.detail.max-attempts:12}")
|
|
|
+ private int robotDetailMaxAttempts;
|
|
|
+
|
|
|
+ @Value("${tongue.robot.forward.detail.interval-ms:1000}")
|
|
|
+ private long robotDetailIntervalMs;
|
|
|
+
|
|
|
+ private final ISaveFileService saveFileService;
|
|
|
+ private final TongueAiLocalDiagnosisService tongueAiLocalDiagnosisService;
|
|
|
+ private final MinioService minioService;
|
|
|
+ private final ITongueImageCheckService tongueImageCheckService;
|
|
|
+ private final ITongueDiagnosisService tongueDiagnosisService;
|
|
|
+ private final ITongueAiDiagnosisService tongueAiDiagnosisService;
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 舌象测试页登录(校验常量账号密码,返回访问令牌)
|
|
|
+ *
|
|
|
+ * @param body 请求体,字段:username、password
|
|
|
+ */
|
|
|
+ @PostMapping("/login")
|
|
|
+ public R<Map<String, Object>> login(@RequestBody Map<String, Object> body) {
|
|
|
+ if (body == null) {
|
|
|
+ return R.fail("请求体不能为空");
|
|
|
+ }
|
|
|
+ String username = body.get("username") == null ? null : String.valueOf(body.get("username"));
|
|
|
+ String password = body.get("password") == null ? null : String.valueOf(body.get("password"));
|
|
|
+ if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) {
|
|
|
+ return R.fail("账号或密码不能为空");
|
|
|
+ }
|
|
|
+ if (TEST_TONGUE_USERNAME.equals(username.trim()) && TEST_TONGUE_PASSWORD.equals(password)) {
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("token", TEST_TONGUE_ACCESS_TOKEN);
|
|
|
+ data.put("tokenType", "Bearer");
|
|
|
+ return R.ok("登录成功", data);
|
|
|
+ }
|
|
|
+ return R.fail(HttpStatus.UNAUTHORIZED, "账号或密码错误");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收舌象图片并调用舌象专精模型
|
|
|
+ *
|
|
|
+ * @param file 舌象图片文件
|
|
|
+ * @return 专精模型诊断结果 + 文件信息
|
|
|
+ */
|
|
|
+ @PostMapping("/check-image")
|
|
|
+ public R<Map<String, Object>> checkTongueImage(
|
|
|
+ @RequestHeader(value = "Authorization", required = false) String authorization,
|
|
|
+ @RequestParam("file") MultipartFile file) {
|
|
|
+ if (!isAuthorized(authorization)) {
|
|
|
+ return R.fail(HttpStatus.UNAUTHORIZED, "未登录或令牌无效,请先登录");
|
|
|
+ }
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ return R.fail("请上传舌象图片文件");
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1) 参考 DiagnosisRobotController 319-322 的本地落盘 + 路径转换逻辑
|
|
|
+ String rawFilePath = saveFileService.uploadFilePath(file);
|
|
|
+ String filePath = convertToLocalModelFileUri(rawFilePath);
|
|
|
+ log.info("文件已保存至服务器并转换为本地模型路径: fileName={}, rawFilePath={}, filePath={}",
|
|
|
+ file.getOriginalFilename(), rawFilePath, filePath);
|
|
|
+
|
|
|
+ if (filePath == null || filePath.isEmpty()) {
|
|
|
+ return R.fail("文件保存至服务器失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 用 filePath 直接组装本地舌象模型请求
|
|
|
+ TongueDiagnosisDetailVo detail = new TongueDiagnosisDetailVo();
|
|
|
+ TongueImageInfo tongueImage = new TongueImageInfo();
|
|
|
+ tongueImage.setPath(filePath);
|
|
|
+ tongueImage.setUrl(filePath);
|
|
|
+ detail.setTongueImage(tongueImage);
|
|
|
+
|
|
|
+ // 3) 直连本地舌象专精模型(ai.local.url / ai.local.model)
|
|
|
+ TongueDiagnosisInfo diagnosisResult = tongueAiLocalDiagnosisService.diagnose(detail);
|
|
|
+ if (diagnosisResult == null) {
|
|
|
+ return R.fail("舌象专精模型返回为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("rawFilePath", rawFilePath);
|
|
|
+ data.put("filePath", filePath);
|
|
|
+ data.put("modelResult", diagnosisResult);
|
|
|
+ return R.ok("舌象专精模型调用成功", data);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("Testtongue 舌象专精模型调用异常", e);
|
|
|
+ return R.fail("调用失败:" + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传舌象图并按机器人端流程完成:舌象校验(同 /api/v1/diagnosis/robot/check-image)→
|
|
|
+ * AI 诊断(同 /api/v1/diagnosis/robot/ai-diagnosis,此处为同步执行,无随机等待)→
|
|
|
+ * 返回详情(与 /api/v1/diagnosis/robot/detail 的 data 结构一致)。
|
|
|
+ * <p>
|
|
|
+ * 测试约定:仅接收 file + patientChiefComplaint,其余参数后端自动生成。
|
|
|
+ */
|
|
|
+ @PostMapping("/upload-robot-flow")
|
|
|
+ public R<TongueDiagnosisDetailVo> uploadRobotFlow(
|
|
|
+ @RequestParam("file") MultipartFile file,
|
|
|
+ @RequestParam(value = "patientChiefComplaint", required = false) String patientChiefComplaint) {
|
|
|
+
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ return R.fail("请上传舌象图片文件");
|
|
|
+ }
|
|
|
+ if (StringUtils.isEmpty(patientChiefComplaint)) {
|
|
|
+ return R.fail("patientChiefComplaint 不能为空");
|
|
|
+ }
|
|
|
+
|
|
|
+ String patientId = generatePatientId();
|
|
|
+ String projectId = "HOSP001";
|
|
|
+ long timestamp = System.currentTimeMillis();
|
|
|
+ String version = "v1.0";
|
|
|
+ int ageVal = 35;
|
|
|
+ int genderVal = 1;
|
|
|
+ String chief = patientChiefComplaint.trim();
|
|
|
+ Map<String, Object> patientInfoMap = new HashMap<>();
|
|
|
+ patientInfoMap.put("patientAge", ageVal);
|
|
|
+ patientInfoMap.put("patientGender", genderVal);
|
|
|
+ patientInfoMap.put("patientChiefComplaint", chief);
|
|
|
+ String bizParamsJson = JSONUtil.toJsonStr(patientInfoMap);
|
|
|
+ Map<String, Object> signParams = new HashMap<>();
|
|
|
+ signParams.put("file", file.getOriginalFilename());
|
|
|
+ signParams.put("patientId", patientId);
|
|
|
+ signParams.put("projectId", projectId);
|
|
|
+ signParams.put("timestamp", timestamp);
|
|
|
+ signParams.put("version", version);
|
|
|
+ signParams.put("bizParams", bizParamsJson);
|
|
|
+ String sign = generateSign(signParams);
|
|
|
+
|
|
|
+ log.info("upload-robot-flow auto params: patientId={}, projectId={}, timestamp={}, version={}, sign={}, bizParams={}",
|
|
|
+ patientId, projectId, timestamp, version, sign, bizParamsJson);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1) 调 /check-image(multipart/form-data)
|
|
|
+ Map<String, String> checkParams = new LinkedHashMap<>();
|
|
|
+ checkParams.put("patientId", patientId);
|
|
|
+ checkParams.put("projectId", projectId);
|
|
|
+ checkParams.put("timestamp", String.valueOf(timestamp));
|
|
|
+ checkParams.put("sign", sign);
|
|
|
+ checkParams.put("version", version);
|
|
|
+ checkParams.put("bizParams", bizParamsJson);
|
|
|
+ JsonNode checkResp = postMultipartToRobot("/check-image", file, checkParams);
|
|
|
+ if (!isRobotSuccess(checkResp)) {
|
|
|
+ return R.fail("check-image失败: " + checkResp.path("msg").asText("未知错误"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 调 /ai-diagnosis(application/x-www-form-urlencoded)
|
|
|
+ long aiTimestamp = System.currentTimeMillis();
|
|
|
+ Map<String, Object> aiSignParams = new HashMap<>();
|
|
|
+ aiSignParams.put("patientId", patientId);
|
|
|
+ aiSignParams.put("projectId", projectId);
|
|
|
+ aiSignParams.put("timestamp", aiTimestamp);
|
|
|
+ aiSignParams.put("version", version);
|
|
|
+ String aiSign = generateSign(aiSignParams);
|
|
|
+ Map<String, String> aiParams = new LinkedHashMap<>();
|
|
|
+ aiParams.put("patientId", patientId);
|
|
|
+ aiParams.put("projectId", projectId);
|
|
|
+ aiParams.put("timestamp", String.valueOf(aiTimestamp));
|
|
|
+ aiParams.put("version", version);
|
|
|
+ aiParams.put("sign", aiSign);
|
|
|
+ JsonNode aiResp = postFormToRobot("/ai-diagnosis", aiParams);
|
|
|
+ if (!isRobotSuccess(aiResp)) {
|
|
|
+ return R.fail("ai-diagnosis失败: " + aiResp.path("msg").asText("未知错误"));
|
|
|
+ }
|
|
|
+ long taskId = aiResp.path("data").asLong(-1L);
|
|
|
+ if (taskId <= 0L) {
|
|
|
+ log.warn("ai-diagnosis返回的任务ID异常: {}", aiResp.path("data").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) 调 /detail(GET),轮询到 uploadStatus=2 再返回
|
|
|
+ JsonNode detailResp = pollDetailUntilFinished(patientId, projectId, version, taskId);
|
|
|
+ if (!isRobotSuccess(detailResp)) {
|
|
|
+ return R.fail("detail失败: " + detailResp.path("msg").asText("未知错误"));
|
|
|
+ }
|
|
|
+
|
|
|
+ JsonNode detailData = detailResp.get("data");
|
|
|
+ TongueDiagnosisDetailVo detail = detailData == null || detailData.isNull()
|
|
|
+ ? null
|
|
|
+ : objectMapper.treeToValue(detailData, TongueDiagnosisDetailVo.class);
|
|
|
+ return R.ok(detail);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("upload-robot-flow 异常", e);
|
|
|
+ return R.fail("编排失败:" + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String generatePatientId() {
|
|
|
+ long random11 = ThreadLocalRandom.current().nextLong(10000000000L, 100000000000L);
|
|
|
+ return "GH" + random11;
|
|
|
+ }
|
|
|
+
|
|
|
+ private JsonNode postMultipartToRobot(String apiPath, MultipartFile file, Map<String, String> formFields) throws Exception {
|
|
|
+ String boundary = "----emoonBoundary" + System.currentTimeMillis();
|
|
|
+ byte[] body = buildMultipartBody(boundary, file, formFields);
|
|
|
+ HttpRequest request = HttpRequest.newBuilder()
|
|
|
+ .uri(URI.create(robotForwardBaseUrl + apiPath))
|
|
|
+ .timeout(Duration.ofMillis(Math.max(robotForwardTimeoutMs, 1000)))
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
|
|
+ .POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
|
|
+ .build();
|
|
|
+ return sendRobotRequest(request, apiPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ private JsonNode postFormToRobot(String apiPath, Map<String, String> formFields) throws Exception {
|
|
|
+ String body = buildFormBody(formFields);
|
|
|
+ HttpRequest request = HttpRequest.newBuilder()
|
|
|
+ .uri(URI.create(robotForwardBaseUrl + apiPath))
|
|
|
+ .timeout(Duration.ofMillis(Math.max(robotForwardTimeoutMs, 1000)))
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .header("Content-Type", "application/x-www-form-urlencoded")
|
|
|
+ .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
|
|
|
+ .build();
|
|
|
+ return sendRobotRequest(request, apiPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ private JsonNode getToRobot(String apiPath, Map<String, String> queryParams) throws Exception {
|
|
|
+ String query = buildFormBody(queryParams);
|
|
|
+ HttpRequest request = HttpRequest.newBuilder()
|
|
|
+ .uri(URI.create(robotForwardBaseUrl + apiPath + "?" + query))
|
|
|
+ .timeout(Duration.ofMillis(Math.max(robotForwardTimeoutMs, 1000)))
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .GET()
|
|
|
+ .build();
|
|
|
+ return sendRobotRequest(request, apiPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ private JsonNode sendRobotRequest(HttpRequest request, String apiPath) throws Exception {
|
|
|
+ HttpClient client = HttpClient.newBuilder()
|
|
|
+ .connectTimeout(Duration.ofMillis(Math.max(robotForwardTimeoutMs, 1000)))
|
|
|
+ .build();
|
|
|
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
|
+ int status = response.statusCode();
|
|
|
+ String body = response.body();
|
|
|
+ log.info("robot-forward调用完成: path={}, status={}, body={}", apiPath, status, body);
|
|
|
+ if (status < 200 || status >= 300) {
|
|
|
+ throw new RuntimeException("调用" + apiPath + " HTTP状态异常: " + status + ", body=" + body);
|
|
|
+ }
|
|
|
+ return objectMapper.readTree(body);
|
|
|
+ }
|
|
|
+
|
|
|
+ private JsonNode pollDetailUntilFinished(String patientId, String projectId, String version, long taskId) throws Exception {
|
|
|
+ int maxAttempts = Math.max(robotDetailMaxAttempts, 1);
|
|
|
+ long interval = Math.max(robotDetailIntervalMs, 200L);
|
|
|
+ JsonNode lastDetailResp = null;
|
|
|
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
|
+ long detailTimestamp = System.currentTimeMillis();
|
|
|
+ Map<String, Object> detailSignParams = new HashMap<>();
|
|
|
+ detailSignParams.put("patientId", patientId);
|
|
|
+ detailSignParams.put("projectId", projectId);
|
|
|
+ detailSignParams.put("timestamp", detailTimestamp);
|
|
|
+ detailSignParams.put("version", version);
|
|
|
+ String detailSign = generateSign(detailSignParams);
|
|
|
+
|
|
|
+ Map<String, String> detailParams = new LinkedHashMap<>();
|
|
|
+ detailParams.put("patientId", patientId);
|
|
|
+ detailParams.put("projectId", projectId);
|
|
|
+ detailParams.put("timestamp", String.valueOf(detailTimestamp));
|
|
|
+ detailParams.put("version", version);
|
|
|
+ detailParams.put("sign", detailSign);
|
|
|
+
|
|
|
+ JsonNode detailResp = getToRobot("/detail", detailParams);
|
|
|
+ if (!isRobotSuccess(detailResp)) {
|
|
|
+ return detailResp;
|
|
|
+ }
|
|
|
+ lastDetailResp = detailResp;
|
|
|
+ JsonNode data = detailResp.path("data");
|
|
|
+ int uploadStatus = data.path("uploadStatus").asInt(-1);
|
|
|
+ long detailId = data.path("id").asLong(-1L);
|
|
|
+ if (uploadStatus == 2) {
|
|
|
+ log.info("detail轮询完成: attempt={}, taskId={}, detailId={}, uploadStatus=2", attempt, taskId, detailId);
|
|
|
+ return detailResp;
|
|
|
+ }
|
|
|
+ log.info("detail轮询中: attempt={}/{}, taskId={}, detailId={}, uploadStatus={}",
|
|
|
+ attempt, maxAttempts, taskId, detailId, uploadStatus);
|
|
|
+ if (attempt < maxAttempts) {
|
|
|
+ Thread.sleep(interval);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return lastDetailResp;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 兼容不同网关/转发层返回规范:
|
|
|
+ * - code=0(常见成功)
|
|
|
+ * - code=200(部分 R 包装成功)
|
|
|
+ */
|
|
|
+ private boolean isRobotSuccess(JsonNode resp) {
|
|
|
+ int code = resp == null ? -1 : resp.path("code").asInt(-1);
|
|
|
+ return code == 0 || code == 200;
|
|
|
+ }
|
|
|
+
|
|
|
+ private byte[] buildMultipartBody(String boundary, MultipartFile file, Map<String, String> formFields) throws Exception {
|
|
|
+ String lineEnd = "\r\n";
|
|
|
+ java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
|
|
|
+ for (Map.Entry<String, String> entry : formFields.entrySet()) {
|
|
|
+ output.write(("--" + boundary + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"" + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write((entry.getValue() == null ? "" : entry.getValue()).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(lineEnd.getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+
|
|
|
+ String fileName = file.getOriginalFilename() == null ? "upload.jpg" : file.getOriginalFilename();
|
|
|
+ String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType();
|
|
|
+ output.write(("--" + boundary + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"" + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(("Content-Type: " + contentType + lineEnd + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(file.getBytes());
|
|
|
+ output.write(lineEnd.getBytes(StandardCharsets.UTF_8));
|
|
|
+ output.write(("--" + boundary + "--" + lineEnd).getBytes(StandardCharsets.UTF_8));
|
|
|
+ return output.toByteArray();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String buildFormBody(Map<String, String> params) {
|
|
|
+ List<String> pairs = new ArrayList<>();
|
|
|
+ for (Map.Entry<String, String> entry : params.entrySet()) {
|
|
|
+ String key = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
|
|
|
+ String value = URLEncoder.encode(entry.getValue() == null ? "" : entry.getValue(), StandardCharsets.UTF_8);
|
|
|
+ pairs.add(key + "=" + value);
|
|
|
+ }
|
|
|
+ return String.join("&", pairs);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收舌象图片并直接调用本地舌象模型,返回模型原始响应
|
|
|
+ * (仅供 Testtongue.vue 调试联调用)
|
|
|
+ */
|
|
|
+ @PostMapping("/check-image-raw")
|
|
|
+ public R<Map<String, Object>> checkTongueImageRaw(
|
|
|
+ @RequestHeader(value = "Authorization", required = false) String authorization,
|
|
|
+ @RequestParam("file") MultipartFile file) {
|
|
|
+
|
|
|
+ if (!isAuthorized(authorization)) {
|
|
|
+ return R.fail(HttpStatus.UNAUTHORIZED, "未登录或令牌无效,请先登录");
|
|
|
+ }
|
|
|
+ if (file == null || file.isEmpty()) {
|
|
|
+ return R.fail("请上传舌象图片文件");
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ String rawFilePath = saveFileService.uploadFilePath(file);
|
|
|
+ String filePath = convertToLocalModelFileUri(rawFilePath);
|
|
|
+ if (filePath == null || filePath.isEmpty()) {
|
|
|
+ return R.fail("文件保存至服务器失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ String modelRawResponse = callLocalModelRaw(filePath);
|
|
|
+ Map<String, Object> data = new HashMap<>();
|
|
|
+ data.put("rawFilePath", rawFilePath);
|
|
|
+ data.put("filePath", filePath);
|
|
|
+ data.put("modelRawResponse", modelRawResponse);
|
|
|
+
|
|
|
+ // 便于前端快速显示:尝试提取 choices[0].message.content
|
|
|
+ String assistantContent = extractAssistantContent(modelRawResponse);
|
|
|
+ if (assistantContent != null) {
|
|
|
+ data.put("assistantContent", assistantContent);
|
|
|
+ }
|
|
|
+ return R.ok("本地舌象模型调用成功", data);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("Testtongue 原始模型调用异常", e);
|
|
|
+ return R.fail("调用失败:" + e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isAuthorized(String authorization) {
|
|
|
+ if (authorization == null || authorization.isBlank()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ String expected = "Bearer " + TEST_TONGUE_ACCESS_TOKEN;
|
|
|
+ return authorization.trim().equals(expected);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用 filePath 直接组装 OpenAI chat/completions 请求,返回模型原始响应字符串
|
|
|
+ */
|
|
|
+ private String callLocalModelRaw(String filePath) throws Exception {
|
|
|
+ if (localApiUrl == null || localApiUrl.trim().isEmpty()) {
|
|
|
+ throw new IllegalStateException("未配置 ai.local.url");
|
|
|
+ }
|
|
|
+ if (localModel == null || localModel.trim().isEmpty()) {
|
|
|
+ throw new IllegalStateException("未配置 ai.local.model");
|
|
|
+ }
|
|
|
+
|
|
|
+ Map<String, Object> requestBody = buildLocalRawRequest(filePath);
|
|
|
+ String jsonBody = objectMapper.writeValueAsString(requestBody);
|
|
|
+
|
|
|
+ HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
|
|
|
+ .uri(URI.create(localApiUrl.trim()))
|
|
|
+ .timeout(Duration.ofMillis(Math.max(localTimeoutMs, 1000)))
|
|
|
+ .header("Accept", "application/json")
|
|
|
+ .header("Content-Type", "application/json")
|
|
|
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody));
|
|
|
+
|
|
|
+ if (localAuthHeader != null && !localAuthHeader.isBlank()
|
|
|
+ && localAuthToken != null && !localAuthToken.isBlank()) {
|
|
|
+ reqBuilder.header(localAuthHeader.trim(), localAuthToken.trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ HttpClient client = HttpClient.newBuilder()
|
|
|
+ .connectTimeout(Duration.ofMillis(Math.max(localTimeoutMs, 1000)))
|
|
|
+ .build();
|
|
|
+
|
|
|
+ HttpResponse<String> response = client.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
|
|
|
+ int status = response.statusCode();
|
|
|
+ String body = response.body();
|
|
|
+ log.info("本地舌象模型原始调用完成: url={}, status={}", localApiUrl, status);
|
|
|
+
|
|
|
+ if (status < 200 || status >= 300) {
|
|
|
+ throw new RuntimeException("本地舌象模型HTTP状态异常: " + status + ", body=" + body);
|
|
|
+ }
|
|
|
+ return body;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildLocalRawRequest(String filePath) {
|
|
|
+ Map<String, Object> requestBody = new HashMap<>();
|
|
|
+ requestBody.put("model", localModel);
|
|
|
+
|
|
|
+ List<Map<String, Object>> messages = new ArrayList<>();
|
|
|
+
|
|
|
+ Map<String, Object> systemMsg = new HashMap<>();
|
|
|
+ systemMsg.put("role", "system");
|
|
|
+ systemMsg.put("content", localSystemPrompt);
|
|
|
+ messages.add(systemMsg);
|
|
|
+
|
|
|
+ Map<String, Object> userMsg = new HashMap<>();
|
|
|
+ userMsg.put("role", "user");
|
|
|
+
|
|
|
+ List<Map<String, Object>> content = new ArrayList<>();
|
|
|
+ Map<String, Object> imageContent = new HashMap<>();
|
|
|
+ imageContent.put("type", "image_url");
|
|
|
+ Map<String, Object> imageUrl = new HashMap<>();
|
|
|
+ imageUrl.put("url", filePath);
|
|
|
+ imageContent.put("image_url", imageUrl);
|
|
|
+ content.add(imageContent);
|
|
|
+
|
|
|
+ Map<String, Object> textContent = new HashMap<>();
|
|
|
+ textContent.put("type", "text");
|
|
|
+ textContent.put("text", "这位病人舌苔呈现什么现象?结合这些现象,给出诊断结果和处方。");
|
|
|
+ content.add(textContent);
|
|
|
+
|
|
|
+ userMsg.put("content", content);
|
|
|
+ messages.add(userMsg);
|
|
|
+
|
|
|
+ requestBody.put("messages", messages);
|
|
|
+ return requestBody;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractAssistantContent(String rawResponse) {
|
|
|
+ try {
|
|
|
+ JsonNode root = objectMapper.readTree(rawResponse);
|
|
|
+ JsonNode choices = root.get("choices");
|
|
|
+ if (choices != null && choices.isArray() && !choices.isEmpty()) {
|
|
|
+ JsonNode message = choices.get(0).get("message");
|
|
|
+ if (message != null && message.get("content") != null) {
|
|
|
+ return message.get("content").asText();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将落盘服务返回的 filePath(例如:/ruoyi/file-downloads/xxx.jpg)
|
|
|
+ * 转换为本地模型可直接读取的 file:// URI(例如:file:///home/gansu/gansu/shetou_picture/xxx.jpg)
|
|
|
+ */
|
|
|
+ private String convertToLocalModelFileUri(String rawFilePath) {
|
|
|
+ if (rawFilePath == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ String s = rawFilePath.trim();
|
|
|
+ if (s.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 已经是 file://... 形式就直接返回
|
|
|
+ if (s.startsWith("file://")) {
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 取最后的文件名(兼容 / 和 \)
|
|
|
+ int idx1 = s.lastIndexOf('/');
|
|
|
+ int idx2 = s.lastIndexOf('\\');
|
|
|
+ int idx = Math.max(idx1, idx2);
|
|
|
+ String fileName = idx >= 0 ? s.substring(idx + 1) : s;
|
|
|
+ if (fileName.isEmpty()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 固定替换为模型服务器本地目录
|
|
|
+ return "file:///home/gansu/gansu/shetou_picture/" + fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String generateSign(Map<String, Object> params) {
|
|
|
+ // 按照ASCII码排序参数
|
|
|
+ List<String> sortedKeys = new ArrayList<>(params.keySet());
|
|
|
+ Collections.sort(sortedKeys);
|
|
|
+
|
|
|
+ // 拼接参数字符串
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (String key : sortedKeys) {
|
|
|
+ Object value = params.get(key);
|
|
|
+ if (value != null && StringUtils.isNotEmpty(value.toString())) {
|
|
|
+ sb.append(key).append("=").append(value).append("&");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除最后一个&
|
|
|
+ if (!sb.isEmpty()) {
|
|
|
+ sb.deleteCharAt(sb.length() - 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // MD5加密并转为大写
|
|
|
+ return SecureUtil.md5(sb.toString()).toUpperCase();
|
|
|
+ }
|
|
|
+}
|