初始化dev

This commit is contained in:
2025-09-08 14:23:44 +08:00
parent 0ea0e0c5d4
commit e56a2e7873
98 changed files with 4670 additions and 0 deletions

View File

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

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
package com.qingqiu.interview.ai.factory;
import com.qingqiu.interview.ai.service.AIClientService;
public interface AIClientFactory {
AIClientService createAIClient();
}

View File

@@ -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<String, AIClientFactory> factories;
public AIClientManager(Map<String, AIClientFactory> 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();
}
}

View File

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

View File

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

View File

@@ -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<Message> messages) {
return null;
}
}

View File

@@ -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<Message> messages) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("type", "json_object");
Map<String, Object> 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;
}
}

View File

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

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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<Integer, ResultCode> 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);
}
}

View File

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

View File

@@ -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<T> {
private Integer code;
private String message;
private T data;
/**
* 成功返回结果
*
*/
public static <T> R<T> success() {
return new R<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
}
/**
* 成功返回结果
*
* @param data 获取的数据
*/
public static <T> R<T> success(T data) {
return new R<T>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
/**
* 成功返回结果
*
* @param data 获取的数据
* @param message 提示信息
*/
public static <T> R<T> success(T data, String message) {
return new R<T>(ResultCode.SUCCESS.getCode(), message, data);
}
/**
* 失败返回结果
* @param errorCode 错误码
* @param message 错误信息
*/
public static <T> R<T> error(Integer errorCode, String message) {
return new R<T>(errorCode, message, null);
}
/**
* 失败返回结果
* @param errorCode 错误码
*/
public static <T> R<T> error(IErrorCode errorCode) {
return new R<T>(errorCode.getCode(), errorCode.getMessage(), null);
}
/**
* 失败返回结果
* @param resultCode 错误码
*/
public static <T> R<T> error(ResultCode resultCode, T data) {
R<T> commonResult = new R<>();
commonResult.setMessage(resultCode.getMessage());
commonResult.setCode(resultCode.getCode());
commonResult.setData(data);
return commonResult;
}
/**
* 失败返回结果
* @param errorCode 错误码
* @param message 错误信息
*/
public static <T> R<T> error(IErrorCode errorCode, String message) {
return new R<T>(errorCode.getCode(), message, null);
}
/**
* 失败返回结果
* @param message 提示信息
*/
public static <T> R<T> error(String message) {
return new R<T>(ResultCode.INTERNAL.getCode(), message, null);
}
/**
* 失败返回结果
*/
public static <T> R<T> error() {
return error(ResultCode.INTERNAL);
}
/**
* 参数验证失败返回结果
*/
public static <T> R<T> validateerror() {
return error(ResultCode.METHOD_ARGUMENT_NOT_VALID);
}
/**
* 参数验证失败返回结果
* @param message 提示信息
*/
public static <T> R<T> validateerror(String message) {
return new R<T>(ResultCode.METHOD_ARGUMENT_NOT_VALID.getCode(), message, null);
}
/**
* 未登录返回结果
*/
public static <T> R<T> unauthorized(T data) {
return new R<T>(ResultCode.UNAUTHORIZED.getCode(), ResultCode.UNAUTHORIZED.getMessage(), data);
}
/**
* 未授权返回结果
*/
public static <T> R<T> forbidden(T data) {
return new R<T>(ResultCode.FORBIDDEN.getCode(), ResultCode.FORBIDDEN.getMessage(), data);
}
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.qingqiu.interview.common.service;
import reactor.core.publisher.Mono;
public interface HttpService {
<T> Mono<T> post(String url, Object requestBody, Class<T> responseType);
<T> Mono<T> postWithAuth(
String url,
Object requestBody,
Class<T> responseType,
String authHeader
);
}

View File

@@ -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 <T> Mono<T> post(String url, Object requestBody, Class<T> responseType) {
return webClient.post()
.uri(url)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(responseType);
}
@Override
public <T> Mono<T> postWithAuth(String url, Object requestBody, Class<T> 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);
}
}

View File

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

View File

@@ -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> T getBean(Class<T> beanClass) {
return applicationContext.getBean(beanClass);
}
public static Object getBean(String beanName) {
return applicationContext.getBean(beanName);
}
public static <T> T getBean(String beanName, Class<T> 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);
}
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
package com.qingqiu.interview.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* <p>
* ai会话记录 前端控制器
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
@RestController
@RequestMapping("/ai-session-log")
public class AiSessionLogController {
}

View File

@@ -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<DashboardStatsResponse> getDashboardStats() {
return ApiResponse.success(dashboardService.getDashboardStats());
}
}

View File

@@ -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<InterviewResponse> 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<InterviewResponse> continueInterview(@Validated @RequestBody ChatRequest request) {
InterviewResponse response = interviewService.continueInterview(request);
return ApiResponse.success(response);
}
/**
* 获取所有面试会话列表
*/
@PostMapping("/get-history-list")
public ApiResponse<java.util.List<InterviewSession>> getInterviewHistoryList() {
return ApiResponse.success(interviewService.getInterviewSessions());
}
/**
* 获取单次面试的详细复盘报告
*/
@PostMapping("/get-report-detail")
public ApiResponse<InterviewReportResponse> getInterviewReportDetail(@RequestBody SessionRequest request) {
return ApiResponse.success(interviewService.getInterviewReport(request.getSessionId()));
}
}

View File

@@ -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;
/**
* <p>
* 面试问题进度跟踪表 前端控制器
* </p>
*
* @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));
}
}

View File

@@ -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<Page<Question>> getQuestionPage(@RequestBody QuestionPageParams params) {
return ApiResponse.success(questionService.getQuestionPage(params));
}
/**
* 新增题目
*/
@PostMapping("/add")
public ApiResponse<Object> addQuestion(@Validated @RequestBody Question question) {
questionService.addQuestion(question);
return ApiResponse.success();
}
/**
* 更新题目
*/
@PostMapping("/update")
public ApiResponse<Object> updateQuestion(@Validated @RequestBody Question question) {
questionService.updateQuestion(question);
return ApiResponse.success();
}
/**
* 删除题目
*/
@PostMapping("/delete")
public ApiResponse<Object> deleteQuestion(@RequestBody Question question) {
questionService.deleteQuestion(question.getId());
return ApiResponse.success();
}
/**
* AI批量导入题目
*/
@PostMapping("/import-by-ai")
public ApiResponse<Object> 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();
}
}

View File

@@ -0,0 +1,30 @@
package com.qingqiu.interview.dto;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(0);
response.setMessage("Success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> success() {
return success(null);
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(code);
response.setMessage(message);
return response;
}
}

View File

@@ -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;
}

View File

@@ -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<CategoryStat> questionCategoryStats; // 题库分类统计 (饼图)
private List<DailyStat> recentInterviewStats; // 近期面试次数 (柱状图)
private List<CategoryScoreStat> categoryAverageScores; // 各技术分类平均分 (雷达图)
private List<WeakestQuestionStat> 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;
}
}

View File

@@ -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<QuestionDetail> questionDetails;
private List<InterviewMessage> 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;
}
}

View File

@@ -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;
}

View File

@@ -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单独传递
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<MessageDto> messages;
@Data
@Accessors(chain = true)
public static class MessageDto {
private String messageType;
private String sender;
private String content;
private Integer messageOrder;
private LocalDateTime createdTime;
}
}

View File

@@ -0,0 +1,9 @@
package com.qingqiu.interview.dto;
import lombok.Data;
@Data
public class SessionRequest {
private String sessionId;
}

View File

@@ -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;
/**
* <p>
* ai会话记录
* </p>
*
* @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;
}

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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;
}

View File

@@ -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;
/**
* <p>
* ai会话记录 Mapper 接口
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
@Mapper
public interface AiSessionLogMapper extends BaseMapper<AiSessionLog> {
}

View File

@@ -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<InterviewEvaluation> {
List<InterviewEvaluation> selectBySessionId(@Param("sessionId") String sessionId);
InterviewEvaluation selectBySessionIdAndQuestionId(@Param("sessionId") String sessionId, @Param("questionId") Long questionId);
}

View File

@@ -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<InterviewMessage> {
List<InterviewMessage> selectBySessionIdOrderByOrder(@Param("sessionId") String sessionId);
InterviewMessage selectLatestBySessionId(@Param("sessionId") String sessionId);
int selectMaxOrderBySessionId(@Param("sessionId") String sessionId);
}

View File

@@ -0,0 +1,16 @@
package com.qingqiu.interview.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.qingqiu.interview.entity.InterviewQuestionProgress;
/**
* <p>
* 面试问题进度跟踪表 Mapper 接口
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
public interface InterviewQuestionProgressMapper extends BaseMapper<InterviewQuestionProgress> {
}

View File

@@ -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> {
InterviewSession selectBySessionId(@Param("sessionId") String sessionId);
List<InterviewSession> selectActiveSessionsByModel(@Param("aiModel") String aiModel);
int updateSessionStatus(@Param("sessionId") String sessionId, @Param("status") String status);
List<com.qingqiu.interview.dto.DashboardStatsResponse.DailyStat> countRecentInterviews(@Param("days") int days);
}

View File

@@ -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<Question> {
List<Question> selectByCategory(@Param("category") String category);
List<Question> selectByCategories(@Param("categories") List<String> categories);
List<Question> selectRandomByCategories(@Param("categories") List<String> categories, @Param("limit") int limit);
Question selectByContent(@Param("content") String content);
List<com.qingqiu.interview.dto.DashboardStatsResponse.CategoryStat> countByCategory();
}

View File

@@ -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<DashboardStatsResponse.DailyStat> recentStats = sessionMapper.countRecentInterviews(7);
stats.setRecentInterviewStats(fillMissingDates(recentStats, 7));
return stats;
}
/**
* 填充最近几天内没有面试数据的日期补0
*/
private List<DashboardStatsResponse.DailyStat> fillMissingDates(List<DashboardStatsResponse.DailyStat> existingStats, int days) {
Map<String, Long> 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());
}
}

View File

@@ -0,0 +1,16 @@
package com.qingqiu.interview.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.qingqiu.interview.entity.AiSessionLog;
/**
* <p>
* ai会话记录 服务类
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
public interface IAiSessionLogService extends IService<AiSessionLog> {
}

View File

@@ -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;
/**
* <p>
* 面试问题进度跟踪表 服务类
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
public interface IInterviewQuestionProgressService extends IService<InterviewQuestionProgress> {
Page<InterviewQuestionProgress> pageList(QuestionProgressPageParams params);
}

View File

@@ -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<DocumentParser> 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<String, DocumentParser> 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<Question> 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<Long> 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<InterviewQuestionProgress>()
.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<InterviewQuestionProgress>()
.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<InterviewQuestionProgress>()
.eq(InterviewQuestionProgress::getSessionId, request.getSessionId())
.eq(InterviewQuestionProgress::getQuestionId, currentQuestionId)
.orderByDesc(InterviewQuestionProgress::getCreatedTime)
.last("limit 1")
);
// 将ai返回的内容拼装返回给页面
// 查询数据
InterviewQuestionProgress currentQuestionData = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.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<InterviewMessage> messages = messageMapper.selectBySessionIdOrderByOrder(sessionId);
List<SessionHistoryResponse.MessageDto> 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<Question> selectQuestionsByAi(String resumeContent, String sessionId) throws JsonProcessingException {
// 1. 获取全部题库
List<Question> 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<Long> 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<Question> 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<InterviewQuestionProgress> interviewQuestionProgresses = questionProgressMapper.selectList(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.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<String> 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<InterviewQuestionProgress>()
.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<InterviewEvaluation> 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<InterviewEvaluation> 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<Long> selectedQuestionIds = objectMapper.readValue(session.getSelectedQuestionIds(), new com.fasterxml.jackson.core.type.TypeReference<List<Long>>() {
});
// 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<InterviewSession> 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<InterviewEvaluation> evaluations = evaluationMapper.selectBySessionId(sessionId);
List<InterviewReportResponse.QuestionDetail> 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<InterviewMessage> interviewMessages = messageMapper.selectList(
new LambdaQueryWrapper<InterviewMessage>()
.eq(InterviewMessage::getSessionId, sessionId)
);
// 获取当前面试的 问题
InterviewQuestionProgress progress = questionProgressMapper.selectOne(
new LambdaQueryWrapper<InterviewQuestionProgress>()
.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();
}
}

View File

@@ -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<Question> 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<Question> parseAiResponse(String aiResponse) {
List<Question> 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<Question> fallbackParsing(String content) {
log.warn("使用降级解析策略");
List<Question> 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;
}
}

View File

@@ -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<DocumentParser> documentParserList; // This will be injected by Spring
private final LlmService llmService;
/**
* 分页查询题库
*/
public Page<Question> getQuestionPage(QuestionPageParams params) {
log.info("分页查询题库,当前页: {}, 每页数量: {}", params.getCurrent(), params.getSize());
return questionMapper.selectPage(
Page.of(params.getCurrent(), params.getSize()),
new LambdaQueryWrapper<Question>()
.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<Question> 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<Question> questions = questionMapper.selectList(
new LambdaQueryWrapper<Question>()
.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<Long> list = questionsIds.toList(Long.class);
questionMapper.delete(
new LambdaQueryWrapper<Question>()
.notIn(Question::getId, list)
);
}
@NotNull
private static String getPrompt(List<Question> 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();
}
}

View File

@@ -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;
/**
* <p>
* ai会话记录 服务实现类
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
@Service
public class AiSessionLogServiceImpl extends ServiceImpl<AiSessionLogMapper, AiSessionLog> implements IAiSessionLogService {
}

View File

@@ -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;
/**
* <p>
* 面试问题进度跟踪表 服务实现类
* </p>
*
* @author huangpeng
* @since 2025-08-30
*/
@Service
public class InterviewQuestionProgressServiceImpl extends ServiceImpl<InterviewQuestionProgressMapper, InterviewQuestionProgress> implements IInterviewQuestionProgressService {
@Override
public Page<InterviewQuestionProgress> pageList(QuestionProgressPageParams params) {
return page(
Page.of(params.getCurrent(), params.getSize()),
new LambdaQueryWrapper<InterviewQuestionProgress>()
.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)
);
}
}

View File

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

View File

@@ -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<AiSessionLog> aiSessionLogs = aiSessionLogMapper.selectList(
new LambdaQueryWrapper<AiSessionLog>()
.eq(AiSessionLog::getToken, token)
.orderByDesc(AiSessionLog::getCreatedTime)
);
// 构造发给ai的消息
List<Message> 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<Integer> integers = tokenizer.encodeOrdinary(prompt);
return integers.size();
}
}

View File

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

View File

@@ -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";
}
}

View File

@@ -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"; // 返回支持的文档类型
}
}

View File

@@ -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

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.InterviewSessionMapper">
<select id="selectBySessionId" resultType="com.qingqiu.interview.entity.InterviewSession">
SELECT * FROM interview_session
WHERE session_id = #{sessionId} AND deleted = 0
</select>
<select id="selectActiveSessionsByModel" resultType="com.qingqiu.interview.entity.InterviewSession">
SELECT * FROM interview_session
WHERE ai_model = #{aiModel} AND status = 'ACTIVE' AND deleted = 0
ORDER BY created_time DESC
</select>
<update id="updateSessionStatus">
UPDATE interview_session
SET status = #{status}, updated_time = NOW()
WHERE session_id = #{sessionId}
</update>
<select id="countRecentInterviews" resultType="com.qingqiu.interview.dto.DashboardStatsResponse$DailyStat">
SELECT DATE(created_time) as date, COUNT(*) as count
FROM interview_session
WHERE created_time >= DATE_SUB(NOW(), INTERVAL #{days} DAY)
GROUP BY DATE(created_time)
ORDER BY date ASC
</select>
</mapper>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.AiSessionLogMapper">
</mapper>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.InterviewEvaluationMapper">
<select id="selectBySessionId" resultType="com.qingqiu.interview.entity.InterviewEvaluation">
SELECT * FROM interview_evaluation
WHERE session_id = #{sessionId}
ORDER BY created_time ASC
</select>
<select id="selectBySessionIdAndQuestionId" resultType="com.qingqiu.interview.entity.InterviewEvaluation">
SELECT * FROM interview_evaluation
WHERE session_id = #{sessionId} AND question_id = #{questionId}
LIMIT 1
</select>
</mapper>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.InterviewMessageMapper">
<select id="selectBySessionIdOrderByOrder" resultType="com.qingqiu.interview.entity.InterviewMessage">
SELECT * FROM interview_message
WHERE session_id = #{sessionId}
ORDER BY message_order ASC
</select>
<select id="selectLatestBySessionId" resultType="com.qingqiu.interview.entity.InterviewMessage">
SELECT * FROM interview_message
WHERE session_id = #{sessionId}
ORDER BY message_order DESC
LIMIT 1
</select>
<select id="selectMaxOrderBySessionId" resultType="int">
SELECT COALESCE(MAX(message_order), 0) FROM interview_message
WHERE session_id = #{sessionId}
</select>
</mapper>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.InterviewQuestionProgressMapper">
</mapper>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.qingqiu.interview.mapper.QuestionMapper">
<select id="selectByCategory" resultType="com.qingqiu.interview.entity.Question">
SELECT *
FROM question
WHERE category = #{category} AND deleted = 0
ORDER BY created_time DESC
</select>
<select id="selectByCategories" resultType="com.qingqiu.interview.entity.Question">
SELECT *
FROM question
WHERE category IN
<foreach collection="categories" item="category" open="(" separator="," close=")">
#{category}
</foreach>
AND deleted = 0
ORDER BY created_time DESC
</select>
<select id="selectRandomByCategories" resultType="com.qingqiu.interview.entity.Question">
SELECT *
FROM question
WHERE category IN
<foreach collection="categories" item="category" open="(" separator="," close=")">
#{category}
</foreach>
AND deleted = 0
ORDER BY RAND()
LIMIT #{limit}
</select>
<select id="selectByContent" resultType="com.qingqiu.interview.entity.Question">
SELECT *
FROM question
WHERE content = #{content} AND deleted = 0
LIMIT 1
</select>
<select id="countByCategory" resultType="com.qingqiu.interview.dto.DashboardStatsResponse$CategoryStat">
SELECT category as name, COUNT(*) as value
FROM question
WHERE deleted = 0
GROUP BY category
ORDER BY value DESC
</select>
</mapper>

View File

@@ -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)
);

View File

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