From dab58ec1dc7d942638da314019921635f20c908e Mon Sep 17 00:00:00 2001 From: YoLin02 Date: Wed, 6 May 2026 14:42:16 +0800 Subject: [PATCH] v0.4 --- pom.xml | 5 + spring-dev.log | Bin 6233 -> 13998 bytes .../ai/chat/controller/AiChatController.java | 13 +- .../ai/chat/dto/ChatAttachmentDTO.java | 9 + .../platform/ai/chat/dto/ChatMessageDTO.java | 3 +- .../platform/ai/chat/dto/ChatRequest.java | 4 +- .../chat/service/impl/AiChatServiceImpl.java | 342 +++++----- .../service/impl/ChatAttachmentResolver.java | 152 +++++ .../platform/ai/core/enums/AiSceneType.java | 19 + .../ai/core/gateway/AiGatewayChatResult.java | 13 + .../gateway/AiGatewayStructuredResult.java | 14 + .../ai/core/gateway/LangChain4jAiGateway.java | 125 ++++ .../service/AiInvocationAuditService.java | 70 ++ .../ai/core/service/AiModelResolver.java | 87 +++ .../service/AiPromptTemplateResolver.java | 30 + .../impl/AiDocumentReviewServiceImpl.java | 373 ++++------- .../service/impl/DocumentTextExtractor.java | 41 ++ .../support/DocumentReviewPromptBuilder.java | 46 ++ .../service/impl/AiReviewServiceImpl.java | 517 ++++++--------- .../SubjectiveReviewPromptBuilder.java | 37 ++ .../aiedu/platform/config/SaTokenConfig.java | 2 +- .../health/controller/MonitorController.java | 64 +- src/main/resources/application-dev.yml | 1 + src/main/resources/application-local.yml | 3 + src/main/resources/application.yml | 1 + src/main/resources/db/data-h2.sql | 1 + src/main/resources/db/data-mysql.sql | 1 + ui/src/App.tsx | 4 + ui/src/components/dashboard/KPICards.tsx | 6 +- ui/src/components/dashboard/QuickActions.tsx | 153 ++++- ui/src/components/dashboard/SystemHealth.tsx | 6 +- ui/src/components/layout/AppLayout.tsx | 21 +- ui/src/components/layout/Header.tsx | 495 ++++++++++---- ui/src/components/layout/Sidebar.tsx | 175 +++-- ui/src/components/system/RequiredLabel.tsx | 12 + ui/src/components/ui/SideDrawer.tsx | 61 ++ ui/src/lib/api.ts | 3 +- ui/src/lib/form-validation.ts | 10 + ui/src/lib/workbench.ts | 97 +++ ui/src/pages/Config.tsx | 27 +- ui/src/pages/Dashboard.tsx | 220 ++++++- ui/src/pages/Departments.tsx | 34 +- ui/src/pages/Dictionary.tsx | 48 +- ui/src/pages/Notifications.tsx | 188 ++++++ ui/src/pages/Permissions.tsx | 25 +- ui/src/pages/Roles.tsx | 25 +- ui/src/pages/SearchCenter.tsx | 331 ++++++++++ ui/src/pages/Users.tsx | 75 ++- ui/src/pages/ai/Chat.tsx | 621 +++++++++++++----- ui/src/pages/ai/DocumentReview.tsx | 320 +++++---- ui/src/pages/ai/Model.tsx | 29 +- ui/src/pages/ai/Prompt.tsx | 29 +- ui/src/pages/ai/ReviewRecord.tsx | 223 ++++--- ui/src/pages/education/Chapter.tsx | 23 +- ui/src/pages/education/Class.tsx | 25 +- ui/src/pages/education/College.tsx | 32 +- ui/src/pages/education/Course.tsx | 29 +- ui/src/pages/education/Homework.tsx | 55 +- ui/src/pages/education/Knowledge.tsx | 29 +- ui/src/pages/education/Major.tsx | 27 +- ui/src/pages/education/Question.tsx | 41 +- ui/src/pages/education/School.tsx | 30 +- ui/src/pages/education/Student.tsx | 36 +- ui/src/pages/education/Teacher.tsx | 38 +- ui/src/services/ai.ts | 11 + ui/src/types/ai.ts | 11 +- 66 files changed, 4112 insertions(+), 1486 deletions(-) create mode 100644 src/main/java/com/aiedu/platform/ai/chat/dto/ChatAttachmentDTO.java create mode 100644 src/main/java/com/aiedu/platform/ai/chat/service/impl/ChatAttachmentResolver.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/enums/AiSceneType.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayChatResult.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayStructuredResult.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/gateway/LangChain4jAiGateway.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/service/AiInvocationAuditService.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/service/AiModelResolver.java create mode 100644 src/main/java/com/aiedu/platform/ai/core/service/AiPromptTemplateResolver.java create mode 100644 src/main/java/com/aiedu/platform/ai/review/document/support/DocumentReviewPromptBuilder.java create mode 100644 src/main/java/com/aiedu/platform/ai/review/support/SubjectiveReviewPromptBuilder.java create mode 100644 ui/src/components/system/RequiredLabel.tsx create mode 100644 ui/src/components/ui/SideDrawer.tsx create mode 100644 ui/src/lib/form-validation.ts create mode 100644 ui/src/lib/workbench.ts create mode 100644 ui/src/pages/Notifications.tsx create mode 100644 ui/src/pages/SearchCenter.tsx diff --git a/pom.xml b/pom.xml index cd99bac..dba945f 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,11 @@ poi-ooxml 5.4.1 + + org.apache.poi + poi-scratchpad + 5.4.1 + org.apache.pdfbox pdfbox diff --git a/spring-dev.log b/spring-dev.log index 1fcdef14fa63e642689dbac4649c92b719d49e82..b9657fe34f573c0fdf6e1b8cb0d8a4bf845d997f 100644 GIT binary patch literal 13998 zcmeI3-A-J|5y$&FQlwmGZ;o=Y1|>6m490*KYcb$8yBKW1Sy_xj24)O{2L{c6jd#79 z+^?c2Pmp)W8$`-$M0tYTCQ>3L`Tx46=X?!tutAfoqNBr{(;wB<|EjL8sy_VtU)yfQ zt+|J8-Mw&;+jM)b=8knWb?>X>rE9y6eoyqb?G9Z-Elyq6mHqWY_XC%vkS{5@qSq%H zXHTOXiFQpKG+f=CxjFw_>b;f3NlPWCJS{Fs@qITOF<4_18wHMbyW-!G(Uip&y zlEHnA*Y$d0x>ItynrT~-nsXIlb=TuHrMHgoK33}yA>NQ2YkF^~T}je!3HedgLG4tv z-CjA^op>oVRP(9st7K$6<$2AQ*t{e0#dvLT(-9{;8|QF8;rY(i51K=s3iBz#vGI}T zZ&5s*xVL@ntcYHI6xM3cv96y%RhWU&v8xVje=545jIQi!zNc!31ZtXj*U$fIUZ#e~ zOP;$RNwXqBUphTGPPnO_B-8bSq?m5;p%Ps4>)@a-K|?-0MkM`5t6RFTgGo71j>IGB z=867V|GG+9Bjo{BS@u$N=_M+j=Rf0tb zyHj>9ORAxaJ&Jwnt$Hm=`c?PLYjj6{tM0k#Ry~bzH>vls+PA$eI@jA9-}99yK#3J! zl_k3;tQ&e-(0=*6@rPgk^W9&*t*Q=7QT0-QuR-f9FNu6fQN0alFN(*cJj zCjSk3{E6eb$-M?@0WbETG`2-7xAByhYmt&5`wT-mO+UmRCU$1yrU61f)2qVAE7jv)s)7rJkF9PF;@`nh@zK`@fOf>0Oj%ubo^;XhARf zavQ}l*_^80$9x&}cY5`4%Mq1~CQ5B|qeI{LhUSP$xJOlPTt@Dl-i>Z};x{})!N&h( zue7S)_%=@AlU6o592t%H$tYSW&pg3w&ZgJ&IJv`nv2Ik@h9M z>CryzNkt@+ZdzW%xO{E?PAeLjP}!XBS^xK*^lwIQ6Yi&~pHbPkF2)N{U?d-DR{bYc z7A(`YH2bo6h{%zsW6%{mP}-PfO~!U*;gF8z%GzO@Zdt4AOiw4io^`~k4AvnY2eixz zb$q?yc{PPI*^Pky=c4^ucO+E6$KpEt+Sm#`5k5;QiLn{ERlnjgpB$TL^;jOP(60A$ z(q2vE=Xaalk7?d@v~KEqq;*@`o!D#0kKnPly|$SCO{m=RB`+lf_9>*nei(K+_9=At zC%z}z-O@7_q6nU|(o$CF>`?G5YbIssU0D_k&$;Y~6xaz_Q^D`XLuD6iYWF9ej@R#! zT7${HF9REpc&t|B0Py$?v9yg`@~v5=?CV$uT7fANAfHfo*mK*9*{{R;X0`B!e_@_N zmRGY(gSidkHLfY0%8+dA?MNr=P=;kqG9tD%ZNErPXodNEgHR{ChxDKl$+j*l2f3gq zZB=_#jJZX&Mc2fPR>k!9XiYry=wLapHCXzKuqIN?Yc-yBI-6@~SG337^C+{DBOxDF zP~^RV%Oyul4IOr$j#eKw4OteI6)S^$T1C6*FuahqMu*#7-EVX^WKnkYx2}u7WzCEz z#(a*l^f7+>QZ05R1!is$4892VE7{8M!4_If&~#sU%1Ms`Y2mX(^1|@M@WMtUXQHnY z`MJgRf`H|;makV?v80?ng@)JG1ZIo zi(*^Rsvb^k!KyZGGoNHp?^!LO1W(QUtph!=pS|!c$MhS=6+shMgj{+ZgFokDX-k~7 zA(1mnF*t%T-w6A8uJNz4(!sYfl$DO9#A@R2rS&Us6N}8+baz8Cu*kPKURYbCkd{mA zUxql-{Kya^(IfJE4qA^A(QjY=Yr@nn#%A{QuTeTjvNpf94m!FplBJJ_$~hwj~>^hDa7TMJykR>&Mxto zuCrEW)nhmj8q(to{VQ3-b6xD6W%(9Oov4thZR+h&;w{8ho9Y+NKGTS>=8`?0s3mc4 zN!H%Zt+KmN{Cz-?y<<`wO~?xx&fZZ3FZ?xToJKC-kVeaF+vRvI^!?2ceX_47bKtfH zkF^_vK5}U(uXw0_yJ|Nld6L;XQm-TZlA+ttGm-I*JhF6FjsE6C&C7Z(6xQP)m*IHo zMdXr1`DXQa&^}@@j@dY~d%@nB286d{3u@ABd9yO?$+l{(1UqN>>bQiKX35NUl1;WU z8-rRbCbdwrQhb@X7wwaD!YH(;`mt?fxUjN7EyN?7`;6)8O3zC2S=zEBc$N4Ad+J42 zny79{7a3HGUp~^@D$>#;uXE&xLzWr-$S&U(-N+q0{j~b{cgMfNtEzd=-l#8XoRF2F zcRcQz+M)UL(&aOi!uy`R)Og4yT+F{pD)p}Bvd{%$SdXM{jQ8FX9m1&K2gGycb zRbrVV)$eIUTcyN**&am0Ex*#}N91mE*wgqOU6!4i7cYDR)9}=3wW%4{3IDv0sAC!y z*|VC7k?P8|5z+It#=K}h)BA=bNdDyKLU&QWJL2lOzDHX2)XBe}_s-$)f#8R18E9}e z0H1blMl5f6%vF`__b_{`U*h6<(O7q%>3$~q^fJm@n#tQf4&4M*=0zr7NrBYTu@wW_i3=*>sJyzQ472M@BLea(2Zk<$_ckAOrH`7Pn_g%Dor zOE8Jq7otc$=Ap*miRgb_@1T>W4@QG}l1;ERfUnKhICl1t?av(cr8}qMgbeheACWbH zZWf~vuPXNNx6%QyJ=4V*r|CZwYhIe1nZnV5Gz`SxtFHFslxUlzmgIaRz(v_z}`$RTc_}ntzYS{I%>S!dX&DGb8V_r?LsZKMglOju+DH z*$(p-I^MJ4OedUD-0?f!<*lyP`7GL#x1jUI&?Z|RY&Mz}&$X<0?P@$?+sza9dqvhD zo%IeL0$yTFpKtZ>9&Q@T+c;j1&VALKj3*+FqHujaKq zj^oH?T%5U>)0!aeWW+gdrQ)7*TF;ifuyy^>#y@_6ul9bv)W^U1`zGJL1&WdT`t`FN z=wS_GF1F+^p9rP-MA@j1Gk(1O=kNad^pD>qBfoiX?WUCf=A;cTs-$~bZ+w@?uD`3d clJ_3&DF;DqUA1-9)$~5CKD+v@XZ+m%0D>EgBLDyZ literal 6233 zcmd5>TW=#t6@K?$;N@svCJ}b|*11{ZXg#?wnaL(m>|wRCtx)N%#GSOeOjmaZ^0Fn(p4g?qQ$W zt_(vzJf|}q(J0av%8iW>qR>tI^Ji(^^_*Ni6w+5-(vW@$kJ2cRF<1?0bKZbbDd+bh z@dJ$<2mCf`&3lcwc4Y{4UEx1~q7EXVqkX(}rdxxJzE!`_4w#hPLnovXO#2GSU1 z?lW-~VUjcB1!fY7u)eB;1?Cc(iZ2z}dlq+~;{h)Yz;g~Il0iTjev*-*WW@cRjMY4= zovtX8HEZlRm;Y4WcbeY=%Ut>+3Ux^8#y2qo%w@doM{pn=-O_fWcdet#E$yk^S6{sQ z*}H%K?$_UZ^_zFS*4{7w_~(DU`{GZZ_gF%9Nubw9}iAG+L(Z?xiAy4HZ?xe8;`pl-j=j%Db{$Ro&bs6#{h z`uv=A==sLgR|>lGLy~UnL&4X6Jjj7uxIu$Pfs#f+-$RhHQ8J5Ckg^aQZfu~k_BoOm z3|>0&RKzwaue9$Wh{XWoOIWcE&jKF-w@@GwM0A1+kL;Rx;`~pG#nm&1axmiB3H&)(gP}>GudVyfepLegNt2F*D7YemX zZM9T!O0|tnt=2$DuCI$qwesD{dZSVy;*>>BWxcveY(U+6xus9t8O|IV`>eq>z+d?6l7QfFLn!KdyFDEseqrL46&eBuD!QI$QhUZfr zo!-)CdfyK#H8!RvFj{^@PAlJzoH_%AY9a{qHA|(+RmB-+N8eblx>61L$WvDz%|W># z)||BkPzobF5eZiutC2Q72jVUGn17x`R+ZZ-Q0GV#)Syq0#C>>kZhPA@mNe8}5-0=s zN$3>`ZmPHnfGQ%mDZD=CYF&52mdC02%UM~(I6q)H>^eXxMr~MJ^sfcVNVojc-R2yME}K0StW>^^B-ZUZMha-Ko;h2-;-@QHAa;jzc$8 zb|z6;&0AGfIO~gefrds-#kEkwQ{{Qe6R{p5NQjPoe5y{{DnbS!*jL&FJU)kd|1H*M zlgUU&F|AkDE3`C-EU>WfJC8m8%rOkNX9 zd^k1m9&?(Vi>ooU*EIDZJt9VO7GmdHU8%wQ{y!@yu8yFQF@EqiJ`r5e;C0MuI-!Cs zXnJK7IB88YKIt*_@|X*l9-*|sg*xcYWe3}qwEs?L`oXoy8qHB?O*k8icrjNdZ{^L6 zip0Rbl#wr9+6M>qw%t8uXd0Au9BCbts#Ka3D!h$Wl*UV~oYkn9A_d;BFr|`B(=N=62W2fq@yUM9QvW)fr>M}k^yo^2d z3{l{En61s$>y!7?5bdWmz%KWr_8oJb&Sq)On|q8si6Ycc%{WSIQ+t5@dNZRyh7XXZy<-`ktLE05AhHvK{x-)$C&-?#m9 z9CYA=t!P{Gb}gecFjp&}+~(F-9kx)EJnm&X@5}|8E~wd-sZMjl_hvcrPCaL?FyF*T z8OUK6+~&-9>Nad4BStEZBu*J3)BMqo`+b7vZ4Br7U;AheX9o%F8M2Y~bP6-(1C~=hkFiHB&&oKpNg#*l*_?!djd^vEeEi|Hf4+f7rn_JDr+Wc6 ztp;=SkD#tQ3#K`HK>x(^4J7CX#~ZNO4Y9iFkWoPF8Ct@Y9y=`TDwFAd9-tO?9A~-k EAAP&L3;+NC diff --git a/src/main/java/com/aiedu/platform/ai/chat/controller/AiChatController.java b/src/main/java/com/aiedu/platform/ai/chat/controller/AiChatController.java index e0e2b94..455c0e7 100644 --- a/src/main/java/com/aiedu/platform/ai/chat/controller/AiChatController.java +++ b/src/main/java/com/aiedu/platform/ai/chat/controller/AiChatController.java @@ -4,24 +4,35 @@ import com.aiedu.platform.ai.chat.service.AiChatService; import com.aiedu.platform.ai.chat.vo.AiChatResponse; import com.aiedu.platform.common.result.ApiResponse; +import com.aiedu.platform.file.service.FileService; +import com.aiedu.platform.file.vo.FileUploadVO; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; 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; @RestController @RequestMapping("/api/ai") public class AiChatController { private final AiChatService aiChatService; + private final FileService fileService; - public AiChatController(AiChatService aiChatService) { + public AiChatController(AiChatService aiChatService, FileService fileService) { this.aiChatService = aiChatService; + this.fileService = fileService; } @PostMapping("/chat") public ApiResponse chat(@Valid @RequestBody ChatRequest request) { return ApiResponse.success(aiChatService.chat(request)); } + + @PostMapping("/chat/uploads") + public ApiResponse upload(@RequestParam("file") MultipartFile file) { + return ApiResponse.success("上传成功", fileService.upload(file, "AI_CHAT", null)); + } } diff --git a/src/main/java/com/aiedu/platform/ai/chat/dto/ChatAttachmentDTO.java b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatAttachmentDTO.java new file mode 100644 index 0000000..602c92f --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatAttachmentDTO.java @@ -0,0 +1,9 @@ +package com.aiedu.platform.ai.chat.dto; + +public record ChatAttachmentDTO( + Long fileId, + String fileName, + String contentType, + String kind +) { +} diff --git a/src/main/java/com/aiedu/platform/ai/chat/dto/ChatMessageDTO.java b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatMessageDTO.java index e21bd79..30066f0 100644 --- a/src/main/java/com/aiedu/platform/ai/chat/dto/ChatMessageDTO.java +++ b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatMessageDTO.java @@ -2,6 +2,7 @@ public record ChatMessageDTO( String role, - String content + String content, + java.util.List attachments ) { } diff --git a/src/main/java/com/aiedu/platform/ai/chat/dto/ChatRequest.java b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatRequest.java index 9c3bef1..db0c259 100644 --- a/src/main/java/com/aiedu/platform/ai/chat/dto/ChatRequest.java +++ b/src/main/java/com/aiedu/platform/ai/chat/dto/ChatRequest.java @@ -1,13 +1,11 @@ package com.aiedu.platform.ai.chat.dto; -import jakarta.validation.constraints.NotBlank; - import java.util.List; public record ChatRequest( - @NotBlank(message = "消息不能为空") String message, List history, + List attachments, Long modelConfigId, String promptTemplateCode ) { diff --git a/src/main/java/com/aiedu/platform/ai/chat/service/impl/AiChatServiceImpl.java b/src/main/java/com/aiedu/platform/ai/chat/service/impl/AiChatServiceImpl.java index a616340..d73bcb4 100644 --- a/src/main/java/com/aiedu/platform/ai/chat/service/impl/AiChatServiceImpl.java +++ b/src/main/java/com/aiedu/platform/ai/chat/service/impl/AiChatServiceImpl.java @@ -1,36 +1,40 @@ package com.aiedu.platform.ai.chat.service.impl; +import com.aiedu.platform.ai.chat.dto.ChatAttachmentDTO; import com.aiedu.platform.ai.chat.dto.ChatMessageDTO; import com.aiedu.platform.ai.chat.dto.ChatRequest; import com.aiedu.platform.ai.chat.service.AiChatService; import com.aiedu.platform.ai.chat.vo.AiChatResponse; +import com.aiedu.platform.ai.core.enums.AiSceneType; +import com.aiedu.platform.ai.core.gateway.AiGatewayChatResult; +import com.aiedu.platform.ai.core.gateway.LangChain4jAiGateway; +import com.aiedu.platform.ai.core.service.AiInvocationAuditService; +import com.aiedu.platform.ai.core.service.AiModelResolver; +import com.aiedu.platform.ai.core.service.AiPromptTemplateResolver; import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; -import com.aiedu.platform.ai.model.mapper.AiModelConfigMapper; import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; -import com.aiedu.platform.ai.prompt.mapper.AiPromptTemplateMapper; -import com.aiedu.platform.ai.review.domain.entity.AiGenerationRecord; -import com.aiedu.platform.ai.review.domain.entity.AiTokenUsage; -import com.aiedu.platform.ai.review.mapper.AiGenerationRecordMapper; -import com.aiedu.platform.ai.review.mapper.AiTokenUsageMapper; import com.aiedu.platform.common.exception.BusinessException; -import com.aiedu.platform.common.utils.LoginUserUtils; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.output.TokenUsage; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestClient; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; @Service public class AiChatServiceImpl implements AiChatService { @@ -38,91 +42,125 @@ public class AiChatServiceImpl implements AiChatService { private static final String DEFAULT_TEMPLATE_CODE = "teacher_chat_default"; private static final Set ALLOWED_ROLES = Set.of("user", "assistant"); private static final int MAX_HISTORY_MESSAGES = 12; + private static final String DEFAULT_ATTACHMENT_PROMPT = "请分析我上传的图片和文件内容,并结合问题给出准确、结构化的中文回答。"; + private static final Set IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "webp", "bmp"); - private final AiModelConfigMapper aiModelConfigMapper; - private final AiPromptTemplateMapper aiPromptTemplateMapper; - private final AiGenerationRecordMapper aiGenerationRecordMapper; - private final AiTokenUsageMapper aiTokenUsageMapper; + private final AiModelResolver aiModelResolver; + private final AiPromptTemplateResolver aiPromptTemplateResolver; + private final LangChain4jAiGateway aiGateway; + private final AiInvocationAuditService aiInvocationAuditService; + private final ChatAttachmentResolver chatAttachmentResolver; private final ObjectMapper objectMapper; - @Value("${app.ai.api-key:}") - private String apiKey; + @Value("${app.ai.vision-model-code:qwen3-vl-plus}") + private String visionModelCode; - public AiChatServiceImpl(AiModelConfigMapper aiModelConfigMapper, - AiPromptTemplateMapper aiPromptTemplateMapper, - AiGenerationRecordMapper aiGenerationRecordMapper, - AiTokenUsageMapper aiTokenUsageMapper, + public AiChatServiceImpl(AiModelResolver aiModelResolver, + AiPromptTemplateResolver aiPromptTemplateResolver, + LangChain4jAiGateway aiGateway, + AiInvocationAuditService aiInvocationAuditService, + ChatAttachmentResolver chatAttachmentResolver, ObjectMapper objectMapper) { - this.aiModelConfigMapper = aiModelConfigMapper; - this.aiPromptTemplateMapper = aiPromptTemplateMapper; - this.aiGenerationRecordMapper = aiGenerationRecordMapper; - this.aiTokenUsageMapper = aiTokenUsageMapper; + this.aiModelResolver = aiModelResolver; + this.aiPromptTemplateResolver = aiPromptTemplateResolver; + this.aiGateway = aiGateway; + this.aiInvocationAuditService = aiInvocationAuditService; + this.chatAttachmentResolver = chatAttachmentResolver; this.objectMapper = objectMapper; } @Override public AiChatResponse chat(ChatRequest request) { - if (apiKey == null || apiKey.isBlank()) { - throw new BusinessException(400, "未配置 DASHSCOPE_API_KEY,无法进行 AI 对话"); - } - - AiModelConfig modelConfig = resolveModelConfig(request.modelConfigId()); - AiPromptTemplate promptTemplate = resolvePromptTemplate(request.promptTemplateCode()); - Map requestBody = buildRequestBody(request, modelConfig, promptTemplate); - String requestJson = toJson(requestBody); - String responseText = null; + AiModelConfig requestedModelConfig = aiModelResolver.resolve(request.modelConfigId()); + AiModelConfig modelConfig = resolveEffectiveModelConfig(request, requestedModelConfig); + AiPromptTemplate promptTemplate = aiPromptTemplateResolver.resolve(request.promptTemplateCode(), DEFAULT_TEMPLATE_CODE); + String requestJson = toJson(buildAuditPayload(request, modelConfig, promptTemplate)); try { - responseText = RestClient.builder() - .baseUrl(trimTrailingSlash(modelConfig.getBaseUrl())) - .build() - .post() - .uri("/chat/completions") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + apiKey) - .body(requestBody) - .retrieve() - .body(String.class); - - JsonNode root = objectMapper.readTree(responseText); - String reply = root.path("choices").path(0).path("message").path("content").asText(""); - Integer promptTokens = root.path("usage").path("prompt_tokens").isMissingNode() ? null : root.path("usage").path("prompt_tokens").asInt(); - Integer completionTokens = root.path("usage").path("completion_tokens").isMissingNode() ? null : root.path("usage").path("completion_tokens").asInt(); - Integer totalTokens = root.path("usage").path("total_tokens").isMissingNode() ? null : root.path("usage").path("total_tokens").asInt(); - String requestId = root.path("id").asText(null); - - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, true, null); - saveTokenUsage(modelConfig, promptTokens, completionTokens, totalTokens, requestId, true); - + List messages = buildMessages(request, promptTemplate); + AiGatewayChatResult result = aiGateway.chat(modelConfig, messages); + aiInvocationAuditService.saveGenerationRecord( + AiSceneType.CHAT, + modelConfig, + promptTemplate, + requestJson, + toJson(Map.of( + "reply", result.text(), + "requestId", result.requestId(), + "modelName", result.modelName(), + "tokenUsage", buildTokenUsagePayload(result.tokenUsage()) + )), + true, + null + ); + aiInvocationAuditService.saveTokenUsage(AiSceneType.CHAT, "SYSTEM_USER", modelConfig, result.tokenUsage(), result.requestId(), true); return new AiChatResponse( - reply, + result.text(), modelConfig.getId(), - modelConfig.getModelName(), - promptTokens, - completionTokens, - totalTokens, - requestId + Optional.ofNullable(result.modelName()).filter(value -> !value.isBlank()).orElse(modelConfig.getModelName()), + result.tokenUsage() == null ? null : result.tokenUsage().inputTokenCount(), + result.tokenUsage() == null ? null : result.tokenUsage().outputTokenCount(), + result.tokenUsage() == null ? null : result.tokenUsage().totalTokenCount(), + result.requestId() ); } catch (BusinessException exception) { - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, false, exception.getMessage()); - saveTokenUsage(modelConfig, null, null, null, null, false); + aiInvocationAuditService.saveGenerationRecord(AiSceneType.CHAT, modelConfig, promptTemplate, requestJson, null, false, exception.getMessage()); + aiInvocationAuditService.saveTokenUsage(AiSceneType.CHAT, "SYSTEM_USER", modelConfig, null, null, false); throw exception; } catch (Exception exception) { String errorMessage = "AI 对话调用失败: " + exception.getMessage(); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, false, errorMessage); - saveTokenUsage(modelConfig, null, null, null, null, false); + aiInvocationAuditService.saveGenerationRecord(AiSceneType.CHAT, modelConfig, promptTemplate, requestJson, null, false, errorMessage); + aiInvocationAuditService.saveTokenUsage(AiSceneType.CHAT, "SYSTEM_USER", modelConfig, null, null, false); throw new BusinessException(500, errorMessage); } } - private Map buildRequestBody(ChatRequest request, - AiModelConfig modelConfig, - AiPromptTemplate promptTemplate) { - List> messages = new ArrayList<>(); - messages.add(Map.of( - "role", "system", - "content", Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是教育平台教师助手,回答需准确、结构清晰。") - )); + private AiModelConfig resolveEffectiveModelConfig(ChatRequest request, AiModelConfig requestedModelConfig) { + if (!hasImageAttachment(request) || supportsImageInput(requestedModelConfig.getModelCode())) { + return requestedModelConfig; + } + return aiModelResolver.resolveByCodeOrRuntimeFallback(visionModelCode, requestedModelConfig); + } + + private boolean hasImageAttachment(ChatRequest request) { + if (hasImageAttachment(Optional.ofNullable(request.attachments()).orElse(List.of()))) { + return true; + } + return Optional.ofNullable(request.history()).orElse(List.of()).stream() + .anyMatch(item -> hasImageAttachment(Optional.ofNullable(item.attachments()).orElse(List.of()))); + } + + private boolean hasImageAttachment(List attachments) { + return attachments.stream().anyMatch(attachment -> { + if (attachment == null) { + return false; + } + String kind = Optional.ofNullable(attachment.kind()).orElse("").toLowerCase(Locale.ROOT); + String contentType = Optional.ofNullable(attachment.contentType()).orElse("").toLowerCase(Locale.ROOT); + String fileName = Optional.ofNullable(attachment.fileName()).orElse("").toLowerCase(Locale.ROOT); + String extension = fileName.contains(".") ? fileName.substring(fileName.lastIndexOf('.') + 1) : ""; + return "image".equals(kind) || contentType.startsWith("image/") || IMAGE_EXTENSIONS.contains(extension); + }); + } + + private boolean supportsImageInput(String modelCode) { + String normalized = Optional.ofNullable(modelCode).orElse("").toLowerCase(Locale.ROOT); + return normalized.contains("-vl") + || normalized.contains("vision") + || normalized.contains("omni") + || normalized.startsWith("qwen-vl") + || normalized.startsWith("qwen3.6"); + } + + private List buildMessages(ChatRequest request, AiPromptTemplate promptTemplate) { + String currentMessage = Optional.ofNullable(request.message()).orElse("").trim(); + List currentAttachments = Optional.ofNullable(request.attachments()).orElse(List.of()); + if (currentMessage.isBlank() && currentAttachments.isEmpty()) { + throw new BusinessException(400, "消息内容和附件不能同时为空"); + } + + List messages = new ArrayList<>(); + messages.add(SystemMessage.from(Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是教育平台教师助手,回答需准确、结构清晰。"))); List history = Optional.ofNullable(request.history()).orElse(List.of()); int start = Math.max(history.size() - MAX_HISTORY_MESSAGES, 0); @@ -130,106 +168,77 @@ private Map buildRequestBody(ChatRequest request, ChatMessageDTO item = history.get(index); String role = Optional.ofNullable(item.role()).orElse("").trim().toLowerCase(); String content = Optional.ofNullable(item.content()).orElse("").trim(); - if (!ALLOWED_ROLES.contains(role) || content.isBlank()) { + List attachments = Optional.ofNullable(item.attachments()).orElse(List.of()); + if (!ALLOWED_ROLES.contains(role) || content.isBlank() && attachments.isEmpty()) { continue; } - messages.add(Map.of("role", role, "content", content)); + if ("assistant".equals(role)) { + messages.add(AiMessage.from(content)); + continue; + } + messages.add(buildUserMessage(content, attachments, false, null)); } String userPrompt = Optional.ofNullable(promptTemplate.getUserPrompt()).orElse("结合当前问题与上下文,用简洁专业的中文输出答案。"); - messages.add(Map.of( - "role", "user", - "content", userPrompt + "\n\n当前问题:\n" + request.message().trim() - )); - - Map requestBody = new LinkedHashMap<>(); - requestBody.put("model", modelConfig.getModelCode()); - requestBody.put("temperature", Optional.ofNullable(modelConfig.getTemperature()).orElse(BigDecimal.valueOf(0.6))); - requestBody.put("max_tokens", Optional.ofNullable(modelConfig.getMaxTokens()).orElse(2048)); - requestBody.put("messages", messages); - return requestBody; + messages.add(buildUserMessage(currentMessage, currentAttachments, true, userPrompt)); + return messages; } - private AiModelConfig resolveModelConfig(Long requestedId) { - if (requestedId != null) { - AiModelConfig config = aiModelConfigMapper.selectById(requestedId); - if (config == null || config.getDeleted() != null && config.getDeleted() == 1) { - throw new BusinessException(404, "模型配置不存在"); - } - return config; + private UserMessage buildUserMessage(String message, + List attachments, + boolean includePrompt, + String promptTemplate) { + String normalizedMessage = Optional.ofNullable(message).orElse("").trim(); + List resolvedAttachments = chatAttachmentResolver.resolve(attachments); + List contents = new ArrayList<>(); + + String leadText = normalizedMessage; + if (leadText.isBlank()) { + leadText = resolvedAttachments.isEmpty() ? "" : DEFAULT_ATTACHMENT_PROMPT; } - AiModelConfig config = aiModelConfigMapper.selectOne(new LambdaQueryWrapper() - .eq(AiModelConfig::getDeleted, 0) - .eq(AiModelConfig::getEnabled, 1) - .eq(AiModelConfig::getStatus, 1) - .orderByAsc(AiModelConfig::getId) - .last("limit 1")); - if (config == null) { - throw new BusinessException(400, "没有可用的模型配置"); + if (includePrompt) { + leadText = Optional.ofNullable(promptTemplate).orElse("请结合问题与上下文回答。") + "\n\n当前问题:\n" + leadText; } - return config; - } - - private AiPromptTemplate resolvePromptTemplate(String requestedCode) { - String templateCode = requestedCode == null || requestedCode.isBlank() ? DEFAULT_TEMPLATE_CODE : requestedCode; - AiPromptTemplate template = aiPromptTemplateMapper.selectOne(new LambdaQueryWrapper() - .eq(AiPromptTemplate::getDeleted, 0) - .eq(AiPromptTemplate::getStatus, 1) - .eq(AiPromptTemplate::getTemplateCode, templateCode) - .last("limit 1")); - if (template == null) { - throw new BusinessException(404, "Prompt 模板不存在: " + templateCode); + if (!leadText.isBlank()) { + contents.add(TextContent.from(leadText)); } - return template; - } - private void saveGenerationRecord(AiModelConfig modelConfig, - AiPromptTemplate promptTemplate, - String requestContent, - String resultContent, - boolean success, - String errorMessage) { - AiGenerationRecord record = new AiGenerationRecord(); - record.setId(IdWorker.getId()); - record.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); - record.setTaskType("CHAT"); - record.setModelConfigId(modelConfig.getId()); - record.setPromptTemplateId(promptTemplate.getId()); - record.setRequestContent(requestContent); - record.setResultContent(resultContent); - record.setSuccess(success ? 1 : 0); - record.setErrorMessage(errorMessage); - record.setDeleted(0); - aiGenerationRecordMapper.insert(record); - } + for (ChatAttachmentResolver.ResolvedAttachment attachment : resolvedAttachments) { + if ("image".equals(attachment.kind())) { + contents.add(TextContent.from("[图片附件] 文件名: " + attachment.fileName())); + contents.add(ImageContent.from(attachment.base64Data(), attachment.contentType())); + continue; + } + contents.add(TextContent.from("[文件附件] 文件名: " + attachment.fileName() + "\n提取内容如下:\n" + attachment.extractedText())); + } - private void saveTokenUsage(AiModelConfig modelConfig, - Integer promptTokens, - Integer completionTokens, - Integer totalTokens, - String requestId, - boolean success) { - AiTokenUsage usage = new AiTokenUsage(); - usage.setId(IdWorker.getId()); - usage.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); - usage.setUserType("TEACHER"); - usage.setSceneType("CHAT"); - usage.setModelName(modelConfig.getModelName()); - usage.setProviderCode(modelConfig.getProviderCode()); - usage.setPromptTokens(promptTokens); - usage.setCompletionTokens(completionTokens); - usage.setTotalTokens(totalTokens); - usage.setRequestId(requestId); - usage.setSuccess(success ? 1 : 0); - aiTokenUsageMapper.insert(usage); + if (contents.isEmpty()) { + contents.add(TextContent.from(DEFAULT_ATTACHMENT_PROMPT)); + } + return UserMessage.from(contents); } - private String trimTrailingSlash(String baseUrl) { - String value = Optional.ofNullable(baseUrl).orElse(""); - if (value.endsWith("/")) { - return value.substring(0, value.length() - 1); - } - return value; + private Map buildAuditPayload(ChatRequest request, + AiModelConfig modelConfig, + AiPromptTemplate promptTemplate) { + Map payload = new LinkedHashMap<>(); + payload.put("scene", AiSceneType.CHAT.code()); + payload.put("model", modelConfig.getModelCode()); + payload.put("modelName", modelConfig.getModelName()); + payload.put("promptTemplateCode", promptTemplate.getTemplateCode()); + payload.put("message", Optional.ofNullable(request.message()).orElse("").trim()); + payload.put("historyCount", Optional.ofNullable(request.history()).orElse(List.of()).size()); + payload.put("attachments", Optional.ofNullable(request.attachments()).orElse(List.of()).stream() + .map(item -> { + Map metadata = new LinkedHashMap<>(); + metadata.put("fileId", item.fileId()); + metadata.put("fileName", item.fileName()); + metadata.put("contentType", item.contentType()); + metadata.put("kind", item.kind()); + return metadata; + }) + .collect(Collectors.toList())); + return payload; } private String toJson(Object value) { @@ -239,4 +248,15 @@ private String toJson(Object value) { throw new BusinessException(500, "JSON 序列化失败: " + exception.getMessage()); } } + + private Map buildTokenUsagePayload(TokenUsage tokenUsage) { + if (tokenUsage == null) { + return Map.of(); + } + Map payload = new LinkedHashMap<>(); + payload.put("inputTokenCount", tokenUsage.inputTokenCount()); + payload.put("outputTokenCount", tokenUsage.outputTokenCount()); + payload.put("totalTokenCount", tokenUsage.totalTokenCount()); + return payload; + } } diff --git a/src/main/java/com/aiedu/platform/ai/chat/service/impl/ChatAttachmentResolver.java b/src/main/java/com/aiedu/platform/ai/chat/service/impl/ChatAttachmentResolver.java new file mode 100644 index 0000000..df1f1f2 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/chat/service/impl/ChatAttachmentResolver.java @@ -0,0 +1,152 @@ +package com.aiedu.platform.ai.chat.service.impl; + +import com.aiedu.platform.ai.chat.dto.ChatAttachmentDTO; +import com.aiedu.platform.ai.review.document.service.impl.DocumentTextExtractor; +import com.aiedu.platform.common.exception.BusinessException; +import com.aiedu.platform.file.domain.entity.FileRecord; +import com.aiedu.platform.file.mapper.FileRecordMapper; +import io.minio.GetObjectArgs; +import io.minio.MinioClient; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +@Component +public class ChatAttachmentResolver { + + private static final int MAX_ATTACHMENTS = 6; + private static final long MAX_IMAGE_SIZE = 10 * 1024 * 1024L; + private static final Set IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "webp", "bmp"); + private static final Set DOCUMENT_EXTENSIONS = Set.of( + "txt", "md", "markdown", "json", "csv", "log", "java", "xml", "yml", "yaml", "pdf", "doc", "docx", "xlsx", "xls" + ); + + private final FileRecordMapper fileRecordMapper; + private final MinioClient minioClient; + private final DocumentTextExtractor documentTextExtractor; + + public ChatAttachmentResolver(FileRecordMapper fileRecordMapper, + MinioClient minioClient, + DocumentTextExtractor documentTextExtractor) { + this.fileRecordMapper = fileRecordMapper; + this.minioClient = minioClient; + this.documentTextExtractor = documentTextExtractor; + } + + public List resolve(List attachments) { + if (attachments == null || attachments.isEmpty()) { + return List.of(); + } + if (attachments.size() > MAX_ATTACHMENTS) { + throw new BusinessException(400, "单次对话最多上传 6 个附件"); + } + + List resolved = new ArrayList<>(); + for (ChatAttachmentDTO attachment : attachments) { + Long fileId = attachment == null ? null : attachment.fileId(); + if (fileId == null) { + continue; + } + FileRecord fileRecord = getFileRecord(fileId); + String kind = resolveKind(fileRecord); + if ("image".equals(kind)) { + if (fileRecord.getFileSize() != null && fileRecord.getFileSize() > MAX_IMAGE_SIZE) { + throw new BusinessException(400, "图片附件不能超过 10MB: " + fileRecord.getOriginalName()); + } + resolved.add(new ResolvedAttachment( + fileRecord.getId(), + fileRecord.getOriginalName(), + imageContentType(fileRecord), + "image", + toBase64Data(fileRecord), + null + )); + continue; + } + + DocumentTextExtractor.ExtractedDocument extractedDocument = documentTextExtractor.extract(fileId); + resolved.add(new ResolvedAttachment( + fileRecord.getId(), + fileRecord.getOriginalName(), + safeContentType(fileRecord.getContentType()), + "document", + null, + extractedDocument.content() + )); + } + return resolved; + } + + private FileRecord getFileRecord(Long fileId) { + FileRecord fileRecord = fileRecordMapper.selectById(fileId); + if (fileRecord == null || fileRecord.getDeleted() != null && fileRecord.getDeleted() == 1) { + throw new BusinessException(404, "附件不存在: " + fileId); + } + return fileRecord; + } + + private String resolveKind(FileRecord fileRecord) { + String extension = Optional.ofNullable(fileRecord.getFileExt()) + .orElse("") + .toLowerCase(Locale.ROOT) + .replace(".", ""); + String contentType = safeContentType(fileRecord.getContentType()).toLowerCase(Locale.ROOT); + + if (contentType.startsWith("image/") || IMAGE_EXTENSIONS.contains(extension)) { + return "image"; + } + if (DOCUMENT_EXTENSIONS.contains(extension)) { + return "document"; + } + throw new BusinessException(400, "当前聊天暂不支持该附件类型: " + fileRecord.getOriginalName()); + } + + private String toBase64Data(FileRecord fileRecord) { + try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder() + .bucket(fileRecord.getBucketName()) + .object(fileRecord.getObjectKey()) + .build())) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + inputStream.transferTo(outputStream); + return Base64.getEncoder().encodeToString(outputStream.toByteArray()); + } catch (Exception exception) { + throw new BusinessException(500, "读取图片附件失败: " + exception.getMessage()); + } + } + + private String imageContentType(FileRecord fileRecord) { + String contentType = safeContentType(fileRecord.getContentType()); + if (contentType.toLowerCase(Locale.ROOT).startsWith("image/")) { + return contentType; + } + return switch (Optional.ofNullable(fileRecord.getFileExt()).orElse("").toLowerCase(Locale.ROOT).replace(".", "")) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "gif" -> "image/gif"; + case "webp" -> "image/webp"; + case "bmp" -> "image/bmp"; + default -> "image/png"; + }; + } + + private String safeContentType(String contentType) { + return contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType; + } + + public record ResolvedAttachment( + Long fileId, + String fileName, + String contentType, + String kind, + String base64Data, + String extractedText + ) { + } +} diff --git a/src/main/java/com/aiedu/platform/ai/core/enums/AiSceneType.java b/src/main/java/com/aiedu/platform/ai/core/enums/AiSceneType.java new file mode 100644 index 0000000..7cdd171 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/enums/AiSceneType.java @@ -0,0 +1,19 @@ +package com.aiedu.platform.ai.core.enums; + +public enum AiSceneType { + + CHAT("CHAT"), + SUBJECTIVE_REVIEW("SUBJECTIVE_REVIEW"), + DOCUMENT_REVIEW("DOCUMENT_REVIEW"), + RAG("RAG"); + + private final String code; + + AiSceneType(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayChatResult.java b/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayChatResult.java new file mode 100644 index 0000000..4de5e12 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayChatResult.java @@ -0,0 +1,13 @@ +package com.aiedu.platform.ai.core.gateway; + +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; + +public record AiGatewayChatResult( + String text, + String requestId, + String modelName, + TokenUsage tokenUsage, + FinishReason finishReason +) { +} diff --git a/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayStructuredResult.java b/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayStructuredResult.java new file mode 100644 index 0000000..3814a71 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/gateway/AiGatewayStructuredResult.java @@ -0,0 +1,14 @@ +package com.aiedu.platform.ai.core.gateway; + +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; + +public record AiGatewayStructuredResult( + T content, + String rawText, + String requestId, + String modelName, + TokenUsage tokenUsage, + FinishReason finishReason +) { +} diff --git a/src/main/java/com/aiedu/platform/ai/core/gateway/LangChain4jAiGateway.java b/src/main/java/com/aiedu/platform/ai/core/gateway/LangChain4jAiGateway.java new file mode 100644 index 0000000..bb628d4 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/gateway/LangChain4jAiGateway.java @@ -0,0 +1,125 @@ +package com.aiedu.platform.ai.core.gateway; + +import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; +import com.aiedu.platform.common.exception.BusinessException; +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.Capability; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.output.TokenUsage; +import dev.langchain4j.service.output.JsonSchemas; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; + +@Component +public class LangChain4jAiGateway { + + private final ObjectMapper objectMapper; + + @Value("${app.ai.api-key:}") + private String apiKey; + + public LangChain4jAiGateway(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public AiGatewayChatResult chat(AiModelConfig modelConfig, List messages) { + try { + ChatResponse response = buildChatModel(modelConfig, false) + .chat(messages); + return new AiGatewayChatResult( + response.aiMessage() == null ? "" : response.aiMessage().text(), + response.id(), + response.modelName(), + response.tokenUsage(), + response.finishReason() + ); + } catch (Exception exception) { + throw new BusinessException(500, "LangChain4j 对话调用失败: " + exception.getMessage()); + } + } + + public AiGatewayStructuredResult structuredChat(AiModelConfig modelConfig, + List messages, + Class outputType) { + try { + JsonSchema jsonSchema = JsonSchemas.jsonSchemaFrom(outputType) + .orElseThrow(() -> new BusinessException(500, "无法生成结构化输出 JSON Schema: " + outputType.getSimpleName())); + ChatRequest request = ChatRequest.builder() + .messages(messages) + .responseFormat(ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .jsonSchema(jsonSchema) + .build()) + .build(); + ChatResponse response = buildChatModel(modelConfig, true).chat(request); + String text = response.aiMessage() == null ? "" : response.aiMessage().text(); + T parsed = objectMapper.readValue(stripCodeFence(text), outputType); + return new AiGatewayStructuredResult<>( + parsed, + text, + response.id(), + response.modelName(), + response.tokenUsage(), + response.finishReason() + ); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException(500, "LangChain4j 结构化调用失败: " + exception.getMessage()); + } + } + + private OpenAiChatModel buildChatModel(AiModelConfig modelConfig, boolean structuredOutput) { + if (apiKey == null || apiKey.isBlank()) { + throw new BusinessException(400, "未配置 DASHSCOPE_API_KEY,无法进行 AI 调用"); + } + OpenAiChatModel.OpenAiChatModelBuilder builder = OpenAiChatModel.builder() + .baseUrl(trimTrailingSlash(modelConfig.getBaseUrl())) + .apiKey(apiKey) + .modelName(modelConfig.getModelCode()) + .temperature(decimalToDouble(modelConfig.getTemperature(), 0.2D)) + .maxTokens(modelConfig.getMaxTokens() == null ? 2048 : modelConfig.getMaxTokens()) + .timeout(Duration.ofSeconds(90)) + .maxRetries(1) + .logRequests(false) + .logResponses(false); + if (structuredOutput) { + builder.supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA) + .strictJsonSchema(true); + } + return builder.build(); + } + + private String trimTrailingSlash(String baseUrl) { + String value = baseUrl == null ? "" : baseUrl.trim(); + if (value.endsWith("/")) { + return value.substring(0, value.length() - 1); + } + return value; + } + + private Double decimalToDouble(BigDecimal value, double defaultValue) { + return value == null ? defaultValue : value.doubleValue(); + } + + private String stripCodeFence(String content) { + String text = content == null ? "" : content.trim(); + if (text.startsWith("```")) { + text = text.replaceFirst("^```json", "").replaceFirst("^```", ""); + if (text.endsWith("```")) { + text = text.substring(0, text.length() - 3); + } + } + return text.trim(); + } +} diff --git a/src/main/java/com/aiedu/platform/ai/core/service/AiInvocationAuditService.java b/src/main/java/com/aiedu/platform/ai/core/service/AiInvocationAuditService.java new file mode 100644 index 0000000..719f108 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/service/AiInvocationAuditService.java @@ -0,0 +1,70 @@ +package com.aiedu.platform.ai.core.service; + +import com.aiedu.platform.ai.core.enums.AiSceneType; +import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; +import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; +import com.aiedu.platform.ai.review.domain.entity.AiGenerationRecord; +import com.aiedu.platform.ai.review.domain.entity.AiTokenUsage; +import com.aiedu.platform.ai.review.mapper.AiGenerationRecordMapper; +import com.aiedu.platform.ai.review.mapper.AiTokenUsageMapper; +import com.aiedu.platform.common.utils.LoginUserUtils; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import dev.langchain4j.model.output.TokenUsage; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class AiInvocationAuditService { + + private final AiGenerationRecordMapper aiGenerationRecordMapper; + private final AiTokenUsageMapper aiTokenUsageMapper; + + public AiInvocationAuditService(AiGenerationRecordMapper aiGenerationRecordMapper, + AiTokenUsageMapper aiTokenUsageMapper) { + this.aiGenerationRecordMapper = aiGenerationRecordMapper; + this.aiTokenUsageMapper = aiTokenUsageMapper; + } + + public void saveGenerationRecord(AiSceneType sceneType, + AiModelConfig modelConfig, + AiPromptTemplate promptTemplate, + String requestContent, + String resultContent, + boolean success, + String errorMessage) { + AiGenerationRecord record = new AiGenerationRecord(); + record.setId(IdWorker.getId()); + record.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); + record.setTaskType(sceneType.code()); + record.setModelConfigId(modelConfig.getId()); + record.setPromptTemplateId(promptTemplate == null ? null : promptTemplate.getId()); + record.setRequestContent(requestContent); + record.setResultContent(resultContent); + record.setSuccess(success ? 1 : 0); + record.setErrorMessage(errorMessage); + record.setDeleted(0); + aiGenerationRecordMapper.insert(record); + } + + public void saveTokenUsage(AiSceneType sceneType, + String userType, + AiModelConfig modelConfig, + TokenUsage tokenUsage, + String requestId, + boolean success) { + AiTokenUsage usage = new AiTokenUsage(); + usage.setId(IdWorker.getId()); + usage.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); + usage.setUserType(userType); + usage.setSceneType(sceneType.code()); + usage.setModelName(modelConfig.getModelName()); + usage.setProviderCode(modelConfig.getProviderCode()); + usage.setPromptTokens(tokenUsage == null ? null : tokenUsage.inputTokenCount()); + usage.setCompletionTokens(tokenUsage == null ? null : tokenUsage.outputTokenCount()); + usage.setTotalTokens(tokenUsage == null ? null : tokenUsage.totalTokenCount()); + usage.setRequestId(requestId); + usage.setSuccess(success ? 1 : 0); + aiTokenUsageMapper.insert(usage); + } +} diff --git a/src/main/java/com/aiedu/platform/ai/core/service/AiModelResolver.java b/src/main/java/com/aiedu/platform/ai/core/service/AiModelResolver.java new file mode 100644 index 0000000..610ce1d --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/service/AiModelResolver.java @@ -0,0 +1,87 @@ +package com.aiedu.platform.ai.core.service; + +import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; +import com.aiedu.platform.ai.model.mapper.AiModelConfigMapper; +import com.aiedu.platform.common.exception.BusinessException; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Locale; + +@Component +public class AiModelResolver { + + private final AiModelConfigMapper aiModelConfigMapper; + + public AiModelResolver(AiModelConfigMapper aiModelConfigMapper) { + this.aiModelConfigMapper = aiModelConfigMapper; + } + + public AiModelConfig resolve(Long requestedId) { + if (requestedId != null) { + AiModelConfig config = aiModelConfigMapper.selectById(requestedId); + if (config == null || isDeleted(config)) { + throw new BusinessException(404, "模型配置不存在"); + } + return config; + } + AiModelConfig config = aiModelConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(AiModelConfig::getDeleted, 0) + .eq(AiModelConfig::getEnabled, 1) + .eq(AiModelConfig::getStatus, 1) + .orderByAsc(AiModelConfig::getId) + .last("limit 1")); + if (config == null) { + throw new BusinessException(400, "没有可用的模型配置"); + } + return config; + } + + public AiModelConfig resolveByCodeOrRuntimeFallback(String modelCode, AiModelConfig fallbackConfig) { + String normalizedCode = modelCode == null ? "" : modelCode.trim(); + if (normalizedCode.isBlank()) { + return fallbackConfig; + } + AiModelConfig config = aiModelConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(AiModelConfig::getDeleted, 0) + .eq(AiModelConfig::getEnabled, 1) + .eq(AiModelConfig::getStatus, 1) + .eq(AiModelConfig::getModelCode, normalizedCode) + .last("limit 1")); + if (config != null) { + return config; + } + return buildRuntimeFallback(normalizedCode, fallbackConfig); + } + + private boolean isDeleted(AiModelConfig config) { + return config.getDeleted() != null && config.getDeleted() == 1; + } + + private AiModelConfig buildRuntimeFallback(String modelCode, AiModelConfig fallbackConfig) { + AiModelConfig runtime = new AiModelConfig(); + runtime.setId(fallbackConfig.getId()); + runtime.setProviderCode(fallbackConfig.getProviderCode()); + runtime.setProviderName(fallbackConfig.getProviderName()); + runtime.setModelCode(modelCode); + runtime.setModelName(readableModelName(modelCode)); + runtime.setBaseUrl(fallbackConfig.getBaseUrl()); + runtime.setApiKeyMasked(fallbackConfig.getApiKeyMasked()); + runtime.setTemperature(fallbackConfig.getTemperature() == null ? BigDecimal.valueOf(0.2D) : fallbackConfig.getTemperature()); + runtime.setMaxTokens(fallbackConfig.getMaxTokens()); + runtime.setEnabled(1); + runtime.setStatus(1); + runtime.setDeleted(0); + runtime.setRemark("运行时视觉模型回退配置"); + return runtime; + } + + private String readableModelName(String modelCode) { + String normalized = modelCode.toLowerCase(Locale.ROOT); + if (normalized.contains("vl")) { + return "通义千问视觉模型"; + } + return modelCode; + } +} diff --git a/src/main/java/com/aiedu/platform/ai/core/service/AiPromptTemplateResolver.java b/src/main/java/com/aiedu/platform/ai/core/service/AiPromptTemplateResolver.java new file mode 100644 index 0000000..edd5a99 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/core/service/AiPromptTemplateResolver.java @@ -0,0 +1,30 @@ +package com.aiedu.platform.ai.core.service; + +import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; +import com.aiedu.platform.ai.prompt.mapper.AiPromptTemplateMapper; +import com.aiedu.platform.common.exception.BusinessException; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Component; + +@Component +public class AiPromptTemplateResolver { + + private final AiPromptTemplateMapper aiPromptTemplateMapper; + + public AiPromptTemplateResolver(AiPromptTemplateMapper aiPromptTemplateMapper) { + this.aiPromptTemplateMapper = aiPromptTemplateMapper; + } + + public AiPromptTemplate resolve(String requestedCode, String defaultTemplateCode) { + String templateCode = requestedCode == null || requestedCode.isBlank() ? defaultTemplateCode : requestedCode; + AiPromptTemplate template = aiPromptTemplateMapper.selectOne(new LambdaQueryWrapper() + .eq(AiPromptTemplate::getDeleted, 0) + .eq(AiPromptTemplate::getStatus, 1) + .eq(AiPromptTemplate::getTemplateCode, templateCode) + .last("limit 1")); + if (template == null) { + throw new BusinessException(404, "Prompt 模板不存在: " + templateCode); + } + return template; + } +} diff --git a/src/main/java/com/aiedu/platform/ai/review/document/service/impl/AiDocumentReviewServiceImpl.java b/src/main/java/com/aiedu/platform/ai/review/document/service/impl/AiDocumentReviewServiceImpl.java index d206c41..0f1dd71 100644 --- a/src/main/java/com/aiedu/platform/ai/review/document/service/impl/AiDocumentReviewServiceImpl.java +++ b/src/main/java/com/aiedu/platform/ai/review/document/service/impl/AiDocumentReviewServiceImpl.java @@ -1,5 +1,11 @@ package com.aiedu.platform.ai.review.document.service.impl; +import com.aiedu.platform.ai.core.enums.AiSceneType; +import com.aiedu.platform.ai.core.gateway.AiGatewayStructuredResult; +import com.aiedu.platform.ai.core.gateway.LangChain4jAiGateway; +import com.aiedu.platform.ai.core.service.AiInvocationAuditService; +import com.aiedu.platform.ai.core.service.AiModelResolver; +import com.aiedu.platform.ai.core.service.AiPromptTemplateResolver; import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; import com.aiedu.platform.ai.model.mapper.AiModelConfigMapper; import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; @@ -8,14 +14,11 @@ import com.aiedu.platform.ai.review.document.dto.DocumentReviewCreateDTO; import com.aiedu.platform.ai.review.document.mapper.AiDocumentReviewTaskMapper; import com.aiedu.platform.ai.review.document.service.AiDocumentReviewService; +import com.aiedu.platform.ai.review.document.support.DocumentReviewPromptBuilder; import com.aiedu.platform.ai.review.document.vo.DocumentReviewDeductionVO; import com.aiedu.platform.ai.review.document.vo.DocumentReviewFileVO; import com.aiedu.platform.ai.review.document.vo.DocumentReviewScoreVO; import com.aiedu.platform.ai.review.document.vo.DocumentReviewTaskVO; -import com.aiedu.platform.ai.review.domain.entity.AiGenerationRecord; -import com.aiedu.platform.ai.review.domain.entity.AiTokenUsage; -import com.aiedu.platform.ai.review.mapper.AiGenerationRecordMapper; -import com.aiedu.platform.ai.review.mapper.AiTokenUsageMapper; import com.aiedu.platform.common.exception.BusinessException; import com.aiedu.platform.common.utils.LoginUserUtils; import com.aiedu.platform.file.domain.entity.FileRecord; @@ -27,13 +30,12 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.output.TokenUsage; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestClient; import java.math.BigDecimal; import java.math.RoundingMode; @@ -56,23 +58,26 @@ public class AiDocumentReviewServiceImpl implements AiDocumentReviewService { private final FileRecordMapper fileRecordMapper; private final FileService fileService; private final SysUserMapper sysUserMapper; - private final AiGenerationRecordMapper aiGenerationRecordMapper; - private final AiTokenUsageMapper aiTokenUsageMapper; private final DocumentTextExtractor documentTextExtractor; + private final AiModelResolver aiModelResolver; + private final AiPromptTemplateResolver aiPromptTemplateResolver; + private final LangChain4jAiGateway aiGateway; + private final AiInvocationAuditService aiInvocationAuditService; + private final DocumentReviewPromptBuilder promptBuilder; private final ObjectMapper objectMapper; - @Value("${app.ai.api-key:}") - private String apiKey; - public AiDocumentReviewServiceImpl(AiDocumentReviewTaskMapper taskMapper, AiModelConfigMapper aiModelConfigMapper, AiPromptTemplateMapper aiPromptTemplateMapper, FileRecordMapper fileRecordMapper, FileService fileService, SysUserMapper sysUserMapper, - AiGenerationRecordMapper aiGenerationRecordMapper, - AiTokenUsageMapper aiTokenUsageMapper, DocumentTextExtractor documentTextExtractor, + AiModelResolver aiModelResolver, + AiPromptTemplateResolver aiPromptTemplateResolver, + LangChain4jAiGateway aiGateway, + AiInvocationAuditService aiInvocationAuditService, + DocumentReviewPromptBuilder promptBuilder, ObjectMapper objectMapper) { this.taskMapper = taskMapper; this.aiModelConfigMapper = aiModelConfigMapper; @@ -80,21 +85,21 @@ public AiDocumentReviewServiceImpl(AiDocumentReviewTaskMapper taskMapper, this.fileRecordMapper = fileRecordMapper; this.fileService = fileService; this.sysUserMapper = sysUserMapper; - this.aiGenerationRecordMapper = aiGenerationRecordMapper; - this.aiTokenUsageMapper = aiTokenUsageMapper; this.documentTextExtractor = documentTextExtractor; + this.aiModelResolver = aiModelResolver; + this.aiPromptTemplateResolver = aiPromptTemplateResolver; + this.aiGateway = aiGateway; + this.aiInvocationAuditService = aiInvocationAuditService; + this.promptBuilder = promptBuilder; this.objectMapper = objectMapper; } @Override @Transactional public DocumentReviewTaskVO createAndReview(DocumentReviewCreateDTO request) { - if (apiKey == null || apiKey.isBlank()) { - throw new BusinessException(400, "未配置 DASHSCOPE_API_KEY,无法进行文档批阅"); - } Long teacherId = Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L); - AiModelConfig modelConfig = resolveModelConfig(request.modelConfigId()); - AiPromptTemplate promptTemplate = resolvePromptTemplate(request.promptTemplateCode()); + AiModelConfig modelConfig = aiModelResolver.resolve(request.modelConfigId()); + AiPromptTemplate promptTemplate = aiPromptTemplateResolver.resolve(request.promptTemplateCode(), DEFAULT_TEMPLATE_CODE); DocumentTextExtractor.ExtractedDocument standardDocument = documentTextExtractor.extract(request.standardFileId()); DocumentTextExtractor.ExtractedDocument studentDocument = documentTextExtractor.extract(request.studentFileId()); @@ -117,41 +122,62 @@ public DocumentReviewTaskVO createAndReview(DocumentReviewCreateDTO request) { task.setUpdateBy(teacherId); taskMapper.insert(task); - Map requestBody = buildRequestBody(modelConfig, promptTemplate, task); - String requestJson = toJson(requestBody); - String responseText = null; + DocumentReviewPromptBuilder.PromptPayload promptPayload = promptBuilder.build(promptTemplate, task); + String requestJson = toJson(Map.of( + "scene", AiSceneType.DOCUMENT_REVIEW.code(), + "model", modelConfig.getModelCode(), + "promptTemplateCode", promptTemplate.getTemplateCode(), + "taskId", task.getId(), + "reviewName", task.getReviewName(), + "standardFileId", task.getStandardFileId(), + "studentFileId", task.getStudentFileId(), + "systemPrompt", promptPayload.systemPrompt(), + "userPrompt", promptPayload.userPrompt() + )); + try { - responseText = RestClient.builder() - .baseUrl(trimTrailingSlash(modelConfig.getBaseUrl())) - .build() - .post() - .uri("/chat/completions") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + apiKey) - .body(requestBody) - .retrieve() - .body(String.class); - - JsonNode root = objectMapper.readTree(responseText); - String content = root.path("choices").path(0).path("message").path("content").asText(""); - Integer promptTokens = root.path("usage").path("prompt_tokens").isMissingNode() ? null : root.path("usage").path("prompt_tokens").asInt(); - Integer completionTokens = root.path("usage").path("completion_tokens").isMissingNode() ? null : root.path("usage").path("completion_tokens").asInt(); - Integer totalTokens = root.path("usage").path("total_tokens").isMissingNode() ? null : root.path("usage").path("total_tokens").asInt(); - String requestId = root.path("id").asText(null); - - ReviewResult result = parseResult(content, task.getTotalScore()); + AiGatewayStructuredResult gatewayResult = aiGateway.structuredChat( + modelConfig, + List.of( + SystemMessage.from(promptPayload.systemPrompt()), + UserMessage.from(promptPayload.userPrompt()) + ), + StructuredDocumentReviewPayload.class + ); + + ReviewResult result = normalizeResult(gatewayResult.content(), task.getTotalScore()); task.setSuggestedScore(result.score()); task.setDeductionDetails(toJson(result.deductions())); task.setStrengths(toJson(result.strengths())); task.setIssues(toJson(result.issues())); task.setSummary(result.summary()); task.setSuggestion(result.suggestion()); - task.setRawResponse(responseText); + task.setRawResponse(gatewayResult.rawText()); task.setReviewStatus(1); taskMapper.updateById(task); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, true, null); - saveTokenUsage(modelConfig, promptTokens, completionTokens, totalTokens, requestId, true); + aiInvocationAuditService.saveGenerationRecord( + AiSceneType.DOCUMENT_REVIEW, + modelConfig, + promptTemplate, + requestJson, + toJson(Map.of( + "rawText", gatewayResult.rawText(), + "structured", gatewayResult.content(), + "requestId", gatewayResult.requestId(), + "tokenUsage", buildTokenUsagePayload(gatewayResult.tokenUsage()) + )), + true, + null + ); + aiInvocationAuditService.saveTokenUsage( + AiSceneType.DOCUMENT_REVIEW, + "TEACHER", + modelConfig, + gatewayResult.tokenUsage(), + gatewayResult.requestId(), + true + ); AiDocumentReviewTask savedTask = getTask(task.getId()); return toVO(savedTask, Map.of(teacherId, getUser(teacherId)), Map.of(standardDocument.fileRecord().getId(), standardDocument.fileRecord(), studentDocument.fileRecord().getId(), studentDocument.fileRecord()), @@ -160,19 +186,17 @@ public DocumentReviewTaskVO createAndReview(DocumentReviewCreateDTO request) { } catch (BusinessException exception) { task.setReviewStatus(4); task.setErrorMessage(exception.getMessage()); - task.setRawResponse(responseText); taskMapper.updateById(task); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, false, exception.getMessage()); - saveTokenUsage(modelConfig, null, null, null, null, false); + aiInvocationAuditService.saveGenerationRecord(AiSceneType.DOCUMENT_REVIEW, modelConfig, promptTemplate, requestJson, null, false, exception.getMessage()); + aiInvocationAuditService.saveTokenUsage(AiSceneType.DOCUMENT_REVIEW, "TEACHER", modelConfig, null, null, false); throw exception; } catch (Exception exception) { String errorMessage = "文档批阅失败: " + exception.getMessage(); task.setReviewStatus(4); task.setErrorMessage(errorMessage); - task.setRawResponse(responseText); taskMapper.updateById(task); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, false, errorMessage); - saveTokenUsage(modelConfig, null, null, null, null, false); + aiInvocationAuditService.saveGenerationRecord(AiSceneType.DOCUMENT_REVIEW, modelConfig, promptTemplate, requestJson, null, false, errorMessage); + aiInvocationAuditService.saveTokenUsage(AiSceneType.DOCUMENT_REVIEW, "TEACHER", modelConfig, null, null, false); throw new BusinessException(500, errorMessage); } } @@ -209,6 +233,42 @@ public void reject(Long id) { taskMapper.updateById(task); } + private ReviewResult normalizeResult(StructuredDocumentReviewPayload payload, BigDecimal maxScore) { + try { + BigDecimal score = Optional.ofNullable(payload.score()).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); + if (score.compareTo(BigDecimal.ZERO) < 0) { + score = BigDecimal.ZERO; + } + if (maxScore != null && score.compareTo(maxScore) > 0) { + score = maxScore; + } + List deductions = Optional.ofNullable(payload.deductions()).orElse(List.of()).stream() + .map(item -> new DocumentReviewDeductionVO( + Optional.ofNullable(item.point()).orElse(""), + Optional.ofNullable(item.score()).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP), + Optional.ofNullable(item.reason()).orElse("") + )) + .toList(); + return new ReviewResult( + score, + sanitizeList(payload.strengths()), + sanitizeList(payload.issues()), + deductions, + Optional.ofNullable(payload.summary()).orElse(""), + Optional.ofNullable(payload.suggestion()).orElse("") + ); + } catch (Exception exception) { + throw new BusinessException(500, "文档批阅结果解析失败: " + exception.getMessage()); + } + } + + private List sanitizeList(List values) { + if (values == null) { + return Collections.emptyList(); + } + return values.stream().filter(item -> item != null && !item.isBlank()).toList(); + } + private List buildVOs(List tasks) { Map userMap = loadUserMap(tasks.stream().map(AiDocumentReviewTask::getTeacherId).collect(Collectors.toSet())); Set fileIds = tasks.stream() @@ -313,96 +373,6 @@ private String statusLabel(Integer reviewStatus) { }; } - private Map buildRequestBody(AiModelConfig modelConfig, AiPromptTemplate promptTemplate, AiDocumentReviewTask task) { - String systemPrompt = Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是一名严谨的教师阅卷助手。"); - String userPrompt = """ - %s - - 请严格返回 JSON,不要使用 markdown 代码块。 - JSON 字段要求: - score(number) - summary(string) - strengths(string array) - issues(string array) - suggestion(string) - deductions(array),每项字段为 point(string), score(number), reason(string) - - 总分:%s - 评分标准:%s - - 标准答案卷: - %s - - 学生答卷: - %s - """.formatted( - Optional.ofNullable(promptTemplate.getUserPrompt()).orElse("请进行文档批阅。"), - task.getTotalScore(), - Optional.ofNullable(task.getScoringRubric()).orElse("未提供额外评分标准,请根据标准答案卷与学生答卷进行综合判分。"), - task.getStandardContent(), - task.getStudentContent() - ); - return Map.of( - "model", modelConfig.getModelCode(), - "temperature", Optional.ofNullable(modelConfig.getTemperature()).orElse(BigDecimal.valueOf(0.2)), - "max_tokens", Optional.ofNullable(modelConfig.getMaxTokens()).orElse(4096), - "response_format", Map.of("type", "json_object"), - "messages", List.of( - Map.of("role", "system", "content", systemPrompt), - Map.of("role", "user", "content", userPrompt) - ) - ); - } - - private ReviewResult parseResult(String content, BigDecimal maxScore) { - try { - JsonNode root = objectMapper.readTree(stripCodeFence(content)); - BigDecimal score = BigDecimal.valueOf(root.path("score").asDouble(0D)).setScale(2, RoundingMode.HALF_UP); - if (score.compareTo(BigDecimal.ZERO) < 0) { - score = BigDecimal.ZERO; - } - if (score.compareTo(maxScore) > 0) { - score = maxScore; - } - List strengths = readStringArray(root.path("strengths")); - List issues = readStringArray(root.path("issues")); - List deductions = readDeductions(root.path("deductions")); - return new ReviewResult( - score, - strengths, - issues, - deductions, - root.path("summary").asText(""), - root.path("suggestion").asText("") - ); - } catch (Exception exception) { - throw new BusinessException(500, "文档批阅结果解析失败: " + exception.getMessage()); - } - } - - private List readStringArray(JsonNode node) { - if (!node.isArray()) { - return Collections.emptyList(); - } - return java.util.stream.StreamSupport.stream(node.spliterator(), false) - .map(JsonNode::asText) - .filter(item -> item != null && !item.isBlank()) - .toList(); - } - - private List readDeductions(JsonNode node) { - if (!node.isArray()) { - return Collections.emptyList(); - } - return java.util.stream.StreamSupport.stream(node.spliterator(), false) - .map(item -> new DocumentReviewDeductionVO( - item.path("point").asText(""), - BigDecimal.valueOf(item.path("score").asDouble(0D)).setScale(2, RoundingMode.HALF_UP), - item.path("reason").asText("") - )) - .toList(); - } - private List parseDeductions(String json) { if (json == null || json.isBlank()) { return Collections.emptyList(); @@ -425,47 +395,6 @@ private List parseStringList(String json) { } } - private void saveGenerationRecord(AiModelConfig modelConfig, - AiPromptTemplate promptTemplate, - String requestContent, - String resultContent, - boolean success, - String errorMessage) { - AiGenerationRecord record = new AiGenerationRecord(); - record.setId(IdWorker.getId()); - record.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); - record.setTaskType("DOCUMENT_REVIEW"); - record.setModelConfigId(modelConfig.getId()); - record.setPromptTemplateId(promptTemplate.getId()); - record.setRequestContent(requestContent); - record.setResultContent(resultContent); - record.setSuccess(success ? 1 : 0); - record.setErrorMessage(errorMessage); - record.setDeleted(0); - aiGenerationRecordMapper.insert(record); - } - - private void saveTokenUsage(AiModelConfig modelConfig, - Integer promptTokens, - Integer completionTokens, - Integer totalTokens, - String requestId, - boolean success) { - AiTokenUsage usage = new AiTokenUsage(); - usage.setId(IdWorker.getId()); - usage.setUserId(Optional.ofNullable(LoginUserUtils.getLoginUserId()).orElse(1L)); - usage.setUserType("TEACHER"); - usage.setSceneType("DOCUMENT_REVIEW"); - usage.setModelName(modelConfig.getModelName()); - usage.setProviderCode(modelConfig.getProviderCode()); - usage.setPromptTokens(promptTokens); - usage.setCompletionTokens(completionTokens); - usage.setTotalTokens(totalTokens); - usage.setRequestId(requestId); - usage.setSuccess(success ? 1 : 0); - aiTokenUsageMapper.insert(usage); - } - private AiDocumentReviewTask getTask(Long id) { AiDocumentReviewTask task = taskMapper.selectById(id); if (task == null || task.getDeleted() != null && task.getDeleted() == 1) { @@ -474,39 +403,6 @@ private AiDocumentReviewTask getTask(Long id) { return task; } - private AiModelConfig resolveModelConfig(Long requestedId) { - if (requestedId != null) { - AiModelConfig config = aiModelConfigMapper.selectById(requestedId); - if (config == null || config.getDeleted() != null && config.getDeleted() == 1) { - throw new BusinessException(404, "模型配置不存在"); - } - return config; - } - AiModelConfig config = aiModelConfigMapper.selectOne(new LambdaQueryWrapper() - .eq(AiModelConfig::getDeleted, 0) - .eq(AiModelConfig::getEnabled, 1) - .eq(AiModelConfig::getStatus, 1) - .orderByAsc(AiModelConfig::getId) - .last("limit 1")); - if (config == null) { - throw new BusinessException(400, "没有可用的模型配置"); - } - return config; - } - - private AiPromptTemplate resolvePromptTemplate(String requestedCode) { - String templateCode = requestedCode == null || requestedCode.isBlank() ? DEFAULT_TEMPLATE_CODE : requestedCode; - AiPromptTemplate template = aiPromptTemplateMapper.selectOne(new LambdaQueryWrapper() - .eq(AiPromptTemplate::getDeleted, 0) - .eq(AiPromptTemplate::getStatus, 1) - .eq(AiPromptTemplate::getTemplateCode, templateCode) - .last("limit 1")); - if (template == null) { - throw new BusinessException(404, "Prompt 模板不存在: " + templateCode); - } - return template; - } - private BigDecimal normalizeTotalScore(BigDecimal totalScore) { if (totalScore == null || totalScore.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.valueOf(100).setScale(2, RoundingMode.HALF_UP); @@ -558,25 +454,6 @@ private Map loadPromptMap(Set ids) { .collect(Collectors.toMap(AiPromptTemplate::getId, Function.identity())); } - private String stripCodeFence(String content) { - String text = Optional.ofNullable(content).orElse("").trim(); - if (text.startsWith("```")) { - text = text.replaceFirst("^```json", "").replaceFirst("^```", ""); - if (text.endsWith("```")) { - text = text.substring(0, text.length() - 3); - } - } - return text.trim(); - } - - private String trimTrailingSlash(String baseUrl) { - String value = Optional.ofNullable(baseUrl).orElse(""); - if (value.endsWith("/")) { - return value.substring(0, value.length() - 1); - } - return value; - } - private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); @@ -585,6 +462,17 @@ private String toJson(Object value) { } } + private Map buildTokenUsagePayload(TokenUsage tokenUsage) { + if (tokenUsage == null) { + return Map.of(); + } + return Map.of( + "inputTokenCount", tokenUsage.inputTokenCount(), + "outputTokenCount", tokenUsage.outputTokenCount(), + "totalTokenCount", tokenUsage.totalTokenCount() + ); + } + private record ReviewResult(BigDecimal score, List strengths, List issues, @@ -592,4 +480,17 @@ private record ReviewResult(BigDecimal score, String summary, String suggestion) { } + + private record StructuredDocumentReviewPayload(BigDecimal score, + String summary, + List strengths, + List issues, + String suggestion, + List deductions) { + } + + private record StructuredDeductionPayload(String point, + BigDecimal score, + String reason) { + } } diff --git a/src/main/java/com/aiedu/platform/ai/review/document/service/impl/DocumentTextExtractor.java b/src/main/java/com/aiedu/platform/ai/review/document/service/impl/DocumentTextExtractor.java index 40ca673..a069412 100644 --- a/src/main/java/com/aiedu/platform/ai/review/document/service/impl/DocumentTextExtractor.java +++ b/src/main/java/com/aiedu/platform/ai/review/document/service/impl/DocumentTextExtractor.java @@ -8,6 +8,14 @@ import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.hwpf.HWPFDocument; +import org.apache.poi.hwpf.extractor.WordExtractor; import org.apache.poi.xwpf.extractor.XWPFWordExtractor; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.springframework.stereotype.Component; @@ -24,6 +32,7 @@ public class DocumentTextExtractor { private static final int MAX_CONTENT_LENGTH = 12000; private static final Set PLAIN_TEXT_TYPES = Set.of("txt", "md", "markdown", "json", "csv", "log", "java", "xml", "yml", "yaml"); + private static final DataFormatter DATA_FORMATTER = new DataFormatter(); private final FileRecordMapper fileRecordMapper; private final MinioClient minioClient; @@ -46,7 +55,9 @@ public ExtractedDocument extract(Long fileId) { String extension = normalizeExtension(record.getFileExt()); String content = switch (extension) { case "pdf" -> extractPdf(bytes); + case "doc" -> extractDoc(bytes); case "docx" -> extractDocx(bytes); + case "xlsx", "xls" -> extractWorkbook(bytes); default -> { if (PLAIN_TEXT_TYPES.contains(extension)) { yield new String(bytes, StandardCharsets.UTF_8); @@ -81,6 +92,36 @@ private String extractDocx(byte[] bytes) throws Exception { } } + private String extractDoc(byte[] bytes) throws Exception { + try (HWPFDocument document = new HWPFDocument(new ByteArrayInputStream(bytes)); + WordExtractor extractor = new WordExtractor(document)) { + return extractor.getText(); + } + } + + private String extractWorkbook(byte[] bytes) throws Exception { + StringBuilder builder = new StringBuilder(); + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) { + for (Sheet sheet : workbook) { + builder.append("工作表: ").append(sheet.getSheetName()).append('\n'); + for (Row row : sheet) { + StringBuilder rowBuilder = new StringBuilder(); + for (Cell cell : row) { + if (rowBuilder.length() > 0) { + rowBuilder.append('\t'); + } + rowBuilder.append(DATA_FORMATTER.formatCellValue(cell)); + } + if (rowBuilder.length() > 0) { + builder.append(rowBuilder).append('\n'); + } + } + builder.append('\n'); + } + } + return builder.toString(); + } + private String normalizeExtension(String extension) { if (extension == null) { return ""; diff --git a/src/main/java/com/aiedu/platform/ai/review/document/support/DocumentReviewPromptBuilder.java b/src/main/java/com/aiedu/platform/ai/review/document/support/DocumentReviewPromptBuilder.java new file mode 100644 index 0000000..602bf42 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/review/document/support/DocumentReviewPromptBuilder.java @@ -0,0 +1,46 @@ +package com.aiedu.platform.ai.review.document.support; + +import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; +import com.aiedu.platform.ai.review.document.domain.entity.AiDocumentReviewTask; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +public class DocumentReviewPromptBuilder { + + public PromptPayload build(AiPromptTemplate promptTemplate, AiDocumentReviewTask task) { + String systemPrompt = Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是一名严谨的教师阅卷助手。"); + String userPrompt = """ + %s + + 请严格返回 JSON,不要使用 markdown 代码块。 + JSON 字段要求: + score(number) + summary(string) + strengths(string array) + issues(string array) + suggestion(string) + deductions(array),每项字段为 point(string), score(number), reason(string) + + 总分:%s + 评分标准:%s + + 标准答案卷: + %s + + 学生答卷: + %s + """.formatted( + Optional.ofNullable(promptTemplate.getUserPrompt()).orElse("请进行文档批阅。"), + task.getTotalScore(), + Optional.ofNullable(task.getScoringRubric()).orElse("未提供额外评分标准,请根据标准答案卷与学生答卷进行综合判分。"), + task.getStandardContent(), + task.getStudentContent() + ); + return new PromptPayload(systemPrompt, userPrompt); + } + + public record PromptPayload(String systemPrompt, String userPrompt) { + } +} diff --git a/src/main/java/com/aiedu/platform/ai/review/service/impl/AiReviewServiceImpl.java b/src/main/java/com/aiedu/platform/ai/review/service/impl/AiReviewServiceImpl.java index ffb2ffc..217cc42 100644 --- a/src/main/java/com/aiedu/platform/ai/review/service/impl/AiReviewServiceImpl.java +++ b/src/main/java/com/aiedu/platform/ai/review/service/impl/AiReviewServiceImpl.java @@ -1,20 +1,21 @@ package com.aiedu.platform.ai.review.service.impl; +import com.aiedu.platform.ai.core.enums.AiSceneType; +import com.aiedu.platform.ai.core.gateway.AiGatewayStructuredResult; +import com.aiedu.platform.ai.core.gateway.LangChain4jAiGateway; +import com.aiedu.platform.ai.core.service.AiInvocationAuditService; +import com.aiedu.platform.ai.core.service.AiModelResolver; +import com.aiedu.platform.ai.core.service.AiPromptTemplateResolver; import com.aiedu.platform.ai.model.domain.entity.AiModelConfig; import com.aiedu.platform.ai.model.mapper.AiModelConfigMapper; import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; -import com.aiedu.platform.ai.prompt.mapper.AiPromptTemplateMapper; -import com.aiedu.platform.ai.review.domain.entity.AiGenerationRecord; import com.aiedu.platform.ai.review.domain.entity.AiReviewRecord; -import com.aiedu.platform.ai.review.domain.entity.AiTokenUsage; import com.aiedu.platform.ai.review.dto.AiReviewGenerateRequest; -import com.aiedu.platform.ai.review.mapper.AiGenerationRecordMapper; import com.aiedu.platform.ai.review.mapper.AiReviewRecordMapper; -import com.aiedu.platform.ai.review.mapper.AiTokenUsageMapper; import com.aiedu.platform.ai.review.service.AiReviewService; +import com.aiedu.platform.ai.review.support.SubjectiveReviewPromptBuilder; import com.aiedu.platform.ai.review.vo.AiReviewRecordVO; import com.aiedu.platform.common.exception.BusinessException; -import com.aiedu.platform.common.utils.LoginUserUtils; import com.aiedu.platform.education.answer.domain.entity.EduStudentAnswer; import com.aiedu.platform.education.answer.domain.entity.EduWrongQuestion; import com.aiedu.platform.education.answer.mapper.EduStudentAnswerMapper; @@ -31,18 +32,17 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.output.TokenUsage; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestClient; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -55,10 +55,12 @@ public class AiReviewServiceImpl implements AiReviewService { private final AiReviewRecordMapper aiReviewRecordMapper; - private final AiTokenUsageMapper aiTokenUsageMapper; - private final AiGenerationRecordMapper aiGenerationRecordMapper; private final AiModelConfigMapper aiModelConfigMapper; - private final AiPromptTemplateMapper aiPromptTemplateMapper; + private final AiModelResolver aiModelResolver; + private final AiPromptTemplateResolver aiPromptTemplateResolver; + private final LangChain4jAiGateway aiGateway; + private final AiInvocationAuditService aiInvocationAuditService; + private final SubjectiveReviewPromptBuilder promptBuilder; private final EduStudentAnswerMapper eduStudentAnswerMapper; private final EduQuestionMapper eduQuestionMapper; private final EduHomeworkQuestionMapper eduHomeworkQuestionMapper; @@ -67,17 +69,16 @@ public class AiReviewServiceImpl implements AiReviewService { private final EduWrongQuestionMapper eduWrongQuestionMapper; private final ObjectMapper objectMapper; - @Value("${app.ai.api-key:}") - private String apiKey; - @Value("${app.ai.review-template-code:subjective_review_v1}") private String defaultReviewTemplateCode; public AiReviewServiceImpl(AiReviewRecordMapper aiReviewRecordMapper, - AiTokenUsageMapper aiTokenUsageMapper, - AiGenerationRecordMapper aiGenerationRecordMapper, AiModelConfigMapper aiModelConfigMapper, - AiPromptTemplateMapper aiPromptTemplateMapper, + AiModelResolver aiModelResolver, + AiPromptTemplateResolver aiPromptTemplateResolver, + LangChain4jAiGateway aiGateway, + AiInvocationAuditService aiInvocationAuditService, + SubjectiveReviewPromptBuilder promptBuilder, EduStudentAnswerMapper eduStudentAnswerMapper, EduQuestionMapper eduQuestionMapper, EduHomeworkQuestionMapper eduHomeworkQuestionMapper, @@ -86,10 +87,12 @@ public AiReviewServiceImpl(AiReviewRecordMapper aiReviewRecordMapper, EduWrongQuestionMapper eduWrongQuestionMapper, ObjectMapper objectMapper) { this.aiReviewRecordMapper = aiReviewRecordMapper; - this.aiTokenUsageMapper = aiTokenUsageMapper; - this.aiGenerationRecordMapper = aiGenerationRecordMapper; this.aiModelConfigMapper = aiModelConfigMapper; - this.aiPromptTemplateMapper = aiPromptTemplateMapper; + this.aiModelResolver = aiModelResolver; + this.aiPromptTemplateResolver = aiPromptTemplateResolver; + this.aiGateway = aiGateway; + this.aiInvocationAuditService = aiInvocationAuditService; + this.promptBuilder = promptBuilder; this.eduStudentAnswerMapper = eduStudentAnswerMapper; this.eduQuestionMapper = eduQuestionMapper; this.eduHomeworkQuestionMapper = eduHomeworkQuestionMapper; @@ -102,9 +105,6 @@ public AiReviewServiceImpl(AiReviewRecordMapper aiReviewRecordMapper, @Override @Transactional public AiReviewRecordVO generate(Long studentAnswerId, AiReviewGenerateRequest request) { - if (apiKey == null || apiKey.isBlank()) { - throw new BusinessException(400, "未配置 DASHSCOPE_API_KEY,无法进行 AI 批改"); - } EduStudentAnswer studentAnswer = getStudentAnswer(studentAnswerId); EduQuestion question = getQuestion(studentAnswer.getQuestionId()); if (isObjective(question.getQuestionType())) { @@ -113,36 +113,88 @@ public AiReviewRecordVO generate(Long studentAnswerId, AiReviewGenerateRequest r EduHomework homework = getHomework(studentAnswer.getHomeworkId()); EduHomeworkQuestion homeworkQuestion = getHomeworkQuestion(studentAnswer.getHomeworkId(), studentAnswer.getQuestionId()); EduStudent student = getStudent(studentAnswer.getStudentId()); - AiModelConfig modelConfig = resolveModelConfig(request == null ? null : request.modelConfigId()); - AiPromptTemplate promptTemplate = resolvePromptTemplate(request == null ? null : request.promptTemplateCode()); - - QwenResponse qwenResponse = invokeModel(modelConfig, promptTemplate, question, homeworkQuestion, studentAnswer); - ReviewSuggestion suggestion = parseSuggestion(qwenResponse.content(), homeworkQuestion.getScore()); - - AiReviewRecord record = new AiReviewRecord(); - record.setId(IdWorker.getId()); - record.setStudentAnswerId(studentAnswer.getId()); - record.setStudentId(student.getId()); - record.setQuestionId(question.getId()); - record.setModelConfigId(modelConfig.getId()); - record.setScoreSuggested(suggestion.score()); - record.setIsCorrectSuggested(suggestion.isCorrect() == null ? null : suggestion.isCorrect() ? 1 : 0); - record.setMissingPoints(toJson(suggestion.missingPoints())); - record.setFeedback(suggestion.feedback()); - record.setSuggestion(suggestion.suggestion()); - record.setRawResponse(qwenResponse.rawResponse()); - record.setReviewStatus(0); - record.setDeleted(0); - aiReviewRecordMapper.insert(record); - - studentAnswer.setAiFeedback(suggestion.feedback() + (suggestion.suggestion() == null || suggestion.suggestion().isBlank() ? "" : "\n建议:" + suggestion.suggestion())); - studentAnswer.setReviewType("AI"); - studentAnswer.setStatus(0); - eduStudentAnswerMapper.updateById(studentAnswer); - - saveTokenUsage(modelConfig, qwenResponse, true); + AiModelConfig modelConfig = aiModelResolver.resolve(request == null ? null : request.modelConfigId()); + AiPromptTemplate promptTemplate = aiPromptTemplateResolver.resolve( + request == null ? null : request.promptTemplateCode(), + defaultReviewTemplateCode + ); + SubjectiveReviewPromptBuilder.PromptPayload promptPayload = promptBuilder.build(promptTemplate, question, homeworkQuestion, studentAnswer); + String requestJson = toJson(Map.of( + "scene", AiSceneType.SUBJECTIVE_REVIEW.code(), + "model", modelConfig.getModelCode(), + "promptTemplateCode", promptTemplate.getTemplateCode(), + "studentAnswerId", studentAnswerId, + "questionId", question.getId(), + "homeworkId", homework.getId(), + "systemPrompt", promptPayload.systemPrompt(), + "userPrompt", promptPayload.userPrompt() + )); - return toVO(record, Map.of(student.getId(), student), Map.of(question.getId(), question), Map.of(modelConfig.getId(), modelConfig)); + try { + AiGatewayStructuredResult gatewayResult = aiGateway.structuredChat( + modelConfig, + List.of( + SystemMessage.from(promptPayload.systemPrompt()), + UserMessage.from(promptPayload.userPrompt()) + ), + StructuredReviewPayload.class + ); + ReviewSuggestion suggestion = normalizeSuggestion(gatewayResult.content(), homeworkQuestion.getScore()); + + AiReviewRecord record = new AiReviewRecord(); + record.setId(IdWorker.getId()); + record.setStudentAnswerId(studentAnswer.getId()); + record.setStudentId(student.getId()); + record.setQuestionId(question.getId()); + record.setModelConfigId(modelConfig.getId()); + record.setScoreSuggested(suggestion.score()); + record.setIsCorrectSuggested(suggestion.isCorrect() == null ? null : suggestion.isCorrect() ? 1 : 0); + record.setMissingPoints(toJson(suggestion.missingPoints())); + record.setFeedback(suggestion.feedback()); + record.setSuggestion(suggestion.suggestion()); + record.setRawResponse(gatewayResult.rawText()); + record.setReviewStatus(0); + record.setDeleted(0); + aiReviewRecordMapper.insert(record); + + studentAnswer.setAiFeedback(suggestion.feedback() + (suggestion.suggestion() == null || suggestion.suggestion().isBlank() ? "" : "\n建议:" + suggestion.suggestion())); + studentAnswer.setReviewType("AI"); + studentAnswer.setStatus(0); + eduStudentAnswerMapper.updateById(studentAnswer); + + aiInvocationAuditService.saveGenerationRecord( + AiSceneType.SUBJECTIVE_REVIEW, + modelConfig, + promptTemplate, + requestJson, + toJson(Map.of( + "rawText", gatewayResult.rawText(), + "structured", gatewayResult.content(), + "requestId", gatewayResult.requestId(), + "tokenUsage", buildTokenUsagePayload(gatewayResult.tokenUsage()) + )), + true, + null + ); + aiInvocationAuditService.saveTokenUsage( + AiSceneType.SUBJECTIVE_REVIEW, + "TEACHER", + modelConfig, + gatewayResult.tokenUsage(), + gatewayResult.requestId(), + true + ); + return toVO(record, Map.of(student.getId(), student), Map.of(question.getId(), question), Map.of(modelConfig.getId(), modelConfig)); + } catch (BusinessException exception) { + aiInvocationAuditService.saveGenerationRecord(AiSceneType.SUBJECTIVE_REVIEW, modelConfig, promptTemplate, requestJson, null, false, exception.getMessage()); + aiInvocationAuditService.saveTokenUsage(AiSceneType.SUBJECTIVE_REVIEW, "TEACHER", modelConfig, null, null, false); + throw exception; + } catch (Exception exception) { + String errorMessage = "AI 批改调用失败: " + exception.getMessage(); + aiInvocationAuditService.saveGenerationRecord(AiSceneType.SUBJECTIVE_REVIEW, modelConfig, promptTemplate, requestJson, null, false, errorMessage); + aiInvocationAuditService.saveTokenUsage(AiSceneType.SUBJECTIVE_REVIEW, "TEACHER", modelConfig, null, null, false); + throw new BusinessException(500, errorMessage); + } } @Override @@ -200,155 +252,41 @@ public void reject(Long reviewId) { aiReviewRecordMapper.updateById(record); } - private QwenResponse invokeModel(AiModelConfig modelConfig, - AiPromptTemplate promptTemplate, - EduQuestion question, - EduHomeworkQuestion homeworkQuestion, - EduStudentAnswer studentAnswer) { - String systemPrompt = Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是一名严谨的教师助教。"); - String userPrompt = buildUserPrompt(promptTemplate, question, homeworkQuestion, studentAnswer); - Map requestBody = Map.of( - "model", modelConfig.getModelCode(), - "temperature", Optional.ofNullable(modelConfig.getTemperature()).orElse(BigDecimal.valueOf(0.2)), - "max_tokens", Optional.ofNullable(modelConfig.getMaxTokens()).orElse(2048), - "response_format", Map.of("type", "json_object"), - "messages", List.of( - Map.of("role", "system", "content", systemPrompt), - Map.of("role", "user", "content", userPrompt) - ) - ); - String requestJson = toJson(requestBody); - - RestClient client = RestClient.builder() - .baseUrl(trimTrailingSlash(modelConfig.getBaseUrl())) - .build(); - String responseText; - try { - responseText = client.post() - .uri("/chat/completions") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + apiKey) - .body(requestBody) - .retrieve() - .body(String.class); - } catch (Exception exception) { - saveTokenUsage(modelConfig, new QwenResponse(null, null, null, null, null, null, null), false); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, null, false, exception.getMessage()); - throw new BusinessException(500, "AI 批改调用失败: " + exception.getMessage()); - } - - try { - JsonNode root = objectMapper.readTree(responseText); - String content = root.path("choices").path(0).path("message").path("content").asText(""); - Integer promptTokens = root.path("usage").path("prompt_tokens").isMissingNode() ? null : root.path("usage").path("prompt_tokens").asInt(); - Integer completionTokens = root.path("usage").path("completion_tokens").isMissingNode() ? null : root.path("usage").path("completion_tokens").asInt(); - Integer totalTokens = root.path("usage").path("total_tokens").isMissingNode() ? null : root.path("usage").path("total_tokens").asInt(); - String requestId = root.path("id").asText(null); - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, true, null); - return new QwenResponse(content, responseText, promptTokens, completionTokens, totalTokens, requestId, modelConfig.getModelName()); - } catch (JsonProcessingException exception) { - saveGenerationRecord(modelConfig, promptTemplate, requestJson, responseText, false, "AI 批改响应解析失败: " + exception.getMessage()); - throw new BusinessException(500, "AI 批改响应解析失败: " + exception.getMessage()); + private ReviewSuggestion normalizeSuggestion(StructuredReviewPayload payload, BigDecimal maxScore) { + BigDecimal score = Optional.ofNullable(payload.score()).orElse(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); + if (score.compareTo(BigDecimal.ZERO) < 0) { + score = BigDecimal.ZERO; } - } - - private String buildUserPrompt(AiPromptTemplate promptTemplate, - EduQuestion question, - EduHomeworkQuestion homeworkQuestion, - EduStudentAnswer studentAnswer) { - StringBuilder builder = new StringBuilder(); - builder.append(Optional.ofNullable(promptTemplate.getUserPrompt()).orElse("请进行结构化评分。")).append("\n\n"); - builder.append("请严格返回 JSON,不要使用 markdown 代码块。\n"); - builder.append("JSON 字段要求:score(number), isCorrect(boolean), missingPoints(string array), feedback(string), suggestion(string)\n\n"); - builder.append("题目类型:").append(question.getQuestionType()).append("\n"); - builder.append("题干:").append(question.getTitle()).append("\n"); - builder.append("标准答案:").append(question.getAnswer()).append("\n"); - builder.append("答案解析:").append(Optional.ofNullable(question.getAnalysis()).orElse("")).append("\n"); - builder.append("满分:").append(Optional.ofNullable(homeworkQuestion.getScore()).orElse(BigDecimal.ZERO)).append("\n"); - builder.append("学生答案:").append(Optional.ofNullable(studentAnswer.getAnswerContent()).orElse("")).append("\n"); - builder.append("评分要求:分数必须在 0 到满分之间,缺失点尽量简洁。"); - return builder.toString(); - } - - private ReviewSuggestion parseSuggestion(String content, BigDecimal maxScore) { - String normalized = stripCodeFence(content); - try { - JsonNode root = objectMapper.readTree(normalized); - BigDecimal score = root.path("score").isMissingNode() - ? BigDecimal.ZERO - : BigDecimal.valueOf(root.path("score").asDouble()).setScale(2, RoundingMode.HALF_UP); - if (score.compareTo(BigDecimal.ZERO) < 0) { - score = BigDecimal.ZERO; - } - if (maxScore != null && score.compareTo(maxScore) > 0) { - score = maxScore; - } - Boolean isCorrect = root.path("isCorrect").isMissingNode() ? null : root.path("isCorrect").asBoolean(); - List missingPoints = new ArrayList<>(); - if (root.path("missingPoints").isArray()) { - root.path("missingPoints").forEach(node -> missingPoints.add(node.asText())); - } - return new ReviewSuggestion( - score, - isCorrect, - missingPoints, - root.path("feedback").asText(""), - root.path("suggestion").asText("") - ); - } catch (Exception exception) { - throw new BusinessException(500, "AI 批改结果不是合法 JSON: " + exception.getMessage()); + if (maxScore != null && score.compareTo(maxScore) > 0) { + score = maxScore; } - } - - private void saveTokenUsage(AiModelConfig modelConfig, QwenResponse response, boolean success) { - AiTokenUsage usage = new AiTokenUsage(); - usage.setId(IdWorker.getId()); - usage.setUserId(LoginUserUtils.getLoginUserId()); - usage.setUserType("TEACHER"); - usage.setSceneType("SUBJECTIVE_REVIEW"); - usage.setModelName(modelConfig.getModelName()); - usage.setProviderCode(modelConfig.getProviderCode()); - usage.setPromptTokens(response.promptTokens()); - usage.setCompletionTokens(response.completionTokens()); - usage.setTotalTokens(response.totalTokens()); - usage.setRequestId(response.requestId()); - usage.setSuccess(success ? 1 : 0); - aiTokenUsageMapper.insert(usage); - } - - private void saveGenerationRecord(AiModelConfig modelConfig, - AiPromptTemplate promptTemplate, - String requestContent, - String resultContent, - boolean success, - String errorMessage) { - AiGenerationRecord record = new AiGenerationRecord(); - record.setId(IdWorker.getId()); - record.setUserId(LoginUserUtils.getLoginUserId()); - record.setTaskType("SUBJECTIVE_REVIEW"); - record.setModelConfigId(modelConfig.getId()); - record.setPromptTemplateId(promptTemplate.getId()); - record.setRequestContent(requestContent); - record.setResultContent(resultContent); - record.setSuccess(success ? 1 : 0); - record.setErrorMessage(errorMessage); - record.setDeleted(0); - aiGenerationRecordMapper.insert(record); + return new ReviewSuggestion( + score, + payload.isCorrect(), + Optional.ofNullable(payload.missingPoints()).orElse(List.of()).stream() + .filter(item -> item != null && !item.isBlank()) + .toList(), + Optional.ofNullable(payload.feedback()).orElse(""), + Optional.ofNullable(payload.suggestion()).orElse("") + ); } private AiReviewRecordVO toVO(AiReviewRecord record, Map studentMap, Map questionMap, Map modelMap) { + EduStudent student = studentMap.get(record.getStudentId()); + EduQuestion question = questionMap.get(record.getQuestionId()); + AiModelConfig modelConfig = modelMap.get(record.getModelConfigId()); return new AiReviewRecordVO( record.getId(), record.getStudentAnswerId(), record.getStudentId(), - Optional.ofNullable(studentMap.get(record.getStudentId())).map(EduStudent::getStudentName).orElse(null), + student == null ? null : student.getStudentName(), record.getQuestionId(), - Optional.ofNullable(questionMap.get(record.getQuestionId())).map(EduQuestion::getTitle).orElse(null), + question == null ? null : question.getTitle(), record.getModelConfigId(), - Optional.ofNullable(modelMap.get(record.getModelConfigId())).map(AiModelConfig::getModelName).orElse(null), + modelConfig == null ? null : modelConfig.getModelName(), record.getScoreSuggested(), record.getIsCorrectSuggested(), parseMissingPoints(record.getMissingPoints()), @@ -359,70 +297,70 @@ private AiReviewRecordVO toVO(AiReviewRecord record, ); } - private List parseMissingPoints(String missingPoints) { - if (missingPoints == null || missingPoints.isBlank()) { + private List parseMissingPoints(String json) { + if (json == null || json.isBlank()) { return Collections.emptyList(); } try { - return objectMapper.readValue(missingPoints, new TypeReference>() {}); + return objectMapper.readValue(json, new TypeReference>() {}); } catch (JsonProcessingException exception) { - return List.of(missingPoints); + return List.of(json); } } - private String toJson(Object value) { - try { - return objectMapper.writeValueAsString(value); - } catch (JsonProcessingException exception) { - throw new BusinessException(500, "JSON 序列化失败: " + exception.getMessage()); - } - } - - private String stripCodeFence(String content) { - String text = Optional.ofNullable(content).orElse("").trim(); - if (text.startsWith("```")) { - text = text.replaceFirst("^```json", "").replaceFirst("^```", ""); - if (text.endsWith("```")) { - text = text.substring(0, text.length() - 3); - } - } - return text.trim(); - } - - private String trimTrailingSlash(String baseUrl) { - String value = Optional.ofNullable(baseUrl).orElse(""); - if (value.endsWith("/")) { - return value.substring(0, value.length() - 1); + private boolean isObjective(String questionType) { + if (questionType == null) { + return false; } - return value; + return Set.of("SINGLE", "MULTIPLE", "JUDGE", "FILL_BLANK").contains(questionType.toUpperCase()); } - private AiReviewRecord getReviewRecord(Long id) { - AiReviewRecord record = aiReviewRecordMapper.selectById(id); - if (record == null || record.getDeleted() != null && record.getDeleted() == 1) { - throw new BusinessException(404, "AI 批改记录不存在"); + private void upsertWrongQuestion(Long studentId, EduHomework homework, EduQuestion question, String suggestion) { + EduWrongQuestion wrongQuestion = eduWrongQuestionMapper.selectOne(new LambdaQueryWrapper() + .eq(EduWrongQuestion::getDeleted, 0) + .eq(EduWrongQuestion::getStudentId, studentId) + .eq(EduWrongQuestion::getQuestionId, question.getId()) + .last("limit 1")); + if (wrongQuestion == null) { + wrongQuestion = new EduWrongQuestion(); + wrongQuestion.setId(IdWorker.getId()); + wrongQuestion.setStudentId(studentId); + wrongQuestion.setCourseId(homework == null ? null : homework.getCourseId()); + wrongQuestion.setQuestionId(question.getId()); + wrongQuestion.setHomeworkId(homework == null ? null : homework.getId()); + wrongQuestion.setWrongReason("主观题 AI 建议判定为需复习"); + wrongQuestion.setAiSuggestion(suggestion); + wrongQuestion.setWrongCount(1); + wrongQuestion.setLastWrongTime(LocalDateTime.now()); + wrongQuestion.setReviewStatus(0); + wrongQuestion.setDeleted(0); + eduWrongQuestionMapper.insert(wrongQuestion); + return; } - return record; + wrongQuestion.setWrongCount(Optional.ofNullable(wrongQuestion.getWrongCount()).orElse(0) + 1); + wrongQuestion.setLastWrongTime(LocalDateTime.now()); + wrongQuestion.setAiSuggestion(suggestion); + eduWrongQuestionMapper.updateById(wrongQuestion); } - private EduStudentAnswer getStudentAnswer(Long id) { - EduStudentAnswer answer = eduStudentAnswerMapper.selectById(id); - if (answer == null || answer.getDeleted() != null && answer.getDeleted() == 1) { + private EduStudentAnswer getStudentAnswer(Long studentAnswerId) { + EduStudentAnswer studentAnswer = eduStudentAnswerMapper.selectById(studentAnswerId); + if (studentAnswer == null || studentAnswer.getDeleted() != null && studentAnswer.getDeleted() == 1) { throw new BusinessException(404, "学生答题记录不存在"); } - return answer; + return studentAnswer; } - private EduQuestion getQuestion(Long id) { - EduQuestion question = eduQuestionMapper.selectById(id); + private EduQuestion getQuestion(Long questionId) { + EduQuestion question = eduQuestionMapper.selectById(questionId); if (question == null || question.getDeleted() != null && question.getDeleted() == 1) { throw new BusinessException(404, "题目不存在"); } return question; } - private EduHomework getHomework(Long id) { - EduHomework homework = eduHomeworkMapper.selectById(id); + private EduHomework getHomework(Long homeworkId) { + EduHomework homework = eduHomeworkMapper.selectById(homeworkId); if (homework == null || homework.getDeleted() != null && homework.getDeleted() == 1) { throw new BusinessException(404, "作业不存在"); } @@ -430,89 +368,31 @@ private EduHomework getHomework(Long id) { } private EduHomeworkQuestion getHomeworkQuestion(Long homeworkId, Long questionId) { - EduHomeworkQuestion relation = eduHomeworkQuestionMapper.selectOne(new LambdaQueryWrapper() + EduHomeworkQuestion homeworkQuestion = eduHomeworkQuestionMapper.selectOne(new LambdaQueryWrapper() + .eq(EduHomeworkQuestion::getDeleted, 0) .eq(EduHomeworkQuestion::getHomeworkId, homeworkId) .eq(EduHomeworkQuestion::getQuestionId, questionId) - .eq(EduHomeworkQuestion::getDeleted, 0) .last("limit 1")); - if (relation == null) { - throw new BusinessException(404, "作业题目关系不存在"); + if (homeworkQuestion == null) { + throw new BusinessException(404, "作业题目配置不存在"); } - return relation; + return homeworkQuestion; } - private EduStudent getStudent(Long id) { - EduStudent student = eduStudentMapper.selectById(id); + private EduStudent getStudent(Long studentId) { + EduStudent student = eduStudentMapper.selectById(studentId); if (student == null || student.getDeleted() != null && student.getDeleted() == 1) { throw new BusinessException(404, "学生不存在"); } return student; } - private AiModelConfig resolveModelConfig(Long requestedId) { - if (requestedId != null) { - AiModelConfig config = aiModelConfigMapper.selectById(requestedId); - if (config == null || config.getDeleted() != null && config.getDeleted() == 1) { - throw new BusinessException(404, "模型配置不存在"); - } - return config; - } - AiModelConfig config = aiModelConfigMapper.selectOne(new LambdaQueryWrapper() - .eq(AiModelConfig::getDeleted, 0) - .eq(AiModelConfig::getEnabled, 1) - .eq(AiModelConfig::getStatus, 1) - .orderByAsc(AiModelConfig::getId) - .last("limit 1")); - if (config == null) { - throw new BusinessException(400, "没有可用的模型配置"); - } - return config; - } - - private AiPromptTemplate resolvePromptTemplate(String requestedCode) { - String templateCode = requestedCode == null || requestedCode.isBlank() ? defaultReviewTemplateCode : requestedCode; - AiPromptTemplate template = aiPromptTemplateMapper.selectOne(new LambdaQueryWrapper() - .eq(AiPromptTemplate::getDeleted, 0) - .eq(AiPromptTemplate::getStatus, 1) - .eq(AiPromptTemplate::getTemplateCode, templateCode) - .last("limit 1")); - if (template == null) { - throw new BusinessException(404, "Prompt 模板不存在: " + templateCode); - } - return template; - } - - private boolean isObjective(String questionType) { - return Set.of("SINGLE", "MULTIPLE", "JUDGE", "FILL").contains(Optional.ofNullable(questionType).orElse("").trim().toUpperCase()); - } - - private void upsertWrongQuestion(Long studentId, EduHomework homework, EduQuestion question, String aiSuggestion) { - EduWrongQuestion wrongQuestion = eduWrongQuestionMapper.selectOne(new LambdaQueryWrapper() - .eq(EduWrongQuestion::getStudentId, studentId) - .eq(EduWrongQuestion::getQuestionId, question.getId()) - .eq(EduWrongQuestion::getHomeworkId, homework.getId()) - .eq(EduWrongQuestion::getDeleted, 0) - .last("limit 1")); - if (wrongQuestion == null) { - wrongQuestion = new EduWrongQuestion(); - wrongQuestion.setId(IdWorker.getId()); - wrongQuestion.setStudentId(studentId); - wrongQuestion.setCourseId(homework.getCourseId()); - wrongQuestion.setQuestionId(question.getId()); - wrongQuestion.setHomeworkId(homework.getId()); - wrongQuestion.setWrongReason("主观题 AI 批改建议未完全正确"); - wrongQuestion.setAiSuggestion(aiSuggestion); - wrongQuestion.setReviewStatus(0); - wrongQuestion.setWrongCount(1); - wrongQuestion.setLastWrongTime(LocalDateTime.now()); - wrongQuestion.setDeleted(0); - eduWrongQuestionMapper.insert(wrongQuestion); - return; + private AiReviewRecord getReviewRecord(Long reviewId) { + AiReviewRecord record = aiReviewRecordMapper.selectById(reviewId); + if (record == null || record.getDeleted() != null && record.getDeleted() == 1) { + throw new BusinessException(404, "AI 批改记录不存在"); } - wrongQuestion.setWrongCount(Optional.ofNullable(wrongQuestion.getWrongCount()).orElse(0) + 1); - wrongQuestion.setAiSuggestion(aiSuggestion); - wrongQuestion.setLastWrongTime(LocalDateTime.now()); - eduWrongQuestionMapper.updateById(wrongQuestion); + return record; } private Map loadStudentMap(Set ids) { @@ -542,15 +422,36 @@ private Map loadModelMap(Set ids) { .collect(Collectors.toMap(AiModelConfig::getId, Function.identity())); } - private record ReviewSuggestion(BigDecimal score, Boolean isCorrect, List missingPoints, String feedback, String suggestion) { + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new BusinessException(500, "JSON 序列化失败: " + exception.getMessage()); + } + } + + private Map buildTokenUsagePayload(TokenUsage tokenUsage) { + if (tokenUsage == null) { + return Map.of(); + } + return Map.of( + "inputTokenCount", tokenUsage.inputTokenCount(), + "outputTokenCount", tokenUsage.outputTokenCount(), + "totalTokenCount", tokenUsage.totalTokenCount() + ); + } + + private record ReviewSuggestion(BigDecimal score, + Boolean isCorrect, + List missingPoints, + String feedback, + String suggestion) { } - private record QwenResponse(String content, - String rawResponse, - Integer promptTokens, - Integer completionTokens, - Integer totalTokens, - String requestId, - String modelName) { + private record StructuredReviewPayload(BigDecimal score, + Boolean isCorrect, + List missingPoints, + String feedback, + String suggestion) { } } diff --git a/src/main/java/com/aiedu/platform/ai/review/support/SubjectiveReviewPromptBuilder.java b/src/main/java/com/aiedu/platform/ai/review/support/SubjectiveReviewPromptBuilder.java new file mode 100644 index 0000000..35c7016 --- /dev/null +++ b/src/main/java/com/aiedu/platform/ai/review/support/SubjectiveReviewPromptBuilder.java @@ -0,0 +1,37 @@ +package com.aiedu.platform.ai.review.support; + +import com.aiedu.platform.ai.prompt.domain.entity.AiPromptTemplate; +import com.aiedu.platform.education.answer.domain.entity.EduStudentAnswer; +import com.aiedu.platform.education.homework.domain.entity.EduHomeworkQuestion; +import com.aiedu.platform.education.question.domain.entity.EduQuestion; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Optional; + +@Component +public class SubjectiveReviewPromptBuilder { + + public PromptPayload build(AiPromptTemplate promptTemplate, + EduQuestion question, + EduHomeworkQuestion homeworkQuestion, + EduStudentAnswer studentAnswer) { + String systemPrompt = Optional.ofNullable(promptTemplate.getSystemPrompt()).orElse("你是一名严谨的教师助教。"); + String userPrompt = new StringBuilder() + .append(Optional.ofNullable(promptTemplate.getUserPrompt()).orElse("请进行结构化评分。")).append("\n\n") + .append("请严格返回 JSON,不要使用 markdown 代码块。\n") + .append("JSON 字段要求:score(number), isCorrect(boolean), missingPoints(string array), feedback(string), suggestion(string)\n\n") + .append("题目类型:").append(question.getQuestionType()).append("\n") + .append("题干:").append(question.getTitle()).append("\n") + .append("标准答案:").append(question.getAnswer()).append("\n") + .append("答案解析:").append(Optional.ofNullable(question.getAnalysis()).orElse("")).append("\n") + .append("满分:").append(Optional.ofNullable(homeworkQuestion.getScore()).orElse(BigDecimal.ZERO)).append("\n") + .append("学生答案:").append(Optional.ofNullable(studentAnswer.getAnswerContent()).orElse("")).append("\n") + .append("评分要求:分数必须在 0 到满分之间,缺失点尽量简洁。") + .toString(); + return new PromptPayload(systemPrompt, userPrompt); + } + + public record PromptPayload(String systemPrompt, String userPrompt) { + } +} diff --git a/src/main/java/com/aiedu/platform/config/SaTokenConfig.java b/src/main/java/com/aiedu/platform/config/SaTokenConfig.java index ed24e79..287e820 100644 --- a/src/main/java/com/aiedu/platform/config/SaTokenConfig.java +++ b/src/main/java/com/aiedu/platform/config/SaTokenConfig.java @@ -50,7 +50,7 @@ public class SaTokenConfig implements WebMvcConfigurer { Map.entry("/api/edu/wrong-questions/**", "edu:wrong-question:list"), Map.entry("/api/ai/models/**", "ai:model:list"), Map.entry("/api/ai/prompt-templates/**", "ai:prompt:list"), - Map.entry("/api/ai/chat", "ai:chat:use"), + Map.entry("/api/ai/chat/**", "ai:chat:use"), Map.entry("/api/ai/reviews/**", "ai:review:list"), Map.entry("/api/ai/document-reviews/**", "ai:document-review:list"), Map.entry("/api/ai/generation-records/**", "ai:review:list"), diff --git a/src/main/java/com/aiedu/platform/monitor/health/controller/MonitorController.java b/src/main/java/com/aiedu/platform/monitor/health/controller/MonitorController.java index a00d11a..0822b98 100644 --- a/src/main/java/com/aiedu/platform/monitor/health/controller/MonitorController.java +++ b/src/main/java/com/aiedu/platform/monitor/health/controller/MonitorController.java @@ -1,11 +1,17 @@ package com.aiedu.platform.monitor.health.controller; import com.aiedu.platform.common.result.ApiResponse; +import com.sun.management.OperatingSystemMXBean; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.lang.management.ManagementFactory; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.Map; @RestController @@ -20,5 +26,61 @@ public ApiResponse> ping() { "status", "UP" )); } -} + @GetMapping("/runtime-metrics") + public ApiResponse> runtimeMetrics() { + OperatingSystemMXBean osBean = ManagementFactory.getPlatformMXBean(OperatingSystemMXBean.class); + Runtime runtime = Runtime.getRuntime(); + + double cpuLoad = osBean == null ? -1D : osBean.getCpuLoad(); + long totalMemory = osBean == null ? runtime.maxMemory() : osBean.getTotalMemorySize(); + long freeMemory = osBean == null ? runtime.freeMemory() : osBean.getFreeMemorySize(); + long usedMemory = Math.max(totalMemory - freeMemory, 0L); + + Path workspace = Path.of(System.getProperty("user.dir")); + long totalDisk = 0L; + long usableDisk = 0L; + try { + FileStore fileStore = Files.getFileStore(workspace); + totalDisk = fileStore.getTotalSpace(); + usableDisk = fileStore.getUsableSpace(); + } catch (Exception ignored) { + totalDisk = 0L; + usableDisk = 0L; + } + + int cpuUsage = toPercentage(cpuLoad); + int memoryUsage = toPercentage(totalMemory <= 0 ? -1D : (double) usedMemory / totalMemory); + int diskUsage = toPercentage(totalDisk <= 0 ? -1D : (double) (totalDisk - usableDisk) / totalDisk); + + Map metrics = new LinkedHashMap<>(); + metrics.put("service", "ai-edu-platform"); + metrics.put("time", LocalDateTime.now().toString()); + metrics.put("status", "UP"); + metrics.put("cpuUsage", cpuUsage); + metrics.put("memoryUsage", memoryUsage); + metrics.put("diskUsage", diskUsage); + metrics.put("cpuCoreCount", runtime.availableProcessors()); + metrics.put("heapUsedMb", bytesToMb(usedMemory)); + metrics.put("heapTotalMb", bytesToMb(totalMemory)); + metrics.put("diskUsedGb", bytesToGb(Math.max(totalDisk - usableDisk, 0L))); + metrics.put("diskTotalGb", bytesToGb(totalDisk)); + metrics.put("uptimeSeconds", ManagementFactory.getRuntimeMXBean().getUptime() / 1000); + return ApiResponse.success(metrics); + } + + private int toPercentage(double ratio) { + if (Double.isNaN(ratio) || ratio < 0) { + return 0; + } + return (int) Math.max(0, Math.min(100, Math.round(ratio * 100))); + } + + private long bytesToMb(long bytes) { + return bytes <= 0 ? 0L : Math.round(bytes / 1024D / 1024D); + } + + private long bytesToGb(long bytes) { + return bytes <= 0 ? 0L : Math.round(bytes / 1024D / 1024D / 1024D); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c1bc10f..bd6b3ed 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -35,6 +35,7 @@ app: allow-mock-login: false ai: api-key: ${DASHSCOPE_API_KEY:sk-b6ddf1d28b814fa4ab7dccf50e73be8a} + vision-model-code: ${AI_VISION_MODEL_CODE:qwen3-vl-plus} springdoc: api-docs: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 205f589..4ea2771 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -30,6 +30,9 @@ minio: bucket: ${MINIO_BUCKET:ai-edu-platform} app: + ai: + api-key: sk-b6ddf1d28b814fa4ab7dccf50e73be8a + vision-model-code: qwen3-vl-plus security: allow-mock-login: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 006eb80..7e0c3d6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,4 +42,5 @@ logging: app: ai: api-key: ${DASHSCOPE_API_KEY:} + vision-model-code: ${AI_VISION_MODEL_CODE:qwen3-vl-plus} review-template-code: ${AI_REVIEW_TEMPLATE_CODE:subjective_review_v1} diff --git a/src/main/resources/db/data-h2.sql b/src/main/resources/db/data-h2.sql index c9bc1e0..ef7d3d2 100644 --- a/src/main/resources/db/data-h2.sql +++ b/src/main/resources/db/data-h2.sql @@ -99,6 +99,7 @@ VALUES INSERT INTO ai_model_config (id, provider_code, provider_name, model_code, model_name, base_url, api_key_masked, temperature, max_tokens, enabled, remark, status, deleted, create_by, update_by, create_time, update_time) VALUES (1000011, 'aliyun', '阿里云百炼', 'qwen-plus', '通义千问 Plus', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-****be8a', 0.70, 4096, 1, '教师端 AI 批改默认模型', 1, 0, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + (1000013, 'aliyun', '阿里云百炼', 'qwen3-vl-plus', '通义千问视觉 Plus', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-****be8a', 0.20, 4096, 1, '图片理解与多模态对话默认模型', 1, 0, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), (1000012, 'deepseek', 'DeepSeek', 'deepseek-chat', 'DeepSeek Chat', 'https://api.deepseek.com', NULL, 0.60, 4096, 0, '备用模型配置', 1, 0, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); INSERT INTO ai_prompt_template (id, template_code, template_name, task_type, system_prompt, user_prompt, status, deleted, remark, create_by, update_by, create_time, update_time) diff --git a/src/main/resources/db/data-mysql.sql b/src/main/resources/db/data-mysql.sql index 392f1ee..70f94b8 100644 --- a/src/main/resources/db/data-mysql.sql +++ b/src/main/resources/db/data-mysql.sql @@ -109,6 +109,7 @@ ON DUPLICATE KEY UPDATE course_name = VALUES(course_name); INSERT INTO ai_model_config (id, provider_code, provider_name, model_code, model_name, base_url, api_key_masked, temperature, max_tokens, enabled, remark, status, deleted, create_by, update_by) VALUES (1000011, 'aliyun', '阿里云百炼', 'qwen-plus', '通义千问 Plus', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-****be8a', 0.70, 4096, 1, '教师端 AI 批改默认模型', 1, 0, 1, 1), + (1000013, 'aliyun', '阿里云百炼', 'qwen3-vl-plus', '通义千问视觉 Plus', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-****be8a', 0.20, 4096, 1, '图片理解与多模态对话默认模型', 1, 0, 1, 1), (1000012, 'deepseek', 'DeepSeek', 'deepseek-chat', 'DeepSeek Chat', 'https://api.deepseek.com', NULL, 0.60, 4096, 0, '备用模型配置', 1, 0, 1, 1) ON DUPLICATE KEY UPDATE model_name = VALUES(model_name); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c558fc0..6ca0ebc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -51,6 +51,8 @@ import { OssComponent } from "./pages/file/Oss"; import { ResourceComponent } from "./pages/file/Resource"; import { LoginPage } from "./pages/Login"; import { Forbidden } from "./pages/Forbidden"; +import { NotificationCenter } from "./pages/Notifications"; +import { SearchCenter } from "./pages/SearchCenter"; function protectedPage(path: keyof typeof routePermissionByPath, element: React.ReactElement) { return {element}; @@ -99,6 +101,8 @@ export default function App() { )} /> )} /> )} /> + } /> + } /> )} /> )} /> diff --git a/ui/src/components/dashboard/KPICards.tsx b/ui/src/components/dashboard/KPICards.tsx index f8c32e1..d1ee2d5 100644 --- a/ui/src/components/dashboard/KPICards.tsx +++ b/ui/src/components/dashboard/KPICards.tsx @@ -22,9 +22,9 @@ export function KPICards({ items }: { items: DashboardKpiCard[] }) { key={item.title} type="button" onClick={item.onClick} - className="glass-card p-5 relative overflow-hidden flex flex-col group text-left hover:-translate-y-0.5 hover:shadow-md transition-all" + className="glass-card min-h-[148px] p-5 relative overflow-hidden flex flex-col group text-left hover:-translate-y-0.5 hover:shadow-md transition-all" > -
+
@@ -38,7 +38,7 @@ export function KPICards({ items }: { items: DashboardKpiCard[] }) {
-
+
({ value, pointIndex }))}> diff --git a/ui/src/components/dashboard/QuickActions.tsx b/ui/src/components/dashboard/QuickActions.tsx index c87e9eb..345d1e3 100644 --- a/ui/src/components/dashboard/QuickActions.tsx +++ b/ui/src/components/dashboard/QuickActions.tsx @@ -1,6 +1,10 @@ +import { Check, PencilLine, X } from "lucide-react"; +import { useMemo, useState } from "react"; import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; export interface QuickActionItem { + code: string; icon: LucideIcon; label: string; color: string; @@ -8,10 +12,56 @@ export interface QuickActionItem { onClick: () => void; } -export function QuickActions({ actions }: { actions: QuickActionItem[] }) { +interface QuickActionsProps { + actions: QuickActionItem[]; + availableActions: QuickActionItem[]; + selectedCodes: string[]; + onSave: (codes: string[]) => void; +} + +export function QuickActions({ actions, availableActions, selectedCodes, onSave }: QuickActionsProps) { + const [editing, setEditing] = useState(false); + const [draftCodes, setDraftCodes] = useState(selectedCodes); + + const orderedCandidates = useMemo(() => { + const selectedSet = new Set(draftCodes); + const selected = draftCodes + .map((code) => availableActions.find((item) => item.code === code)) + .filter((item): item is QuickActionItem => Boolean(item)); + const unselected = availableActions.filter((item) => !selectedSet.has(item.code)); + return [...selected, ...unselected]; + }, [availableActions, draftCodes]); + + const toggleDraftAction = (code: string) => { + setDraftCodes((previous) => { + if (previous.includes(code)) { + return previous.filter((item) => item !== code); + } + if (previous.length >= 5) { + return previous; + } + return [...previous, code]; + }); + }; + + const openEditor = () => { + setDraftCodes(selectedCodes); + setEditing(true); + }; + return (
-

快捷操作

+
+

快捷操作

+ +
{actions.map((action) => ( @@ -28,6 +78,105 @@ export function QuickActions({ actions }: { actions: QuickActionItem[] }) { ))}
+ + {editing ? ( +
+
+
+
+

编辑快捷操作

+

最多选择 5 个入口,保存后会替换当前仪表盘快捷操作。

+
+ +
+ +
+
+ {draftCodes.length ? ( + draftCodes.map((code) => { + const action = availableActions.find((item) => item.code === code); + if (!action) return null; + return ( + + {action.label} + + ); + }) + ) : ( + 当前未选择快捷操作。 + )} +
+ +
+ {orderedCandidates.map((action) => { + const checked = draftCodes.includes(action.code); + return ( + + ); + })} +
+
+ +
+
已选择 {draftCodes.length} / 5
+
+ + +
+
+
+
+ ) : null}
); } diff --git a/ui/src/components/dashboard/SystemHealth.tsx b/ui/src/components/dashboard/SystemHealth.tsx index 08fb8bf..17a23db 100644 --- a/ui/src/components/dashboard/SystemHealth.tsx +++ b/ui/src/components/dashboard/SystemHealth.tsx @@ -69,9 +69,9 @@ export function SystemHealth({ health }: { health: SystemHealthData }) {
- - - + + +
diff --git a/ui/src/components/layout/AppLayout.tsx b/ui/src/components/layout/AppLayout.tsx index a2372b7..deeaf4d 100644 --- a/ui/src/components/layout/AppLayout.tsx +++ b/ui/src/components/layout/AppLayout.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from "../../stores/auth-store"; export function AppLayout() { const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); const [isThemeDark, setIsThemeDark] = useState(false); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => localStorage.getItem("nebula.sidebar.collapsed") === "true"); const navigate = useNavigate(); const logout = useAuthStore((state) => state.logout); @@ -39,10 +40,26 @@ export function AppLayout() { navigate("/login", { replace: true }); }; + const handleToggleSidebar = () => { + setIsSidebarCollapsed((previous) => { + const next = !previous; + localStorage.setItem("nebula.sidebar.collapsed", String(next)); + return next; + }); + }; + return (
-
- +
+ setIsSidebarCollapsed(false)} + />
([]); + const [notificationLoading, setNotificationLoading] = useState(false); + const [readNotificationIds, setReadNotificationIds] = useState(() => { + try { + const raw = localStorage.getItem(NOTIFICATION_READ_STORAGE_KEY); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } + }); const [profileForm, setProfileForm] = useState({ nickname: "", phone: "", @@ -35,9 +54,64 @@ export function Header({ const updateProfile = useAuthStore((state) => state.updateProfile); const profileSubmitting = useAuthStore((state) => state.profileSubmitting); const navigate = useNavigate(); + const location = useLocation(); const notifRef = useRef(null); const profileRef = useRef(null); + const searchRef = useRef(null); + + const authorizedMenus = useMemo( + () => filterMenuByPermissions(appMenuTree, profile?.roles, permissions), + [permissions, profile?.roles], + ); + const searchableMenus = useMemo(() => flattenLeafMenus(authorizedMenus), [authorizedMenus]); + const searchSuggestions = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase(); + if (!keyword) { + return []; + } + + return searchableMenus + .filter((item) => + [item.name, item.path, item.code] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(keyword)), + ) + .slice(0, 6); + }, [searchKeyword, searchableMenus]); + const canReadLoginLogs = hasPermission(profile?.roles, permissions, "system:login-log:list"); + const canReadOperationLogs = hasPermission(profile?.roles, permissions, "system:operation-log:list"); + const unreadNotificationCount = useMemo( + () => notifications.filter((item) => !readNotificationIds.includes(item.id)).length, + [notifications, readNotificationIds], + ); + const displayName = profile?.nickname || profile?.username || "管理员"; + const avatarText = displayName.slice(0, 1).toUpperCase(); + + const persistReadNotificationIds = (nextIds: string[]) => { + setReadNotificationIds(nextIds); + localStorage.setItem(NOTIFICATION_READ_STORAGE_KEY, JSON.stringify(nextIds)); + }; + + const loadNotifications = async () => { + if (!canReadLoginLogs && !canReadOperationLogs) { + setNotifications([]); + return; + } + + setNotificationLoading(true); + try { + const [loginResult, operationResult] = await Promise.allSettled([ + canReadLoginLogs ? systemService.listLoginLogs() : Promise.resolve([]), + canReadOperationLogs ? systemService.listOperationLogs() : Promise.resolve([]), + ]); + const loginLogs = loginResult.status === "fulfilled" ? loginResult.value : []; + const operationLogs = operationResult.status === "fulfilled" ? operationResult.value : []; + setNotifications(buildWorkbenchNotifications(loginLogs, operationLogs).slice(0, 12)); + } finally { + setNotificationLoading(false); + } + }; useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -47,6 +121,9 @@ export function Header({ if (profileRef.current && !profileRef.current.contains(event.target as Node)) { setIsProfileOpen(false); } + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsSearchFocused(false); + } } document.addEventListener("mousedown", handleClickOutside); return () => { @@ -54,25 +131,17 @@ export function Header({ }; }, []); - const toggleFullscreen = () => { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen().catch(err => { - console.log(`Error attempting to enable full-screen mode: ${err.message}`); - }); - } else { - if (document.exitFullscreen) { - document.exitFullscreen(); - } - } - }; + useEffect(() => { + loadNotifications(); + }, [canReadLoginLogs, canReadOperationLogs]); - const mockNotifs = [ - { id: 1, title: "系统升级通知", desc: "Nebula Admin Pro 已经升级至 2.0 版本,快来体验新功能吧!", time: "10 分钟前", read: false }, - { id: 2, title: "备份完成", desc: "昨晚系统自动备份已完成,全量数据安全。", time: "3 小时前", read: true }, - { id: 3, title: "新用户注册", desc: "有 3 位新用户注册了系统,请求进行审核权限。", time: "昨天", read: true }, - ]; - const displayName = profile?.nickname || profile?.username || "管理员"; - const avatarText = displayName.slice(0, 1).toUpperCase(); + useEffect(() => { + if (!location.pathname.startsWith("/search")) { + return; + } + const search = new URLSearchParams(location.search); + setSearchKeyword(search.get("q") ?? ""); + }, [location.pathname, location.search]); useEffect(() => { if (!isProfileDetailOpen || !profile) { @@ -90,6 +159,19 @@ export function Header({ setProfileError(""); }, [isProfileDetailOpen, profile]); + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch((error) => { + console.log(`Error attempting to enable full-screen mode: ${error.message}`); + }); + return; + } + + if (document.exitFullscreen) { + document.exitFullscreen(); + } + }; + const closeProfileMenu = () => setIsProfileOpen(false); const handleOpenProfileDetail = () => { @@ -102,23 +184,70 @@ export function Header({ navigate("/dashboard"); }; + const handleSearchSubmit = (event?: FormEvent) => { + event?.preventDefault(); + const keyword = searchKeyword.trim(); + navigate(keyword ? `/search?q=${encodeURIComponent(keyword)}` : "/search"); + }; + + const handleOpenNotification = (item: WorkbenchNotification) => { + if (!readNotificationIds.includes(item.id)) { + persistReadNotificationIds([...readNotificationIds, item.id]); + } + setIsNotifOpen(false); + navigate(item.path); + }; + const handleProfileSubmit = async (event: FormEvent) => { event.preventDefault(); setProfileMessage(""); setProfileError(""); - if (!profileForm.nickname.trim()) { + const nickname = profileForm.nickname.trim(); + const phone = profileForm.phone.trim(); + const email = profileForm.email.trim(); + const currentPassword = profileForm.currentPassword.trim(); + const newPassword = profileForm.newPassword.trim(); + const isChangingPassword = Boolean(currentPassword || newPassword); + + if (!nickname) { setProfileError("昵称不能为空。"); return; } + if (!isValidOptionalPhone(phone)) { + setProfileError("手机号必须为 11 位数字。"); + return; + } + + if (!isValidOptionalEmail(email)) { + setProfileError("邮箱格式不正确,请按 name@example.com 格式填写。"); + return; + } + + if (isChangingPassword && (!currentPassword || !newPassword)) { + setProfileError("修改密码时,当前密码和新密码必须同时填写。"); + return; + } + + if ( + isChangingPassword && + (currentPassword.length < 6 || + currentPassword.length > 64 || + newPassword.length < 6 || + newPassword.length > 64) + ) { + setProfileError("密码长度必须为 6 到 64 个字符。"); + return; + } + try { await updateProfile({ - nickname: profileForm.nickname.trim(), - phone: profileForm.phone.trim() || undefined, - email: profileForm.email.trim() || undefined, - currentPassword: profileForm.currentPassword || undefined, - newPassword: profileForm.newPassword || undefined, + nickname, + phone: phone || undefined, + email: email || undefined, + currentPassword: currentPassword || undefined, + newPassword: newPassword || undefined, }); setProfileForm((previous) => ({ ...previous, @@ -127,175 +256,244 @@ export function Header({ })); setProfileMessage("个人信息已保存。"); } catch (error) { - setProfileError((error as AppRequestError).message || "保存失败,请稍后重试。"); + const message = (error as AppRequestError).message || ""; + setProfileError( + message.includes("Validation failed") + ? "请检查表单内容,密码长度需为 6 到 64 个字符。" + : message || "保存失败,请稍后重试。", + ); } }; return ( -
+
-

+

欢迎回来,{displayName} 👋

-

{today}

+

{today}

- {/* Search */} -
- - + + setSearchKeyword(event.target.value)} + onFocus={() => setIsSearchFocused(true)} + placeholder="搜索功能、菜单、数据..." + className="h-11 w-[300px] rounded-[22px] border border-slate-200/80 bg-white/72 pl-10 pr-14 text-[13px] text-slate-700 shadow-[0_10px_28px_rgba(91,115,153,0.08)] outline-none backdrop-blur-md transition-all placeholder:text-slate-400 focus:border-blue-300 focus:bg-white focus:ring-4 focus:ring-blue-100/60 focus:shadow-[0_14px_34px_rgba(59,130,246,0.13)]" /> -
+
-
+ - {/* Actions */} -
+ {isSearchFocused && searchSuggestions.length ? ( +
+
+ 匹配到 {searchSuggestions.length} 个菜单,回车可查看完整搜索结果 +
+
+ {searchSuggestions.map((item) => ( + + ))} +
+
+ ) : null} + + +
- - {/* Notifications Dropdown */} +
- - - {isNotifOpen && ( -
-
- 消息通知 - -
-
- {mockNotifs.map(notif => ( -
- {!notif.read &&
} -
-
- {notif.title} - {notif.time} + + {isNotifOpen ? ( +
+
+ 消息通知 + +
+
+ {notificationLoading ? ( +
+ + 正在加载通知... +
+ ) : notifications.length ? ( + notifications.slice(0, 6).map((item) => { + const read = readNotificationIds.includes(item.id); + return ( +
- ))} -
-
- -
+ + ); + }) + ) : ( +
暂无可用通知
+ )} +
+
+ +
- )} + ) : null}
- + - {/* Profile Dropdown */} -
-
setIsProfileOpen(!isProfileOpen)} - > -
+
+
setIsProfileOpen((previous) => !previous)}> +
{avatarText}
- {displayName} + {displayName} {profile?.username || "未登录"}
- +
- {isProfileOpen && ( -
-
-

{displayName}

-

{profile?.username || "--"}

-
- -
- - -
- -
- -
+ {isProfileOpen ? ( +
+
+

{displayName}

+

{profile?.username || "--"}

+
+ +
+ + +
+ +
+ +
- )} + ) : null}
-
{isProfileDetailOpen ? ( -
-
-
+
+
+

个人信息

-

当前登录账号资料来自后端认证接口。

+

当前登录账号资料来自后端认证接口。

-
+
-
+
{avatarText}
{displayName}
-
{profile?.username || "--"}
+
{profile?.username || "--"}
@@ -305,6 +503,7 @@ export function Header({ setProfileForm((previous) => ({ ...previous, nickname: event.target.value }))} + required className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-[13px] text-slate-800 outline-none transition-all focus:border-blue-300 focus:ring-4 focus:ring-blue-100/60" placeholder="请输入昵称" /> @@ -314,6 +513,9 @@ export function Header({ setProfileForm((previous) => ({ ...previous, phone: event.target.value }))} + inputMode="numeric" + maxLength={11} + pattern="\d{11}" className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-[13px] text-slate-800 outline-none transition-all focus:border-blue-300 focus:ring-4 focus:ring-blue-100/60" placeholder="请输入手机号" /> @@ -321,10 +523,11 @@ export function Header({
@@ -340,7 +543,11 @@ export function Header({ setProfileForm((previous) => ({ ...previous, currentPassword: event.target.value }))} + onChange={(event) => + setProfileForm((previous) => ({ ...previous, currentPassword: event.target.value })) + } + minLength={6} + maxLength={64} className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-[13px] text-slate-800 outline-none transition-all focus:border-blue-300 focus:ring-4 focus:ring-blue-100/60" placeholder="当前密码" autoComplete="current-password" @@ -352,6 +559,8 @@ export function Header({ type="password" value={profileForm.newPassword} onChange={(event) => setProfileForm((previous) => ({ ...previous, newPassword: event.target.value }))} + minLength={6} + maxLength={64} className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-[13px] text-slate-800 outline-none transition-all focus:border-blue-300 focus:ring-4 focus:ring-blue-100/60" placeholder="至少 6 位" autoComplete="new-password" @@ -366,8 +575,16 @@ export function Header({
- {profileError ?
{profileError}
: null} - {profileMessage ?
{profileMessage}
: null} + {profileError ? ( +
+ {profileError} +
+ ) : null} + {profileMessage ? ( +
+ {profileMessage} +
+ ) : null}
) : ( - {exactActive ? : null} - - {item.name} + {exactActive ? ( + + ) : null} + + {!collapsed || level > 0 ? {item.name} : null} )} - {hasChildren && isExpanded ?
{renderNavItems(item.children!, level + 1)}
: null} + {hasChildren && isExpanded && !collapsed ? ( +
{renderNavItems(item.children!, level + 1)}
+ ) : null}
); }); return ( -
-
- Nebula logo -
- Nebula - Admin Console +
+
+ + +
+ Nebula logo + {!collapsed ? ( +
+ Nebula + Admin Console +
+ ) : null}
-
+
{renderNavItems(menuItems)}
-
-
- + + ) : ( + + )}
); diff --git a/ui/src/components/system/RequiredLabel.tsx b/ui/src/components/system/RequiredLabel.tsx new file mode 100644 index 0000000..df836be --- /dev/null +++ b/ui/src/components/system/RequiredLabel.tsx @@ -0,0 +1,12 @@ +interface RequiredLabelProps { + children: string; +} + +export function RequiredLabel({ children }: RequiredLabelProps) { + return ( + + {children} + * + + ); +} diff --git a/ui/src/components/ui/SideDrawer.tsx b/ui/src/components/ui/SideDrawer.tsx new file mode 100644 index 0000000..7a34b20 --- /dev/null +++ b/ui/src/components/ui/SideDrawer.tsx @@ -0,0 +1,61 @@ +import { ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface SideDrawerProps { + open: boolean; + title: string; + description?: string; + onClose: () => void; + children: ReactNode; + widthClassName?: string; +} + +export function SideDrawer({ + open, + title, + description, + onClose, + children, + widthClassName = "w-full max-w-[620px]", +}: SideDrawerProps) { + return ( +
+
+ +
+ ); +} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 91f0979..7d3f037 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -9,7 +9,8 @@ function isAuthFailure(code?: number, message?: string) { return true; } - return /token|登录|login|认证|会话|禁用/i.test(message ?? ""); + const normalizedMessage = (message ?? "").trim(); + return /未登录|请先登录|登录已过期|登录失效|认证失败|无效凭证|token已过期|token失效|token无效/i.test(normalizedMessage); } function buildError(code: number, message: string) { diff --git a/ui/src/lib/form-validation.ts b/ui/src/lib/form-validation.ts new file mode 100644 index 0000000..d7b8161 --- /dev/null +++ b/ui/src/lib/form-validation.ts @@ -0,0 +1,10 @@ +export const phonePattern = /^\d{11}$/; +export const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isValidOptionalPhone(value?: string) { + return !value || phonePattern.test(value); +} + +export function isValidOptionalEmail(value?: string) { + return !value || emailPattern.test(value); +} diff --git a/ui/src/lib/workbench.ts b/ui/src/lib/workbench.ts new file mode 100644 index 0000000..f714ff5 --- /dev/null +++ b/ui/src/lib/workbench.ts @@ -0,0 +1,97 @@ +import type { LoginLogRecord, OperationLogRecord } from "../types/system"; +import type { SidebarMenuItem } from "../types/auth"; + +export interface WorkbenchNotification { + id: string; + title: string; + desc: string; + timeText: string; + timestamp: number; + level: "success" | "info" | "alert"; + source: "login" | "operation"; + path: string; +} + +export function parseWorkbenchDate(value?: string | null) { + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function formatRelativeTime(date: Date) { + const diffMs = Date.now() - date.getTime(); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + if (diffMs < hour) { + return `${Math.max(1, Math.floor(diffMs / minute))} 分钟前`; + } + if (diffMs < day) { + return `${Math.max(1, Math.floor(diffMs / hour))} 小时前`; + } + if (diffMs < day * 2) { + return "昨天"; + } + return `${Math.max(2, Math.floor(diffMs / day))} 天前`; +} + +export function buildWorkbenchNotifications(loginLogs: LoginLogRecord[], operationLogs: OperationLogRecord[]) { + const loginItems: WorkbenchNotification[] = loginLogs + .map((item) => { + const date = parseWorkbenchDate(item.createTime); + if (!date) { + return null; + } + + return { + id: `login-${item.id}`, + title: item.loginStatus === 1 ? "用户登录成功" : "用户登录失败", + desc: `${item.username || "--"} / ${item.loginIp || "--"} / ${item.message || "--"}`, + timeText: formatRelativeTime(date), + timestamp: date.getTime(), + level: item.loginStatus === 1 ? "info" : "alert", + source: "login", + path: "/sys/login-log", + } satisfies WorkbenchNotification; + }) + .filter(Boolean) as WorkbenchNotification[]; + + const operationItems: WorkbenchNotification[] = operationLogs + .map((item) => { + const date = parseWorkbenchDate(item.createTime); + if (!date) { + return null; + } + + const moduleName = item.moduleName || "系统模块"; + const method = item.requestMethod || ""; + const uri = item.requestUri || ""; + const detail = item.operationStatus === 1 ? `${method} ${uri}`.trim() : item.errorMessage || `${method} ${uri}`.trim() || "发生异常"; + + return { + id: `operation-${item.id}`, + title: item.operationStatus === 1 ? `${moduleName} 操作成功` : `${moduleName} 操作异常`, + desc: `${item.operatorName || "未知用户"} / ${detail}`, + timeText: formatRelativeTime(date), + timestamp: date.getTime(), + level: item.operationStatus === 1 ? "success" : "alert", + source: "operation", + path: "/sys/operation-log", + } satisfies WorkbenchNotification; + }) + .filter(Boolean) as WorkbenchNotification[]; + + return [...operationItems, ...loginItems].sort((left, right) => right.timestamp - left.timestamp); +} + +export function flattenLeafMenus(items: SidebarMenuItem[]): SidebarMenuItem[] { + return items.flatMap((item) => { + if (item.children?.length) { + return flattenLeafMenus(item.children); + } + return item.path ? [item] : []; + }); +} diff --git a/ui/src/pages/Config.tsx b/ui/src/pages/Config.tsx index f42acf5..9fee437 100644 --- a/ui/src/pages/Config.tsx +++ b/ui/src/pages/Config.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from "../components/system/ConfirmDialog"; import { EmptyState } from "../components/system/EmptyState"; import { FeedbackBanner } from "../components/system/FeedbackBanner"; import { LoadingState } from "../components/system/LoadingState"; +import { RequiredLabel } from "../components/system/RequiredLabel"; import { StatusPill } from "../components/system/StatusPill"; import { systemService } from "../services/system"; import type { AppRequestError } from "../types/api"; @@ -36,6 +37,7 @@ export function Config() { const [keyword, setKeyword] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(null); + const [modalFeedback, setModalFeedback] = useState<{ type: "success" | "error"; message: string } | null>(null); const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null); const [editingConfig, setEditingConfig] = useState(null); const [formState, setFormState] = useState(initialFormState); @@ -82,6 +84,7 @@ export function Config() { const openCreateModal = () => { setEditingConfig(null); setFormState(initialFormState); + setModalFeedback(null); setModalMode("create"); }; @@ -95,11 +98,13 @@ export function Config() { status: String(config.status ?? 1), remark: config.remark || "", }); + setModalFeedback(null); setModalMode("edit"); }; const closeModal = () => { setModalMode(null); + setModalFeedback(null); setEditingConfig(null); setFormState(initialFormState); }; @@ -108,6 +113,7 @@ export function Config() { event.preventDefault(); setSubmitting(true); setFeedback(null); + setModalFeedback(null); const payload: SystemConfigPayload = { configName: formState.configName.trim(), @@ -119,7 +125,7 @@ export function Config() { }; if (!payload.configName || !payload.configKey || !payload.configValue) { - setFeedback({ type: "error", message: "请填写参数名称、键名和键值。" }); + setModalFeedback({ type: "error", message: "请填写参数名称、键名和键值。" }); setSubmitting(false); return; } @@ -135,7 +141,7 @@ export function Config() { closeModal(); await loadData(false); } catch (error) { - setFeedback({ + setModalFeedback({ type: "error", message: (error as AppRequestError).message || "保存参数失败。", }); @@ -258,26 +264,31 @@ export function Config() {
- + + {modalFeedback ? ( +
+ +
+ ) : null}