Bladeren bron

提交测试接口

ligao 3 weken geleden
bovenliggende
commit
d6f63e0e27

+ 611 - 0
emoon-extend/emoon-tongue/src/main/java/com/emoon/tongue/controller/TesttongueController.java

@@ -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();
+    }
+}