From e56a2e7873f847b633fb2d2c34ee6c9f09dc66c6 Mon Sep 17 00:00:00 2001 From: huangpeng <1764183241@qq.com> Date: Mon, 8 Sep 2025 14:23:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/compiler.xml | 19 + .idea/encodings.xml | 6 + .idea/jarRepositories.xml | 30 + .idea/misc.xml | 12 + HELP.md | 27 + ai-interview-ard.vue | 135 ++++ mvnw | 259 +++++++ mvnw.cmd | 149 ++++ pom.xml | 188 +++++ sql/.idea/.gitignore | 6 + .../interview/AiInterviewApplication.java | 15 + .../qingqiu/interview/ai/entity/Message.java | 22 + .../interview/ai/factory/AIClientFactory.java | 7 + .../interview/ai/factory/AIClientManager.java | 25 + .../ai/factory/DeepSeekClientFactory.java | 14 + .../ai/factory/QwenClientFactory.java | 14 + .../interview/ai/service/AIClientService.java | 13 + .../impl/DeepSeekClientServiceImpl.java | 65 ++ .../service/impl/QwenClientServiceImpl.java | 60 ++ .../common/constants/AIStrategyConstant.java | 10 + .../common/constants/QwenModelConstant.java | 12 + .../interview/common/ex/ApiException.java | 48 ++ .../common/ex/GlobalErrorHandler.java | 101 +++ .../interview/common/res/IErrorCode.java | 11 + .../com/qingqiu/interview/common/res/R.java | 131 ++++ .../interview/common/res/ResultCode.java | 51 ++ .../interview/common/service/HttpService.java | 15 + .../common/service/impl/HttpServiceImpl.java | 37 + .../interview/common/utils/AIUtils.java | 26 + .../utils/SpringApplicationContextUtil.java | 40 ++ .../interview/config/DashScopeConfig.java | 14 + .../interview/config/JacksonConfig.java | 31 + .../interview/config/MyBatisPlusConfig.java | 39 ++ .../interview/config/WebClientConfig.java | 50 ++ .../controller/AiSessionLogController.java | 20 + .../controller/DashboardController.java | 28 + .../controller/InterviewController.java | 58 ++ .../InterviewQuestionProgressController.java | 38 + .../controller/QuestionController.java | 79 +++ .../qingqiu/interview/dto/ApiResponse.java | 30 + .../qingqiu/interview/dto/ChatRequest.java | 17 + .../interview/dto/DashboardStatsResponse.java | 77 +++ .../dto/InterviewReportResponse.java | 42 ++ .../interview/dto/InterviewResponse.java | 18 + .../interview/dto/InterviewStartRequest.java | 15 + .../qingqiu/interview/dto/PageBaseParams.java | 20 + .../interview/dto/QuestionPageParams.java | 14 + .../dto/QuestionProgressPageParams.java | 12 + .../interview/dto/SessionHistoryResponse.java | 31 + .../qingqiu/interview/dto/SessionRequest.java | 9 + .../interview/entity/AiSessionLog.java | 58 ++ .../interview/entity/InterviewEvaluation.java | 43 ++ .../interview/entity/InterviewMessage.java | 50 ++ .../entity/InterviewQuestionProgress.java | 115 +++ .../interview/entity/InterviewSession.java | 71 ++ .../qingqiu/interview/entity/Question.java | 41 ++ .../interview/mapper/AiSessionLogMapper.java | 19 + .../mapper/InterviewEvaluationMapper.java | 16 + .../mapper/InterviewMessageMapper.java | 18 + .../InterviewQuestionProgressMapper.java | 16 + .../mapper/InterviewSessionMapper.java | 20 + .../interview/mapper/QuestionMapper.java | 23 + .../interview/service/DashboardService.java | 59 ++ .../service/IAiSessionLogService.java | 16 + .../IInterviewQuestionProgressService.java | 18 + .../interview/service/InterviewService.java | 654 ++++++++++++++++++ .../QuestionClassificationService.java | 138 ++++ .../interview/service/QuestionService.java | 177 +++++ .../service/impl/AiSessionLogServiceImpl.java | 20 + .../InterviewQuestionProgressServiceImpl.java | 43 ++ .../interview/service/llm/LlmService.java | 23 + .../service/llm/qwen/QwenService.java | 181 +++++ .../service/parser/DocumentParser.java | 19 + .../service/parser/MarkdownParserService.java | 32 + .../service/parser/PdfParserService.java | 57 ++ src/main/resources/application.yml | 27 + src/main/resources/mapper/AIClientService.xml | 30 + .../resources/mapper/AiSessionLogMapper.xml | 5 + .../mapper/InterviewEvaluationMapper.xml | 17 + .../mapper/InterviewMessageMapper.xml | 23 + .../InterviewQuestionProgressMapper.xml | 5 + src/main/resources/mapper/QuestionMapper.xml | 50 ++ src/main/resources/sql/schema.sql | 57 ++ .../AiInterviewApplicationTests.java | 13 + target/classes/application.yml | 27 + target/classes/mapper/AIClientService.xml | 30 + target/classes/mapper/AiSessionLogMapper.xml | 5 + .../mapper/InterviewEvaluationMapper.xml | 17 + .../classes/mapper/InterviewMessageMapper.xml | 23 + .../InterviewQuestionProgressMapper.xml | 5 + target/classes/mapper/QuestionMapper.xml | 50 ++ target/classes/sql/schema.sql | 57 ++ target/maven-archiver/pom.properties | 3 + .../compile/default-compile/createdFiles.lst | 55 ++ .../compile/default-compile/inputFiles.lst | 44 ++ .../default-testCompile/createdFiles.lst | 1 + .../default-testCompile/inputFiles.lst | 1 + 98 files changed, 4670 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/compiler.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 HELP.md create mode 100644 ai-interview-ard.vue create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 sql/.idea/.gitignore create mode 100644 src/main/java/com/qingqiu/interview/AiInterviewApplication.java create mode 100644 src/main/java/com/qingqiu/interview/ai/entity/Message.java create mode 100644 src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java create mode 100644 src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java create mode 100644 src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java create mode 100644 src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java create mode 100644 src/main/java/com/qingqiu/interview/ai/service/AIClientService.java create mode 100644 src/main/java/com/qingqiu/interview/ai/service/impl/DeepSeekClientServiceImpl.java create mode 100644 src/main/java/com/qingqiu/interview/ai/service/impl/QwenClientServiceImpl.java create mode 100644 src/main/java/com/qingqiu/interview/common/constants/AIStrategyConstant.java create mode 100644 src/main/java/com/qingqiu/interview/common/constants/QwenModelConstant.java create mode 100644 src/main/java/com/qingqiu/interview/common/ex/ApiException.java create mode 100644 src/main/java/com/qingqiu/interview/common/ex/GlobalErrorHandler.java create mode 100644 src/main/java/com/qingqiu/interview/common/res/IErrorCode.java create mode 100644 src/main/java/com/qingqiu/interview/common/res/R.java create mode 100644 src/main/java/com/qingqiu/interview/common/res/ResultCode.java create mode 100644 src/main/java/com/qingqiu/interview/common/service/HttpService.java create mode 100644 src/main/java/com/qingqiu/interview/common/service/impl/HttpServiceImpl.java create mode 100644 src/main/java/com/qingqiu/interview/common/utils/AIUtils.java create mode 100644 src/main/java/com/qingqiu/interview/common/utils/SpringApplicationContextUtil.java create mode 100644 src/main/java/com/qingqiu/interview/config/DashScopeConfig.java create mode 100644 src/main/java/com/qingqiu/interview/config/JacksonConfig.java create mode 100644 src/main/java/com/qingqiu/interview/config/MyBatisPlusConfig.java create mode 100644 src/main/java/com/qingqiu/interview/config/WebClientConfig.java create mode 100644 src/main/java/com/qingqiu/interview/controller/AiSessionLogController.java create mode 100644 src/main/java/com/qingqiu/interview/controller/DashboardController.java create mode 100644 src/main/java/com/qingqiu/interview/controller/InterviewController.java create mode 100644 src/main/java/com/qingqiu/interview/controller/InterviewQuestionProgressController.java create mode 100644 src/main/java/com/qingqiu/interview/controller/QuestionController.java create mode 100644 src/main/java/com/qingqiu/interview/dto/ApiResponse.java create mode 100644 src/main/java/com/qingqiu/interview/dto/ChatRequest.java create mode 100644 src/main/java/com/qingqiu/interview/dto/DashboardStatsResponse.java create mode 100644 src/main/java/com/qingqiu/interview/dto/InterviewReportResponse.java create mode 100644 src/main/java/com/qingqiu/interview/dto/InterviewResponse.java create mode 100644 src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java create mode 100644 src/main/java/com/qingqiu/interview/dto/PageBaseParams.java create mode 100644 src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java create mode 100644 src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java create mode 100644 src/main/java/com/qingqiu/interview/dto/SessionHistoryResponse.java create mode 100644 src/main/java/com/qingqiu/interview/dto/SessionRequest.java create mode 100644 src/main/java/com/qingqiu/interview/entity/AiSessionLog.java create mode 100644 src/main/java/com/qingqiu/interview/entity/InterviewEvaluation.java create mode 100644 src/main/java/com/qingqiu/interview/entity/InterviewMessage.java create mode 100644 src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java create mode 100644 src/main/java/com/qingqiu/interview/entity/InterviewSession.java create mode 100644 src/main/java/com/qingqiu/interview/entity/Question.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/AiSessionLogMapper.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/InterviewEvaluationMapper.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/InterviewMessageMapper.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/InterviewQuestionProgressMapper.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/InterviewSessionMapper.java create mode 100644 src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java create mode 100644 src/main/java/com/qingqiu/interview/service/DashboardService.java create mode 100644 src/main/java/com/qingqiu/interview/service/IAiSessionLogService.java create mode 100644 src/main/java/com/qingqiu/interview/service/IInterviewQuestionProgressService.java create mode 100644 src/main/java/com/qingqiu/interview/service/InterviewService.java create mode 100644 src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java create mode 100644 src/main/java/com/qingqiu/interview/service/QuestionService.java create mode 100644 src/main/java/com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.java create mode 100644 src/main/java/com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.java create mode 100644 src/main/java/com/qingqiu/interview/service/llm/LlmService.java create mode 100644 src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java create mode 100644 src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java create mode 100644 src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java create mode 100644 src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/mapper/AIClientService.xml create mode 100644 src/main/resources/mapper/AiSessionLogMapper.xml create mode 100644 src/main/resources/mapper/InterviewEvaluationMapper.xml create mode 100644 src/main/resources/mapper/InterviewMessageMapper.xml create mode 100644 src/main/resources/mapper/InterviewQuestionProgressMapper.xml create mode 100644 src/main/resources/mapper/QuestionMapper.xml create mode 100644 src/main/resources/sql/schema.sql create mode 100644 src/test/java/com/qingqiu/interview/AiInterviewApplicationTests.java create mode 100644 target/classes/application.yml create mode 100644 target/classes/mapper/AIClientService.xml create mode 100644 target/classes/mapper/AiSessionLogMapper.xml create mode 100644 target/classes/mapper/InterviewEvaluationMapper.xml create mode 100644 target/classes/mapper/InterviewMessageMapper.xml create mode 100644 target/classes/mapper/InterviewQuestionProgressMapper.xml create mode 100644 target/classes/mapper/QuestionMapper.xml create mode 100644 target/classes/sql/schema.sql create mode 100644 target/maven-archiver/pom.properties create mode 100644 target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..a7fecba --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..63e9001 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..501f9e4 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..67e1e61 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..7b2f1cb --- /dev/null +++ b/HELP.md @@ -0,0 +1,27 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/maven-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/maven-plugin/build-image.html) +* [Spring Data JDBC](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/reference/data/sql.html#data.sql.jdbc) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.10-SNAPSHOT/reference/web/servlet.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Using Spring Data JDBC](https://github.com/spring-projects/spring-data-examples/tree/master/jdbc/basics) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Maven Parent overrides + +Due to Maven's design, elements are inherited from the parent POM to the project POM. +While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. +To prevent this, the project POM contains empty overrides for these elements. +If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. + diff --git a/ai-interview-ard.vue b/ai-interview-ard.vue new file mode 100644 index 0000000..7aa1e81 --- /dev/null +++ b/ai-interview-ard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..19529dd --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8a39d30 --- /dev/null +++ b/pom.xml @@ -0,0 +1,188 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.10-SNAPSHOT + + + com.qingqiu + AI-Interview + 0.0.1-SNAPSHOT + AI-Interview + AI-Interview + + + + + + + + + + + + + + + 17 + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baomidou + mybatis-plus-jsqlparser + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + + + + + + + + + + + + + + + + + com.alibaba + dashscope-sdk-java + 2.21.5 + + + + cn.hutool + hutool-all + 5.8.25 + + + + org.apache.commons + commons-lang3 + 3.18.0 + + + + com.alibaba.fastjson2 + fastjson2 + 2.0.53 + + + + org.apache.pdfbox + pdfbox + 3.0.2 + + + + + com.atlassian.commonmark + commonmark + 0.17.0 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + 1.18.30 + provided + + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + + + + + + + com.baomidou + mybatis-plus-bom + 3.5.9 + pom + import + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + diff --git a/sql/.idea/.gitignore b/sql/.idea/.gitignore new file mode 100644 index 0000000..8bf4d45 --- /dev/null +++ b/sql/.idea/.gitignore @@ -0,0 +1,6 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/main/java/com/qingqiu/interview/AiInterviewApplication.java b/src/main/java/com/qingqiu/interview/AiInterviewApplication.java new file mode 100644 index 0000000..0736f04 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/AiInterviewApplication.java @@ -0,0 +1,15 @@ +package com.qingqiu.interview; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +@ComponentScan("com.qingqiu") +@SpringBootApplication +public class AiInterviewApplication { + + public static void main(String[] args) { + SpringApplication.run(AiInterviewApplication.class, args); + } + +} diff --git a/src/main/java/com/qingqiu/interview/ai/entity/Message.java b/src/main/java/com/qingqiu/interview/ai/entity/Message.java new file mode 100644 index 0000000..0370f3c --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/entity/Message.java @@ -0,0 +1,22 @@ +package com.qingqiu.interview.ai.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@Accessors(chain = true) +@AllArgsConstructor +@NoArgsConstructor +public class Message implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private String role; + + private String content; +} diff --git a/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java new file mode 100644 index 0000000..8c18ad0 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/factory/AIClientFactory.java @@ -0,0 +1,7 @@ +package com.qingqiu.interview.ai.factory; + +import com.qingqiu.interview.ai.service.AIClientService; + +public interface AIClientFactory { + AIClientService createAIClient(); +} diff --git a/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java b/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java new file mode 100644 index 0000000..470265b --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/factory/AIClientManager.java @@ -0,0 +1,25 @@ +package com.qingqiu.interview.ai.factory; + +import com.qingqiu.interview.ai.service.AIClientService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class AIClientManager { + + private final Map factories; + + public AIClientManager(Map factories) { + this.factories = factories; + } + + public AIClientService getClient(String aiType) { + String factoryName = aiType + "ClientFactory"; + AIClientFactory factory = factories.get(factoryName); + if (factory == null) { + throw new IllegalArgumentException("不支持的AI type: " + aiType); + } + return factory.createAIClient(); + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java new file mode 100644 index 0000000..406c118 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/factory/DeepSeekClientFactory.java @@ -0,0 +1,14 @@ +package com.qingqiu.interview.ai.factory; + +import com.qingqiu.interview.ai.service.AIClientService; +import com.qingqiu.interview.ai.service.impl.DeepSeekClientServiceImpl; +import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; +import org.springframework.stereotype.Service; + +@Service +public class DeepSeekClientFactory implements AIClientFactory{ + @Override + public AIClientService createAIClient() { + return SpringApplicationContextUtil.getBean(DeepSeekClientServiceImpl.class); + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java b/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java new file mode 100644 index 0000000..66633bd --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/factory/QwenClientFactory.java @@ -0,0 +1,14 @@ +package com.qingqiu.interview.ai.factory; + +import com.qingqiu.interview.ai.service.AIClientService; +import com.qingqiu.interview.ai.service.impl.QwenClientServiceImpl; +import com.qingqiu.interview.common.utils.SpringApplicationContextUtil; +import org.springframework.stereotype.Service; + +@Service +public class QwenClientFactory implements AIClientFactory{ + @Override + public AIClientService createAIClient() { + return SpringApplicationContextUtil.getBean(QwenClientServiceImpl.class); + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/service/AIClientService.java b/src/main/java/com/qingqiu/interview/ai/service/AIClientService.java new file mode 100644 index 0000000..da699a0 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/service/AIClientService.java @@ -0,0 +1,13 @@ +package com.qingqiu.interview.ai.service; + +import com.alibaba.dashscope.common.Message; + +import java.util.List; + +public abstract class AIClientService { + public abstract String chatCompletion(String prompt); + + public String chatCompletion(List messages) { + return null; + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/service/impl/DeepSeekClientServiceImpl.java b/src/main/java/com/qingqiu/interview/ai/service/impl/DeepSeekClientServiceImpl.java new file mode 100644 index 0000000..2e19ce8 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/service/impl/DeepSeekClientServiceImpl.java @@ -0,0 +1,65 @@ +package com.qingqiu.interview.ai.service.impl; + +import com.alibaba.dashscope.common.Message; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.qingqiu.interview.ai.service.AIClientService; +import com.qingqiu.interview.common.service.HttpService; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage; + +/** + * deepseek 接入 + */ +@Service +@RequiredArgsConstructor +public class DeepSeekClientServiceImpl extends AIClientService { + + private final HttpService httpService; + + @Value("${deepseek.api-url}") + private String apiUrl; + + @Value("${deepseek.api-key}") + private String apiKey; + + + @Override + public String chatCompletion(String prompt) { + return chatCompletion(Collections.singletonList(createUserMessage(prompt))); + } + + @Override + public String chatCompletion(List messages) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("type", "json_object"); + Map requestBody = Map.of( + "model", "deepseek-chat", + "messages", messages, + "max_tokens", 8192, + "response_format", Map.of("type", "json_object") + ); + String res = httpService.postWithAuth( + apiUrl, + requestBody, + String.class, + "Bearer " + apiKey + ).block(); + if (StringUtils.isNotBlank(res)) { + JSONObject jsonRes = JSONObject.parse(res); + JSONArray choices = jsonRes.getJSONArray("choices"); + JSONObject resContent = choices.getJSONObject(0); + JSONObject message = resContent.getJSONObject("message"); + return message.getString("content"); + } + return null; + } +} diff --git a/src/main/java/com/qingqiu/interview/ai/service/impl/QwenClientServiceImpl.java b/src/main/java/com/qingqiu/interview/ai/service/impl/QwenClientServiceImpl.java new file mode 100644 index 0000000..b243de8 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/ai/service/impl/QwenClientServiceImpl.java @@ -0,0 +1,60 @@ +package com.qingqiu.interview.ai.service.impl; + +import com.alibaba.dashscope.aigc.generation.Generation; +import com.alibaba.dashscope.aigc.generation.GenerationParam; +import com.alibaba.dashscope.aigc.generation.GenerationResult; +import com.alibaba.dashscope.common.Message; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.qingqiu.interview.ai.service.AIClientService; +import com.qingqiu.interview.common.res.ResultCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; + +import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_PLUS_LATEST; +import static com.qingqiu.interview.common.utils.AIUtils.createUserMessage; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QwenClientServiceImpl extends AIClientService { + + @Value("${dashscope.api-key}") + private String apiKey; + + private final Generation generation; + + @Override + public String chatCompletion(String prompt) { + return chatCompletion(Collections.singletonList(createUserMessage(prompt))); + } + + @Override + public String chatCompletion(List messages) { + + GenerationParam param = GenerationParam.builder() + .model(QWEN_PLUS_LATEST) // 可根据需要更换模型 + .messages(messages) + .resultFormat(GenerationParam.ResultFormat.MESSAGE) + .apiKey(apiKey) + .build(); + + GenerationResult result = null; + try { + result = generation.call(param); + return result.getOutput().getChoices().get(0).getMessage().getContent(); + } catch (NoApiKeyException e) { + log.error("没有api key,请先确认配置!"); + throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL); + } catch (ApiException | InputRequiredException e) { + log.error("调用AI服务失败", e); + throw new com.qingqiu.interview.common.ex.ApiException(ResultCode.INTERNAL); + } + } +} diff --git a/src/main/java/com/qingqiu/interview/common/constants/AIStrategyConstant.java b/src/main/java/com/qingqiu/interview/common/constants/AIStrategyConstant.java new file mode 100644 index 0000000..22bfe6a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/constants/AIStrategyConstant.java @@ -0,0 +1,10 @@ +package com.qingqiu.interview.common.constants; + +/** + * AI策略常量 + */ +public class AIStrategyConstant { + + public static final String DEEPSEEK = "deepSeek"; + public static final String QWEN = "qwen"; +} diff --git a/src/main/java/com/qingqiu/interview/common/constants/QwenModelConstant.java b/src/main/java/com/qingqiu/interview/common/constants/QwenModelConstant.java new file mode 100644 index 0000000..b9944bd --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/constants/QwenModelConstant.java @@ -0,0 +1,12 @@ +package com.qingqiu.interview.common.constants; + +public class QwenModelConstant { + + public static final String QWEN_MAX = "qwen-max"; + public static final String QWEN_MAX_LATEST = "qwen-max-latest"; + + public static final String QWEN_PLUS = "qwen-plus"; + public static final String QWEN_PLUS_LATEST = "qwen-plus-latest"; + public static final String DEEPSEEK_3_1 = "deepseek-v3.1"; + public static final String DEEPSEEK_3 = "deepseek-v3"; +} diff --git a/src/main/java/com/qingqiu/interview/common/ex/ApiException.java b/src/main/java/com/qingqiu/interview/common/ex/ApiException.java new file mode 100644 index 0000000..c6d7f42 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/ex/ApiException.java @@ -0,0 +1,48 @@ +package com.qingqiu.interview.common.ex; + + +import com.qingqiu.interview.common.res.ResultCode; + +public class ApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private Object object; + + private ResultCode resultCode; + + public ApiException(String msg) { + super(msg); + } + + public ApiException(String msg, Object object) { + super(msg); + this.object = object; + } + + public ApiException(String msg, Throwable cause) { + super(msg, cause); + } + + + public ApiException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + public ApiException(ResultCode resultCode, Object object) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + this.object = object; + } + + + public Object getObject() { + return object; + } + + public ResultCode getResultCode() { + return resultCode; + } + +} \ No newline at end of file diff --git a/src/main/java/com/qingqiu/interview/common/ex/GlobalErrorHandler.java b/src/main/java/com/qingqiu/interview/common/ex/GlobalErrorHandler.java new file mode 100644 index 0000000..6a72479 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/ex/GlobalErrorHandler.java @@ -0,0 +1,101 @@ +package com.qingqiu.interview.common.ex; + +import com.qingqiu.interview.common.res.R; +import com.qingqiu.interview.common.res.ResultCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 全局异常处理 + * + * @author Administrator + */ +@Slf4j +@RestControllerAdvice +public class GlobalErrorHandler { + + private static final Map ERROR_CODE_MAP = + new HashMap<>(ResultCode.values().length); + + static { + for (ResultCode ResultCode : ResultCode.values()) { + ERROR_CODE_MAP.put(ResultCode.getCode(), ResultCode); + } + } + + @ExceptionHandler(Exception.class) + public R exception(Exception e) { + log.error("系统异常", e); + e.printStackTrace(); + return R.error(ResultCode.INTERNAL); + } + + @ExceptionHandler(ApiException.class) + public R apiException(ApiException ex) { + return R.error(ex.getResultCode()); + } + +// /** +// * sa的权限验证错误处理 +// * @param ex +// * @return +// */ +// @ExceptionHandler({NotPermissionException.class, NotRoleException.class}) +// public R notPermissionException(Exception ex) { +// return R.error(ex.getMessage()); +// } + + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R methodArgumentNotValidException(MethodArgumentNotValidException e) { + return R.error(ResultCode.FORBIDDEN.getCode(), Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage()); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R missingServletRequestParameterException(MissingServletRequestParameterException e) { + String message = String.format("%s不能为空", e.getParameterName()); + return R.error(HttpStatus.BAD_REQUEST.value(), message); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R missingServletRequestPartException(MissingServletRequestPartException e) { + String message = String.format("%s不能为空", e.getRequestPartName()); + return R.error(ResultCode.FORBIDDEN.getCode(), message); + } + + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R bindException(BindException e) { + return R.error(ResultCode.BAD_REQUEST.getCode(), e.getBindingResult().getAllErrors().get(0).getDefaultMessage()); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R missingRequestHeaderException(MissingRequestHeaderException e) { + return R.error(HttpStatus.BAD_REQUEST.value(), String.format("缺少请求头%s", e.getHeaderName())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public R httpMessageNotReadableException(HttpMessageNotReadableException e) { + e.printStackTrace(); + return R.error(ResultCode.BAD_REQUEST); + } + +} diff --git a/src/main/java/com/qingqiu/interview/common/res/IErrorCode.java b/src/main/java/com/qingqiu/interview/common/res/IErrorCode.java new file mode 100644 index 0000000..5354ee0 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/res/IErrorCode.java @@ -0,0 +1,11 @@ +package com.qingqiu.interview.common.res; + +/** + * 封装API的错误码 + * Created by macro on 2019/4/19. + */ +public interface IErrorCode { + Integer getCode(); + + String getMessage(); +} diff --git a/src/main/java/com/qingqiu/interview/common/res/R.java b/src/main/java/com/qingqiu/interview/common/res/R.java new file mode 100644 index 0000000..d3fd557 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/res/R.java @@ -0,0 +1,131 @@ +package com.qingqiu.interview.common.res; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 通用返回对象 + * Created by huangpeng 2023/9/27. + */ + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class R { + private Integer code; + private String message; + private T data; + + + /** + * 成功返回结果 + * + */ + public static R success() { + return new R(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + /** + * 成功返回结果 + * + * @param data 获取的数据 + */ + public static R success(T data) { + return new R(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } + + /** + * 成功返回结果 + * + * @param data 获取的数据 + * @param message 提示信息 + */ + public static R success(T data, String message) { + return new R(ResultCode.SUCCESS.getCode(), message, data); + } + + + /** + * 失败返回结果 + * @param errorCode 错误码 + * @param message 错误信息 + */ + public static R error(Integer errorCode, String message) { + return new R(errorCode, message, null); + } + + /** + * 失败返回结果 + * @param errorCode 错误码 + */ + public static R error(IErrorCode errorCode) { + return new R(errorCode.getCode(), errorCode.getMessage(), null); + } + + /** + * 失败返回结果 + * @param resultCode 错误码 + */ + public static R error(ResultCode resultCode, T data) { + R commonResult = new R<>(); + commonResult.setMessage(resultCode.getMessage()); + commonResult.setCode(resultCode.getCode()); + commonResult.setData(data); + return commonResult; + } + + /** + * 失败返回结果 + * @param errorCode 错误码 + * @param message 错误信息 + */ + public static R error(IErrorCode errorCode, String message) { + return new R(errorCode.getCode(), message, null); + } + + /** + * 失败返回结果 + * @param message 提示信息 + */ + public static R error(String message) { + return new R(ResultCode.INTERNAL.getCode(), message, null); + } + + /** + * 失败返回结果 + */ + public static R error() { + return error(ResultCode.INTERNAL); + } + + /** + * 参数验证失败返回结果 + */ + public static R validateerror() { + return error(ResultCode.METHOD_ARGUMENT_NOT_VALID); + } + + /** + * 参数验证失败返回结果 + * @param message 提示信息 + */ + public static R validateerror(String message) { + return new R(ResultCode.METHOD_ARGUMENT_NOT_VALID.getCode(), message, null); + } + + /** + * 未登录返回结果 + */ + public static R unauthorized(T data) { + return new R(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data); + } + + /** + * 未授权返回结果 + */ + public static R forbidden(T data) { + return new R(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data); + } + +} diff --git a/src/main/java/com/qingqiu/interview/common/res/ResultCode.java b/src/main/java/com/qingqiu/interview/common/res/ResultCode.java new file mode 100644 index 0000000..ae9ed03 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/res/ResultCode.java @@ -0,0 +1,51 @@ +package com.qingqiu.interview.common.res; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 枚举了一些常用API操作码 + * Created by huangpeng on 2023/9/27. + */ +@Getter +@AllArgsConstructor +@NoArgsConstructor +public enum ResultCode implements IErrorCode { + SUCCESS(0, "操作成功"), + FAILED(-1, "操作失败"), + SERVER_ERROR(5, "服务器出了点小差"), + BAD_REQUEST(400, "请求参数格式错误"), + VALIDATE_FAILED(404, "参数检验失败"), + UNAUTHORIZED(401, "暂未登录或session已经过期"), + FORBIDDEN(403, "没有相关权限"), + UPLOAD_ERROR(-103, "文件上传错误"), + INTERNAL(500, "服务器繁忙,请稍后再试"), + METHOD_ARGUMENT_NOT_VALID(2, "参数校验失败"), + HTTP_MESSAGE_NOT_READABLE(3, "请求参数格式有误"), + USERNAME_OR_PASSWORD_IS_INCORRECT(4, "用户名或密码错误"), + USER_NOT_FOUND(6, "用户不存在!"), + USER_EXIST(7, "用户已存在!"), + NEED_LOGIN_USER(10, "请先登录!"), + LOGIN_STATUS_EXPIRED(10010, "登录状态已过期!"), + TOKEN_VALID_FAILED(11, "token校验失败!"), + + CONVERTING_PRODUCT_IMAGE_TYPES_FAILED(1001, "转换图片类型失败!"), + + PRODUCT_LOW_STOCK(2001, "商品库存不足!"), + ORDER_IN_THE_QUEUE(2002, "订单正在处理中,请稍后..."), + ORDER_CREATE_FAILED(2003, "订单创建失败,请稍后重试..."), + + VERIFICATION_CODE_EXPIRED(3001, "验证码已过期,请重新获取验证码!"), + VERIFICATION_CODE_ERROR(3002, "验证码错误!"), + SEND_SMS_FREQUENT(3003, "短信发送过于频繁,请稍后再试!"), + ; + private Integer code; + private String message; + + @Override + public String toString() { + return "ResultCode{" + "code='" + code + '\'' + ", message='" + message + '\'' + "} " + super.toString(); + } +} + diff --git a/src/main/java/com/qingqiu/interview/common/service/HttpService.java b/src/main/java/com/qingqiu/interview/common/service/HttpService.java new file mode 100644 index 0000000..be6ceca --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/service/HttpService.java @@ -0,0 +1,15 @@ +package com.qingqiu.interview.common.service; + +import reactor.core.publisher.Mono; + +public interface HttpService { + + Mono post(String url, Object requestBody, Class responseType); + + Mono postWithAuth( + String url, + Object requestBody, + Class responseType, + String authHeader + ); +} diff --git a/src/main/java/com/qingqiu/interview/common/service/impl/HttpServiceImpl.java b/src/main/java/com/qingqiu/interview/common/service/impl/HttpServiceImpl.java new file mode 100644 index 0000000..7cbfb19 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/service/impl/HttpServiceImpl.java @@ -0,0 +1,37 @@ +package com.qingqiu.interview.common.service.impl; + +import com.qingqiu.interview.common.service.HttpService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class HttpServiceImpl implements HttpService { + + private final WebClient webClient; + + @Override + public Mono post(String url, Object requestBody, Class responseType) { + return webClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(responseType); + } + + @Override + public Mono postWithAuth(String url, Object requestBody, Class responseType, String authHeader) { + return webClient.post() + .uri(url) + .header("Authorization", authHeader) + .header("Accept", MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(responseType); + } +} diff --git a/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java b/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java new file mode 100644 index 0000000..35b8e66 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/utils/AIUtils.java @@ -0,0 +1,26 @@ +package com.qingqiu.interview.common.utils; + +import com.alibaba.dashscope.common.Message; +import com.alibaba.dashscope.common.Role; + +public class AIUtils { + + public static Message createMessage(String role, String content) { + return Message.builder() + .role(role) + .content(content) + .build(); + } + + public static Message createUserMessage(String prompt) { + return createMessage(Role.USER.getValue(), prompt); + } + + public static Message createAIMessage(String prompt) { + return createMessage(Role.ASSISTANT.getValue(), prompt); + } + + public static Message createSystemMessage(String prompt) { + return createMessage(Role.SYSTEM.getValue(), prompt); + } +} diff --git a/src/main/java/com/qingqiu/interview/common/utils/SpringApplicationContextUtil.java b/src/main/java/com/qingqiu/interview/common/utils/SpringApplicationContextUtil.java new file mode 100644 index 0000000..04e28d6 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/common/utils/SpringApplicationContextUtil.java @@ -0,0 +1,40 @@ +package com.qingqiu.interview.common.utils; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +public class SpringApplicationContextUtil implements ApplicationContextAware { + private static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + SpringApplicationContextUtil.applicationContext = applicationContext; + } + + public static T getBean(Class beanClass) { + return applicationContext.getBean(beanClass); + } + + public static Object getBean(String beanName) { + return applicationContext.getBean(beanName); + } + + public static T getBean(String beanName, Class beanClass) { + return applicationContext.getBean(beanName, beanClass); + } + + public static boolean containsBean(String beanName) { + return applicationContext.containsBean(beanName); + } + + public static boolean isSingleton(String beanName) { + return applicationContext.isSingleton(beanName); + } + + public static Class getType(String beanName) { + return applicationContext.getType(beanName); + } +} diff --git a/src/main/java/com/qingqiu/interview/config/DashScopeConfig.java b/src/main/java/com/qingqiu/interview/config/DashScopeConfig.java new file mode 100644 index 0000000..2bd2038 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/config/DashScopeConfig.java @@ -0,0 +1,14 @@ +package com.qingqiu.interview.config; + +import com.alibaba.dashscope.aigc.generation.Generation; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DashScopeConfig { + + @Bean + public Generation generation() { + return new Generation(); + } +} diff --git a/src/main/java/com/qingqiu/interview/config/JacksonConfig.java b/src/main/java/com/qingqiu/interview/config/JacksonConfig.java new file mode 100644 index 0000000..923cb31 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/config/JacksonConfig.java @@ -0,0 +1,31 @@ +package com.qingqiu.interview.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + JavaTimeModule module = new JavaTimeModule(); + + // 配置 LocalDateTime 的序列化和反序列化格式 + module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + module.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + + mapper.registerModule(module); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} \ No newline at end of file diff --git a/src/main/java/com/qingqiu/interview/config/MyBatisPlusConfig.java b/src/main/java/com/qingqiu/interview/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..586e253 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/config/MyBatisPlusConfig.java @@ -0,0 +1,39 @@ +package com.qingqiu.interview.config; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; + +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.LocalDateTime; + +@Slf4j +@Configuration +@MapperScan("com.qingqiu.interview.**.mapper") +public class MyBatisPlusConfig implements MetaObjectHandler { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); + return interceptor; + } + + @Override + public void insertFill(MetaObject metaObject) { + log.info("开始插入填充..."); + this.strictInsertFill(metaObject, "createdTime", LocalDateTime.class, LocalDateTime.now()); + this.strictInsertFill(metaObject, "updatedTime", LocalDateTime.class, LocalDateTime.now()); + } + + @Override + public void updateFill(MetaObject metaObject) { + log.info("开始更新填充..."); + this.strictUpdateFill(metaObject, "updatedTime", LocalDateTime.class, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/qingqiu/interview/config/WebClientConfig.java b/src/main/java/com/qingqiu/interview/config/WebClientConfig.java new file mode 100644 index 0000000..6917d32 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/config/WebClientConfig.java @@ -0,0 +1,50 @@ +package com.qingqiu.interview.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Configuration +public class WebClientConfig { + + @Bean + public WebClient createWebClient() { + return WebClient.builder() + .filter(logRequest()) // 请求日志 + .filter(logResponse()) // 响应日志 + .filter(errorHandler()) + .build(); // 统一错误处理 + } + + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> { + log.info("请求: {} {}", clientRequest.method(), clientRequest.url()); + return Mono.just(clientRequest); + }); + } + + private ExchangeFilterFunction logResponse() { + return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> { + log.info("返回: {}", clientResponse.statusCode()); + return Mono.just(clientResponse); + }); + } + + private ExchangeFilterFunction errorHandler() { + return ExchangeFilterFunction.ofResponseProcessor(response -> { + if (response.statusCode().isError()) { + return response.bodyToMono(String.class) + .flatMap(errorBody -> Mono.error(new HttpClientErrorException( + response.statusCode(), + "接口报错: " + errorBody + ))); + } + return Mono.just(response); + }); + } +} diff --git a/src/main/java/com/qingqiu/interview/controller/AiSessionLogController.java b/src/main/java/com/qingqiu/interview/controller/AiSessionLogController.java new file mode 100644 index 0000000..269ceca --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/AiSessionLogController.java @@ -0,0 +1,20 @@ +package com.qingqiu.interview.controller; + + +import org.springframework.web.bind.annotation.RequestMapping; + +import org.springframework.web.bind.annotation.RestController; + +/** + *

+ * ai会话记录 前端控制器 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@RestController +@RequestMapping("/ai-session-log") +public class AiSessionLogController { + +} diff --git a/src/main/java/com/qingqiu/interview/controller/DashboardController.java b/src/main/java/com/qingqiu/interview/controller/DashboardController.java new file mode 100644 index 0000000..36ac9b3 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/DashboardController.java @@ -0,0 +1,28 @@ +package com.qingqiu.interview.controller; + +import com.qingqiu.interview.dto.ApiResponse; +import com.qingqiu.interview.dto.DashboardStatsResponse; +import com.qingqiu.interview.service.DashboardService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 仪表盘数据统计接口 + */ +@RestController +@RequestMapping("/api/v1/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + /** + * 获取仪表盘所有统计数据 + */ + @PostMapping("/stats") + public ApiResponse getDashboardStats() { + return ApiResponse.success(dashboardService.getDashboardStats()); + } +} diff --git a/src/main/java/com/qingqiu/interview/controller/InterviewController.java b/src/main/java/com/qingqiu/interview/controller/InterviewController.java new file mode 100644 index 0000000..acb9056 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/InterviewController.java @@ -0,0 +1,58 @@ +package com.qingqiu.interview.controller; + +import com.qingqiu.interview.dto.*; +import com.qingqiu.interview.entity.InterviewSession; +import com.qingqiu.interview.service.InterviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + * 面试流程相关接口 + */ +@RestController +@RequestMapping("/api/v1/interview") +@RequiredArgsConstructor +public class InterviewController { + + private final InterviewService interviewService; + + /** + * 开始新的面试会话 + */ + @PostMapping("/start") + public ApiResponse startInterview( + @RequestParam("resume") MultipartFile resume, + @Validated @ModelAttribute InterviewStartRequest request) throws IOException { + InterviewResponse response = interviewService.startInterview(resume, request); + return ApiResponse.success(response); + } + + /** + * 继续面试会话(用户回答) + */ + @PostMapping("/chat") + public ApiResponse continueInterview(@Validated @RequestBody ChatRequest request) { + InterviewResponse response = interviewService.continueInterview(request); + return ApiResponse.success(response); + } + + /** + * 获取所有面试会话列表 + */ + @PostMapping("/get-history-list") + public ApiResponse> getInterviewHistoryList() { + return ApiResponse.success(interviewService.getInterviewSessions()); + } + + /** + * 获取单次面试的详细复盘报告 + */ + @PostMapping("/get-report-detail") + public ApiResponse getInterviewReportDetail(@RequestBody SessionRequest request) { + return ApiResponse.success(interviewService.getInterviewReport(request.getSessionId())); + } +} \ No newline at end of file diff --git a/src/main/java/com/qingqiu/interview/controller/InterviewQuestionProgressController.java b/src/main/java/com/qingqiu/interview/controller/InterviewQuestionProgressController.java new file mode 100644 index 0000000..31356dd --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/InterviewQuestionProgressController.java @@ -0,0 +1,38 @@ +package com.qingqiu.interview.controller; + + +import com.qingqiu.interview.common.res.R; +import com.qingqiu.interview.dto.QuestionProgressPageParams; +import com.qingqiu.interview.service.IInterviewQuestionProgressService; +import lombok.RequiredArgsConstructor; +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.RestController; + +/** + *

+ * 面试问题进度跟踪表 前端控制器 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@RestController +@RequestMapping("/api/v1/interview-question-progress") +@RequiredArgsConstructor +public class InterviewQuestionProgressController { + + private final IInterviewQuestionProgressService service; + + /** + * 面试问题进度列表 + * @param params 查询参数 + * @return data + */ + @PostMapping("/page") + public R pageList(@RequestBody QuestionProgressPageParams params) { + return R.success(service.pageList(params)); + } + +} diff --git a/src/main/java/com/qingqiu/interview/controller/QuestionController.java b/src/main/java/com/qingqiu/interview/controller/QuestionController.java new file mode 100644 index 0000000..1482b63 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/controller/QuestionController.java @@ -0,0 +1,79 @@ +package com.qingqiu.interview.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.qingqiu.interview.common.res.R; +import com.qingqiu.interview.dto.ApiResponse; +import com.qingqiu.interview.dto.QuestionPageParams; +import com.qingqiu.interview.entity.Question; +import com.qingqiu.interview.service.QuestionService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + * 题库管理相关接口 + */ +@RestController +@RequestMapping("/api/v1/question") +@RequiredArgsConstructor +public class QuestionController { + + private final QuestionService questionService; + + /** + * 分页查询题库 + */ + @PostMapping("/page") + public ApiResponse> getQuestionPage(@RequestBody QuestionPageParams params) { + return ApiResponse.success(questionService.getQuestionPage(params)); + } + + /** + * 新增题目 + */ + @PostMapping("/add") + public ApiResponse addQuestion(@Validated @RequestBody Question question) { + questionService.addQuestion(question); + return ApiResponse.success(); + } + + /** + * 更新题目 + */ + @PostMapping("/update") + public ApiResponse updateQuestion(@Validated @RequestBody Question question) { + questionService.updateQuestion(question); + return ApiResponse.success(); + } + + /** + * 删除题目 + */ + @PostMapping("/delete") + public ApiResponse deleteQuestion(@RequestBody Question question) { + questionService.deleteQuestion(question.getId()); + return ApiResponse.success(); + } + + /** + * AI批量导入题目 + */ + @PostMapping("/import-by-ai") + public ApiResponse importQuestionsByAi(@RequestParam("file") MultipartFile file) throws IOException { + questionService.importQuestionsFromFile(file); + return ApiResponse.success(); + } + + /** + * 校验数据 + * @return + */ + @PostMapping("/check-question-data") + public R checkQuestionData() { + questionService.useAiCheckQuestionData(); + return R.success(); + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/ApiResponse.java b/src/main/java/com/qingqiu/interview/dto/ApiResponse.java new file mode 100644 index 0000000..94ab4c4 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/ApiResponse.java @@ -0,0 +1,30 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; + +@Data +public class ApiResponse { + + private int code; + private String message; + private T data; + + public static ApiResponse success(T data) { + ApiResponse response = new ApiResponse<>(); + response.setCode(0); + response.setMessage("Success"); + response.setData(data); + return response; + } + + public static ApiResponse success() { + return success(null); + } + + public static ApiResponse error(int code, String message) { + ApiResponse response = new ApiResponse<>(); + response.setCode(code); + response.setMessage(message); + return response; + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/ChatRequest.java b/src/main/java/com/qingqiu/interview/dto/ChatRequest.java new file mode 100644 index 0000000..7b7c185 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/ChatRequest.java @@ -0,0 +1,17 @@ +package com.qingqiu.interview.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ChatRequest { + + @NotBlank(message = "会话ID不能为空") + private String sessionId; + + @NotBlank(message = "用户回答不能为空") + private String userAnswer; + @NotNull(message = "当前问题ID不能为空") + private Long currentQuestionId; +} diff --git a/src/main/java/com/qingqiu/interview/dto/DashboardStatsResponse.java b/src/main/java/com/qingqiu/interview/dto/DashboardStatsResponse.java new file mode 100644 index 0000000..5d71076 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/DashboardStatsResponse.java @@ -0,0 +1,77 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 仪表盘数据统计响应DTO + */ +@Data +@NoArgsConstructor +public class DashboardStatsResponse { + + // --- 核心KPI数据 --- + private long totalInterviews; // 面试总次数 + private long totalQuestions; // 题库总题数 + private double recentAverageScore; // 近期平均分 + + // --- 图表数据 --- + private List questionCategoryStats; // 题库分类统计 (饼图) + private List recentInterviewStats; // 近期面试次数 (柱状图) + private List categoryAverageScores; // 各技术分类平均分 (雷达图) + private List weakestQuestions; // 表现最差的题目Top5 (列表) + + /** + * 分类统计内部类 + */ + @Data + @NoArgsConstructor + public static class CategoryStat { + private String name; // 分类名 + private long value; // 数量 + + public CategoryStat(String name, long value) { + this.name = name; + this.value = value; + } + } + + /** + * 每日统计内部类 + */ + @Data + @NoArgsConstructor + public static class DailyStat { + private String date; // 日期 + private long count; // 次数 + + public DailyStat(String date, long count) { + this.date = date; + this.count = count; + } + } + + /** + * 各技术分类平均分 (雷达图) + */ + @Data + @NoArgsConstructor + public static class CategoryScoreStat { + private String name; // 分类名 + private Double value; // 平均分 + } + + /** + * 表现最差的题目 (列表) + */ + @Data + @NoArgsConstructor + public static class WeakestQuestionStat { + private Long questionId; + private String questionContent; + private Double averageScore; + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/InterviewReportResponse.java b/src/main/java/com/qingqiu/interview/dto/InterviewReportResponse.java new file mode 100644 index 0000000..571b341 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/InterviewReportResponse.java @@ -0,0 +1,42 @@ +package com.qingqiu.interview.dto; + +import com.qingqiu.interview.entity.InterviewMessage; +import com.qingqiu.interview.entity.InterviewSession; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 用于封装详细面试报告的数据传输对象 + */ +@Data +@NoArgsConstructor +public class InterviewReportResponse { + + // 面试会话的整体详情 + private InterviewSession sessionDetails; + + // 每个问题的详细问答和评估列表 + private List questionDetails; + + private List messages; + + private Long currentQuestionId; + + /** + * 单个问题的详情内部类 + */ + @Data + @NoArgsConstructor + public static class QuestionDetail { + private Long questionId; + private String questionContent; + private String userAnswer; + private String aiFeedback; + private String suggestions; + private BigDecimal score; + } +} + diff --git a/src/main/java/com/qingqiu/interview/dto/InterviewResponse.java b/src/main/java/com/qingqiu/interview/dto/InterviewResponse.java new file mode 100644 index 0000000..3ca8f2a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/InterviewResponse.java @@ -0,0 +1,18 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class InterviewResponse { + + private String sessionId; + private String message; + private String messageType; // QUESTION, ANSWER, SYSTEM + private String sender; // AI, USER, SYSTEM + private Integer currentQuestionIndex; + private Integer totalQuestions; + private String status; // ACTIVE, COMPLETED, TERMINATED + private Long currentQuestionId; +} diff --git a/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java b/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java new file mode 100644 index 0000000..7c39425 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java @@ -0,0 +1,15 @@ +package com.qingqiu.interview.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class InterviewStartRequest { + + @NotBlank(message = "候选人姓名不能为空") + private String candidateName; + + + + // 简历文件通过MultipartFile单独传递 +} diff --git a/src/main/java/com/qingqiu/interview/dto/PageBaseParams.java b/src/main/java/com/qingqiu/interview/dto/PageBaseParams.java new file mode 100644 index 0000000..563bccb --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/PageBaseParams.java @@ -0,0 +1,20 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.io.Serial; +import java.io.Serializable; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +public class PageBaseParams implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + private Integer current; + + private Integer size; +} diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java new file mode 100644 index 0000000..d62ec7c --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/QuestionPageParams.java @@ -0,0 +1,14 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + + +@Data +@EqualsAndHashCode(callSuper = true) +@Accessors(chain = true) +public class QuestionPageParams extends PageBaseParams{ + + private String content; +} diff --git a/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java b/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java new file mode 100644 index 0000000..df94e8d --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/QuestionProgressPageParams.java @@ -0,0 +1,12 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +@EqualsAndHashCode(callSuper = true) +@Data +@Accessors(chain = true) +public class QuestionProgressPageParams extends PageBaseParams{ + private String questionName; +} diff --git a/src/main/java/com/qingqiu/interview/dto/SessionHistoryResponse.java b/src/main/java/com/qingqiu/interview/dto/SessionHistoryResponse.java new file mode 100644 index 0000000..c13299a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/SessionHistoryResponse.java @@ -0,0 +1,31 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Accessors(chain = true) +public class SessionHistoryResponse { + + private String sessionId; + private String candidateName; + private String aiModel; + private String status; + private Integer totalQuestions; + private Integer currentQuestionIndex; + private LocalDateTime createdTime; + private List messages; + + @Data + @Accessors(chain = true) + public static class MessageDto { + private String messageType; + private String sender; + private String content; + private Integer messageOrder; + private LocalDateTime createdTime; + } +} diff --git a/src/main/java/com/qingqiu/interview/dto/SessionRequest.java b/src/main/java/com/qingqiu/interview/dto/SessionRequest.java new file mode 100644 index 0000000..108fa8e --- /dev/null +++ b/src/main/java/com/qingqiu/interview/dto/SessionRequest.java @@ -0,0 +1,9 @@ +package com.qingqiu.interview.dto; + +import lombok.Data; + +@Data +public class SessionRequest { + private String sessionId; +} + diff --git a/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java b/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java new file mode 100644 index 0000000..9de8909 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/AiSessionLog.java @@ -0,0 +1,58 @@ +package com.qingqiu.interview.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + *

+ * ai会话记录 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("ai_session_log") +public class AiSessionLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * user 或者 assistant + */ + private String role; + + /** + * 输入内容 + */ + private String content; + + /** + * 生成的会话token + */ + private String token; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + +} diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewEvaluation.java b/src/main/java/com/qingqiu/interview/entity/InterviewEvaluation.java new file mode 100644 index 0000000..f61a209 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/InterviewEvaluation.java @@ -0,0 +1,43 @@ +package com.qingqiu.interview.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("interview_evaluation") +public class InterviewEvaluation { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("session_id") + private String sessionId; + + @TableField("question_id") + private Long questionId; + + @TableField("user_answer") + private String userAnswer; + + @TableField("ai_feedback") + private String aiFeedback; + + @TableField("score") + private BigDecimal score; + + @TableField("evaluation_criteria") + private String evaluationCriteria; + + @TableField(value = "created_time", fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; +} diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewMessage.java b/src/main/java/com/qingqiu/interview/entity/InterviewMessage.java new file mode 100644 index 0000000..6edcd2e --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/InterviewMessage.java @@ -0,0 +1,50 @@ +package com.qingqiu.interview.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("interview_message") +public class InterviewMessage { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("session_id") + private String sessionId; + + @TableField("message_type") + private String messageType; + + @TableField("sender") + private String sender; + + @TableField("content") + private String content; + + @TableField("question_id") + private Long questionId; + + @TableField("message_order") + private Integer messageOrder; + + @TableField(value = "created_time", fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + public enum MessageType { + QUESTION, ANSWER, SYSTEM + } + + public enum Sender { + AI, USER, SYSTEM + } +} diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java b/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java new file mode 100644 index 0000000..8fa8358 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/InterviewQuestionProgress.java @@ -0,0 +1,115 @@ +package com.qingqiu.interview.entity; + + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 面试问题进度 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("interview_question_progress") +public class InterviewQuestionProgress { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** + * 问题id + */ + @TableField("question_id") + private Long questionId; + + /** + * 问题内容 + */ + @TableField("question_content") + private String questionContent; + + /** + * 面试会话ID + */ + @TableField("session_id") + private String sessionId; + + /** + * 候选人名称 + */ + @TableField("candidate_name") + private String candidateName; + + /** + * AI模型 + */ + @TableField("ai_model") + private String aiModel; + + /** + * 状态 + */ + @TableField("status") + private String status; + + /** + * 问题总数 + */ + @TableField("total_questions") + private Integer totalQuestions; + + /** + * 当前问题成绩 + */ + @TableField("score") + private BigDecimal score; + + /** + * 最终报告 + */ + @TableField("final_report") + private String finalReport; + + /** + * ai返回意见 + */ + @TableField("feedback") + private String feedback; + + /** + * 建议 + */ + @TableField("suggestions") + private String suggestions; + + /** + * AI返回答案 + */ + @TableField("ai_answer") + private String aiAnswer; + + /** + * 用户答案 + */ + @TableField("user_answer") + private String userAnswer; + + @TableField(value = "created_time", fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + @TableLogic + @TableField("deleted") + private Integer deleted; + + public enum Status { + DEFAULT, ACTIVE, COMPLETED, TERMINATED + } +} diff --git a/src/main/java/com/qingqiu/interview/entity/InterviewSession.java b/src/main/java/com/qingqiu/interview/entity/InterviewSession.java new file mode 100644 index 0000000..d7db6f0 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/InterviewSession.java @@ -0,0 +1,71 @@ +package com.qingqiu.interview.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("interview_session") +public class InterviewSession implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("session_id") + private String sessionId; + + @TableField("candidate_name") + private String candidateName; + + @TableField("resume_content") + private String resumeContent; + + @TableField("extracted_skills") + private String extractedSkills; + + @TableField("ai_model") + private String aiModel; + + @TableField("status") + private String status; + + @TableField("total_questions") + private Integer totalQuestions; + + @TableField("current_question_index") + private Integer currentQuestionIndex; + + @TableField("score") + private BigDecimal score; + + @TableField("selected_question_ids") + private String selectedQuestionIds; + + @TableField("final_report") + private String finalReport; + + @TableField(value = "created_time", fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + @TableLogic + @TableField("deleted") + private Integer deleted; + + public enum Status { + ACTIVE, COMPLETED, TERMINATED + } +} diff --git a/src/main/java/com/qingqiu/interview/entity/Question.java b/src/main/java/com/qingqiu/interview/entity/Question.java new file mode 100644 index 0000000..a0d7e29 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/entity/Question.java @@ -0,0 +1,41 @@ +package com.qingqiu.interview.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("question") +public class Question { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @TableField("content") + private String content; + + @TableField("category") + private String category; + + @TableField("difficulty") + private String difficulty; + + @TableField("tags") + private String tags; + + @TableField(value = "created_time", fill = FieldFill.INSERT) + private LocalDateTime createdTime; + + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; + + @TableLogic + @TableField("deleted") + private Integer deleted; +} + diff --git a/src/main/java/com/qingqiu/interview/mapper/AiSessionLogMapper.java b/src/main/java/com/qingqiu/interview/mapper/AiSessionLogMapper.java new file mode 100644 index 0000000..413551f --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/AiSessionLogMapper.java @@ -0,0 +1,19 @@ +package com.qingqiu.interview.mapper; + + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.AiSessionLog; +import org.apache.ibatis.annotations.Mapper; + +/** + *

+ * ai会话记录 Mapper 接口 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@Mapper +public interface AiSessionLogMapper extends BaseMapper { + +} diff --git a/src/main/java/com/qingqiu/interview/mapper/InterviewEvaluationMapper.java b/src/main/java/com/qingqiu/interview/mapper/InterviewEvaluationMapper.java new file mode 100644 index 0000000..3289481 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/InterviewEvaluationMapper.java @@ -0,0 +1,16 @@ +package com.qingqiu.interview.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.InterviewEvaluation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InterviewEvaluationMapper extends BaseMapper { + + List selectBySessionId(@Param("sessionId") String sessionId); + + InterviewEvaluation selectBySessionIdAndQuestionId(@Param("sessionId") String sessionId, @Param("questionId") Long questionId); +} diff --git a/src/main/java/com/qingqiu/interview/mapper/InterviewMessageMapper.java b/src/main/java/com/qingqiu/interview/mapper/InterviewMessageMapper.java new file mode 100644 index 0000000..80e93d4 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/InterviewMessageMapper.java @@ -0,0 +1,18 @@ +package com.qingqiu.interview.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.InterviewMessage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InterviewMessageMapper extends BaseMapper { + + List selectBySessionIdOrderByOrder(@Param("sessionId") String sessionId); + + InterviewMessage selectLatestBySessionId(@Param("sessionId") String sessionId); + + int selectMaxOrderBySessionId(@Param("sessionId") String sessionId); +} diff --git a/src/main/java/com/qingqiu/interview/mapper/InterviewQuestionProgressMapper.java b/src/main/java/com/qingqiu/interview/mapper/InterviewQuestionProgressMapper.java new file mode 100644 index 0000000..df161ca --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/InterviewQuestionProgressMapper.java @@ -0,0 +1,16 @@ +package com.qingqiu.interview.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.InterviewQuestionProgress; + +/** + *

+ * 面试问题进度跟踪表 Mapper 接口 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +public interface InterviewQuestionProgressMapper extends BaseMapper { + +} diff --git a/src/main/java/com/qingqiu/interview/mapper/InterviewSessionMapper.java b/src/main/java/com/qingqiu/interview/mapper/InterviewSessionMapper.java new file mode 100644 index 0000000..86ed55a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/InterviewSessionMapper.java @@ -0,0 +1,20 @@ +package com.qingqiu.interview.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.InterviewSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InterviewSessionMapper extends BaseMapper { + + InterviewSession selectBySessionId(@Param("sessionId") String sessionId); + + List selectActiveSessionsByModel(@Param("aiModel") String aiModel); + + int updateSessionStatus(@Param("sessionId") String sessionId, @Param("status") String status); + + List countRecentInterviews(@Param("days") int days); +} diff --git a/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java b/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java new file mode 100644 index 0000000..019dde9 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/mapper/QuestionMapper.java @@ -0,0 +1,23 @@ +package com.qingqiu.interview.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.qingqiu.interview.entity.Question; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface QuestionMapper extends BaseMapper { + + List selectByCategory(@Param("category") String category); + + List selectByCategories(@Param("categories") List categories); + + List selectRandomByCategories(@Param("categories") List categories, @Param("limit") int limit); + + Question selectByContent(@Param("content") String content); + + List countByCategory(); +} + diff --git a/src/main/java/com/qingqiu/interview/service/DashboardService.java b/src/main/java/com/qingqiu/interview/service/DashboardService.java new file mode 100644 index 0000000..7704a3a --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/DashboardService.java @@ -0,0 +1,59 @@ +package com.qingqiu.interview.service; + +import com.qingqiu.interview.mapper.InterviewSessionMapper; +import com.qingqiu.interview.mapper.QuestionMapper; +import com.qingqiu.interview.dto.DashboardStatsResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@Service +@RequiredArgsConstructor +public class DashboardService { + + private final QuestionMapper questionMapper; + private final InterviewSessionMapper sessionMapper; + + public DashboardStatsResponse getDashboardStats() { + DashboardStatsResponse stats = new DashboardStatsResponse(); + + // 1. 获取核心KPI + stats.setTotalQuestions(questionMapper.selectCount(null)); + stats.setTotalInterviews(sessionMapper.selectCount(null)); + + // 2. 获取题库分类统计 + stats.setQuestionCategoryStats(questionMapper.countByCategory()); + + // 3. 获取最近7天的面试统计,并补全没有数据的日期 + List recentStats = sessionMapper.countRecentInterviews(7); + stats.setRecentInterviewStats(fillMissingDates(recentStats, 7)); + + return stats; + } + + /** + * 填充最近几天内没有面试数据的日期,补0 + */ + private List fillMissingDates(List existingStats, int days) { + Map statsMap = existingStats.stream() + .collect(Collectors.toMap(DashboardStatsResponse.DailyStat::getDate, DashboardStatsResponse.DailyStat::getCount)); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + return IntStream.range(0, days) + .mapToObj(i -> LocalDate.now().minusDays(i)) + .map(date -> { + String dateString = date.format(formatter); + long count = statsMap.getOrDefault(dateString, 0L); + return new DashboardStatsResponse.DailyStat(dateString, count); + }) + .sorted((d1, d2) -> d1.getDate().compareTo(d2.getDate())) // 按日期升序排序 + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/qingqiu/interview/service/IAiSessionLogService.java b/src/main/java/com/qingqiu/interview/service/IAiSessionLogService.java new file mode 100644 index 0000000..22159ad --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/IAiSessionLogService.java @@ -0,0 +1,16 @@ +package com.qingqiu.interview.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.qingqiu.interview.entity.AiSessionLog; + +/** + *

+ * ai会话记录 服务类 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +public interface IAiSessionLogService extends IService { + +} diff --git a/src/main/java/com/qingqiu/interview/service/IInterviewQuestionProgressService.java b/src/main/java/com/qingqiu/interview/service/IInterviewQuestionProgressService.java new file mode 100644 index 0000000..208699b --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/IInterviewQuestionProgressService.java @@ -0,0 +1,18 @@ +package com.qingqiu.interview.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.IService; +import com.qingqiu.interview.dto.QuestionProgressPageParams; +import com.qingqiu.interview.entity.InterviewQuestionProgress; + +/** + *

+ * 面试问题进度跟踪表 服务类 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +public interface IInterviewQuestionProgressService extends IService { + Page pageList(QuestionProgressPageParams params); +} diff --git a/src/main/java/com/qingqiu/interview/service/InterviewService.java b/src/main/java/com/qingqiu/interview/service/InterviewService.java new file mode 100644 index 0000000..f81beec --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/InterviewService.java @@ -0,0 +1,654 @@ +package com.qingqiu.interview.service; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qingqiu.interview.dto.*; +import com.qingqiu.interview.entity.*; +import com.qingqiu.interview.mapper.*; +import com.qingqiu.interview.service.llm.LlmService; +import com.qingqiu.interview.service.parser.DocumentParser; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.qingqiu.interview.common.constants.QwenModelConstant.QWEN_MAX; + +@Slf4j +@Service +@RequiredArgsConstructor +public class InterviewService { + + private final LlmService llmService; // Changed to a single service + private final List documentParserList; + private final QuestionMapper questionMapper; + private final InterviewSessionMapper sessionMapper; + private final InterviewMessageMapper messageMapper; + private final InterviewEvaluationMapper evaluationMapper; + private final InterviewQuestionProgressMapper questionProgressMapper; + + private final ObjectMapper objectMapper; + + + private Map documentParsers; + + private static final int MAX_QUESTIONS_PER_INTERVIEW = 10; + + @PostConstruct + public void init() { + this.documentParsers = documentParserList.stream() + .collect(Collectors.toMap(DocumentParser::getSupportedType, Function.identity())); + } + + /** + * 开始新的面试会话 + */ + @Transactional(rollbackFor = Exception.class) + public InterviewResponse startInterview(MultipartFile resume, InterviewStartRequest request) throws IOException { + log.info("开始新面试会话,候选人: {}, AI模型: qwen-max", request.getCandidateName()); + + // 1. 解析简历 + String resumeContent = parseResume(resume); + + + // 2. 创建会话 并发送AI请求 让其从题库中智能抽题 + String sessionId = UUID.randomUUID().toString(); + List selectedQuestions = selectQuestionsByAi(resumeContent, sessionId); + if (selectedQuestions.isEmpty()) { + throw new IllegalStateException("AI未能成功选取题目,请检查AI服务或题库。"); + } + + // 生成面试问题进度数据 + if (CollectionUtil.isNotEmpty(selectedQuestions)) { + for (Question question : selectedQuestions) { + InterviewQuestionProgress progress = + new InterviewQuestionProgress() + .setSessionId(sessionId) + .setQuestionId(question.getId()) + .setQuestionContent(question.getContent()) + .setStatus(InterviewQuestionProgress.Status.DEFAULT.name()) + .setTotalQuestions(selectedQuestions.size()) + .setScore(BigDecimal.ZERO) + .setAiModel(QWEN_MAX) + .setCandidateName(request.getCandidateName()); + questionProgressMapper.insert(progress); + } + + } + + // 3. 保存AI选择的题目ID列表 + List selectedQuestionIds = selectedQuestions.stream().map(Question::getId).collect(Collectors.toList()); + String selectedQuestionIdsJson = objectMapper.writeValueAsString(selectedQuestionIds); + + InterviewSession session = createSession(sessionId, request, resumeContent, selectedQuestionIdsJson); + session.setTotalQuestions(selectedQuestions.size()); // 更新会话中的总问题数 + sessionMapper.updateById(session); // 更新数据库 + + // 4. 生成第一个问题 + Question firstQuestion = selectedQuestions.get(0); + String firstQuestionContent = generateFirstQuestion(session, firstQuestion, sessionId); + // 激活问题 + questionProgressMapper.update( + new LambdaUpdateWrapper() + .set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) + .eq(InterviewQuestionProgress::getQuestionId, firstQuestion.getId()) + .eq(InterviewQuestionProgress::getSessionId, sessionId) + ); + + // 5. 保存消息记录 + saveMessage(sessionId, InterviewMessage.MessageType.QUESTION.name(), + InterviewMessage.Sender.AI.name(), firstQuestionContent, firstQuestion.getId(), 1); + + // 6. 返回响应 + return new InterviewResponse() + .setSessionId(sessionId) + .setMessage(firstQuestionContent) + .setMessageType(InterviewMessage.MessageType.QUESTION.name()) + .setSender(InterviewMessage.Sender.AI.name()) + .setCurrentQuestionIndex(1) + .setCurrentQuestionId(firstQuestion.getId()) + .setTotalQuestions(selectedQuestions.size()) + .setStatus(InterviewSession.Status.ACTIVE.name()); + } + + /** + * 处理用户回答并生成下一个问题 + */ + @Transactional(rollbackFor = Exception.class) + public InterviewResponse continueInterview(ChatRequest request) { + log.info("继续面试会话: {}", request.getSessionId()); + + InterviewSession session = sessionMapper.selectBySessionId(request.getSessionId()); + if (session == null) { + throw new IllegalArgumentException("会话不存在: " + request.getSessionId()); + } + + if (!InterviewSession.Status.ACTIVE.name().equals(session.getStatus())) { + throw new IllegalStateException("会话已结束"); + } + + + // 1. 保存用户回答 + int nextOrder = messageMapper.selectMaxOrderBySessionId(request.getSessionId()) + 1; + saveMessage(request.getSessionId(), InterviewMessage.MessageType.ANSWER.name(), + InterviewMessage.Sender.USER.name(), request.getUserAnswer(), null, nextOrder); + // 检查是否结束面试 + InterviewQuestionProgress progress = questionProgressMapper.selectOne( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) + .orderByDesc(InterviewQuestionProgress::getCreatedTime) + .last("limit 1") + ); + if (Objects.nonNull(progress) && Objects.equals(progress.getQuestionId(), request.getCurrentQuestionId())) { + + } + // 2. 评估回答 + Long currentQuestionId = evaluateAnswer(session, request.getUserAnswer()); + // 比对返回的id是否与当前id一致 + if (currentQuestionId.equals(0L)) { + return finishInterview(session); + } + InterviewQuestionProgress nextQuestionProgress = questionProgressMapper.selectOne( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) + .eq(InterviewQuestionProgress::getQuestionId, currentQuestionId) + .orderByDesc(InterviewQuestionProgress::getCreatedTime) + .last("limit 1") + ); + // 将ai返回的内容拼装返回给页面 + // 查询数据 + InterviewQuestionProgress currentQuestionData = questionProgressMapper.selectOne( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, request.getSessionId()) + .eq(InterviewQuestionProgress::getQuestionId, request.getCurrentQuestionId()) + .orderByDesc(InterviewQuestionProgress::getCreatedTime) + .last("limit 1") + ); + StringBuilder sb = new StringBuilder(); + if (Objects.nonNull(currentQuestionData)) { + if (StringUtils.isNotBlank(currentQuestionData.getFeedback())) { + sb.append(currentQuestionData.getFeedback()).append("\n"); + } + if (StringUtils.isNotBlank(currentQuestionData.getSuggestions())) { + sb.append(currentQuestionData.getSuggestions()).append("\n"); + } + if (StringUtils.isNotBlank(currentQuestionData.getAiAnswer())) { + sb.append(currentQuestionData.getAiAnswer()).append("\n"); + } + } + + if (!currentQuestionId.equals(request.getCurrentQuestionId())) { + // 5. 生成并保存AI的提问消息 + String nextQuestionContent = String.format("好的,下一个问题是:%s", nextQuestionProgress.getQuestionContent()); + sb.append(nextQuestionContent); + int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1; + saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(), + InterviewMessage.Sender.AI.name(), nextQuestionContent, currentQuestionId, messageOrder); + } + // 6. 返回响应 + return new InterviewResponse() + .setSessionId(session.getSessionId()) + .setMessage(sb.toString()) + .setMessageType(InterviewMessage.MessageType.QUESTION.name()) + .setSender(InterviewMessage.Sender.AI.name()) + .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) + .setTotalQuestions(session.getTotalQuestions()) + .setCurrentQuestionId(currentQuestionId) + .setStatus(InterviewSession.Status.ACTIVE.name()); + + } + + /** + * 导入题库(使用AI自动分类) + */ + + + /** + * 获取会话历史 + */ + public SessionHistoryResponse getSessionHistory(String sessionId) { + InterviewSession session = sessionMapper.selectBySessionId(sessionId); + if (session == null) { + throw new IllegalArgumentException("会话不存在: " + sessionId); + } + + List messages = messageMapper.selectBySessionIdOrderByOrder(sessionId); + List messageDtos = messages.stream() + .map(msg -> new SessionHistoryResponse.MessageDto() + .setMessageType(msg.getMessageType()) + .setSender(msg.getSender()) + .setContent(msg.getContent()) + .setMessageOrder(msg.getMessageOrder()) + .setCreatedTime(msg.getCreatedTime())) + .collect(Collectors.toList()); + + return new SessionHistoryResponse() + .setSessionId(sessionId) + .setCandidateName(session.getCandidateName()) + .setAiModel(session.getAiModel()) + .setStatus(session.getStatus()) + .setTotalQuestions(session.getTotalQuestions()) + .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) + .setCreatedTime(session.getCreatedTime()) + .setMessages(messageDtos); + } + + private String parseResume(MultipartFile resume) throws IOException { + String fileExtension = getFileExtension(resume.getOriginalFilename()); + DocumentParser parser = documentParsers.get(fileExtension); + if (parser == null) { + throw new IllegalArgumentException("不支持的简历文件类型: " + fileExtension); + } + return parser.parse(resume.getInputStream()); + } + + private List selectQuestionsByAi(String resumeContent, String sessionId) throws JsonProcessingException { + // 1. 获取全部题库 + List allQuestions = questionMapper.selectList(null); + String questionBankJson = objectMapper.writeValueAsString(allQuestions); + + // 2. 构建发送给AI的提示 + String prompt = String.format(""" + 你是一位专业的面试官。请根据以下候选人的简历内容,从提供的题库中,精心挑选出 %d 道最相关的题目进行面试。 + + 要求: + 1. 题目必须严格从【题库JSON】中选择。 + 2. 挑选的题目应根据候选人的简历内容来抽取。 + 3. 返回一个只包含所选题目ID的JSON数组,格式为:{"question_ids": [1, 5, 23, ...]}。 + 4. 不要返回任何多余的代码,包括markdown形式的代码,我只需要JSON对象,请严格按照api接口形式返回 + 5. 不要返回任何额外的解释或文字,只返回JSON对象。 + 6. 严格按照前后端分离的接口形式返回JSON数据给我,不要返回"```json```" + 7. 请保证返回数据的完整性,不要返回不完整的数据,否则我的JSON解析会报错!!! + + 【候选人简历】: + %s + + 【题库JSON】: + %s + """, MAX_QUESTIONS_PER_INTERVIEW, resumeContent, questionBankJson); + + // 3. 调用AI服务 + String aiResponse = llmService.chat(prompt); + log.info("AI抽题响应: {}", aiResponse); + + // 4. 解析AI返回的题目ID + List selectedIds = new ArrayList<>(); + try { + JsonNode rootNode = objectMapper.readTree(aiResponse); + JsonNode idsNode = rootNode.get("question_ids"); + if (idsNode != null && idsNode.isArray()) { + for (JsonNode idNode : idsNode) { + selectedIds.add(idNode.asLong()); + } + } + } catch (JsonProcessingException e) { + log.error("解析AI返回的题目ID列表失败", e); + return Collections.emptyList(); // 解析失败则返回空列表 + } + + if (selectedIds.isEmpty()) { + return Collections.emptyList(); + } + + // 5. 根据ID从数据库中获取完整的题目信息,并保持AI选择的顺序 + List finalQuestions = questionMapper.selectBatchIds(selectedIds); + finalQuestions.sort(Comparator.comparing(q -> selectedIds.indexOf(q.getId()))); // 保持AI返回的顺序 + + return finalQuestions; + } + + private InterviewSession createSession(String sessionId, InterviewStartRequest request, + String resumeContent, String selectedQuestionIdsJson) { + InterviewSession session = new InterviewSession() + .setSessionId(sessionId) + .setCandidateName(request.getCandidateName()) + .setResumeContent(resumeContent) + .setSelectedQuestionIds(selectedQuestionIdsJson) + .setAiModel("qwen-max") // Hardcoded to qwen-max + .setStatus(InterviewSession.Status.ACTIVE.name()) + .setCurrentQuestionIndex(0); + + sessionMapper.insert(session); + return session; + } + + private String generateFirstQuestion(InterviewSession session, Question question, String sessionId) { + String prompt = String.format(""" + 你是一位专业的技术面试官。现在要开始面试,候选人是 %s。 + + 第一个问题是:%s + + 请以友好但专业的语气提出这个问题,可以适当添加一些引导性的话语。 + """, session.getCandidateName(), question.getContent()); + + return this.llmService.chat(prompt, sessionId); + } + + private void saveMessage(String sessionId, String messageType, String sender, + String content, Long questionId, int order) { + InterviewMessage message = new InterviewMessage() + .setSessionId(sessionId) + .setMessageType(messageType) + .setSender(sender) + .setContent(content) + .setQuestionId(questionId) + .setMessageOrder(order); + + messageMapper.insert(message); + } + + /** + * 评估答案 + * + * @param session 会话数据 + * @param userAnswer 用户回答 + * @return 当前问题id + */ + private Long evaluateAnswer(InterviewSession session, String userAnswer) { + // 根据会话id查询当前会话所有问题 + List interviewQuestionProgresses = questionProgressMapper.selectList( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, session.getSessionId()) + .orderByAsc(InterviewQuestionProgress::getCreatedTime) + ); + if (CollectionUtil.isEmpty(interviewQuestionProgresses)) { + throw new RuntimeException("当前会话没有任何可询问的问题!"); + } + + // 1. 获取当前正在回答的问题 + InterviewQuestionProgress currentQuestionProgress = null; + for (InterviewQuestionProgress interviewQuestionProgress : interviewQuestionProgresses) { + if (interviewQuestionProgress.getStatus().equals(InterviewQuestionProgress.Status.ACTIVE.name())) { + currentQuestionProgress = interviewQuestionProgress; + break; + } + } + if (Objects.isNull(currentQuestionProgress)) { + throw new RuntimeException("当前没有正在回答的问题"); + } + Long currentQuestionId = currentQuestionProgress.getQuestionId(); + + + List questionIds = interviewQuestionProgresses.stream() + .map(data -> { + return data.getQuestionId().toString(); + }) + .collect(Collectors.toList()); + String join = String.join(",", questionIds); + // 2. 构建评估提示 + String prompt = String.format(""" + 你是一位资深的技术面试官。请根据以下问题和候选人的回答,进行一次专业的评估。 + + 要求: + 1. 对回答的质量进行打分,分数范围为1-5分。 + 2. 给出简洁、专业的评语。 + 3. 提出具体的改进建议以及你认为应该回答的答案。 + 4. 以严格的JSON格式返回,不要包含任何额外的解释文字。格式如下: + { + "score": 4.5, + "feedback": "回答基本正确,但可以更深入...", + "suggestions": "可以补充关于XXX方面的知识点...", + "answer": "关于当前问题,您应该这样回答xxx", + "currentQuestionId": xxx + } + 5. 不要返回任何多余字符,请严格按照api接口格式的JSON数据进行返回,不要包含"```json```" + 6. 如果你认为面试人对当前问题回答不完美,可以继续对当前问题进行补充提问,但不要修改currentQuestionId + 7. 如果你认为面试人对当前问题回答已经比较好了,或者面试人回答不上来了,请你根据questionIds数据顺序选择下一个问题,并修改currentQuestionId进行返回 + 8. 如果所有问题都已回答完成,请将currentQuestionId设置为0 + { + "questionIds": %s, + "currentQuestionId": %s + } + 【面试问题】: + %s + + 【候选人回答】: + %s + """, join, currentQuestionProgress.getQuestionId(), currentQuestionProgress.getQuestionContent(), userAnswer); + + // 3. 调用AI进行评估 + String aiResponse = llmService.chat(prompt, session.getSessionId()); + log.info("AI评估响应: {}", aiResponse); + + // 4. 解析AI响应并存储评估结果 + try { + JsonNode rootNode = objectMapper.readTree(aiResponse); + InterviewEvaluation evaluation = new InterviewEvaluation() + .setSessionId(session.getSessionId()) + .setQuestionId(currentQuestionId) + .setUserAnswer(userAnswer) + .setScore(new java.math.BigDecimal(rootNode.get("score").asText())) + .setAiFeedback(rootNode.get("feedback").asText()) + .setEvaluationCriteria(rootNode.get("suggestions").asText()); // 暂时复用这个字段存建议 + JsonNode currentQuestionId1 = rootNode.get("currentQuestionId"); + JsonNode aiAnswerNode = rootNode.get("answer"); + if (Objects.nonNull(currentQuestionId1)) { + String text = currentQuestionId1.asText(); + if (StringUtils.isNoneBlank(text)) { + currentQuestionProgress + .setScore(new BigDecimal(rootNode.get("score").asText())) + .setSuggestions(rootNode.get("suggestions").asText()) + .setFeedback(rootNode.get("feedback").asText()) + .setAiAnswer(Objects.nonNull(aiAnswerNode) ? aiAnswerNode.asText() : null) + .setUserAnswer(userAnswer) + ; + if (!StrUtil.equals(text, currentQuestionProgress.getQuestionId().toString())) { + currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name()); + questionProgressMapper.updateById(currentQuestionProgress); + questionProgressMapper.update( + new LambdaUpdateWrapper() + .set(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) + .eq(InterviewQuestionProgress::getSessionId, session.getSessionId()) + .eq(InterviewQuestionProgress::getQuestionId, Long.valueOf(text)) + ); + } else if (text.equals("0")) { + currentQuestionProgress.setStatus(InterviewQuestionProgress.Status.COMPLETED.name()); + questionProgressMapper.updateById(currentQuestionProgress); + } + currentQuestionId = Long.valueOf(text); + } + } + evaluationMapper.insert(evaluation); + log.info("成功存储对问题ID {} 的评估结果", currentQuestionId); + return currentQuestionId; + } catch (Exception e) { + log.error("解析或存储AI评估结果失败", e); + throw new RuntimeException("解析或存储AI评估结果失败"); + } + } + + private InterviewResponse finishInterview(InterviewSession session) { + // 1. 获取本次面试的所有评估数据 + List evaluations = evaluationMapper.selectBySessionId(session.getSessionId()); + + // 2. 构建生成最终报告的提示 + String prompt = buildFinalReportPrompt(session, evaluations); + + // 3. 调用AI生成报告 + String finalReportJson = llmService.chat(prompt, session.getSessionId()); + log.info("AI生成的最终面试报告: {}", finalReportJson); + + // 4. 更新会话状态和最终报告 + session.setStatus(InterviewSession.Status.COMPLETED.name()); + session.setFinalReport(finalReportJson); + sessionMapper.updateById(session); + + // 5. 返回结束信息 + return new InterviewResponse() + .setSessionId(session.getSessionId()) + .setMessage("面试已结束,感谢您的参与!AI正在生成您的面试报告,请稍后在面试历史中查看。") + .setMessageType(InterviewMessage.MessageType.SYSTEM.name()) + .setSender(InterviewMessage.Sender.SYSTEM.name()) + .setCurrentQuestionId(null) + .setStatus(InterviewSession.Status.COMPLETED.name()); + } + + private String buildFinalReportPrompt(InterviewSession session, List evaluations) { + StringBuilder historyBuilder = new StringBuilder(); + for (InterviewEvaluation eval : evaluations) { + Question q = questionMapper.selectById(eval.getQuestionId()); + historyBuilder.append(String.format("\n【问题】: %s\n【回答】: %s\n【AI单题反馈】: %s\n【AI单题建议】: %s\n【AI单题评分】: %s/5.0\n", + q.getContent(), eval.getUserAnswer(), eval.getAiFeedback(), eval.getEvaluationCriteria(), eval.getScore())); + } + + return String.format(""" + 你是一位资深的HR和技术总监。请根据以下候选人的简历、完整的面试问答历史和AI对每一题的初步评估,给出一份全面、专业、有深度的最终面试报告。 + + 要求: + 1. **综合评价**: 对候选人的整体表现给出一个总结性的评语,点出其核心亮点和主要不足。 + 2. **技术能力评估**: 分点阐述候选人在不同技术领域(如Java基础, Spring, 数据库等)的掌握程度。 + 3. **改进建议**: 给出3-5条具体的、可操作的学习和改进建议。 + 4. **综合得分**: 给出一个1-100分的最终综合得分。 + 5. **录用建议**: 给出明确的录用建议(如:强烈推荐、推荐、待考虑、不推荐)。 + 6. 以严格的JSON格式返回,不要包含任何额外的解释文字。格式如下: + { + "overallScore": 85, + "overallFeedback": "候选人Java基础扎实,但在高并发场景下的经验有所欠缺...", + "technicalAssessment": { + "Java基础": "掌握良好,对集合框架理解深入。", + "Spring框架": "熟悉基本使用,但对底层原理理解不足。", + "数据库": "能够编写常规SQL,但在索引优化方面知识欠缺。" + }, + "suggestions": [ + "深入学习Spring AOP和事务管理的实现原理。", + "系统学习MySQL索引优化和查询性能分析。", + "通过实际项目积累高并发处理经验。" + ], + "hiringRecommendation": "推荐" + } + + 【候选人简历摘要】: + %s + + 【面试问答与评估历史】: + %s + """, session.getResumeContent(), historyBuilder.toString()); + } + + private InterviewResponse generateNextQuestion(InterviewSession session) { + try { + // 1. 解析出AI选择的题目ID列表 + List selectedQuestionIds = objectMapper.readValue(session.getSelectedQuestionIds(), new com.fasterxml.jackson.core.type.TypeReference>() { + }); + + // 2. 获取下一个问题的索引 + int nextQuestionIndex = session.getCurrentQuestionIndex(); // 数据库中存的是已回答问题的数量 + if (nextQuestionIndex >= selectedQuestionIds.size()) { + return finishInterview(session); // 如果没有更多问题,则结束面试 + } + + // 3. 获取下一个问题的ID并从数据库查询 + Long nextQuestionId = selectedQuestionIds.get(nextQuestionIndex); + Question nextQuestion = questionMapper.selectById(nextQuestionId); + if (nextQuestion == null) { + log.error("无法找到ID为 {} 的问题,跳过此问题。", nextQuestionId); + // 更新会话状态并尝试下一个问题 + session.setCurrentQuestionIndex(nextQuestionIndex + 1); + sessionMapper.updateById(session); + return generateNextQuestion(session); // 递归调用以获取再下一个问题 + } + + // 4. 更新会话状态(当前问题索引+1) + session.setCurrentQuestionIndex(nextQuestionIndex + 1); + sessionMapper.updateById(session); + + // 5. 生成并保存AI的提问消息 + String questionContent = String.format("好的,下一个问题是:%s", nextQuestion.getContent()); + int messageOrder = messageMapper.selectMaxOrderBySessionId(session.getSessionId()) + 1; + saveMessage(session.getSessionId(), InterviewMessage.MessageType.QUESTION.name(), + InterviewMessage.Sender.AI.name(), questionContent, nextQuestion.getId(), messageOrder); + + // 6. 返回响应 + return new InterviewResponse() + .setSessionId(session.getSessionId()) + .setMessage(questionContent) + .setMessageType(InterviewMessage.MessageType.QUESTION.name()) + .setSender(InterviewMessage.Sender.AI.name()) + .setCurrentQuestionIndex(session.getCurrentQuestionIndex()) + .setTotalQuestions(session.getTotalQuestions()) + .setStatus(InterviewSession.Status.ACTIVE.name()); + + } catch (JsonProcessingException e) { + log.error("解析会话中的题目ID列表失败", e); + return finishInterview(session); // 解析失败则直接结束面试 + } + } + + + /** + * 获取所有面试会话列表 + */ + public List getInterviewSessions() { + log.info("Fetching all interview sessions"); + return sessionMapper.selectList(null); // 实际中可能需要分页 + } + + /** + * 获取详细的面试复盘报告 + */ + public InterviewReportResponse getInterviewReport(String sessionId) { + log.info("Fetching interview report for session id: {}", sessionId); + + InterviewSession session = sessionMapper.selectBySessionId(sessionId); + if (session == null) { + throw new IllegalArgumentException("找不到ID为 " + sessionId + " 的面试会话。"); + } + + List evaluations = evaluationMapper.selectBySessionId(sessionId); + + List questionDetails = evaluations.stream().map(eval -> { + Question question = questionMapper.selectById(eval.getQuestionId()); + InterviewReportResponse.QuestionDetail detail = new InterviewReportResponse.QuestionDetail(); + detail.setQuestionId(eval.getQuestionId()); + detail.setQuestionContent(question != null ? question.getContent() : "题目已不存在"); + detail.setUserAnswer(eval.getUserAnswer()); + detail.setAiFeedback(eval.getAiFeedback()); + detail.setSuggestions(eval.getEvaluationCriteria()); + detail.setScore(eval.getScore()); + return detail; + }).collect(Collectors.toList()); + + InterviewReportResponse report = new InterviewReportResponse(); + report.setSessionDetails(session); + report.setQuestionDetails(questionDetails); + List interviewMessages = messageMapper.selectList( + new LambdaQueryWrapper() + .eq(InterviewMessage::getSessionId, sessionId) + ); + // 获取当前面试的 问题 + InterviewQuestionProgress progress = questionProgressMapper.selectOne( + new LambdaQueryWrapper() + .eq(InterviewQuestionProgress::getSessionId, sessionId) + .eq(InterviewQuestionProgress::getStatus, InterviewQuestionProgress.Status.ACTIVE.name()) + .last("LIMIT 1") + ); + if (Objects.nonNull(progress)) { + report.setCurrentQuestionId(progress.getQuestionId()); + } + report.setMessages(interviewMessages); + + return report; + } + + private String getFileExtension(String fileName) { + if (fileName == null || fileName.lastIndexOf('.') == -1) { + return ""; + } + return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + } +} + diff --git a/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java b/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java new file mode 100644 index 0000000..d7e2ac2 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java @@ -0,0 +1,138 @@ +package com.qingqiu.interview.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qingqiu.interview.entity.Question; +import com.qingqiu.interview.service.llm.LlmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QuestionClassificationService { + + private final LlmService llmService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 使用AI对题目进行分类 + */ + public List classifyQuestions(String rawContent) { + log.info("开始使用AI分类题目,内容长度: {}", rawContent.length()); + + String prompt = buildClassificationPrompt(rawContent); + String aiResponse = llmService.chat(prompt); + + log.info("AI分类响应: {}", aiResponse); + + return parseAiResponse(aiResponse); + } + + private String buildClassificationPrompt(String content) { + return """ + 请分析以下面试题内容,将其分类并提取信息。请严格按照以下JSON格式返回结果: + + { + "questions": [ + { + "content": "题目内容", + "category": "分类(如:Java基础、Spring框架、数据库、算法、系统设计等)", + "difficulty": "难度(Easy、Medium、Hard)", + "tags": "相关标签,用逗号分隔" + } + ] + } + + 分类规则: + 1. category应该是具体的技术领域,如:Java基础、Spring框架、MySQL、Redis、算法与数据结构、系统设计、网络协议等 + 2. difficulty根据题目复杂度判断:Easy(基础概念)、Medium(实际应用)、Hard(深入原理或复杂场景) + 3. tags包含更细粒度的标签,如:多线程、JVM、事务、索引等 + 4. 如果内容包含多个独立的题目,请分别提取 + 5. 只返回JSON,不要其他解释文字 + + 待分析内容: + """ + content; + } + + private List parseAiResponse(String aiResponse) { + List questions = new ArrayList<>(); + + try { + // 清理响应,移除可能的markdown标记 + String cleanResponse = aiResponse.trim(); + if (cleanResponse.startsWith("```json")) { + cleanResponse = cleanResponse.substring(7); + } + if (cleanResponse.endsWith("```")) { + cleanResponse = cleanResponse.substring(0, cleanResponse.length() - 3); + } + cleanResponse = cleanResponse.trim(); + + JsonNode rootNode = objectMapper.readTree(cleanResponse); + JsonNode questionsNode = rootNode.get("questions"); + + if (questionsNode != null && questionsNode.isArray()) { + for (JsonNode questionNode : questionsNode) { + Question question = new Question() + .setContent(getTextValue(questionNode, "content")) + .setCategory(getTextValue(questionNode, "category")) + .setDifficulty(getTextValue(questionNode, "difficulty")) + .setTags(getTextValue(questionNode, "tags")); + + if (isValidQuestion(question)) { + questions.add(question); + } + } + } + + log.info("成功解析出 {} 个题目", questions.size()); + + } catch (JsonProcessingException e) { + log.error("解析AI响应失败: {}", e.getMessage()); + log.error("原始响应: {}", aiResponse); + + // 降级处理:如果AI返回格式不正确,尝试简单分割 + questions.addAll(fallbackParsing(aiResponse)); + } + + return questions; + } + + private String getTextValue(JsonNode node, String fieldName) { + JsonNode fieldNode = node.get(fieldName); + return fieldNode != null ? fieldNode.asText("") : ""; + } + + private boolean isValidQuestion(Question question) { + return question.getContent() != null && !question.getContent().trim().isEmpty() + && question.getCategory() != null && !question.getCategory().trim().isEmpty(); + } + + private List fallbackParsing(String content) { + log.warn("使用降级解析策略"); + List questions = new ArrayList<>(); + + // 简单的降级策略:按行分割,每行作为一个题目 + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && line.length() > 10) { // 过滤太短的内容 + Question question = new Question() + .setContent(line) + .setCategory("未分类") + .setDifficulty("Medium") + .setTags("待分类"); + questions.add(question); + } + } + + return questions; + } +} diff --git a/src/main/java/com/qingqiu/interview/service/QuestionService.java b/src/main/java/com/qingqiu/interview/service/QuestionService.java new file mode 100644 index 0000000..eae9abc --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/QuestionService.java @@ -0,0 +1,177 @@ +package com.qingqiu.interview.service; + +import cn.hutool.core.collection.CollectionUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.qingqiu.interview.dto.QuestionPageParams; +import com.qingqiu.interview.entity.Question; +import com.qingqiu.interview.mapper.QuestionMapper; +import com.qingqiu.interview.service.llm.LlmService; +import com.qingqiu.interview.service.parser.DocumentParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class QuestionService { + + private final QuestionMapper questionMapper; + private final QuestionClassificationService classificationService; + private final List documentParserList; // This will be injected by Spring + private final LlmService llmService; + + /** + * 分页查询题库 + */ + public Page getQuestionPage(QuestionPageParams params) { + log.info("分页查询题库,当前页: {}, 每页数量: {}", params.getCurrent(), params.getSize()); + return questionMapper.selectPage( + Page.of(params.getCurrent(), params.getSize()), + new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(params.getContent()), Question::getContent, params.getContent()) + .orderByDesc(Question::getCreatedTime) + ); + } + + /** + * 新增题目,并进行重复校验 + */ + public void addQuestion(Question question) { + validateQuestion(question.getContent(), null); + log.info("新增题目: {}", question.getContent()); + questionMapper.insert(question); + } + + /** + * 更新题目,并进行重复校验 + */ + public void updateQuestion(Question question) { + validateQuestion(question.getContent(), question.getId()); + log.info("更新题目ID: {}", question.getId()); + questionMapper.updateById(question); + } + + /** + * 删除题目 + */ + public void deleteQuestion(Long id) { + log.info("删除题目ID: {}", id); + questionMapper.deleteById(id); + } + + /** + * AI批量导入题库,并进行去重 + */ + public void importQuestionsFromFile(MultipartFile file) throws IOException { + log.info("开始从文件导入题库: {}", file.getOriginalFilename()); + String fileExtension = getFileExtension(file.getOriginalFilename()); + DocumentParser parser = documentParserList.stream() + .filter(p -> p.getSupportedType().equals(fileExtension)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("不支持的文件类型: " + fileExtension)); + + String content = parser.parse(file.getInputStream()); + List questionsFromAi = classificationService.classifyQuestions(content); + + int newQuestionsCount = 0; + for (Question question : questionsFromAi) { + try { + validateQuestion(question.getContent(), null); + questionMapper.insert(question); + newQuestionsCount++; + } catch (IllegalArgumentException e) { + log.warn("跳过重复题目: {}", question.getContent()); + } + } + log.info("成功导入 {} 个新题目,跳过 {} 个重复题目。", newQuestionsCount, questionsFromAi.size() - newQuestionsCount); + } + + /** + * 调用AI检查题库中的数据是否重复 + */ + @Transactional(rollbackFor = Exception.class) + public void useAiCheckQuestionData() { + // 查询数据库 + List questions = questionMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(Question::getCreatedTime) + ); + // 组装prompt + if (CollectionUtil.isEmpty(questions)) { + return; + } + String prompt = getPrompt(questions); + log.info("发送内容: {}", prompt); + // 验证token上下文长度 + Integer promptTokens = llmService.getPromptTokens(prompt); + log.info("当前prompt长度: {}", promptTokens); + String chat = llmService.chat(prompt); + // 调用AI + log.info("AI返回内容: {}", chat); + JSONObject parse = JSONObject.parse(chat); + JSONArray questionsIds = parse.getJSONArray("questions"); + List list = questionsIds.toList(Long.class); + questionMapper.delete( + new LambdaQueryWrapper() + .notIn(Question::getId, list) + ); + } + + @NotNull + private static String getPrompt(List questions) { + JSONArray jsonArray = new JSONArray(); + for (Question question : questions) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("id", question.getId()); + jsonObject.put("content", question.getContent()); + jsonArray.add(jsonObject); + } + JSONObject jsonObject = new JSONObject(); + jsonObject.put("data", jsonArray); + return String.format(""" + 请对以下数据进行重复校验,如果题目内容相似,请只保留1条数据,并返回对应数据的id。请严格按照以下JSON格式返回结果: + + { + "questions": [1, 2, 3, .....] + } + + 分类规则: + 1. 只返回JSON,不要其他解释文字 + 2. 请严格按照API接口形式返回,不要返回任何额外的文字内容,包括'```json```'!!!! + 3. 请严格按照网络接口的形式返回JSON数据!!! + 数据如下: + %s + """, jsonObject.toJSONString()); + } + + /** + * 校验题目内容是否重复 + * + * @param content 题目内容 + * @param currentId 当前题目ID,更新时传入,用于排除自身 + */ + private void validateQuestion(String content, Long currentId) { + Question existingQuestion = questionMapper.selectByContent(content); + if (existingQuestion != null && (currentId == null || !existingQuestion.getId().equals(currentId))) { + throw new IllegalArgumentException("题目内容已存在,请勿重复添加。"); + } + } + + private String getFileExtension(String fileName) { + if (fileName == null || fileName.lastIndexOf('.') == -1) { + return ""; + } + return fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + } +} diff --git a/src/main/java/com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.java new file mode 100644 index 0000000..dd2fd0d --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.java @@ -0,0 +1,20 @@ +package com.qingqiu.interview.service.impl; + +import com.qingqiu.interview.entity.AiSessionLog; +import com.qingqiu.interview.mapper.AiSessionLogMapper; +import com.qingqiu.interview.service.IAiSessionLogService; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.springframework.stereotype.Service; + +/** + *

+ * ai会话记录 服务实现类 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@Service +public class AiSessionLogServiceImpl extends ServiceImpl implements IAiSessionLogService { + +} diff --git a/src/main/java/com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.java b/src/main/java/com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.java new file mode 100644 index 0000000..ff21eca --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.java @@ -0,0 +1,43 @@ +package com.qingqiu.interview.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.qingqiu.interview.dto.QuestionProgressPageParams; +import com.qingqiu.interview.entity.InterviewQuestionProgress; +import com.qingqiu.interview.mapper.InterviewQuestionProgressMapper; +import com.qingqiu.interview.service.IInterviewQuestionProgressService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Arrays; + +/** + *

+ * 面试问题进度跟踪表 服务实现类 + *

+ * + * @author huangpeng + * @since 2025-08-30 + */ +@Service +public class InterviewQuestionProgressServiceImpl extends ServiceImpl implements IInterviewQuestionProgressService { + + @Override + public Page pageList(QuestionProgressPageParams params) { + return page( + Page.of(params.getCurrent(), params.getSize()), + new LambdaQueryWrapper() + .like(StringUtils.isNotBlank(params.getQuestionName()), InterviewQuestionProgress::getQuestionContent, params.getQuestionName()) + .in(InterviewQuestionProgress::getStatus, + Arrays.asList( + InterviewQuestionProgress.Status.ACTIVE.name(), + InterviewQuestionProgress.Status.COMPLETED.name() + ) + ) + .orderByAsc(InterviewQuestionProgress::getStatus) + .orderByDesc(InterviewQuestionProgress::getUpdatedTime) + .orderByDesc(InterviewQuestionProgress::getCreatedTime) + ); + } +} diff --git a/src/main/java/com/qingqiu/interview/service/llm/LlmService.java b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java new file mode 100644 index 0000000..ebf288d --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/llm/LlmService.java @@ -0,0 +1,23 @@ +package com.qingqiu.interview.service.llm; + +public interface LlmService { + + + /** + * 与模型进行单轮对话 + * @param prompt 提示词 + * @return ai回复 + */ + String chat(String prompt); + + /** + * 与模型进行多轮对话 + * @param prompt 提示词 + * @param token 会话token + * @return ai回复 + */ + String chat(String prompt, String token); + + Integer getPromptTokens(String prompt); +} + diff --git a/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java new file mode 100644 index 0000000..9dacdb2 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java @@ -0,0 +1,181 @@ +package com.qingqiu.interview.service.llm.qwen; + +import cn.hutool.core.collection.CollectionUtil; +import com.alibaba.dashscope.aigc.generation.Generation; +import com.alibaba.dashscope.common.Message; +import com.alibaba.dashscope.common.Role; +import com.alibaba.dashscope.tokenizers.Tokenizer; +import com.alibaba.dashscope.tokenizers.TokenizerFactory; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.qingqiu.interview.ai.factory.AIClientManager; +import com.qingqiu.interview.common.constants.AIStrategyConstant; +import com.qingqiu.interview.entity.AiSessionLog; +import com.qingqiu.interview.mapper.AiSessionLogMapper; +import com.qingqiu.interview.service.llm.LlmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static com.qingqiu.interview.common.utils.AIUtils.createMessage; + +@Slf4j +@Service("qwenService") +@RequiredArgsConstructor +public class QwenService implements LlmService { + + private final Generation generation; + + private final AiSessionLogMapper aiSessionLogMapper; + + @Value("${dashscope.api-key}") + private String apiKey; + + private final AIClientManager aiClientManager; + + + @Override + public String chat(String prompt) { +// log.info("开始调用API...."); +// long l = System.currentTimeMillis(); + return aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(prompt); +// GenerationParam param = GenerationParam.builder() +// .model(DEEPSEEK_3) // 可根据需要更换模型 +// .messages(Collections.singletonList(createMessage(Role.USER.getValue(), prompt))) +// .resultFormat(GenerationParam.ResultFormat.MESSAGE) +// .apiKey(apiKey) +// .build(); +// +// GenerationResult result = null; +// try { +// result = generation.call(param); +// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l); +// log.debug("响应结果: {}", result.getOutput().getChoices().get(0).getMessage().getContent()); +// return result.getOutput().getChoices().get(0).getMessage().getContent(); +// } catch (ApiException | InputRequiredException e) { +// throw new RuntimeException("调用AI服务失败", e); +// } catch (NoApiKeyException e) { +// throw new RuntimeException("请检查API密钥是否正确", e); +// } + } + + @Override + public String chat(String prompt, String token) { + // 根据token查询会话记录 + List aiSessionLogs = aiSessionLogMapper.selectList( + new LambdaQueryWrapper() + .eq(AiSessionLog::getToken, token) + .orderByDesc(AiSessionLog::getCreatedTime) + ); + // 构造发给ai的消息 + List messages = new ArrayList<>(); + if (CollectionUtil.isNotEmpty(aiSessionLogs)) { + // 预估tokens + StringBuilder sb = new StringBuilder(); + for (AiSessionLog aiSessionLog : aiSessionLogs) { + sb.append(aiSessionLog.getContent()); + } + // 加上本次对话内容 + sb.append(prompt); + Integer promptTokens = getPromptTokens(sb.toString()); + // 如果token大于了模型上限,则执行丢弃操作 + int size = aiSessionLogs.size(); + log.info("当前会话id: {}, tokens: {}", token, promptTokens); + + // 假设模型上限为30000个token(根据实际模型调整) + int maxTokens = 100000; + if (promptTokens > maxTokens) { + // 需要丢弃30%的会话记录(按时间倒序,丢弃最旧的) + int discardCount = (int) (size * 0.3); + // 从当前会话记录列表中移除旧的会话记录,而不是删除数据库中的记录 + for (int i = 0; i < discardCount; i++) { + aiSessionLogs.remove(aiSessionLogs.size() - 1); + } + } + // 移除旧记录后再按时间正序排序(最旧的在前面,最新的在后面) + aiSessionLogs = aiSessionLogs.stream() + .sorted((log1, log2) -> log1.getCreatedTime().compareTo(log2.getCreatedTime())) + .collect(Collectors.toList()); + for (AiSessionLog aiSessionLog : aiSessionLogs) { + messages.add( + createMessage(aiSessionLog.getRole(), aiSessionLog.getContent()) + ); + } + + + } + + messages.add( + createMessage(Role.USER.getValue(), prompt) + ); + + String aiResponse = aiClientManager.getClient(AIStrategyConstant.DEEPSEEK).chatCompletion(messages); + // 存储用户提问 + AiSessionLog userLog = new AiSessionLog(); + userLog.setToken(token); + userLog.setRole(Role.USER.getValue()); + userLog.setContent(prompt); + aiSessionLogMapper.insert(userLog); + + // 存储AI回复 + AiSessionLog aiLog = new AiSessionLog(); + aiLog.setToken(token); + aiLog.setRole(Role.ASSISTANT.getValue()); + aiLog.setContent(aiResponse); + aiSessionLogMapper.insert(aiLog); + return aiResponse; + +// // 调用AI模型 +// try { +// log.info("开始调用API...."); +// long l = System.currentTimeMillis(); +// GenerationParam param = GenerationParam.builder() +// .model(DEEPSEEK_3_1) // 可根据需要更换模型 +// .messages(messages) +// .resultFormat(GenerationParam.ResultFormat.MESSAGE) +// .apiKey(apiKey) +// .build(); +// +// GenerationResult result = generation.call(param); +// log.info("调用成功,耗时: {} ms", System.currentTimeMillis() - l); +// String aiResponse = result.getOutput().getChoices().get(0).getMessage().getContent(); +// log.debug("响应结果: {}", aiResponse); +// // 存储用户提问 +// AiSessionLog userLog = new AiSessionLog(); +// userLog.setToken(token); +// userLog.setRole(Role.USER.getValue()); +// userLog.setContent(prompt); +// aiSessionLogMapper.insert(userLog); +// +// // 存储AI回复 +// AiSessionLog aiLog = new AiSessionLog(); +// aiLog.setToken(token); +// aiLog.setRole(Role.ASSISTANT.getValue()); +// aiLog.setContent(aiResponse); +// aiSessionLogMapper.insert(aiLog); +// +// return aiResponse; +// } catch (ApiException | NoApiKeyException | InputRequiredException e) { +// throw new RuntimeException("调用AI服务失败", e); +// } + } + + /** + * 获取prompt的token数 + * + * @param prompt 输入 + * @return tokens + */ + @Override + public Integer getPromptTokens(String prompt) { + Tokenizer tokenizer = TokenizerFactory.qwen(); + List integers = tokenizer.encodeOrdinary(prompt); + return integers.size(); + } + +} + diff --git a/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java b/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java new file mode 100644 index 0000000..c47d5f8 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java @@ -0,0 +1,19 @@ +package com.qingqiu.interview.service.parser; + +import java.io.InputStream; + +public interface DocumentParser { + /** + * 解析文档内容 + * @param inputStream 文档输入流 + * @return 文档的文本内容 + */ + String parse(InputStream inputStream); + + /** + * 获取支持的文件类型 + * @return "pdf", "md", etc. + */ + String getSupportedType(); +} + diff --git a/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java b/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java new file mode 100644 index 0000000..e917acf --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java @@ -0,0 +1,32 @@ +package com.qingqiu.interview.service.parser; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.text.TextContentRenderer; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.io.InputStreamReader; + +@Service("mdParser") +public class MarkdownParserService implements DocumentParser { + + private final Parser parser = Parser.builder().build(); + private final TextContentRenderer renderer = TextContentRenderer.builder().build(); + + @Override + public String parse(InputStream inputStream) { + try (InputStreamReader reader = new InputStreamReader(inputStream)) { + Node document = parser.parseReader(reader); + return renderer.render(document); + } catch (Exception e) { + throw new RuntimeException("Failed to parse Markdown document", e); + } + } + + @Override + public String getSupportedType() { + return "md"; + } +} + diff --git a/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java b/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java new file mode 100644 index 0000000..9f1b022 --- /dev/null +++ b/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java @@ -0,0 +1,57 @@ +package com.qingqiu.interview.service.parser; + + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSDocument; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.fdf.FDFDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.Objects; + +@Service("pdfParser") +public class PdfParserService implements DocumentParser { + + /** + * 解析 PDF 文档内容 + * @param inputStream PDF 文件输入流 + * @return 提取的文本内容 + */ + @Override + public String parse(InputStream inputStream) { + // 检查输入流是否为 null,避免空指针异常 + Objects.requireNonNull(inputStream, "PDF文件输入流不能为空"); + + // 使用 try-with-resources 确保 PDDocument 资源自动关闭 + + try (PDDocument document = Loader.loadPDF(inputStream.readAllBytes())) { + + // 创建 PDF 文本提取器 + PDFTextStripper pdfStripper = new PDFTextStripper(); + + // 配置提取参数 + pdfStripper.setSortByPosition(true); // 按位置排序,保持原始布局 + pdfStripper.setAddMoreFormatting(true); // 保留更多格式信息 + pdfStripper.setSuppressDuplicateOverlappingText(true); // 去除重复文本 + + // 执行文本提取并返回结果 + return pdfStripper.getText(document); + + } catch (Exception e) { + // 处理其他未知异常 + throw new RuntimeException("解析PDF时发生未知错误", e); + } + } + + /** + * 获取该解析器支持的文档类型 + * @return 支持的文档类型标识(此处为"pdf") + */ + @Override + public String getSupportedType() { + return "pdf"; // 返回支持的文档类型 + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..f2d48f0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +dashscope: + api-key: sk-58d6fed688c54e8db02e6a7ffbfc7a5f +deepseek: + api-url: https://api.deepseek.com/chat/completions + api-key: sk-faaa2a1b485442ccbf115ff1271a3480 +spring: + datasource: + url: jdbc:mysql://gz-cynosdbmysql-grp-5ai5zw7r.sql.tencentcdb.com:24944/ai_interview?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 + username: qingqiu + password: 020979hP + driver-class-name: com.mysql.cj.jdbc.Driver +# ai: +# openai: +# api-key: sk-faaa2a1b485442ccbf115ff1271a3480 +# base-url: https://api.deepseek.com +# chat: +# options: +# model: deepseek-chat +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + logic-delete-field: deleted # 全局逻辑删除字段名 + logic-delete-value: 1 # 逻辑已删除值。可选,默认值为 1 + logic-not-delete-value: 0 # 逻辑未删除值。可选,默认值为 0 \ No newline at end of file diff --git a/src/main/resources/mapper/AIClientService.xml b/src/main/resources/mapper/AIClientService.xml new file mode 100644 index 0000000..257def2 --- /dev/null +++ b/src/main/resources/mapper/AIClientService.xml @@ -0,0 +1,30 @@ + + + + + + + + + + UPDATE interview_session + SET status = #{status}, updated_time = NOW() + WHERE session_id = #{sessionId} + + + + + diff --git a/src/main/resources/mapper/AiSessionLogMapper.xml b/src/main/resources/mapper/AiSessionLogMapper.xml new file mode 100644 index 0000000..0c48b49 --- /dev/null +++ b/src/main/resources/mapper/AiSessionLogMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/InterviewEvaluationMapper.xml b/src/main/resources/mapper/InterviewEvaluationMapper.xml new file mode 100644 index 0000000..7093c10 --- /dev/null +++ b/src/main/resources/mapper/InterviewEvaluationMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/src/main/resources/mapper/InterviewMessageMapper.xml b/src/main/resources/mapper/InterviewMessageMapper.xml new file mode 100644 index 0000000..d938622 --- /dev/null +++ b/src/main/resources/mapper/InterviewMessageMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/src/main/resources/mapper/InterviewQuestionProgressMapper.xml b/src/main/resources/mapper/InterviewQuestionProgressMapper.xml new file mode 100644 index 0000000..5de5868 --- /dev/null +++ b/src/main/resources/mapper/InterviewQuestionProgressMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/mapper/QuestionMapper.xml b/src/main/resources/mapper/QuestionMapper.xml new file mode 100644 index 0000000..be5f22e --- /dev/null +++ b/src/main/resources/mapper/QuestionMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 0000000..29b601e --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,57 @@ +-- 题库表 +CREATE TABLE IF NOT EXISTS question ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + content TEXT NOT NULL COMMENT '题目内容', + category VARCHAR(100) NOT NULL COMMENT '题目分类', + difficulty VARCHAR(20) NOT NULL COMMENT '难度等级', + tags VARCHAR(500) COMMENT '标签,逗号分隔', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记' +); + +-- 面试会话表 +CREATE TABLE IF NOT EXISTS interview_session ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) UNIQUE NOT NULL COMMENT '会话唯一标识', + candidate_name VARCHAR(100) COMMENT '候选人姓名', + resume_content TEXT COMMENT '简历内容', + extracted_skills TEXT COMMENT '提取的技能,JSON格式', + ai_model VARCHAR(50) NOT NULL COMMENT '使用的AI模型', + status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '会话状态:ACTIVE, COMPLETED, TERMINATED', + total_questions INT DEFAULT 0 COMMENT '总问题数', + current_question_index INT DEFAULT 0 COMMENT '当前问题索引', + score DECIMAL(5,2) COMMENT '面试评分', + selected_question_ids TEXT COMMENT 'AI选择的题目ID列表,JSON格式', + final_report TEXT COMMENT 'AI生成的最终面试报告,JSON格式', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT DEFAULT 0 +); + +-- 面试消息记录表 +CREATE TABLE IF NOT EXISTS interview_message ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL COMMENT '会话ID', + message_type VARCHAR(20) NOT NULL COMMENT '消息类型:QUESTION, ANSWER, SYSTEM', + sender VARCHAR(20) NOT NULL COMMENT '发送者:AI, USER, SYSTEM', + content TEXT NOT NULL COMMENT '消息内容', + question_id BIGINT COMMENT '关联的题目ID', + message_order INT NOT NULL COMMENT '消息顺序', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id), + INDEX idx_session_order (session_id, message_order) +); + +-- 面试评估表 +CREATE TABLE IF NOT EXISTS interview_evaluation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL COMMENT '会话ID', + question_id BIGINT COMMENT '题目ID', + user_answer TEXT COMMENT '用户回答', + ai_feedback TEXT COMMENT 'AI反馈', + score DECIMAL(3,1) COMMENT '单题得分', + evaluation_criteria TEXT COMMENT '评估标准,JSON格式', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id) +); diff --git a/src/test/java/com/qingqiu/interview/AiInterviewApplicationTests.java b/src/test/java/com/qingqiu/interview/AiInterviewApplicationTests.java new file mode 100644 index 0000000..e186a36 --- /dev/null +++ b/src/test/java/com/qingqiu/interview/AiInterviewApplicationTests.java @@ -0,0 +1,13 @@ +package com.qingqiu.interview; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AiInterviewApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 0000000..f2d48f0 --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1,27 @@ +dashscope: + api-key: sk-58d6fed688c54e8db02e6a7ffbfc7a5f +deepseek: + api-url: https://api.deepseek.com/chat/completions + api-key: sk-faaa2a1b485442ccbf115ff1271a3480 +spring: + datasource: + url: jdbc:mysql://gz-cynosdbmysql-grp-5ai5zw7r.sql.tencentcdb.com:24944/ai_interview?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 + username: qingqiu + password: 020979hP + driver-class-name: com.mysql.cj.jdbc.Driver +# ai: +# openai: +# api-key: sk-faaa2a1b485442ccbf115ff1271a3480 +# base-url: https://api.deepseek.com +# chat: +# options: +# model: deepseek-chat +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + logic-delete-field: deleted # 全局逻辑删除字段名 + logic-delete-value: 1 # 逻辑已删除值。可选,默认值为 1 + logic-not-delete-value: 0 # 逻辑未删除值。可选,默认值为 0 \ No newline at end of file diff --git a/target/classes/mapper/AIClientService.xml b/target/classes/mapper/AIClientService.xml new file mode 100644 index 0000000..257def2 --- /dev/null +++ b/target/classes/mapper/AIClientService.xml @@ -0,0 +1,30 @@ + + + + + + + + + + UPDATE interview_session + SET status = #{status}, updated_time = NOW() + WHERE session_id = #{sessionId} + + + + + diff --git a/target/classes/mapper/AiSessionLogMapper.xml b/target/classes/mapper/AiSessionLogMapper.xml new file mode 100644 index 0000000..0c48b49 --- /dev/null +++ b/target/classes/mapper/AiSessionLogMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/target/classes/mapper/InterviewEvaluationMapper.xml b/target/classes/mapper/InterviewEvaluationMapper.xml new file mode 100644 index 0000000..7093c10 --- /dev/null +++ b/target/classes/mapper/InterviewEvaluationMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/target/classes/mapper/InterviewMessageMapper.xml b/target/classes/mapper/InterviewMessageMapper.xml new file mode 100644 index 0000000..d938622 --- /dev/null +++ b/target/classes/mapper/InterviewMessageMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/target/classes/mapper/InterviewQuestionProgressMapper.xml b/target/classes/mapper/InterviewQuestionProgressMapper.xml new file mode 100644 index 0000000..5de5868 --- /dev/null +++ b/target/classes/mapper/InterviewQuestionProgressMapper.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/target/classes/mapper/QuestionMapper.xml b/target/classes/mapper/QuestionMapper.xml new file mode 100644 index 0000000..be5f22e --- /dev/null +++ b/target/classes/mapper/QuestionMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/target/classes/sql/schema.sql b/target/classes/sql/schema.sql new file mode 100644 index 0000000..29b601e --- /dev/null +++ b/target/classes/sql/schema.sql @@ -0,0 +1,57 @@ +-- 题库表 +CREATE TABLE IF NOT EXISTS question ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + content TEXT NOT NULL COMMENT '题目内容', + category VARCHAR(100) NOT NULL COMMENT '题目分类', + difficulty VARCHAR(20) NOT NULL COMMENT '难度等级', + tags VARCHAR(500) COMMENT '标签,逗号分隔', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记' +); + +-- 面试会话表 +CREATE TABLE IF NOT EXISTS interview_session ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) UNIQUE NOT NULL COMMENT '会话唯一标识', + candidate_name VARCHAR(100) COMMENT '候选人姓名', + resume_content TEXT COMMENT '简历内容', + extracted_skills TEXT COMMENT '提取的技能,JSON格式', + ai_model VARCHAR(50) NOT NULL COMMENT '使用的AI模型', + status VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '会话状态:ACTIVE, COMPLETED, TERMINATED', + total_questions INT DEFAULT 0 COMMENT '总问题数', + current_question_index INT DEFAULT 0 COMMENT '当前问题索引', + score DECIMAL(5,2) COMMENT '面试评分', + selected_question_ids TEXT COMMENT 'AI选择的题目ID列表,JSON格式', + final_report TEXT COMMENT 'AI生成的最终面试报告,JSON格式', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted TINYINT DEFAULT 0 +); + +-- 面试消息记录表 +CREATE TABLE IF NOT EXISTS interview_message ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL COMMENT '会话ID', + message_type VARCHAR(20) NOT NULL COMMENT '消息类型:QUESTION, ANSWER, SYSTEM', + sender VARCHAR(20) NOT NULL COMMENT '发送者:AI, USER, SYSTEM', + content TEXT NOT NULL COMMENT '消息内容', + question_id BIGINT COMMENT '关联的题目ID', + message_order INT NOT NULL COMMENT '消息顺序', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id), + INDEX idx_session_order (session_id, message_order) +); + +-- 面试评估表 +CREATE TABLE IF NOT EXISTS interview_evaluation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(64) NOT NULL COMMENT '会话ID', + question_id BIGINT COMMENT '题目ID', + user_answer TEXT COMMENT '用户回答', + ai_feedback TEXT COMMENT 'AI反馈', + score DECIMAL(3,1) COMMENT '单题得分', + evaluation_criteria TEXT COMMENT '评估标准,JSON格式', + created_time DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id) +); diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..3bd8127 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=AI-Interview +groupId=com.qingqiu +version=0.0.1-SNAPSHOT diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..71f513a --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,55 @@ +com/qingqiu/interview/controller/InterviewQuestionProgressController.class +com/qingqiu/interview/dto/ChatRequest.class +com/qingqiu/interview/service/QuestionClassificationService.class +com/qingqiu/interview/db/entity/InterviewSession.class +com/qingqiu/interview/dto/DashboardStatsResponse$CategoryStat.class +com/qingqiu/interview/db/mapper/InterviewEvaluationMapper.class +com/qingqiu/interview/dto/ApiResponse.class +com/qingqiu/interview/controller/QuestionController.class +com/qingqiu/interview/db/entity/InterviewMessage.class +com/qingqiu/interview/controller/InterviewController.class +com/qingqiu/interview/controller/AiSessionLogController.class +com/qingqiu/interview/db/entity/AiSessionLog.class +com/qingqiu/interview/dto/DashboardStatsResponse$CategoryScoreStat.class +com/qingqiu/interview/dto/InterviewReportResponse$QuestionDetail.class +com/qingqiu/interview/db/mapper/InterviewMessageMapper.class +com/qingqiu/interview/config/JacksonConfig.class +com/qingqiu/interview/db/entity/InterviewQuestionProgress.class +com/qingqiu/interview/service/llm/LlmService.class +com/qingqiu/interview/service/InterviewService$1.class +com/qingqiu/interview/service/IInterviewQuestionProgressService.class +com/qingqiu/interview/db/entity/InterviewMessage$Sender.class +com/qingqiu/interview/config/MyBatisPlusConfig.class +com/qingqiu/interview/db/mapper/InterviewSessionMapper.class +com/qingqiu/interview/service/parser/MarkdownParserService.class +com/qingqiu/interview/db/mapper/QuestionMapper.class +com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.class +com/qingqiu/interview/db/entity/Question.class +com/qingqiu/interview/dto/InterviewStartRequest.class +com/qingqiu/interview/service/parser/PdfParserService.class +com/qingqiu/interview/service/parser/DocumentParser.class +com/qingqiu/interview/dto/DashboardStatsResponse$WeakestQuestionStat.class +com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.class +com/qingqiu/interview/db/entity/InterviewQuestionProgress$Status.class +com/qingqiu/interview/service/QuestionService.class +com/qingqiu/interview/constants/QwenModelConstant.class +com/qingqiu/interview/controller/DashboardController.class +com/qingqiu/interview/dto/DashboardStatsResponse$DailyStat.class +com/qingqiu/interview/service/InterviewService.class +com/qingqiu/interview/exception/GlobalExceptionHandler.class +com/qingqiu/interview/dto/SessionRequest.class +com/qingqiu/interview/service/DashboardService.class +com/qingqiu/interview/db/entity/InterviewSession$Status.class +com/qingqiu/interview/service/IAiSessionLogService.class +com/qingqiu/interview/config/DashScopeConfig.class +com/qingqiu/interview/service/llm/qwen/QwenService.class +com/qingqiu/interview/dto/SessionHistoryResponse$MessageDto.class +com/qingqiu/interview/dto/InterviewResponse.class +com/qingqiu/interview/db/entity/InterviewMessage$MessageType.class +com/qingqiu/interview/db/mapper/InterviewQuestionProgressMapper.class +com/qingqiu/interview/db/mapper/AiSessionLogMapper.class +com/qingqiu/interview/db/entity/InterviewEvaluation.class +com/qingqiu/interview/AiInterviewApplication.class +com/qingqiu/interview/dto/InterviewReportResponse.class +com/qingqiu/interview/dto/DashboardStatsResponse.class +com/qingqiu/interview/dto/SessionHistoryResponse.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..bb52bd3 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,44 @@ +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/AiInterviewApplication.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/config/DashScopeConfig.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/config/JacksonConfig.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/config/MyBatisPlusConfig.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/constants/QwenModelConstant.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/controller/AiSessionLogController.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/controller/DashboardController.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/controller/InterviewController.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/controller/InterviewQuestionProgressController.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/controller/QuestionController.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/AiSessionLog.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/InterviewEvaluation.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/InterviewMessage.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/InterviewQuestionProgress.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/InterviewSession.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/entity/Question.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/AiSessionLogMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/InterviewEvaluationMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/InterviewMessageMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/InterviewQuestionProgressMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/InterviewSessionMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/db/mapper/QuestionMapper.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/ApiResponse.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/ChatRequest.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/DashboardStatsResponse.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/InterviewReportResponse.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/InterviewResponse.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/InterviewStartRequest.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/SessionHistoryResponse.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/dto/SessionRequest.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/exception/GlobalExceptionHandler.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/DashboardService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/IAiSessionLogService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/IInterviewQuestionProgressService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/InterviewService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/QuestionClassificationService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/QuestionService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/impl/AiSessionLogServiceImpl.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/impl/InterviewQuestionProgressServiceImpl.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/llm/LlmService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/llm/qwen/QwenService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/parser/DocumentParser.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/parser/MarkdownParserService.java +/data/study/real-end/Java/AI-Interview/src/main/java/com/qingqiu/interview/service/parser/PdfParserService.java diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst new file mode 100644 index 0000000..e914e77 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst @@ -0,0 +1 @@ +com/qingqiu/interview/AiInterviewApplicationTests.class diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst new file mode 100644 index 0000000..91d4824 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst @@ -0,0 +1 @@ +/data/study/real-end/Java/AI-Interview/src/test/java/com/qingqiu/interview/AiInterviewApplicationTests.java