🤖 AI面试系统开发完整教程
📋 项目概述
本教程将带你从零开始构建一个完整的AI面试系统,包含前端、后端、AI集成和部署的全流程。
技术栈
- 前端: React + TypeScript + Vite + Vaadin Components
- 后端: Spring Boot + Spring Security + JPA
- 数据库: PostgreSQL (含向量扩展)
- AI集成: 阿里千问大模型 + Spring AI
- 实时通信: WebSocket + WebRTC
- 部署: Docker + Docker Compose
项目结构
AI-Interview/
├── frontend/ # React前端项目
│ ├── src/
│ │ ├── components/ # React组件
│ │ ├── services/ # API服务
│ │ └── utils/ # 工具函数
├── consumer-api/ # Spring Boot后端API
│ ├── src/main/java/
│ │ ├── controller/ # REST控制器
│ │ ├── service/ # 业务逻辑层
│ │ ├── repository/ # 数据访问层
│ │ └── entity/ # 实体类
├── common/ # 公共模块
└── docs/ # 文档
🎯 学习路径 - 阶段性项目验收
阶段1-2: 基础面试页面 + 视频通话功能
阶段1: 项目环境搭建与基础架构
1.1 创建Spring Boot后端项目
创建主启动类
java
// consumer-api/src/main/java/com/ai/interview/Application.java
@SpringBootApplication
@EnableJpaRepositories
@EnableWebSecurity
public class Application {
/**
* 应用程序主入口点
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
/**
* 配置CORS跨域支持
* 知识点: CORS(Cross-Origin Resource Sharing)是浏览器安全策略
* 前后端分离项目必须配置CORS才能正常通信
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许的源地址
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
// 允许的HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带认证信息
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
知识点讲解:
@SpringBootApplication
: Spring Boot核心注解,包含了自动配置、组件扫描等功能@EnableJpaRepositories
: 启用JPA数据访问,Spring会自动创建Repository实现类- CORS配置是前后端分离项目的必备配置,解决浏览器同源策略限制
配置文件设置
yaml
# consumer-api/src/main/resources/application.yml
server:
port: 8080
servlet:
context-path: /api
spring:
# 数据源配置
datasource:
url: jdbc:postgresql://localhost:5432/ai_interview
username: postgres
password: password
driver-class-name: org.postgresql.Driver
# JPA配置
jpa:
hibernate:
ddl-auto: update # 自动更新数据库结构
show-sql: true # 显示SQL语句
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
# AI配置
ai:
alibaba:
api-key: ${ALIBABA_AI_API_KEY:your-api-key}
# 日志配置
logging:
level:
com.ai.interview: DEBUG
org.springframework.security: DEBUG
知识点讲解:
ddl-auto: update
: 开发阶段使用,自动根据实体类更新数据库表结构show-sql: true
: 开发调试时显示Hibernate生成的SQL语句- 环境变量配置(
${ALIBABA_AI_API_KEY:your-api-key}
):生产环境安全实践
1.2 创建基础实体类
用户实体类
java
// consumer-api/src/main/java/com/ai/interview/entity/User.java
@Entity
@Table(name = "users")
@Data // Lombok注解,自动生成getter/setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 用户名,业务主键
* 知识点: @Column注解用于自定义列属性
* unique=true 确保用户名唯一性
* nullable=false 确保非空约束
*/
@Column(unique = true, nullable = false, length = 50)
private String username;
/**
* 密码,使用BCrypt加密存储
* 知识点: 密码安全存储,永远不要明文存储密码
*/
@Column(nullable = false)
private String password;
/**
* 邮箱地址
*/
@Column(unique = true, nullable = false)
private String email;
/**
* 用户角色
* 知识点: @Enumerated注解处理枚举类型
* EnumType.STRING: 存储枚举的字符串值,可读性好
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role = UserRole.USER;
/**
* 创建时间
* 知识点: @CreationTimestamp自动设置创建时间
*/
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
/**
* 更新时间
* 知识点: @UpdateTimestamp自动更新修改时间
*/
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/**
* 账户状态
*/
private Boolean enabled = true;
}
/**
* 用户角色枚举
* 知识点: 枚举类型在Java中的最佳实践
*/
public enum UserRole {
USER("普通用户"),
ADMIN("管理员");
private final String description;
UserRole(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
面试记录实体类
java
// consumer-api/src/main/java/com/ai/interview/entity/InterviewSession.java
@Entity
@Table(name = "interview_sessions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class InterviewSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 面试会话与用户的多对一关系
* 知识点: JPA关联关系映射
* @ManyToOne: 多个面试记录对应一个用户
* @JoinColumn: 指定外键列名
* fetch = FetchType.LAZY: 懒加载,只有访问时才查询用户信息
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
/**
* 面试类型
*/
@Enumerated(EnumType.STRING)
private InterviewType type;
/**
* 面试状态
*/
@Enumerated(EnumType.STRING)
private InterviewStatus status = InterviewStatus.PENDING;
/**
* 面试开始时间
*/
@Column(name = "start_time")
private LocalDateTime startTime;
/**
* 面试结束时间
*/
@Column(name = "end_time")
private LocalDateTime endTime;
/**
* 面试评分
*/
private Integer score;
/**
* AI评价内容
* 知识点: @Lob注解用于存储大文本
*/
@Lob
private String feedback;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
}
/**
* 面试类型枚举
*/
public enum InterviewType {
TECHNICAL("技术面试"),
BEHAVIORAL("行为面试"),
CODING("编程面试");
private final String description;
InterviewType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
/**
* 面试状态枚举
*/
public enum InterviewStatus {
PENDING("待开始"),
IN_PROGRESS("进行中"),
COMPLETED("已完成"),
CANCELLED("已取消");
private final String description;
InterviewStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
1.3 创建数据访问层
用户Repository
java
// consumer-api/src/main/java/com/ai/interview/repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 根据用户名查找用户
* 知识点: Spring Data JPA方法命名规则
* findBy + 属性名:会自动生成查询方法
* Optional<T>: Java 8引入,优雅处理可能为null的情况
*/
Optional<User> findByUsername(String username);
/**
* 根据邮箱查找用户
*/
Optional<User> findByEmail(String email);
/**
* 检查用户名是否存在
* 知识点: existsBy开头的方法返回boolean类型
*/
boolean existsByUsername(String username);
/**
* 检查邮箱是否存在
*/
boolean existsByEmail(String email);
/**
* 根据角色查找用户列表
* 知识点: List<T>返回类型表示可能有多个结果
*/
List<User> findByRole(UserRole role);
/**
* 根据启用状态查找用户
*/
List<User> findByEnabled(Boolean enabled);
/**
* 自定义查询:根据用户名模糊查找
* 知识点: @Query注解编写自定义JPQL查询
* ?1表示第一个参数,%用于模糊匹配
*/
@Query("SELECT u FROM User u WHERE u.username LIKE %?1%")
List<User> findByUsernameContaining(String username);
}
面试记录Repository
java
// consumer-api/src/main/java/com/ai/interview/repository/InterviewSessionRepository.java
@Repository
public interface InterviewSessionRepository extends JpaRepository<InterviewSession, Long> {
/**
* 根据用户ID查找面试记录
* 知识点: 关联实体的查询方式
* user.id表示通过user关联实体的id属性查询
*/
List<InterviewSession> findByUserId(Long userId);
/**
* 根据面试类型查找记录
*/
List<InterviewSession> findByType(InterviewType type);
/**
* 根据面试状态查找记录
*/
List<InterviewSession> findByStatus(InterviewStatus status);
/**
* 查找用户的最新面试记录
* 知识点: 排序和限制结果数量
* ORDER BY创建时间倒序,取第一条
*/
@Query("SELECT i FROM InterviewSession i WHERE i.user.id = ?1 ORDER BY i.createdAt DESC")
List<InterviewSession> findLatestByUserId(Long userId, Pageable pageable);
/**
* 统计用户的面试次数
* 知识点: 聚合查询COUNT函数
*/
@Query("SELECT COUNT(i) FROM InterviewSession i WHERE i.user.id = ?1")
Long countByUserId(Long userId);
/**
* 查找指定时间范围内的面试记录
* 知识点: 时间范围查询
* BETWEEN关键字用于范围查询
*/
@Query("SELECT i FROM InterviewSession i WHERE i.startTime BETWEEN ?1 AND ?2")
List<InterviewSession> findByTimeRange(LocalDateTime start, LocalDateTime end);
}
知识点总结:
- JPA Repository继承层次 :
JpaRepository
→PagingAndSortingRepository
→CrudRepository
- 方法命名规则: Spring Data JPA通过方法名自动生成查询
- 关联查询 : 通过
.
操作符访问关联实体属性 - Optional类型: 避免NullPointerException的最佳实践
- 分页查询 : 使用
Pageable
参数实现分页功能
1.4 创建前端React项目结构
前端项目package.json
json
{
"name": "ai-interview-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vaadin/react-components": "^24.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0",
"axios": "^1.3.0",
"simple-peer": "^9.11.1"
},
"devDependencies": {
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@vitejs/plugin-react": "^3.1.0",
"typescript": "^4.9.3",
"vite": "^4.1.0"
}
}
知识点讲解:
- Vite: 现代前端构建工具,比Webpack更快的热重载
- @vaadin/react-components: 企业级UI组件库,提供丰富的表单和布局组件
- simple-peer: WebRTC的简化封装,用于实现视频通话功能
- axios: HTTP客户端库,用于API调用
Vite配置文件
typescript
// frontend/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
* Vite配置
* 知识点: Vite是基于ES模块的构建工具
*/
export default defineConfig({
plugins: [react()],
// 开发服务器配置
server: {
port: 3000,
// 代理配置,解决开发环境跨域问题
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
// 构建配置
build: {
outDir: 'dist',
sourcemap: true
}
});
知识点讲解:
- 代理配置: 开发环境下将API请求代理到后端服务器,避免CORS问题
- sourcemap: 生成源码映射,便于生产环境调试
1.5 创建基础API服务
API服务基类
typescript
// frontend/src/services/api.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* API响应数据结构
* 知识点: TypeScript泛型接口,T表示数据的具体类型
*/
export interface ApiResponse<T = any> {
success: boolean;
data: T;
message: string;
code: number;
}
/**
* API服务类
* 知识点: 单例模式,全局统一的API客户端
*/
class ApiService {
private axiosInstance: AxiosInstance;
constructor() {
// 创建axios实例
this.axiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器
this.setupRequestInterceptor();
// 响应拦截器
this.setupResponseInterceptor();
}
/**
* 设置请求拦截器
* 知识点: 拦截器用于统一处理请求/响应
* 在每个请求发送前自动添加认证token
*/
private setupRequestInterceptor(): void {
this.axiosInstance.interceptors.request.use(
(config: AxiosRequestConfig) => {
// 从localStorage获取token
const token = localStorage.getItem('user_token');
if (token) {
// 添加认证头
config.headers = {
...config.headers,
'user_token': token
};
}
console.log('发送请求:', config.method?.toUpperCase(), config.url);
return config;
},
(error) => {
console.error('请求错误:', error);
return Promise.reject(error);
}
);
}
/**
* 设置响应拦截器
* 知识点: 统一处理响应数据和错误
*/
private setupResponseInterceptor(): void {
this.axiosInstance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
console.log('收到响应:', response.status, response.data);
return response;
},
(error) => {
// 统一错误处理
if (error.response?.status === 401) {
// token失效,跳转到登录页
localStorage.removeItem('user_token');
window.location.href = '/login';
}
console.error('响应错误:', error.response?.data || error.message);
return Promise.reject(error);
}
);
}
/**
* GET请求
* 知识点: 泛型方法,T表示返回数据的类型
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.get<ApiResponse<T>>(url, config);
return response.data;
}
/**
* POST请求
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.post<ApiResponse<T>>(url, data, config);
return response.data;
}
/**
* PUT请求
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.put<ApiResponse<T>>(url, data, config);
return response.data;
}
/**
* DELETE请求
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.delete<ApiResponse<T>>(url, config);
return response.data;
}
}
// 导出单例实例
export const apiService = new ApiService();
知识点讲解:
- 单例模式: 确保全局只有一个API客户端实例
- 拦截器机制: 统一处理请求头添加和错误处理
- TypeScript泛型: 提供类型安全的API调用
- Promise/async-await: 现代JavaScript异步编程
阶段2: 用户认证与登录页面
2.1 实现Spring Security配置
Security配置类
java
// consumer-api/src/main/java/com/ai/interview/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
@Autowired
private JwtTokenFilter jwtTokenFilter;
/**
* 密码编码器配置
* 知识点: BCrypt是目前最安全的密码加密算法之一
* strength=12表示加密强度,数字越大越安全但速度越慢
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* 认证管理器配置
* 知识点: AuthenticationManager用于处理用户认证
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
/**
* 认证提供者配置
* 知识点: DaoAuthenticationProvider使用数据库验证用户
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
/**
* 安全过滤链配置
* 知识点: Spring Security 6.x新的配置方式
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF,因为我们使用JWT token
.csrf(csrf -> csrf.disable())
// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 配置认证异常处理
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
// 配置会话管理为无状态
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 配置URL访问权限
.authorizeHttpRequests(authz -> authz
// 公开接口,无需认证
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// 管理员接口
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// 其他所有接口需要认证
.anyRequest().authenticated()
);
// 配置认证提供者
http.authenticationProvider(authenticationProvider());
// 添加JWT过滤器
http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* CORS配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("*"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
JWT工具类
java
// consumer-api/src/main/java/com/ai/interview/util/JwtUtil.java
@Component
public class JwtUtil {
// JWT密钥,生产环境应从配置文件读取
private String jwtSecret = "aiInterviewSecretKey2024";
// token有效期(毫秒)
private int jwtExpirationMs = 86400000; // 24小时
private final Logger logger = LoggerFactory.getLogger(JwtUtil.class);
/**
* 生成JWT token
* 知识点: JWT由三部分组成:header.payload.signature
* @param userPrincipal 用户主体信息
* @return JWT token字符串
*/
public String generateJwtToken(UserPrincipal userPrincipal) {
return generateTokenFromUsername(userPrincipal.getUsername());
}
/**
* 根据用户名生成token
*/
public String generateTokenFromUsername(String username) {
return Jwts.builder()
.setSubject(username) // 设置主题(用户名)
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + jwtExpirationMs)) // 设置过期时间
.signWith(SignatureAlgorithm.HS512, jwtSecret) // 使用HS512算法签名
.compact();
}
/**
* 从token中获取用户名
* 知识点: JWT解析过程,验证签名并提取Claims
*/
public String getUserNameFromJwtToken(String token) {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
/**
* 验证JWT token是否有效
* 知识点: JWT验证包括签名验证、过期时间检查等
*/
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (MalformedJwtException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
logger.error("JWT token is unsupported: {}", e.getMessage());
} catch (IllegalArgumentException e) {
logger.error("JWT claims string is empty: {}", e.getMessage());
}
return false;
}
}
JWT过滤器
java
// consumer-api/src/main/java/com/ai/interview/security/JwtTokenFilter.java
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
private static final Logger logger = LoggerFactory.getLogger(JwtTokenFilter.class);
/**
* JWT过滤器核心逻辑
* 知识点: 每个请求都会经过这个过滤器进行token验证
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// 从请求头中解析JWT token
String jwt = parseJwt(request);
if (jwt != null && jwtUtil.validateJwtToken(jwt)) {
// 从token中获取用户名
String username = jwtUtil.getUserNameFromJwtToken(jwt);
// 加载用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 设置认证信息到安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Cannot set user authentication: {}", e);
}
// 继续过滤链
filterChain.doFilter(request, response);
}
/**
* 从请求中解析JWT token
* 知识点: 支持多种token传递方式
*/
private String parseJwt(HttpServletRequest request) {
// 方式1: 从Authorization header中获取 (Bearer token)
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7);
}
// 方式2: 从自定义header中获取
String tokenHeader = request.getHeader("user_token");
if (StringUtils.hasText(tokenHeader)) {
return tokenHeader;
}
return null;
}
}
2.2 实现用户服务层
用户详情服务
java
// consumer-api/src/main/java/com/ai/interview/security/UserDetailsServiceImpl.java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
/**
* 根据用户名加载用户详情
* 知识点: 这是Spring Security认证的核心方法
* 返回的UserDetails对象包含了用户的认证和授权信息
*/
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
return UserPrincipal.create(user);
}
}
/**
* 用户主体类,实现Spring Security的UserDetails接口
* 知识点: UserDetails是Spring Security用户信息的标准接口
*/
@Data
@AllArgsConstructor
public class UserPrincipal implements UserDetails {
private Long id;
private String username;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
/**
* 从User实体创建UserPrincipal
*/
public static UserPrincipal create(User user) {
List<GrantedAuthority> authorities = Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
);
return new UserPrincipal(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getPassword(),
authorities
);
}
// UserDetails接口实现
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
用户业务服务
java
// consumer-api/src/main/java/com/ai/interview/service/UserService.java
@Service
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户注册
* 知识点: 业务层方法设计,包含验证、加密、保存等步骤
*/
public User registerUser(UserRegistrationRequest request) {
// 1. 验证用户名是否已存在
if (userRepository.existsByUsername(request.getUsername())) {
throw new BusinessException("用户名已存在");
}
// 2. 验证邮箱是否已存在
if (userRepository.existsByEmail(request.getEmail())) {
throw new BusinessException("邮箱已被注册");
}
// 3. 创建新用户
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword())) // 密码加密
.role(UserRole.USER)
.enabled(true)
.build();
// 4. 保存用户
return userRepository.save(user);
}
/**
* 用户登录验证
*/
public User authenticateUser(String username, String password) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new BusinessException("用户名或密码错误"));
// 验证密码
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
if (!user.getEnabled()) {
throw new BusinessException("账户已被禁用");
}
return user;
}
/**
* 根据ID查找用户
*/
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new BusinessException("用户不存在"));
}
/**
* 更新用户信息
*/
public User updateUser(Long userId, UserUpdateRequest request) {
User user = findById(userId);
if (StringUtils.hasText(request.getEmail()) &&
!user.getEmail().equals(request.getEmail())) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new BusinessException("邮箱已被注册");
}
user.setEmail(request.getEmail());
}
return userRepository.save(user);
}
/**
* 修改密码
*/
public void changePassword(Long userId, String oldPassword, String newPassword) {
User user = findById(userId);
// 验证旧密码
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
throw new BusinessException("原密码错误");
}
// 设置新密码
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
}
}
2.3 创建认证控制器
认证请求DTO
java
// consumer-api/src/main/java/com/ai/interview/dto/UserLoginRequest.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50个字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 100, message = "密码长度必须在6-100个字符之间")
private String password;
}
// consumer-api/src/main/java/com/ai/interview/dto/UserRegistrationRequest.java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserRegistrationRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 50, message = "用户名长度必须在3-50个字符之间")
private String username;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 100, message = "密码长度必须在6-100个字符之间")
private String password;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
/**
* 自定义验证:确认密码必须与密码一致
*/
@AssertTrue(message = "确认密码与密码不一致")
public boolean isPasswordsMatch() {
return password != null && password.equals(confirmPassword);
}
}
认证响应DTO
java
// consumer-api/src/main/java/com/ai/interview/dto/JwtResponse.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JwtResponse {
private String token;
private String type = "Bearer";
private Long id;
private String username;
private String email;
private String role;
public JwtResponse(String accessToken, UserPrincipal userPrincipal) {
this.token = accessToken;
this.id = userPrincipal.getId();
this.username = userPrincipal.getUsername();
this.email = userPrincipal.getEmail();
// 提取角色信息
this.role = userPrincipal.getAuthorities().stream()
.findFirst()
.map(GrantedAuthority::getAuthority)
.orElse("ROLE_USER");
}
}
认证控制器
java
// consumer-api/src/main/java/com/ai/interview/controller/AuthController.java
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "*", maxAge = 3600)
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserService userService;
@Autowired
private JwtUtil jwtUtil;
/**
* 用户登录接口
* 知识点: RESTful API设计,POST方法用于创建session
*/
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody UserLoginRequest loginRequest) {
try {
// 1. 执行认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
)
);
// 2. 认证成功,生成JWT token
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
String jwt = jwtUtil.generateJwtToken(userPrincipal);
// 3. 返回认证信息
JwtResponse response = new JwtResponse(jwt, userPrincipal);
return ResponseEntity.ok(ApiResponse.success(response, "登录成功"));
} catch (BadCredentialsException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("用户名或密码错误"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("登录失败: " + e.getMessage()));
}
}
/**
* 用户注册接口
*/
@PostMapping("/register")
public ResponseEntity<?> registerUser(@Valid @RequestBody UserRegistrationRequest signUpRequest) {
try {
// 1. 注册用户
User user = userService.registerUser(signUpRequest);
// 2. 自动登录,生成token
UserPrincipal userPrincipal = UserPrincipal.create(user);
String jwt = jwtUtil.generateJwtToken(userPrincipal);
// 3. 返回注册成功信息
JwtResponse response = new JwtResponse(jwt, userPrincipal);
return ResponseEntity.ok(ApiResponse.success(response, "注册成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("注册失败: " + e.getMessage()));
}
}
/**
* 获取当前用户信息
*/
@GetMapping("/me")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> getCurrentUser(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
return ResponseEntity.ok(ApiResponse.success(userPrincipal, "获取用户信息成功"));
}
/**
* 用户登出接口
* 知识点: JWT是无状态的,登出只需要客户端删除token
*/
@PostMapping("/logout")
public ResponseEntity<?> logoutUser() {
return ResponseEntity.ok(ApiResponse.success(null, "登出成功"));
}
}
2.4 创建前端登录页面
登录组件
typescript
// frontend/src/components/LoginPage.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
TextField,
VerticalLayout,
HorizontalLayout,
Notification
} from '@vaadin/react-components';
import { authService } from '../services/authService';
/**
* 登录表单数据接口
* 知识点: TypeScript接口定义,提供类型安全
*/
interface LoginFormData {
username: string;
password: string;
}
/**
* 登录页面组件
* 知识点: React函数组件 + Hooks
*/
export const LoginPage: React.FC = () => {
const navigate = useNavigate();
// 表单状态管理
const [formData, setFormData] = useState<LoginFormData>({
username: '',
password: ''
});
// 加载状态
const [loading, setLoading] = useState(false);
/**
* 处理输入框变化
* 知识点: 受控组件,状态驱动UI
*/
const handleInputChange = (field: keyof LoginFormData) => (event: any) => {
setFormData(prev => ({
...prev,
[field]: event.target.value
}));
};
/**
* 处理登录提交
* 知识点: 异步操作处理,错误处理
*/
const handleLogin = async () => {
// 表单验证
if (!formData.username.trim()) {
Notification.show('请输入用户名', { theme: 'error' });
return;
}
if (!formData.password) {
Notification.show('请输入密码', { theme: 'error' });
return;
}
setLoading(true);
try {
// 调用认证服务
const response = await authService.login(formData);
if (response.success) {
// 保存token到localStorage
localStorage.setItem('user_token', response.data.token);
localStorage.setItem('user_info', JSON.stringify(response.data));
Notification.show('登录成功', { theme: 'success' });
// 跳转到主页
navigate('/dashboard');
} else {
Notification.show(response.message || '登录失败', { theme: 'error' });
}
} catch (error: any) {
console.error('登录错误:', error);
Notification.show(
error.response?.data?.message || '登录失败,请重试',
{ theme: 'error' }
);
} finally {
setLoading(false);
}
};
/**
* 处理注册跳转
*/
const handleRegister = () => {
navigate('/register');
};
return (
<VerticalLayout
style={{
padding: '2rem',
maxWidth: '400px',
margin: '2rem auto',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
borderRadius: '8px'
}}
>
<h2 style={{ textAlign: 'center', marginBottom: '2rem' }}>
AI面试系统登录
</h2>
{/* 用户名输入框 */}
<TextField
label="用户名"
placeholder="请输入用户名"
value={formData.username}
onInput={handleInputChange('username')}
style={{ width: '100%' }}
required
/>
{/* 密码输入框 */}
<TextField
label="密码"
type="password"
placeholder="请输入密码"
value={formData.password}
onInput={handleInputChange('password')}
style={{ width: '100%' }}
required
/>
{/* 按钮组 */}
<HorizontalLayout style={{ width: '100%', gap: '1rem' }}>
<Button
theme="primary"
onClick={handleLogin}
disabled={loading}
style={{ flex: 1 }}
>
{loading ? '登录中...' : '登录'}
</Button>
<Button
theme="secondary"
onClick={handleRegister}
style={{ flex: 1 }}
>
注册
</Button>
</HorizontalLayout>
</VerticalLayout>
);
};
认证服务
typescript
// frontend/src/services/authService.ts
import { apiService, ApiResponse } from './api';
/**
* 登录请求数据接口
*/
export interface LoginRequest {
username: string;
password: string;
}
/**
* 注册请求数据接口
*/
export interface RegisterRequest {
username: string;
email: string;
password: string;
confirmPassword: string;
}
/**
* JWT响应数据接口
*/
export interface JwtResponse {
token: string;
type: string;
id: number;
username: string;
email: string;
role: string;
}
/**
* 用户信息接口
*/
export interface UserInfo {
id: number;
username: string;
email: string;
role: string;
}
/**
* 认证服务类
* 知识点: 服务层模式,封装认证相关API调用
*/
class AuthService {
/**
* 用户登录
* 知识点: async/await异步编程
*/
async login(loginData: LoginRequest): Promise<ApiResponse<JwtResponse>> {
return await apiService.post<JwtResponse>('/auth/login', loginData);
}
/**
* 用户注册
*/
async register(registerData: RegisterRequest): Promise<ApiResponse<JwtResponse>> {
return await apiService.post<JwtResponse>('/auth/register', registerData);
}
/**
* 获取当前用户信息
*/
async getCurrentUser(): Promise<ApiResponse<UserInfo>> {
return await apiService.get<UserInfo>('/auth/me');
}
/**
* 用户登出
*/
async logout(): Promise<ApiResponse<null>> {
const response = await apiService.post<null>('/auth/logout');
// 清除本地存储的认证信息
localStorage.removeItem('user_token');
localStorage.removeItem('user_info');
return response;
}
/**
* 检查用户是否已登录
* 知识点: 客户端状态检查
*/
isLoggedIn(): boolean {
const token = localStorage.getItem('user_token');
return token != null && token.length > 0;
}
/**
* 获取当前用户信息(从本地存储)
*/
getCurrentUserFromStorage(): UserInfo | null {
const userInfoStr = localStorage.getItem('user_info');
if (userInfoStr) {
try {
return JSON.parse(userInfoStr);
} catch (error) {
console.error('解析用户信息失败:', error);
return null;
}
}
return null;
}
/**
* 获取当前用户token
*/
getToken(): string | null {
return localStorage.getItem('user_token');
}
}
// 导出单例实例
export const authService = new AuthService();
知识点总结:
-
Spring Security架构:
- SecurityFilterChain: 定义安全过滤器链
- AuthenticationManager: 处理认证请求
- UserDetailsService: 加载用户详情
- PasswordEncoder: 密码加密
-
JWT认证流程:
- 用户提交用户名密码
- 后端验证成功后生成JWT token
- 前端存储token,后续请求携带token
- 后端验证token有效性
-
前端状态管理:
- useState Hook管理组件状态
- localStorage存储认证信息
- 受控组件模式处理表单
-
错误处理机制:
- 后端统一异常处理
- 前端try-catch捕获错误
- 用户友好的错误提示
🎯 阶段1-2验收标准
完成以上代码后,您应该能够:
- ✅ 启动Spring Boot后端服务(端口8080)
- ✅ 启动React前端服务(端口3000)
- ✅ 访问登录页面,进行用户注册
- ✅ 使用注册的账号进行登录
- ✅ 登录成功后跳转到主页面
- ✅ 查看浏览器Network面板,确认API调用正常
- ✅ 查看数据库,确认用户数据正确保存
测试步骤:
bash
# 1. 启动后端
cd consumer-api
mvn spring-boot:run
# 2. 启动前端
cd frontend
npm run dev
# 3. 访问 http://localhost:3000 进行测试
阶段3-4: 后端API开发 + 面试功能实现
阶段3: 面试题库管理系统
3.1 创建题库相关实体类
题目实体类
java
// consumer-api/src/main/java/com/ai/interview/entity/Question.java
@Entity
@Table(name = "questions")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Question {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 题目标题
*/
@Column(nullable = false, length = 500)
private String title;
/**
* 题目内容描述
* 知识点: @Lob注解用于存储大文本内容
*/
@Lob
@Column(nullable = false)
private String content;
/**
* 题目类型
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private QuestionType type;
/**
* 难度级别
*/
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private DifficultyLevel difficulty;
/**
* 技术标签
* 知识点: @ElementCollection用于存储集合类型
* 自动创建question_tags表存储标签信息
*/
@ElementCollection
@CollectionTable(name = "question_tags",
joinColumns = @JoinColumn(name = "question_id"))
@Column(name = "tag")
private Set<String> tags = new HashSet<>();
/**
* 预期答案
*/
@Lob
private String expectedAnswer;
/**
* 代码模板(编程题使用)
*/
@Lob
private String codeTemplate;
/**
* 测试用例(编程题使用)
*/
@Lob
private String testCases;
/**
* 题目状态
*/
@Enumerated(EnumType.STRING)
private QuestionStatus status = QuestionStatus.ACTIVE;
/**
* 创建者
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by")
private User createdBy;
/**
* 使用次数统计
*/
private Integer usageCount = 0;
/**
* 平均评分
*/
private Double averageRating = 0.0;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
/**
* 题目类型枚举
*/
public enum QuestionType {
TECHNICAL("技术问题"),
BEHAVIORAL("行为问题"),
CODING("编程题"),
SYSTEM_DESIGN("系统设计"),
ALGORITHM("算法题");
private final String description;
QuestionType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
/**
* 难度级别枚举
*/
public enum DifficultyLevel {
EASY("简单"),
MEDIUM("中等"),
HARD("困难"),
EXPERT("专家级");
private final String description;
DifficultyLevel(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
/**
* 题目状态枚举
*/
public enum QuestionStatus {
ACTIVE("启用"),
INACTIVE("禁用"),
DRAFT("草稿");
private final String description;
QuestionStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
面试回答记录实体
java
// consumer-api/src/main/java/com/ai/interview/entity/InterviewAnswer.java
@Entity
@Table(name = "interview_answers")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class InterviewAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 关联的面试会话
* 知识点: 多对一关系,一个面试会话包含多个回答
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "session_id", nullable = false)
private InterviewSession session;
/**
* 关联的题目
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id", nullable = false)
private Question question;
/**
* 用户的文字回答
*/
@Lob
private String textAnswer;
/**
* 用户的代码回答(编程题)
*/
@Lob
private String codeAnswer;
/**
* 语音回答文件路径
*/
private String audioFilePath;
/**
* 语音识别转换的文字
*/
@Lob
private String speechToText;
/**
* 回答开始时间
*/
@Column(name = "start_time")
private LocalDateTime startTime;
/**
* 回答结束时间
*/
@Column(name = "end_time")
private LocalDateTime endTime;
/**
* 回答用时(秒)
*/
private Integer duration;
/**
* AI评分
*/
private Integer aiScore;
/**
* AI评价反馈
*/
@Lob
private String aiFeedback;
/**
* 代码执行结果
*/
@Lob
private String codeExecutionResult;
/**
* 是否通过代码测试
*/
private Boolean codeTestPassed;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
}
3.2 创建题库数据访问层
题目Repository
java
// consumer-api/src/main/java/com/ai/interview/repository/QuestionRepository.java
@Repository
public interface QuestionRepository extends JpaRepository<Question, Long> {
/**
* 根据题目类型查找
*/
List<Question> findByType(QuestionType type);
/**
* 根据难度级别查找
*/
List<Question> findByDifficulty(DifficultyLevel difficulty);
/**
* 根据状态查找
*/
List<Question> findByStatus(QuestionStatus status);
/**
* 根据标签查找题目
* 知识点: 集合类型的查询,使用MEMBER OF操作符
*/
@Query("SELECT q FROM Question q WHERE :tag MEMBER OF q.tags")
List<Question> findByTag(String tag);
/**
* 根据多个条件查找题目
* 知识点: 复杂条件查询,使用可选参数
*/
@Query("SELECT q FROM Question q WHERE " +
"(:type IS NULL OR q.type = :type) AND " +
"(:difficulty IS NULL OR q.difficulty = :difficulty) AND " +
"q.status = :status " +
"ORDER BY q.createdAt DESC")
List<Question> findByConditions(
@Param("type") QuestionType type,
@Param("difficulty") DifficultyLevel difficulty,
@Param("status") QuestionStatus status);
/**
* 随机获取指定数量的题目
* 知识点: 使用原生SQL进行随机查询
*/
@Query(value = "SELECT * FROM questions WHERE type = ?1 AND status = 'ACTIVE' " +
"ORDER BY RANDOM() LIMIT ?2", nativeQuery = true)
List<Question> findRandomQuestions(String type, int limit);
/**
* 根据标题搜索题目
*/
@Query("SELECT q FROM Question q WHERE q.title LIKE %:keyword% OR q.content LIKE %:keyword%")
List<Question> searchByKeyword(@Param("keyword") String keyword);
/**
* 获取热门题目(根据使用次数排序)
*/
List<Question> findTop10ByStatusOrderByUsageCountDesc(QuestionStatus status);
/**
* 统计各类型题目数量
* 知识点: 分组统计查询
*/
@Query("SELECT q.type, COUNT(q) FROM Question q WHERE q.status = 'ACTIVE' GROUP BY q.type")
List<Object[]> countByType();
}
面试回答Repository
java
// consumer-api/src/main/java/com/ai/interview/repository/InterviewAnswerRepository.java
@Repository
public interface InterviewAnswerRepository extends JpaRepository<InterviewAnswer, Long> {
/**
* 根据面试会话查找所有回答
*/
List<InterviewAnswer> findBySessionId(Long sessionId);
/**
* 根据用户查找回答历史
*/
@Query("SELECT a FROM InterviewAnswer a JOIN a.session s WHERE s.user.id = :userId")
List<InterviewAnswer> findByUserId(@Param("userId") Long userId);
/**
* 根据题目查找所有回答
*/
List<InterviewAnswer> findByQuestionId(Long questionId);
/**
* 获取用户对特定题目的最新回答
*/
@Query("SELECT a FROM InterviewAnswer a JOIN a.session s " +
"WHERE s.user.id = :userId AND a.question.id = :questionId " +
"ORDER BY a.createdAt DESC")
List<InterviewAnswer> findLatestAnswerByUserAndQuestion(
@Param("userId") Long userId,
@Param("questionId") Long questionId,
Pageable pageable);
/**
* 统计用户回答的题目数量
*/
@Query("SELECT COUNT(DISTINCT a.question.id) FROM InterviewAnswer a JOIN a.session s " +
"WHERE s.user.id = :userId")
Long countDistinctQuestionsByUserId(@Param("userId") Long userId);
/**
* 获取用户平均分数
*/
@Query("SELECT AVG(a.aiScore) FROM InterviewAnswer a JOIN a.session s " +
"WHERE s.user.id = :userId AND a.aiScore IS NOT NULL")
Double getAverageScoreByUserId(@Param("userId") Long userId);
}
3.3 实现题库业务服务
题目服务类
java
// consumer-api/src/main/java/com/ai/interview/service/QuestionService.java
@Service
@Transactional
public class QuestionService {
@Autowired
private QuestionRepository questionRepository;
@Autowired
private UserService userService;
/**
* 创建新题目
* 知识点: 业务逻辑封装,包含权限验证和数据验证
*/
public Question createQuestion(CreateQuestionRequest request, Long creatorId) {
// 验证创建者权限
User creator = userService.findById(creatorId);
if (!creator.getRole().equals(UserRole.ADMIN)) {
throw new BusinessException("只有管理员可以创建题目");
}
// 构建题目对象
Question question = Question.builder()
.title(request.getTitle())
.content(request.getContent())
.type(request.getType())
.difficulty(request.getDifficulty())
.tags(new HashSet<>(request.getTags()))
.expectedAnswer(request.getExpectedAnswer())
.codeTemplate(request.getCodeTemplate())
.testCases(request.getTestCases())
.status(QuestionStatus.ACTIVE)
.createdBy(creator)
.usageCount(0)
.averageRating(0.0)
.build();
return questionRepository.save(question);
}
/**
* 更新题目
*/
public Question updateQuestion(Long questionId, UpdateQuestionRequest request, Long updaterId) {
Question question = findById(questionId);
// 权限验证
User updater = userService.findById(updaterId);
if (!updater.getRole().equals(UserRole.ADMIN) &&
!question.getCreatedBy().getId().equals(updaterId)) {
throw new BusinessException("没有权限修改此题目");
}
// 更新字段
if (StringUtils.hasText(request.getTitle())) {
question.setTitle(request.getTitle());
}
if (StringUtils.hasText(request.getContent())) {
question.setContent(request.getContent());
}
if (request.getType() != null) {
question.setType(request.getType());
}
if (request.getDifficulty() != null) {
question.setDifficulty(request.getDifficulty());
}
if (request.getTags() != null && !request.getTags().isEmpty()) {
question.setTags(new HashSet<>(request.getTags()));
}
if (StringUtils.hasText(request.getExpectedAnswer())) {
question.setExpectedAnswer(request.getExpectedAnswer());
}
if (StringUtils.hasText(request.getCodeTemplate())) {
question.setCodeTemplate(request.getCodeTemplate());
}
if (StringUtils.hasText(request.getTestCases())) {
question.setTestCases(request.getTestCases());
}
if (request.getStatus() != null) {
question.setStatus(request.getStatus());
}
return questionRepository.save(question);
}
/**
* 根据ID查找题目
*/
public Question findById(Long id) {
return questionRepository.findById(id)
.orElseThrow(() -> new BusinessException("题目不存在"));
}
/**
* 分页查询题目
* 知识点: Spring Data分页功能
*/
public Page<Question> findQuestions(QuestionSearchRequest request, Pageable pageable) {
// 这里可以使用Specification进行动态查询
// 为简化演示,使用基础的Repository方法
if (request.getType() != null) {
return questionRepository.findAll(
(root, query, criteriaBuilder) ->
criteriaBuilder.equal(root.get("type"), request.getType()),
pageable
);
}
return questionRepository.findAll(pageable);
}
/**
* 根据条件查询题目
*/
public List<Question> findByConditions(QuestionType type, DifficultyLevel difficulty) {
return questionRepository.findByConditions(type, difficulty, QuestionStatus.ACTIVE);
}
/**
* 随机获取面试题目
* 知识点: 算法设计,面试题目选择策略
*/
public List<Question> getRandomQuestionsForInterview(InterviewType interviewType, int count) {
// 根据面试类型映射题目类型
List<QuestionType> questionTypes = mapInterviewTypeToQuestionTypes(interviewType);
List<Question> selectedQuestions = new ArrayList<>();
for (QuestionType type : questionTypes) {
int questionsPerType = count / questionTypes.size();
List<Question> randomQuestions = questionRepository.findRandomQuestions(
type.name(), questionsPerType
);
selectedQuestions.addAll(randomQuestions);
}
// 如果数量不够,随机补充
if (selectedQuestions.size() < count) {
int needed = count - selectedQuestions.size();
List<Question> additionalQuestions = questionRepository.findRandomQuestions(
QuestionType.TECHNICAL.name(), needed
);
selectedQuestions.addAll(additionalQuestions);
}
return selectedQuestions.subList(0, Math.min(count, selectedQuestions.size()));
}
/**
* 面试类型到题目类型的映射
*/
private List<QuestionType> mapInterviewTypeToQuestionTypes(InterviewType interviewType) {
switch (interviewType) {
case TECHNICAL:
return Arrays.asList(QuestionType.TECHNICAL, QuestionType.ALGORITHM);
case CODING:
return Arrays.asList(QuestionType.CODING, QuestionType.ALGORITHM);
case BEHAVIORAL:
return Arrays.asList(QuestionType.BEHAVIORAL);
default:
return Arrays.asList(QuestionType.TECHNICAL);
}
}
/**
* 增加题目使用次数
*/
public void incrementUsageCount(Long questionId) {
Question question = findById(questionId);
question.setUsageCount(question.getUsageCount() + 1);
questionRepository.save(question);
}
/**
* 获取热门题目
*/
public List<Question> getPopularQuestions() {
return questionRepository.findTop10ByStatusOrderByUsageCountDesc(QuestionStatus.ACTIVE);
}
/**
* 根据关键词搜索题目
*/
public List<Question> searchQuestions(String keyword) {
return questionRepository.searchByKeyword(keyword);
}
/**
* 删除题目(软删除)
*/
public void deleteQuestion(Long questionId, Long deleterId) {
Question question = findById(questionId);
// 权限验证
User deleter = userService.findById(deleterId);
if (!deleter.getRole().equals(UserRole.ADMIN) &&
!question.getCreatedBy().getId().equals(deleterId)) {
throw new BusinessException("没有权限删除此题目");
}
question.setStatus(QuestionStatus.INACTIVE);
questionRepository.save(question);
}
/**
* 获取题目统计信息
*/
public QuestionStatistics getQuestionStatistics() {
List<Object[]> typeStats = questionRepository.countByType();
QuestionStatistics statistics = new QuestionStatistics();
for (Object[] stat : typeStats) {
QuestionType type = (QuestionType) stat[0];
Long count = (Long) stat[1];
statistics.addTypeCount(type, count);
}
return statistics;
}
}
3.4 创建面试会话服务
面试会话服务
java
// consumer-api/src/main/java/com/ai/interview/service/InterviewSessionService.java
@Service
@Transactional
public class InterviewSessionService {
@Autowired
private InterviewSessionRepository sessionRepository;
@Autowired
private QuestionService questionService;
@Autowired
private UserService userService;
/**
* 创建面试会话
* 知识点: 业务流程设计,面试会话生命周期管理
*/
public InterviewSession createSession(CreateInterviewSessionRequest request, Long userId) {
User user = userService.findById(userId);
// 检查是否有正在进行的面试
List<InterviewSession> ongoingSessions = sessionRepository.findByUserIdAndStatus(
userId, InterviewStatus.IN_PROGRESS
);
if (!ongoingSessions.isEmpty()) {
throw new BusinessException("您有正在进行的面试,请先完成或取消当前面试");
}
// 创建面试会话
InterviewSession session = InterviewSession.builder()
.user(user)
.type(request.getType())
.status(InterviewStatus.PENDING)
.build();
return sessionRepository.save(session);
}
/**
* 开始面试
*/
public InterviewSession startInterview(Long sessionId, Long userId) {
InterviewSession session = findById(sessionId);
// 验证权限
if (!session.getUser().getId().equals(userId)) {
throw new BusinessException("无权限操作此面试会话");
}
// 验证状态
if (!session.getStatus().equals(InterviewStatus.PENDING)) {
throw new BusinessException("面试会话状态不正确");
}
// 更新状态
session.setStatus(InterviewStatus.IN_PROGRESS);
session.setStartTime(LocalDateTime.now());
return sessionRepository.save(session);
}
/**
* 完成面试
*/
public InterviewSession completeInterview(Long sessionId, Long userId) {
InterviewSession session = findById(sessionId);
// 验证权限
if (!session.getUser().getId().equals(userId)) {
throw new BusinessException("无权限操作此面试会话");
}
// 验证状态
if (!session.getStatus().equals(InterviewStatus.IN_PROGRESS)) {
throw new BusinessException("面试会话状态不正确");
}
// 更新状态
session.setStatus(InterviewStatus.COMPLETED);
session.setEndTime(LocalDateTime.now());
// 计算总体评分(基于所有回答的平均分)
Integer totalScore = calculateSessionScore(sessionId);
session.setScore(totalScore);
return sessionRepository.save(session);
}
/**
* 取消面试
*/
public InterviewSession cancelInterview(Long sessionId, Long userId) {
InterviewSession session = findById(sessionId);
// 验证权限
if (!session.getUser().getId().equals(userId)) {
throw new BusinessException("无权限操作此面试会话");
}
// 只有未开始或进行中的面试可以取消
if (session.getStatus().equals(InterviewStatus.COMPLETED)) {
throw new BusinessException("已完成的面试无法取消");
}
session.setStatus(InterviewStatus.CANCELLED);
session.setEndTime(LocalDateTime.now());
return sessionRepository.save(session);
}
/**
* 根据ID查找面试会话
*/
public InterviewSession findById(Long id) {
return sessionRepository.findById(id)
.orElseThrow(() -> new BusinessException("面试会话不存在"));
}
/**
* 获取用户的面试历史
*/
public List<InterviewSession> getUserInterviewHistory(Long userId) {
return sessionRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
/**
* 获取面试会话详情(包含所有回答)
*/
public InterviewSessionDetail getSessionDetail(Long sessionId, Long userId) {
InterviewSession session = findById(sessionId);
// 验证权限
if (!session.getUser().getId().equals(userId)) {
throw new BusinessException("无权限查看此面试会话");
}
// 构建详情对象
InterviewSessionDetail detail = new InterviewSessionDetail();
detail.setSession(session);
// 获取所有回答
List<InterviewAnswer> answers = interviewAnswerRepository.findBySessionId(sessionId);
detail.setAnswers(answers);
return detail;
}
/**
* 计算面试会话总分
* 知识点: 算法设计,评分逻辑
*/
private Integer calculateSessionScore(Long sessionId) {
List<InterviewAnswer> answers = interviewAnswerRepository.findBySessionId(sessionId);
if (answers.isEmpty()) {
return 0;
}
// 计算有效评分的平均值
List<Integer> validScores = answers.stream()
.filter(answer -> answer.getAiScore() != null)
.map(InterviewAnswer::getAiScore)
.collect(Collectors.toList());
if (validScores.isEmpty()) {
return 0;
}
double average = validScores.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
return (int) Math.round(average);
}
/**
* 获取面试会话统计信息
*/
public InterviewSessionStatistics getSessionStatistics(Long userId) {
List<InterviewSession> sessions = getUserInterviewHistory(userId);
InterviewSessionStatistics stats = new InterviewSessionStatistics();
stats.setTotalSessions(sessions.size());
long completedSessions = sessions.stream()
.filter(s -> s.getStatus().equals(InterviewStatus.COMPLETED))
.count();
stats.setCompletedSessions((int) completedSessions);
// 计算平均分
double averageScore = sessions.stream()
.filter(s -> s.getScore() != null)
.mapToInt(InterviewSession::getScore)
.average()
.orElse(0.0);
stats.setAverageScore(averageScore);
return stats;
}
}
3.5 创建题库管理控制器
题目管理控制器
java
// consumer-api/src/main/java/com/ai/interview/controller/QuestionController.java
@RestController
@RequestMapping("/api/questions")
@CrossOrigin(origins = "*", maxAge = 3600)
public class QuestionController {
@Autowired
private QuestionService questionService;
/**
* 创建题目
* 知识点: RESTful API设计,POST方法用于创建资源
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> createQuestion(
@Valid @RequestBody CreateQuestionRequest request,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Question question = questionService.createQuestion(request, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(question, "题目创建成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("创建题目失败: " + e.getMessage()));
}
}
/**
* 获取题目列表
*/
@GetMapping
public ResponseEntity<?> getQuestions(
@RequestParam(required = false) QuestionType type,
@RequestParam(required = false) DifficultyLevel difficulty,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir) {
try {
// 创建分页参数
Sort.Direction direction = sortDir.equalsIgnoreCase("desc") ?
Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
// 创建查询条件
QuestionSearchRequest searchRequest = new QuestionSearchRequest();
searchRequest.setType(type);
searchRequest.setDifficulty(difficulty);
Page<Question> questions = questionService.findQuestions(searchRequest, pageable);
return ResponseEntity.ok(ApiResponse.success(questions, "获取题目列表成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取题目列表失败: " + e.getMessage()));
}
}
/**
* 根据ID获取题目详情
*/
@GetMapping("/{id}")
public ResponseEntity<?> getQuestionById(@PathVariable Long id) {
try {
Question question = questionService.findById(id);
return ResponseEntity.ok(ApiResponse.success(question, "获取题目详情成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取题目详情失败: " + e.getMessage()));
}
}
/**
* 更新题目
*/
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> updateQuestion(
@PathVariable Long id,
@Valid @RequestBody UpdateQuestionRequest request,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Question question = questionService.updateQuestion(id, request, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(question, "题目更新成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("更新题目失败: " + e.getMessage()));
}
}
/**
* 删除题目
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteQuestion(
@PathVariable Long id,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
questionService.deleteQuestion(id, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(null, "题目删除成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("删除题目失败: " + e.getMessage()));
}
}
/**
* 搜索题目
*/
@GetMapping("/search")
public ResponseEntity<?> searchQuestions(@RequestParam String keyword) {
try {
List<Question> questions = questionService.searchQuestions(keyword);
return ResponseEntity.ok(ApiResponse.success(questions, "搜索成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("搜索失败: " + e.getMessage()));
}
}
/**
* 获取热门题目
*/
@GetMapping("/popular")
public ResponseEntity<?> getPopularQuestions() {
try {
List<Question> questions = questionService.getPopularQuestions();
return ResponseEntity.ok(ApiResponse.success(questions, "获取热门题目成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取热门题目失败: " + e.getMessage()));
}
}
/**
* 获取随机面试题目
*/
@GetMapping("/random")
public ResponseEntity<?> getRandomQuestions(
@RequestParam InterviewType interviewType,
@RequestParam(defaultValue = "5") int count) {
try {
List<Question> questions = questionService.getRandomQuestionsForInterview(
interviewType, count
);
return ResponseEntity.ok(ApiResponse.success(questions, "获取随机题目成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取随机题目失败: " + e.getMessage()));
}
}
/**
* 获取题目统计信息
*/
@GetMapping("/statistics")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> getQuestionStatistics() {
try {
QuestionStatistics statistics = questionService.getQuestionStatistics();
return ResponseEntity.ok(ApiResponse.success(statistics, "获取统计信息成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取统计信息失败: " + e.getMessage()));
}
}
}
🎯 阶段3验收标准
完成以上代码后,您应该能够:
- ✅ 管理员可以创建、编辑、删除题目
- ✅ 题目支持多种类型:技术、行为、编程、算法等
- ✅ 题目具有难度级别和标签分类
- ✅ 支持题目搜索和分页查询
- ✅ 可以随机获取面试题目
- ✅ 数据库正确存储题目和关联数据
测试API:
bash
# 创建题目 (需要管理员权限)
curl -X POST http://localhost:8080/api/questions \
-H "Content-Type: application/json" \
-H "user_token: YOUR_ADMIN_TOKEN" \
-d '{
"title": "什么是Spring Boot?",
"content": "请详细介绍Spring Boot的核心特性和优势",
"type": "TECHNICAL",
"difficulty": "MEDIUM",
"tags": ["Spring", "Java", "后端"],
"expectedAnswer": "Spring Boot是..."
}'
# 获取题目列表
curl -X GET "http://localhost:8080/api/questions?page=0&size=10"
# 获取随机面试题目
curl -X GET "http://localhost:8080/api/questions/random?interviewType=TECHNICAL&count=5"
阶段4: 面试功能实现
阶段4: 面试功能实现
4.1 创建面试会话控制器
面试会话控制器
java
// consumer-api/src/main/java/com/ai/interview/controller/InterviewController.java
@RestController
@RequestMapping("/api/interviews")
@CrossOrigin(origins = "*", maxAge = 3600)
public class InterviewController {
@Autowired
private InterviewSessionService sessionService;
@Autowired
private InterviewAnswerService answerService;
/**
* 创建面试会话
*/
@PostMapping("/sessions")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> createInterviewSession(
@Valid @RequestBody CreateInterviewSessionRequest request,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
InterviewSession session = sessionService.createSession(request, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(session, "面试会话创建成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("创建面试会话失败: " + e.getMessage()));
}
}
/**
* 开始面试
*/
@PostMapping("/sessions/{sessionId}/start")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> startInterview(
@PathVariable Long sessionId,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
InterviewSession session = sessionService.startInterview(sessionId, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(session, "面试开始"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("开始面试失败: " + e.getMessage()));
}
}
/**
* 获取面试题目
*/
@GetMapping("/sessions/{sessionId}/questions")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> getInterviewQuestions(
@PathVariable Long sessionId,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
List<Question> questions = sessionService.getInterviewQuestions(sessionId, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(questions, "获取面试题目成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取面试题目失败: " + e.getMessage()));
}
}
/**
* 提交回答
*/
@PostMapping("/answers")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> submitAnswer(
@Valid @RequestBody SubmitAnswerRequest request,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
InterviewAnswer answer = answerService.submitAnswer(request, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(answer, "回答提交成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("提交回答失败: " + e.getMessage()));
}
}
/**
* 完成面试
*/
@PostMapping("/sessions/{sessionId}/complete")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> completeInterview(
@PathVariable Long sessionId,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
InterviewSession session = sessionService.completeInterview(sessionId, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(session, "面试完成"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("完成面试失败: " + e.getMessage()));
}
}
/**
* 获取面试历史
*/
@GetMapping("/history")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> getInterviewHistory(Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
List<InterviewSession> history = sessionService.getUserInterviewHistory(userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(history, "获取面试历史成功"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取面试历史失败: " + e.getMessage()));
}
}
/**
* 获取面试详情
*/
@GetMapping("/sessions/{sessionId}")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<?> getInterviewDetail(
@PathVariable Long sessionId,
Authentication authentication) {
try {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
InterviewSessionDetail detail = sessionService.getSessionDetail(sessionId, userPrincipal.getId());
return ResponseEntity.ok(ApiResponse.success(detail, "获取面试详情成功"));
} catch (BusinessException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("获取面试详情失败: " + e.getMessage()));
}
}
}
4.2 创建前端面试页面
面试页面主组件
typescript
// frontend/src/components/InterviewPage.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Button,
VerticalLayout,
HorizontalLayout,
TextArea,
Notification,
ProgressBar,
Dialog
} from '@vaadin/react-components';
import { VideoChat } from './VideoChat';
import { CodeEditor } from './CodeEditor';
import { interviewService } from '../services/interviewService';
/**
* 面试页面组件
* 知识点: 复杂状态管理,组件间通信
*/
export const InterviewPage: React.FC = () => {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
// 面试状态
const [session, setSession] = useState<any>(null);
const [questions, setQuestions] = useState<any[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [answers, setAnswers] = useState<{ [key: number]: string }>({});
阶段6: 系统部署与上线
6.1 Docker容器化部署
后端Dockerfile
dockerfile
# consumer-api/Dockerfile
FROM openjdk:17-jdk-slim
# 设置工作目录
WORKDIR /app
# 复制Maven包装器和pom.xml
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
# 下载依赖(利用Docker缓存)
RUN ./mvnw dependency:go-offline -B
# 复制源代码
COPY src src
# 构建应用
RUN ./mvnw clean package -DskipTests
# 运行应用
EXPOSE 8080
CMD ["java", "-jar", "target/consumer-api-1.0.0.jar"]
前端Dockerfile
dockerfile
# frontend/Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建前端
RUN npm run build
# 生产环境镜像
FROM nginx:alpine
# 复制构建结果到nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Nginx配置
nginx
# frontend/nginx.conf
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 压缩配置
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
# 前端静态文件
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket支持
location /ws/ {
proxy_pass http://backend:8080/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
Docker Compose配置
yaml
# docker-compose.yml
version: '3.8'
services:
# PostgreSQL数据库
database:
image: pgvector/pgvector:pg16
container_name: ai-interview-db
environment:
POSTGRES_DB: ai_interview
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- ai-interview-network
# Redis缓存
redis:
image: redis:7-alpine
container_name: ai-interview-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- ai-interview-network
# 后端服务
backend:
build: ./consumer-api
container_name: ai-interview-backend
environment:
SPRING_PROFILES_ACTIVE: docker
SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/ai_interview
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: password
SPRING_REDIS_HOST: redis
ALIBABA_AI_API_KEY: ${ALIBABA_AI_API_KEY}
ports:
- "8080:8080"
depends_on:
- database
- redis
networks:
- ai-interview-network
volumes:
- ./logs:/app/logs
- ./uploads:/app/uploads
# 前端服务
frontend:
build: ./frontend
container_name: ai-interview-frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- ai-interview-network
volumes:
postgres_data:
redis_data:
networks:
ai-interview-network:
driver: bridge
环境配置文件
bash
# .env
# 数据库配置
POSTGRES_DB=ai_interview
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password
# AI服务配置
ALIBABA_AI_API_KEY=your_alibaba_ai_api_key
# Redis配置
REDIS_PASSWORD=your_redis_password
# 应用配置
SPRING_PROFILES_ACTIVE=production
6.2 生产环境配置
生产环境Spring配置
yaml
# consumer-api/src/main/resources/application-production.yml
server:
port: 8080
servlet:
context-path: /api
spring:
profiles:
active: production
# 数据源配置
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:ai_interview}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:password}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
# JPA配置
jpa:
hibernate:
ddl-auto: validate # 生产环境使用validate
show-sql: false # 生产环境关闭SQL日志
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: false
# Redis配置
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 3000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
# AI配置
ai:
alibaba:
api-key: ${ALIBABA_AI_API_KEY}
# 日志配置
logging:
level:
root: INFO
com.ai.interview: INFO
org.springframework.security: WARN
file:
name: logs/ai-interview.log
pattern:
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 管理端点配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
export:
prometheus:
enabled: true
数据库初始化脚本
sql
-- database/init.sql
-- 创建数据库
CREATE DATABASE ai_interview;
-- 切换到ai_interview数据库
\c ai_interview;
-- 启用向量扩展(用于AI嵌入)
CREATE EXTENSION IF NOT EXISTS vector;
-- 创建用户表
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建题目表
CREATE TABLE IF NOT EXISTS questions (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(50) NOT NULL,
difficulty VARCHAR(20) NOT NULL,
expected_answer TEXT,
code_template TEXT,
test_cases TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_by BIGINT REFERENCES users(id),
usage_count INTEGER DEFAULT 0,
average_rating DECIMAL(3,2) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建题目标签表
CREATE TABLE IF NOT EXISTS question_tags (
question_id BIGINT REFERENCES questions(id),
tag VARCHAR(50) NOT NULL,
PRIMARY KEY (question_id, tag)
);
-- 创建面试会话表
CREATE TABLE IF NOT EXISTS interview_sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
start_time TIMESTAMP,
end_time TIMESTAMP,
score INTEGER,
feedback TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 创建面试回答表
CREATE TABLE IF NOT EXISTS interview_answers (
id BIGSERIAL PRIMARY KEY,
session_id BIGINT NOT NULL REFERENCES interview_sessions(id),
question_id BIGINT NOT NULL REFERENCES questions(id),
text_answer TEXT,
code_answer TEXT,
audio_file_path VARCHAR(500),
speech_to_text TEXT,
start_time TIMESTAMP,
end_time TIMESTAMP,
duration INTEGER,
ai_score INTEGER,
ai_feedback TEXT,
code_execution_result TEXT,
code_test_passed BOOLEAN,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 插入默认管理员用户
INSERT INTO users (username, password, email, role) VALUES
('admin', '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewtnKDj8.7/2k9iS', '[email protected]', 'ADMIN')
ON CONFLICT (username) DO NOTHING;
-- 插入示例题目
INSERT INTO questions (title, content, type, difficulty, expected_answer, created_by) VALUES
('什么是Spring Boot?', '请详细介绍Spring Boot的核心特性和优势,以及它与传统Spring框架的区别。', 'TECHNICAL', 'MEDIUM',
'Spring Boot是基于Spring框架的快速应用开发框架...', 1),
('实现单例模式', '请用Java实现一个线程安全的单例模式,并解释为什么这样设计。', 'CODING', 'MEDIUM',
'可以使用双重检查锁定、静态内部类等方式实现...', 1),
('介绍一次你遇到的技术挑战', '请描述一次你在项目中遇到的技术难题,以及你是如何解决的。', 'BEHAVIORAL', 'EASY',
'候选人应该描述具体的技术问题、解决方案和学到的经验...', 1)
ON CONFLICT DO NOTHING;
-- 创建索引优化查询性能
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);
CREATE INDEX IF NOT EXISTS idx_questions_difficulty ON questions(difficulty);
CREATE INDEX IF NOT EXISTS idx_questions_status ON questions(status);
CREATE INDEX IF NOT EXISTS idx_interview_sessions_user_id ON interview_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_interview_sessions_status ON interview_sessions(status);
CREATE INDEX IF NOT EXISTS idx_interview_answers_session_id ON interview_answers(session_id);
CREATE INDEX IF NOT EXISTS idx_interview_answers_question_id ON interview_answers(question_id);
6.3 部署脚本
部署脚本
bash
#!/bin/bash
# deploy.sh
set -e
echo "🚀 开始部署AI面试系统..."
# 检查环境变量
if [ -z "$ALIBABA_AI_API_KEY" ]; then
echo "❌ 错误: 请设置ALIBABA_AI_API_KEY环境变量"
exit 1
fi
# 创建必要的目录
echo "📁 创建目录结构..."
mkdir -p logs uploads database
# 停止现有服务
echo "🛑 停止现有服务..."
docker-compose down
# 清理旧镜像
echo "🧹 清理旧镜像..."
docker system prune -f
# 构建并启动服务
echo "🔨 构建并启动服务..."
docker-compose up --build -d
# 等待服务启动
echo "⏳ 等待服务启动..."
sleep 30
# 健康检查
echo "🔍 执行健康检查..."
backend_health=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/actuator/health || echo "000")
frontend_health=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80 || echo "000")
if [ "$backend_health" = "200" ] && [ "$frontend_health" = "200" ]; then
echo "✅ 部署成功!"
echo "🌐 前端地址: http://localhost"
echo "🔧 后端API: http://localhost:8080/api"
echo "📊 监控地址: http://localhost:8080/api/actuator"
else
echo "❌ 部署失败!"
echo "后端状态: $backend_health"
echo "前端状态: $frontend_health"
echo "查看日志: docker-compose logs"
exit 1
fi
echo "🎉 AI面试系统部署完成!"
监控脚本
bash
#!/bin/bash
# monitor.sh
echo "📊 AI面试系统监控报告"
echo "========================"
# 检查容器状态
echo "🐳 容器状态:"
docker-compose ps
echo ""
# 检查系统资源
echo "💻 系统资源:"
echo "CPU使用率: $(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)%"
echo "内存使用率: $(free | grep Mem | awk '{printf("%.2f%%", $3/$2 * 100.0)}')"
echo "磁盘使用率: $(df -h / | awk 'NR==2{print $5}')"
echo ""
# 检查服务健康状态
echo "🏥 服务健康状态:"
backend_status=$(curl -s http://localhost:8080/api/actuator/health | jq -r '.status' 2>/dev/null || echo "DOWN")
echo "后端服务: $backend_status"
frontend_status=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:80)
if [ "$frontend_status" = "200" ]; then
echo "前端服务: UP"
else
echo "前端服务: DOWN"
fi
echo ""
# 检查数据库连接
echo "🗄️ 数据库状态:"
db_status=$(docker exec ai-interview-db pg_isready -U postgres 2>/dev/null && echo "UP" || echo "DOWN")
echo "PostgreSQL: $db_status"
redis_status=$(docker exec ai-interview-redis redis-cli ping 2>/dev/null || echo "DOWN")
echo "Redis: $redis_status"
echo ""
# 显示最近的错误日志
echo "📝 最近的错误日志:"
docker-compose logs --tail=10 --no-color | grep -i error || echo "无错误日志"
6.4 运维管理
备份脚本
bash
#!/bin/bash
# backup.sh
BACKUP_DIR="/backup/ai-interview"
DATE=$(date +%Y%m%d_%H%M%S)
echo "🔄 开始备份AI面试系统数据..."
# 创建备份目录
mkdir -p $BACKUP_DIR
# 备份数据库
echo "📀 备份数据库..."
docker exec ai-interview-db pg_dump -U postgres ai_interview | gzip > $BACKUP_DIR/database_$DATE.sql.gz
# 备份上传文件
echo "📁 备份上传文件..."
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz uploads/
# 备份配置文件
echo "⚙️ 备份配置文件..."
tar -czf $BACKUP_DIR/config_$DATE.tar.gz docker-compose.yml .env
# 清理旧备份(保留7天)
echo "🧹 清理旧备份..."
find $BACKUP_DIR -name "*.gz" -mtime +7 -delete
echo "✅ 备份完成!"
echo "备份位置: $BACKUP_DIR"
日志管理脚本
bash
#!/bin/bash
# log-management.sh
LOG_DIR="./logs"
MAX_SIZE="100M"
RETENTION_DAYS=30
echo "📋 日志管理任务开始..."
# 检查日志大小并轮转
if [ -f "$LOG_DIR/ai-interview.log" ]; then
size=$(du -h "$LOG_DIR/ai-interview.log" | cut -f1)
echo "当前日志大小: $size"
# 如果日志文件大于100M,进行轮转
if [ $(du -m "$LOG_DIR/ai-interview.log" | cut -f1) -gt 100 ]; then
timestamp=$(date +%Y%m%d_%H%M%S)
mv "$LOG_DIR/ai-interview.log" "$LOG_DIR/ai-interview_$timestamp.log"
echo "日志已轮转: ai-interview_$timestamp.log"
fi
fi
# 清理旧日志文件
echo "🧹 清理超过 $RETENTION_DAYS 天的日志文件..."
find $LOG_DIR -name "*.log" -mtime +$RETENTION_DAYS -delete
# 压缩7天前的日志文件
find $LOG_DIR -name "*.log" -mtime +7 ! -name "ai-interview.log" -exec gzip {} \;
echo "✅ 日志管理完成!"
🎯 最终验收与测试
完整系统测试清单
功能测试
- ✅ 用户注册登录功能
- ✅ 题库管理(增删改查)
- ✅ 面试会话创建和管理
- ✅ 面试进行(题目展示、回答提交)
- ✅ 视频通话功能
- ✅ 语音录制和播放
- ✅ 代码编辑器功能
- ✅ AI自动评估
- ✅ 面试结果查看
- ✅ 面试历史记录
性能测试
- ✅ 并发用户测试(100+用户同时在线)
- ✅ 数据库查询性能(复杂查询<100ms)
- ✅ AI服务响应时间(<5秒)
- ✅ 文件上传下载性能
- ✅ 前端页面加载速度(<3秒)
安全测试
- ✅ JWT Token安全验证
- ✅ SQL注入防护
- ✅ XSS攻击防护
- ✅ CSRF攻击防护
- ✅ 敏感数据加密存储
兼容性测试
- ✅ Chrome浏览器支持
- ✅ Firefox浏览器支持
- ✅ Safari浏览器支持
- ✅ 移动端响应式布局
- ✅ 不同网络环境适配
部署验收
- ✅ Docker容器正常运行
- ✅ 数据库连接正常
- ✅ 缓存服务正常
- ✅ 监控指标正常
- ✅ 日志记录完整
- ✅ 备份恢复功能
🎉 项目总结
技术栈汇总
后端技术栈:
├── Spring Boot 3.x # 主框架
├── Spring Security 6.x # 安全框架
├── Spring Data JPA # 数据访问
├── PostgreSQL + Vector # 数据库
├── Redis # 缓存
├── Spring AI Alibaba # AI集成
└── Docker # 容器化
前端技术栈:
├── React 18 # 前端框架
├── TypeScript # 类型安全
├── Vite # 构建工具
├── Vaadin Components # UI组件库
├── WebRTC # 视频通话
└── MediaRecorder API # 语音录制
核心功能模块
- 用户认证系统 - JWT Token认证,角色权限控制
- 题库管理系统 - 题目CRUD,分类标签,智能推荐
- 面试引擎 - 会话管理,题目分发,答案收集
- 实时通信 - WebRTC视频,WebSocket消息
- AI评估引擎 - 自然语言处理,代码分析,智能评分
- 数据分析 - 面试统计,性能报表,趋势分析
学习收获
通过本项目,您将掌握:
-
全栈开发技能
- Spring Boot现代化开发
- React Hooks和函数组件
- TypeScript类型系统
- RESTful API设计
-
AI技术集成
- 大模型API调用
- Prompt工程技巧
- 向量数据库应用
- 智能评估算法
-
实时通信技术
- WebRTC点对点通信
- WebSocket实时消息
- 媒体流处理
- 音视频编解码
-
系统架构设计
- 微服务架构思想
- 数据库设计优化
- 缓存策略应用
- 安全防护体系
-
DevOps实践
- Docker容器化
- CI/CD流水线
- 监控告警系统
- 自动化运维
扩展方向
项目可以继续扩展的方向:
-
AI能力增强
- 多模态AI分析(表情识别、语调分析)
- 个性化推荐算法
- 智能题目生成
- 语音合成面试官
-
功能完善
- 移动端APP开发
- 直播面试功能
- 团队协作面试
- 面试数据分析报表
-
性能优化
- CDN加速部署
- 数据库读写分离
- 微服务架构拆分
- 负载均衡优化
-
商业化功能
- 企业版权限管理
- 付费增值服务
- API开放平台
- 第三方系统集成
恭喜您完成了AI面试系统的完整开发!
这个项目涵盖了现代软件开发的各个方面,从前端交互到后端架构,从AI集成到系统部署,是一个完整的企业级应用开发实践。
通过循序渐进的学习,您不仅掌握了技术实现,更重要的是理解了系统设计的思想和工程化开发的流程。
继续保持学习的热情,在实践中不断提升技术能力! 🚀