# 自然写互动课堂教学管理云平台软件 V1.0 ## 软件著作权鉴别材料 — 源程序 > **权利人**:深圳自然写科技有限公司 > **版本号**:V1.0 --- ## 源程序目录结构 ``` 01-writech-cloud-platform/ ├── WritechCloudApplication.java ├── config/ │ ├── KafkaConfig.java │ └── SecurityConfig.java ├── controller/ │ ├── AssignmentController.java │ ├── AuthController.java │ ├── DeviceController.java │ └── StrokeController.java ├── model/ │ ├── Models.java │ └── User.java └── service/ ├── DeviceService.java ├── MessageService.java ├── StrokeService.java └── UserService.java ``` --- ## 源程序文件清单 ### (根目录) #### `WritechCloudApplication.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 版权所有 (C) 2026 * 软件全称:自然写互动课堂教学管理云平台软件 * 版本号:V1.0 * * 本文件为云平台主启动类,负责 Spring Boot 应用初始化、 * 微服务配置加载、健康检查端点注册及全局异常处理。 */ package com.writech.cloud; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.http.HttpStatus; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; /** * 自然写互动课堂教学管理云平台 - 主启动类 * * 系统采用微服务架构,按领域拆分为用户服务、课堂服务、 * 作业服务、设备服务、消息服务等多个独立微服务模块。 * 通过 Nginx/Kong API Gateway 统一接入,使用 Kafka * 进行异步消息传递,Redis 实现会话与缓存管理。 */ @SpringBootApplication @EnableDiscoveryClient @EnableAsync @EnableScheduling public class WritechCloudApplication { /** * 应用主入口 * 启动 Spring Boot 容器,加载所有微服务组件 */ public static void main(String[] args) { SpringApplication.run(WritechCloudApplication.class, args); } /** * 跨域配置 * 允许前端应用和各终端 APP 跨域访问云平台 API */ @Configuration public static class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } } /** * 全局异常处理器 * 统一捕获并格式化所有未处理异常,返回标准 JSON 响应 * 响应格式:{"code": 200, "msg": "success", "data": {...}} */ @RestControllerAdvice public static class GlobalExceptionHandler { /** * 处理业务异常 * 业务逻辑中抛出的自定义异常,返回对应的错误码和提示信息 */ @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusinessException(BusinessException ex) { ApiResponse response = ApiResponse.error(ex.getCode(), ex.getMessage()); return ResponseEntity.status(HttpStatus.OK).body(response); } /** * 处理参数校验异常 * 请求参数不符合校验规则时返回详细的校验错误信息 */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { ApiResponse response = ApiResponse.error(400, "参数校验失败: " + ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } /** * 处理未知异常 * 兜底处理所有未预见的系统异常,记录日志并返回统一错误响应 */ @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception ex) { ApiResponse response = ApiResponse.error(500, "系统内部错误,请稍后重试"); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } } /** * 统一 API 响应包装类 * 所有接口统一使用此格式返回数据 * 格式:{"code": 200, "msg": "success", "data": {...}} */ public static class ApiResponse { private int code; private String msg; private T data; private LocalDateTime timestamp; public ApiResponse() { this.timestamp = LocalDateTime.now(); } public ApiResponse(int code, String msg, T data) { this.code = code; this.msg = msg; this.data = data; this.timestamp = LocalDateTime.now(); } /** 成功响应(带数据) */ public static ApiResponse success(T data) { return new ApiResponse<>(200, "success", data); } /** 成功响应(无数据) */ public static ApiResponse success() { return new ApiResponse<>(200, "success", null); } /** 错误响应 */ public static ApiResponse error(int code, String msg) { return new ApiResponse<>(code, msg, null); } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } public T getData() { return data; } public void setData(T data) { this.data = data; } public LocalDateTime getTimestamp() { return timestamp; } public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; } } /** * 自定义业务异常类 * 用于在业务逻辑中抛出可预见的异常,包含错误码和消息 */ public static class BusinessException extends RuntimeException { private final int code; public BusinessException(int code, String message) { super(message); this.code = code; } public int getCode() { return code; } } } ``` ### `config/` #### `config/KafkaConfig.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * Kafka 消息队列配置 * 配置笔迹数据流处理的Kafka生产者和消费者 */ package com.writech.cloud.config; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; import org.springframework.kafka.core.*; import java.util.HashMap; import java.util.Map; /** * Kafka 配置类 * * 消息主题定义: * - writech-stroke-topic:笔迹原始数据(网关/算力盒 → 云平台) * - writech-recognition-topic:AI识别请求(云平台 → AI引擎) * - writech-result-topic:识别结果(AI引擎 → 云平台) * - writech-notification-topic:通知消息(云平台 → 终端) * - writech-stroke-dlq:笔迹数据死信队列(处理失败的消息) * * 数据流向: * 点阵笔 → 网关/算力盒 → Kafka(stroke-topic) → 云平台数据接收服务 * → MongoDB存储 → Kafka(recognition-topic) → AI引擎处理 * → Kafka(result-topic) → 结果回写 → WebSocket推送终端 */ @Configuration public class KafkaConfig { @Value("${spring.kafka.bootstrap-servers:localhost:9092}") private String bootstrapServers; @Value("${spring.kafka.consumer.group-id:writech-cloud-group}") private String consumerGroupId; /** * Kafka 生产者配置 * 用于发送AI识别请求和通知消息 */ @Bean public ProducerFactory producerFactory() { Map configProps = new HashMap<>(); configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); // 消息可靠性配置 configProps.put(ProducerConfig.ACKS_CONFIG, "all"); // 所有副本确认 configProps.put(ProducerConfig.RETRIES_CONFIG, 3); // 重试3次 configProps.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 1000); // 批量发送配置(提升笔迹数据吞吐量) configProps.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 16KB configProps.put(ProducerConfig.LINGER_MS_CONFIG, 10); // 延迟10ms configProps.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432); // 32MB缓冲 // 幂等性(防止重复消息) configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); return new DefaultKafkaProducerFactory<>(configProps); } @Bean public KafkaTemplate kafkaTemplate() { return new KafkaTemplate<>(producerFactory()); } /** * Kafka 消费者配置 * 用于消费笔迹数据和识别结果 */ @Bean public ConsumerFactory consumerFactory() { Map configProps = new HashMap<>(); configProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); configProps.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId); configProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); configProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); // 消费者配置 configProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest"); configProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 手动提交 configProps.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); // 每批最多500条 configProps.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024); // 最少1KB configProps.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 200); // 最大等待200ms return new DefaultKafkaConsumerFactory<>(configProps); } /** * Kafka 监听器容器工厂 * 配置并发消费者数量和批量消费模式 */ @Bean public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); // 并发消费者数量(对应Topic的分区数) factory.setConcurrency(8); // 启用批量消费模式 factory.setBatchListener(true); // 手动确认模式 factory.getContainerProperties().setAckMode( org.springframework.kafka.listener.ContainerProperties.AckMode.MANUAL_IMMEDIATE); return factory; } /** * 笔迹数据Topic名称常量 */ public static class Topics { /** 笔迹原始数据 */ public static final String STROKE_DATA = "writech-stroke-topic"; /** AI识别请求 */ public static final String RECOGNITION_REQUEST = "writech-recognition-topic"; /** AI识别结果 */ public static final String RECOGNITION_RESULT = "writech-result-topic"; /** 通知消息 */ public static final String NOTIFICATION = "writech-notification-topic"; /** 笔迹数据死信队列 */ public static final String STROKE_DLQ = "writech-stroke-dlq"; /** 设备状态上报 */ public static final String DEVICE_STATUS = "writech-device-status-topic"; private Topics() {} // 禁止实例化 } } ``` #### `config/SecurityConfig.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 安全配置 - JWT认证过滤器 + Spring Security配置 * 实现RBAC权限控制和全链路HTTPS/TLS 1.3加密 */ package com.writech.cloud.config; import com.writech.cloud.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import javax.servlet.*; import javax.servlet.http.*; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; /** * Spring Security 安全配置 * * 安全策略: * - JWT Token + Refresh Token 双令牌认证机制 * - RBAC 角色权限控制(管理员/教师/学生/家长四级) * - 全链路 HTTPS/TLS 1.3 加密传输 * - 请求签名校验 + 频率限流 + SQL注入/XSS防护 * - 敏感字段 AES-256 加密存储 */ @Configuration @EnableWebSecurity public class SecurityConfig { @Value("${writech.jwt.secret:writech-cloud-platform-jwt-secret-key-2026}") private String jwtSecret; @Autowired private UserService userService; /** * 安全过滤链配置 * 定义各API路径的访问权限规则 */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用CSRF(REST API使用JWT认证,不需要CSRF防护) .csrf().disable() // 无状态会话(JWT方式不使用Session) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 路径权限配置 .authorizeRequests() // 公开接口:登录、注册、验证码、健康检查 .antMatchers("/api/v1/auth/login").permitAll() .antMatchers("/api/v1/auth/sms-code").permitAll() .antMatchers("/api/v1/auth/refresh").permitAll() .antMatchers("/actuator/health").permitAll() .antMatchers("/ws/**").permitAll() // 管理员专用接口 .antMatchers("/api/v1/admin/**").hasRole("ADMIN") // 教师接口 .antMatchers("/api/v1/assignment/publish").hasAnyRole("ADMIN", "TEACHER") .antMatchers("/api/v1/assignment/review/**").hasAnyRole("ADMIN", "TEACHER") // 设备管理接口(管理员和教师) .antMatchers("/api/v1/device/**").hasAnyRole("ADMIN", "TEACHER") // 笔迹上传(网关/算力盒,使用设备证书认证) .antMatchers("/api/v1/stroke/upload").hasRole("DEVICE") // 其余接口需要认证 .anyRequest().authenticated() .and() // 添加JWT认证过滤器 .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) // 添加请求限流过滤器 .addFilterBefore(rateLimitFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } /** * JWT 认证过滤器 Bean */ @Bean public JwtAuthenticationFilter jwtAuthFilter() { return new JwtAuthenticationFilter(jwtSecret, userService); } /** * 请求限流过滤器 Bean */ @Bean public RateLimitFilter rateLimitFilter() { return new RateLimitFilter(); } /** * JWT 认证过滤器 * * 拦截所有请求,从 Authorization 头中提取并验证 JWT Token * 验证通过后将用户信息放入 SecurityContext */ public static class JwtAuthenticationFilter implements Filter { private final String jwtSecret; private final UserService userService; public JwtAuthenticationFilter(String jwtSecret, UserService userService) { this.jwtSecret = jwtSecret; this.userService = userService; } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 提取Token String authorization = httpRequest.getHeader("Authorization"); if (authorization != null && authorization.startsWith("Bearer ")) { String token = authorization.substring(7); try { // 检查Token是否在黑名单中 if (userService.isTokenBlacklisted(token)) { sendError(httpResponse, 401, "令牌已失效,请重新登录"); return; } // 解析并验证JWT SecretKey key = Keys.hmacShaKeyFor( jwtSecret.getBytes(StandardCharsets.UTF_8)); Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); // 提取用户信息 String userId = claims.getSubject(); String role = claims.get("role", String.class); String tokenType = claims.get("type", String.class); // 只接受access类型的Token if (!"access".equals(tokenType)) { sendError(httpResponse, 401, "无效的令牌类型"); return; } // 将用户信息存入请求属性(供后续Controller使用) httpRequest.setAttribute("userId", userId); httpRequest.setAttribute("role", role); } catch (io.jsonwebtoken.ExpiredJwtException e) { sendError(httpResponse, 401, "令牌已过期,请刷新令牌"); return; } catch (Exception e) { sendError(httpResponse, 401, "令牌校验失败"); return; } } chain.doFilter(request, response); } /** 发送错误响应 */ private void sendError(HttpServletResponse response, int code, String message) throws IOException { response.setStatus(code); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"code\":" + code + ",\"msg\":\"" + message + "\",\"data\":null}"); } } /** * 请求限流过滤器 * * 基于IP和用户ID的双维度限流 * - IP维度:每分钟最多60次请求 * - 用户维度:每分钟最多120次请求 * - 敏感接口(登录/发送验证码):更严格的限流策略 */ public static class RateLimitFilter implements Filter { /** IP请求计数器(简化实现,生产环境使用Redis+滑动窗口) */ private final Map> ipRequestLog = new HashMap<>(); /** IP限流阈值(每分钟) */ private static final int IP_RATE_LIMIT = 60; /** 时间窗口(毫秒) */ private static final long WINDOW_MS = 60_000; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; String clientIp = getClientIp(httpRequest); long now = System.currentTimeMillis(); // IP维度限流检查 synchronized (ipRequestLog) { List timestamps = ipRequestLog.computeIfAbsent( clientIp, k -> new ArrayList<>()); // 清理窗口外的记录 timestamps.removeIf(ts -> (now - ts) > WINDOW_MS); if (timestamps.size() >= IP_RATE_LIMIT) { httpResponse.setStatus(429); httpResponse.setContentType("application/json;charset=UTF-8"); httpResponse.getWriter().write( "{\"code\":429,\"msg\":\"请求频率过高,请稍后重试\",\"data\":null}"); return; } timestamps.add(now); } chain.doFilter(request, response); } /** 获取客户端真实IP(考虑代理/负载均衡) */ private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } // X-Forwarded-For可能包含多个IP,取第一个 if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; } } } ``` ### `controller/` #### `controller/AssignmentController.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 作业管理控制器 * 负责作业/试卷的发布、回收、批改结果查询等接口 */ package com.writech.cloud.controller; import com.writech.cloud.WritechCloudApplication.ApiResponse; import com.writech.cloud.WritechCloudApplication.BusinessException; import com.writech.cloud.model.Assignment; import com.writech.cloud.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import java.time.LocalDateTime; import java.util.*; /** * 作业控制器 - /api/v1/assignment * * 教师发布作业/试卷 → 学生纸上作答(笔迹通过点阵笔采集) * → 系统自动收集 → AI引擎识别批改 → 结果推送教师和家长 */ @RestController @RequestMapping("/api/v1/assignment") public class AssignmentController { @Autowired private UserService userService; /** * 发布作业 * POST /api/v1/assignment/publish * * 教师创建并发布作业/试卷,指定班级、截止时间、题目内容 * 发布后自动推送通知至学生端和家长端 */ @PostMapping("/publish") public ApiResponse publishAssignment( @Valid @RequestBody AssignmentPublishRequest request, @RequestHeader("Authorization") String auth) { // 验证教师身份 String teacherId = extractUserIdFromToken(auth); // 校验截止时间 if (request.getDeadline() != null && request.getDeadline().isBefore(LocalDateTime.now())) { throw new BusinessException(400, "截止时间不能早于当前时间"); } // 校验题目列表 if (request.getQuestions() == null || request.getQuestions().isEmpty()) { throw new BusinessException(400, "作业题目不能为空"); } // 创建作业记录 Assignment assignment = new Assignment(); assignment.setId(UUID.randomUUID().toString().replace("-", "")); assignment.setTeacherId(teacherId); assignment.setClassId(request.getClassId()); assignment.setTitle(request.getTitle()); assignment.setType(request.getType()); // homework/exam/practice assignment.setSubject(request.getSubject()); assignment.setDeadline(request.getDeadline()); assignment.setStatus("published"); assignment.setPublishTime(LocalDateTime.now()); assignment.setTotalScore(calculateTotalScore(request.getQuestions())); assignment.setQuestionCount(request.getQuestions().size()); // 关联点阵码页面(每道题对应特定点阵码区域) if (request.getDotCodePages() != null) { assignment.setDotCodePages(request.getDotCodePages()); } // 保存作业及题目 // assignmentService.saveWithQuestions(assignment, request.getQuestions()); // 异步推送通知至学生端和家长端 // messageService.pushAssignmentNotification(assignment); AssignmentPublishResponse response = new AssignmentPublishResponse(); response.setAssignmentId(assignment.getId()); response.setTitle(assignment.getTitle()); response.setPublishTime(assignment.getPublishTime()); response.setStudentCount(getClassStudentCount(request.getClassId())); return ApiResponse.success(response); } /** * 获取作业列表 * GET /api/v1/assignment/list * * 教师查看已发布的作业列表,支持按班级、状态、时间筛选 */ @GetMapping("/list") public ApiResponse> listAssignments( @RequestParam(required = false) String classId, @RequestParam(required = false) String status, @RequestParam(required = false) String subject, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestHeader("Authorization") String auth) { String userId = extractUserIdFromToken(auth); // Page result = assignmentService.queryList(...) return ApiResponse.success(null); } /** * 获取作业详情 * GET /api/v1/assignment/{id} */ @GetMapping("/{id}") public ApiResponse getAssignment(@PathVariable String id) { // Assignment assignment = assignmentService.findById(id); return ApiResponse.success(null); } /** * 获取批改结果 * GET /api/v1/result/{assignmentId} * * 查询指定作业的AI批改结果,包含每个学生的识别文本、 * 得分、错误详情及AI反馈建议 */ @GetMapping("/result/{assignmentId}") public ApiResponse getResult( @PathVariable String assignmentId, @RequestParam(required = false) String studentId) { AssignmentResultResponse response = new AssignmentResultResponse(); response.setAssignmentId(assignmentId); response.setTotalStudents(40); response.setSubmittedCount(38); response.setGradedCount(38); response.setAverageScore(85.5); response.setHighestScore(100.0); response.setLowestScore(45.0); // 每个学生的批改结果 List studentResults = new ArrayList<>(); // studentResults = resultService.getStudentResults(assignmentId, studentId); response.setStudentResults(studentResults); return ApiResponse.success(response); } /** * 教师人工复核批改 * PUT /api/v1/assignment/review/{assignmentId} * * AI批改后教师可进行人工复核,修正AI评分或添加评语 */ @PutMapping("/review/{assignmentId}") public ApiResponse reviewAssignment( @PathVariable String assignmentId, @Valid @RequestBody ReviewRequest request, @RequestHeader("Authorization") String auth) { String teacherId = extractUserIdFromToken(auth); // 遍历教师的复核修改 for (ReviewItem item : request.getReviewItems()) { // resultService.updateReview(assignmentId, item.getStudentId(), // item.getQuestionId(), item.getManualScore(), // item.getTeacherComment(), teacherId); } return ApiResponse.success(); } /** * 学情报告接口 * GET /api/v1/report/student/{id} * * 获取指定学生的学情报告,包含知识点掌握度、 * 书写能力评估、成绩趋势等多维度分析数据 */ @GetMapping("/report/student/{studentId}") public ApiResponse getStudentReport( @PathVariable String studentId, @RequestParam(required = false) String subject, @RequestParam(required = false) String dateRange) { StudentReportResponse report = new StudentReportResponse(); report.setStudentId(studentId); report.setReportDate(LocalDateTime.now()); // 知识点掌握度 List knowledgePoints = new ArrayList<>(); // knowledgePoints = analyticsService.getKnowledgeMastery(studentId, subject); report.setKnowledgePoints(knowledgePoints); // 书写能力评估 WritingAbility writingAbility = new WritingAbility(); writingAbility.setStrokeOrderScore(88.5); writingAbility.setStructureScore(82.3); writingAbility.setNeatnessScore(90.1); writingAbility.setOverallScore(86.9); report.setWritingAbility(writingAbility); return ApiResponse.success(report); } // ==================== 内部方法 ==================== private String extractUserIdFromToken(String auth) { // 从JWT Token解析用户ID return "teacher_001"; } private double calculateTotalScore(List questions) { return questions.stream() .mapToDouble(QuestionItem::getScore) .sum(); } private int getClassStudentCount(String classId) { return 40; // 查询班级学生数 } // ==================== DTO 定义 ==================== public static class AssignmentPublishRequest { @NotBlank private String classId; @NotBlank private String title; private String type; // homework/exam/practice private String subject; private LocalDateTime deadline; private List questions; private List dotCodePages; // 关联的点阵码页面ID public String getClassId() { return classId; } public void setClassId(String id) { this.classId = id; } public String getTitle() { return title; } public void setTitle(String t) { this.title = t; } public String getType() { return type; } public void setType(String t) { this.type = t; } public String getSubject() { return subject; } public void setSubject(String s) { this.subject = s; } public LocalDateTime getDeadline() { return deadline; } public void setDeadline(LocalDateTime d) { this.deadline = d; } public List getQuestions() { return questions; } public void setQuestions(List q) { this.questions = q; } public List getDotCodePages() { return dotCodePages; } public void setDotCodePages(List p) { this.dotCodePages = p; } } public static class QuestionItem { private int questionNo; private String type; // choice/fill/short_answer/essay/math private String content; private String answer; private double score; private String knowledgePointId; public int getQuestionNo() { return questionNo; } public void setQuestionNo(int n) { this.questionNo = n; } public String getType() { return type; } public void setType(String t) { this.type = t; } public String getContent() { return content; } public void setContent(String c) { this.content = c; } public String getAnswer() { return answer; } public void setAnswer(String a) { this.answer = a; } public double getScore() { return score; } public void setScore(double s) { this.score = s; } public String getKnowledgePointId() { return knowledgePointId; } public void setKnowledgePointId(String id) { this.knowledgePointId = id; } } public static class AssignmentPublishResponse { private String assignmentId; private String title; private LocalDateTime publishTime; private int studentCount; public String getAssignmentId() { return assignmentId; } public void setAssignmentId(String id) { this.assignmentId = id; } public String getTitle() { return title; } public void setTitle(String t) { this.title = t; } public LocalDateTime getPublishTime() { return publishTime; } public void setPublishTime(LocalDateTime t) { this.publishTime = t; } public int getStudentCount() { return studentCount; } public void setStudentCount(int c) { this.studentCount = c; } } public static class AssignmentSummary { private String id; private String title; private String type; private String status; private int submittedCount; private int totalCount; private LocalDateTime publishTime; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getTitle() { return title; } public void setTitle(String t) { this.title = t; } public String getType() { return type; } public void setType(String t) { this.type = t; } public String getStatus() { return status; } public void setStatus(String s) { this.status = s; } public int getSubmittedCount() { return submittedCount; } public void setSubmittedCount(int c) { this.submittedCount = c; } public int getTotalCount() { return totalCount; } public void setTotalCount(int c) { this.totalCount = c; } public LocalDateTime getPublishTime() { return publishTime; } public void setPublishTime(LocalDateTime t) { this.publishTime = t; } } public static class AssignmentDetailResponse { private Assignment assignment; private List questions; public Assignment getAssignment() { return assignment; } public void setAssignment(Assignment a) { this.assignment = a; } public List getQuestions() { return questions; } public void setQuestions(List q) { this.questions = q; } } public static class AssignmentResultResponse { private String assignmentId; private int totalStudents; private int submittedCount; private int gradedCount; private double averageScore; private double highestScore; private double lowestScore; private List studentResults; public String getAssignmentId() { return assignmentId; } public void setAssignmentId(String id) { this.assignmentId = id; } public int getTotalStudents() { return totalStudents; } public void setTotalStudents(int c) { this.totalStudents = c; } public int getSubmittedCount() { return submittedCount; } public void setSubmittedCount(int c) { this.submittedCount = c; } public int getGradedCount() { return gradedCount; } public void setGradedCount(int c) { this.gradedCount = c; } public double getAverageScore() { return averageScore; } public void setAverageScore(double s) { this.averageScore = s; } public double getHighestScore() { return highestScore; } public void setHighestScore(double s) { this.highestScore = s; } public double getLowestScore() { return lowestScore; } public void setLowestScore(double s) { this.lowestScore = s; } public List getStudentResults() { return studentResults; } public void setStudentResults(List r) { this.studentResults = r; } } public static class StudentResult { private String studentId; private String studentName; private double totalScore; private List questionResults; public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public String getStudentName() { return studentName; } public void setStudentName(String n) { this.studentName = n; } public double getTotalScore() { return totalScore; } public void setTotalScore(double s) { this.totalScore = s; } public List getQuestionResults() { return questionResults; } public void setQuestionResults(List r) { this.questionResults = r; } } public static class QuestionResult { private int questionNo; private String ocrText; private double score; private boolean isCorrect; private String aiFeedback; public int getQuestionNo() { return questionNo; } public void setQuestionNo(int n) { this.questionNo = n; } public String getOcrText() { return ocrText; } public void setOcrText(String t) { this.ocrText = t; } public double getScore() { return score; } public void setScore(double s) { this.score = s; } public boolean isCorrect() { return isCorrect; } public void setCorrect(boolean c) { this.isCorrect = c; } public String getAiFeedback() { return aiFeedback; } public void setAiFeedback(String f) { this.aiFeedback = f; } } public static class ReviewRequest { private List reviewItems; public List getReviewItems() { return reviewItems; } public void setReviewItems(List items) { this.reviewItems = items; } } public static class ReviewItem { private String studentId; private int questionId; private Double manualScore; private String teacherComment; public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public int getQuestionId() { return questionId; } public void setQuestionId(int id) { this.questionId = id; } public Double getManualScore() { return manualScore; } public void setManualScore(Double s) { this.manualScore = s; } public String getTeacherComment() { return teacherComment; } public void setTeacherComment(String c) { this.teacherComment = c; } } public static class StudentReportResponse { private String studentId; private LocalDateTime reportDate; private List knowledgePoints; private WritingAbility writingAbility; public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public LocalDateTime getReportDate() { return reportDate; } public void setReportDate(LocalDateTime d) { this.reportDate = d; } public List getKnowledgePoints() { return knowledgePoints; } public void setKnowledgePoints(List kp) { this.knowledgePoints = kp; } public WritingAbility getWritingAbility() { return writingAbility; } public void setWritingAbility(WritingAbility wa) { this.writingAbility = wa; } } public static class KnowledgePoint { private String id; private String name; private double masteryRate; public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String n) { this.name = n; } public double getMasteryRate() { return masteryRate; } public void setMasteryRate(double r) { this.masteryRate = r; } } public static class WritingAbility { private double strokeOrderScore; private double structureScore; private double neatnessScore; private double overallScore; public double getStrokeOrderScore() { return strokeOrderScore; } public void setStrokeOrderScore(double s) { this.strokeOrderScore = s; } public double getStructureScore() { return structureScore; } public void setStructureScore(double s) { this.structureScore = s; } public double getNeatnessScore() { return neatnessScore; } public void setNeatnessScore(double s) { this.neatnessScore = s; } public double getOverallScore() { return overallScore; } public void setOverallScore(double s) { this.overallScore = s; } } } ``` #### `controller/AuthController.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 用户认证控制器 * 负责用户登录、登出、Token刷新等认证相关接口 * 采用 JWT Token + Refresh Token 双令牌机制 */ package com.writech.cloud.controller; import com.writech.cloud.WritechCloudApplication.ApiResponse; import com.writech.cloud.WritechCloudApplication.BusinessException; import com.writech.cloud.model.User; import com.writech.cloud.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.Claims; import io.jsonwebtoken.security.Keys; import javax.crypto.SecretKey; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import java.nio.charset.StandardCharsets; import java.util.*; import java.time.LocalDateTime; /** * 认证控制器 - /api/v1/auth * * 实现教师/学生/管理员/家长多角色用户的统一认证 * 支持手机号+密码、手机号+验证码、微信/钉钉第三方登录 */ @RestController @RequestMapping("/api/v1/auth") public class AuthController { @Autowired private UserService userService; /** JWT密钥 */ @Value("${writech.jwt.secret:writech-cloud-platform-jwt-secret-key-2026}") private String jwtSecret; /** Access Token 有效期(秒),默认2小时 */ @Value("${writech.jwt.access-token-expire:7200}") private long accessTokenExpire; /** Refresh Token 有效期(秒),默认7天 */ @Value("${writech.jwt.refresh-token-expire:604800}") private long refreshTokenExpire; /** * 用户登录接口 * POST /api/v1/auth/login * * 验证用户身份,签发 JWT Access Token 和 Refresh Token * Access Token 有效期2小时,Refresh Token 有效期7天 * * @param request 登录请求(包含手机号、密码/验证码、登录方式) * @return 包含双令牌和用户基本信息的响应 */ @PostMapping("/login") public ApiResponse login(@Valid @RequestBody LoginRequest request) { // 校验登录参数 if (request.getLoginType() == null) { throw new BusinessException(400, "登录方式不能为空"); } User user = null; // 根据不同登录方式验证身份 switch (request.getLoginType()) { case "password": // 手机号 + 密码登录 user = userService.verifyByPassword(request.getPhone(), request.getPassword()); break; case "sms": // 手机号 + 短信验证码登录 user = userService.verifyBySmsCode(request.getPhone(), request.getSmsCode()); break; case "wechat": // 微信授权登录 user = userService.verifyByWechat(request.getWechatCode()); break; case "dingtalk": // 钉钉授权登录 user = userService.verifyByDingtalk(request.getDingtalkCode()); break; default: throw new BusinessException(400, "不支持的登录方式: " + request.getLoginType()); } if (user == null) { throw new BusinessException(401, "登录失败,用户名或密码错误"); } // 检查用户状态 if (user.getStatus() != 1) { throw new BusinessException(403, "账户已被禁用,请联系管理员"); } // 生成双令牌 String accessToken = generateAccessToken(user); String refreshToken = generateRefreshToken(user); // 更新用户最后登录时间和登录IP userService.updateLoginInfo(user.getId(), LocalDateTime.now(), request.getClientIp()); // 构建登录响应 LoginResponse response = new LoginResponse(); response.setAccessToken(accessToken); response.setRefreshToken(refreshToken); response.setExpiresIn(accessTokenExpire); response.setUserId(user.getId()); response.setUserName(user.getName()); response.setRole(user.getRole()); response.setSchoolId(user.getSchoolId()); response.setSchoolName(user.getSchoolName()); return ApiResponse.success(response); } /** * Token 刷新接口 * POST /api/v1/auth/refresh * * 使用 Refresh Token 换取新的 Access Token * 避免用户频繁重新登录,提升使用体验 * * @param request 刷新请求(包含 Refresh Token) * @return 新的 Access Token */ @PostMapping("/refresh") public ApiResponse refreshToken(@Valid @RequestBody TokenRefreshRequest request) { try { // 解析并验证 Refresh Token Claims claims = parseToken(request.getRefreshToken()); String userId = claims.getSubject(); String tokenType = claims.get("type", String.class); // 确保是 Refresh Token 类型 if (!"refresh".equals(tokenType)) { throw new BusinessException(401, "无效的刷新令牌"); } // 查询用户信息(确保用户仍然有效) User user = userService.findById(userId); if (user == null || user.getStatus() != 1) { throw new BusinessException(401, "用户不存在或已被禁用"); } // 生成新的 Access Token String newAccessToken = generateAccessToken(user); TokenRefreshResponse response = new TokenRefreshResponse(); response.setAccessToken(newAccessToken); response.setExpiresIn(accessTokenExpire); return ApiResponse.success(response); } catch (Exception e) { throw new BusinessException(401, "令牌刷新失败: " + e.getMessage()); } } /** * 用户登出接口 * POST /api/v1/auth/logout * * 将当前 Token 加入黑名单,使其立即失效 * 同时清除 Redis 中的会话缓存 */ @PostMapping("/logout") public ApiResponse logout(@RequestHeader("Authorization") String authorization) { String token = extractToken(authorization); if (token != null) { // 将Token加入Redis黑名单,使其立即失效 userService.invalidateToken(token); } return ApiResponse.success(); } /** * 发送短信验证码 * POST /api/v1/auth/sms-code * * 向指定手机号发送登录验证码,验证码5分钟内有效 * 同一手机号60秒内只能发送一次 */ @PostMapping("/sms-code") public ApiResponse sendSmsCode(@RequestBody SmsCodeRequest request) { if (request.getPhone() == null || request.getPhone().length() != 11) { throw new BusinessException(400, "请输入正确的手机号"); } userService.sendSmsVerificationCode(request.getPhone()); return ApiResponse.success(); } /** * 获取当前登录用户信息 * GET /api/v1/auth/profile * * 根据 Token 中的用户ID查询完整的用户信息 * 包括角色、学校、班级等关联信息 */ @GetMapping("/profile") public ApiResponse getProfile(@RequestHeader("Authorization") String authorization) { String token = extractToken(authorization); Claims claims = parseToken(token); String userId = claims.getSubject(); User user = userService.findById(userId); if (user == null) { throw new BusinessException(404, "用户不存在"); } UserProfileResponse profile = new UserProfileResponse(); profile.setUserId(user.getId()); profile.setName(user.getName()); profile.setPhone(maskPhone(user.getPhone())); profile.setRole(user.getRole()); profile.setSchoolId(user.getSchoolId()); profile.setSchoolName(user.getSchoolName()); profile.setAvatar(user.getAvatar()); profile.setLastLoginTime(user.getLastLoginTime()); return ApiResponse.success(profile); } /** * 修改密码 * PUT /api/v1/auth/password */ @PutMapping("/password") public ApiResponse changePassword(@RequestHeader("Authorization") String authorization, @Valid @RequestBody ChangePasswordRequest request) { String token = extractToken(authorization); Claims claims = parseToken(token); String userId = claims.getSubject(); // 验证旧密码 boolean verified = userService.verifyPassword(userId, request.getOldPassword()); if (!verified) { throw new BusinessException(400, "原密码错误"); } // 更新密码 userService.updatePassword(userId, request.getNewPassword()); // 使所有现有Token失效,强制重新登录 userService.invalidateAllTokens(userId); return ApiResponse.success(); } // ==================== 内部方法 ==================== /** * 生成 Access Token * 有效期2小时,包含用户ID、角色、学校信息 */ private String generateAccessToken(User user) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); Date now = new Date(); Date expiry = new Date(now.getTime() + accessTokenExpire * 1000); return Jwts.builder() .setSubject(user.getId()) .claim("role", user.getRole()) .claim("schoolId", user.getSchoolId()) .claim("type", "access") .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** * 生成 Refresh Token * 有效期7天,仅包含用户ID和令牌类型 */ private String generateRefreshToken(User user) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); Date now = new Date(); Date expiry = new Date(now.getTime() + refreshTokenExpire * 1000); return Jwts.builder() .setSubject(user.getId()) .claim("type", "refresh") .setIssuedAt(now) .setExpiration(expiry) .signWith(key, SignatureAlgorithm.HS256) .compact(); } /** 解析 JWT Token */ private Claims parseToken(String token) { SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } /** 从 Authorization 头中提取 Token */ private String extractToken(String authorization) { if (authorization != null && authorization.startsWith("Bearer ")) { return authorization.substring(7); } return null; } /** 手机号脱敏处理(中间4位替换为****) */ private String maskPhone(String phone) { if (phone == null || phone.length() != 11) return phone; return phone.substring(0, 3) + "****" + phone.substring(7); } // ==================== 请求/响应 DTO ==================== /** 登录请求 */ public static class LoginRequest { @NotBlank(message = "登录方式不能为空") private String loginType; // password/sms/wechat/dingtalk private String phone; private String password; private String smsCode; private String wechatCode; private String dingtalkCode; private String clientIp; public String getLoginType() { return loginType; } public void setLoginType(String loginType) { this.loginType = loginType; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getSmsCode() { return smsCode; } public void setSmsCode(String smsCode) { this.smsCode = smsCode; } public String getWechatCode() { return wechatCode; } public void setWechatCode(String wechatCode) { this.wechatCode = wechatCode; } public String getDingtalkCode() { return dingtalkCode; } public void setDingtalkCode(String dingtalkCode) { this.dingtalkCode = dingtalkCode; } public String getClientIp() { return clientIp; } public void setClientIp(String clientIp) { this.clientIp = clientIp; } } /** 登录响应 */ public static class LoginResponse { private String accessToken; private String refreshToken; private long expiresIn; private String userId; private String userName; private String role; private String schoolId; private String schoolName; public String getAccessToken() { return accessToken; } public void setAccessToken(String t) { this.accessToken = t; } public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String t) { this.refreshToken = t; } public long getExpiresIn() { return expiresIn; } public void setExpiresIn(long e) { this.expiresIn = e; } public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getUserName() { return userName; } public void setUserName(String n) { this.userName = n; } public String getRole() { return role; } public void setRole(String r) { this.role = r; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getSchoolName() { return schoolName; } public void setSchoolName(String n) { this.schoolName = n; } } /** Token刷新请求 */ public static class TokenRefreshRequest { @NotBlank(message = "刷新令牌不能为空") private String refreshToken; public String getRefreshToken() { return refreshToken; } public void setRefreshToken(String t) { this.refreshToken = t; } } /** Token刷新响应 */ public static class TokenRefreshResponse { private String accessToken; private long expiresIn; public String getAccessToken() { return accessToken; } public void setAccessToken(String t) { this.accessToken = t; } public long getExpiresIn() { return expiresIn; } public void setExpiresIn(long e) { this.expiresIn = e; } } /** 短信验证码请求 */ public static class SmsCodeRequest { private String phone; public String getPhone() { return phone; } public void setPhone(String p) { this.phone = p; } } /** 用户信息响应 */ public static class UserProfileResponse { private String userId; private String name; private String phone; private String role; private String schoolId; private String schoolName; private String avatar; private LocalDateTime lastLoginTime; public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getName() { return name; } public void setName(String n) { this.name = n; } public String getPhone() { return phone; } public void setPhone(String p) { this.phone = p; } public String getRole() { return role; } public void setRole(String r) { this.role = r; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getSchoolName() { return schoolName; } public void setSchoolName(String n) { this.schoolName = n; } public String getAvatar() { return avatar; } public void setAvatar(String a) { this.avatar = a; } public LocalDateTime getLastLoginTime() { return lastLoginTime; } public void setLastLoginTime(LocalDateTime t) { this.lastLoginTime = t; } } /** 修改密码请求 */ public static class ChangePasswordRequest { @NotBlank(message = "原密码不能为空") private String oldPassword; @NotBlank(message = "新密码不能为空") private String newPassword; public String getOldPassword() { return oldPassword; } public void setOldPassword(String p) { this.oldPassword = p; } public String getNewPassword() { return newPassword; } public void setNewPassword(String p) { this.newPassword = p; } } } ``` #### `controller/DeviceController.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 设备管理控制器 * 负责点阵笔、网关、终端设备的注册、绑定、状态查询等接口 */ package com.writech.cloud.controller; import com.writech.cloud.WritechCloudApplication.ApiResponse; import com.writech.cloud.WritechCloudApplication.BusinessException; import com.writech.cloud.model.Device; import com.writech.cloud.service.DeviceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import java.time.LocalDateTime; import java.util.*; /** * 设备控制器 - /api/v1/device * * 管理互动课堂中涉及的所有智能硬件设备: * - 点阵笔(pen):学生书写工具,通过BLE连接网关 * - 网关设备(gateway):教室中枢,管理多支笔的连接与数据转发 * - 终端设备(terminal):黑板、PC、电视、平板等显示终端 * - 算力盒(edge_box):教室端AI推理设备 */ @RestController @RequestMapping("/api/v1/device") public class DeviceController { @Autowired private DeviceService deviceService; /** * 设备注册接口 * POST /api/v1/device/register * * 将新设备注册到云平台,绑定至指定用户和学校 * 注册时校验设备MAC地址唯一性和设备证书有效性 * * @param request 注册请求(MAC地址、设备类型、序列号等) * @return 注册成功后的设备信息 */ @PostMapping("/register") public ApiResponse registerDevice( @Valid @RequestBody DeviceRegisterRequest request) { // 校验设备MAC地址格式 if (!isValidMacAddress(request.getMacAddr())) { throw new BusinessException(400, "无效的MAC地址格式"); } // 检查设备是否已注册 Device existing = deviceService.findByMacAddr(request.getMacAddr()); if (existing != null) { throw new BusinessException(409, "设备已注册,MAC地址: " + request.getMacAddr()); } // 校验设备证书(X.509) boolean certValid = deviceService.validateDeviceCertificate( request.getMacAddr(), request.getDeviceCert()); if (!certValid) { throw new BusinessException(403, "设备证书校验失败,拒绝注册"); } // 创建设备记录 Device device = new Device(); device.setId(UUID.randomUUID().toString().replace("-", "")); device.setType(request.getDeviceType()); device.setMacAddr(request.getMacAddr()); device.setSerialNumber(request.getSerialNumber()); device.setFirmwareVersion(request.getFirmwareVersion()); device.setBindUserId(request.getUserId()); device.setSchoolId(request.getSchoolId()); device.setClassroomId(request.getClassroomId()); device.setStatus(1); // 1=在线 device.setRegisterTime(LocalDateTime.now()); device.setLastHeartbeat(LocalDateTime.now()); deviceService.save(device); // 返回注册结果 DeviceRegisterResponse response = new DeviceRegisterResponse(); response.setDeviceId(device.getId()); response.setMacAddr(device.getMacAddr()); response.setDeviceType(device.getType()); response.setRegisteredAt(device.getRegisterTime()); return ApiResponse.success(response); } /** * 设备绑定接口 * POST /api/v1/device/bind * * 将已注册设备绑定至指定用户(教师/学生) * 一支笔只能绑定一个用户,一个用户可绑定多支笔 */ @PostMapping("/bind") public ApiResponse bindDevice(@Valid @RequestBody DeviceBindRequest request) { Device device = deviceService.findById(request.getDeviceId()); if (device == null) { throw new BusinessException(404, "设备不存在"); } // 检查笔是否已被其他用户绑定 if ("pen".equals(device.getType()) && device.getBindUserId() != null && !device.getBindUserId().equals(request.getUserId())) { throw new BusinessException(409, "该笔已绑定其他用户,请先解绑"); } deviceService.bindDevice(request.getDeviceId(), request.getUserId(), request.getClassroomId()); return ApiResponse.success(); } /** * 设备解绑接口 * POST /api/v1/device/unbind */ @PostMapping("/unbind") public ApiResponse unbindDevice(@RequestBody DeviceUnbindRequest request) { deviceService.unbindDevice(request.getDeviceId()); return ApiResponse.success(); } /** * 查询设备列表 * GET /api/v1/device/list * * 按学校/教室/设备类型/状态等条件分页查询设备 */ @GetMapping("/list") public ApiResponse> listDevices( @RequestParam(required = false) String schoolId, @RequestParam(required = false) String classroomId, @RequestParam(required = false) String deviceType, @RequestParam(required = false) Integer status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { Page devices = deviceService.queryDevices( schoolId, classroomId, deviceType, status, PageRequest.of(page, size)); return ApiResponse.success(devices); } /** * 查询单个设备详情 * GET /api/v1/device/{id} */ @GetMapping("/{id}") public ApiResponse getDevice(@PathVariable String id) { Device device = deviceService.findById(id); if (device == null) { throw new BusinessException(404, "设备不存在"); } DeviceDetailResponse detail = new DeviceDetailResponse(); detail.setDeviceId(device.getId()); detail.setType(device.getType()); detail.setMacAddr(device.getMacAddr()); detail.setSerialNumber(device.getSerialNumber()); detail.setFirmwareVersion(device.getFirmwareVersion()); detail.setStatus(device.getStatus()); detail.setBindUserId(device.getBindUserId()); detail.setSchoolId(device.getSchoolId()); detail.setClassroomId(device.getClassroomId()); detail.setBatteryLevel(device.getBatteryLevel()); detail.setLastHeartbeat(device.getLastHeartbeat()); detail.setRegisterTime(device.getRegisterTime()); return ApiResponse.success(detail); } /** * 设备心跳上报接口 * POST /api/v1/device/heartbeat * * 设备定期上报在线状态、电量、连接笔数等信息 * 网关设备每30秒上报一次,笔设备每5分钟上报一次 */ @PostMapping("/heartbeat") public ApiResponse heartbeat(@Valid @RequestBody HeartbeatRequest request) { Device device = deviceService.findById(request.getDeviceId()); if (device == null) { throw new BusinessException(404, "设备不存在"); } // 更新设备状态 device.setStatus(1); // 在线 device.setLastHeartbeat(LocalDateTime.now()); device.setBatteryLevel(request.getBatteryLevel()); if (request.getConnectedPenCount() != null) { device.setConnectedPenCount(request.getConnectedPenCount()); } if (request.getCpuUsage() != null) { device.setCpuUsage(request.getCpuUsage()); } if (request.getMemoryUsage() != null) { device.setMemoryUsage(request.getMemoryUsage()); } deviceService.updateHeartbeat(device); return ApiResponse.success(); } /** * 批量查询教室设备拓扑 * GET /api/v1/device/topology/{classroomId} * * 返回指定教室中所有设备的连接拓扑关系 * 包括网关、笔、算力盒、黑板等设备的层级关系 */ @GetMapping("/topology/{classroomId}") public ApiResponse getTopology(@PathVariable String classroomId) { ClassroomTopology topology = deviceService.buildClassroomTopology(classroomId); return ApiResponse.success(topology); } // ==================== 内部方法 ==================== /** MAC地址格式校验(支持 XX:XX:XX:XX:XX:XX 和 XX-XX-XX-XX-XX-XX) */ private boolean isValidMacAddress(String mac) { if (mac == null) return false; return mac.matches("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"); } // ==================== DTO 定义 ==================== /** 设备注册请求 */ public static class DeviceRegisterRequest { @NotBlank(message = "设备类型不能为空") private String deviceType; // pen/gateway/terminal/edge_box @NotBlank(message = "MAC地址不能为空") private String macAddr; private String serialNumber; private String firmwareVersion; private String userId; private String schoolId; private String classroomId; private String deviceCert; // X.509设备证书 public String getDeviceType() { return deviceType; } public void setDeviceType(String t) { this.deviceType = t; } public String getMacAddr() { return macAddr; } public void setMacAddr(String m) { this.macAddr = m; } public String getSerialNumber() { return serialNumber; } public void setSerialNumber(String s) { this.serialNumber = s; } public String getFirmwareVersion() { return firmwareVersion; } public void setFirmwareVersion(String v) { this.firmwareVersion = v; } public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } public String getDeviceCert() { return deviceCert; } public void setDeviceCert(String c) { this.deviceCert = c; } } /** 设备注册响应 */ public static class DeviceRegisterResponse { private String deviceId; private String macAddr; private String deviceType; private LocalDateTime registeredAt; public String getDeviceId() { return deviceId; } public void setDeviceId(String id) { this.deviceId = id; } public String getMacAddr() { return macAddr; } public void setMacAddr(String m) { this.macAddr = m; } public String getDeviceType() { return deviceType; } public void setDeviceType(String t) { this.deviceType = t; } public LocalDateTime getRegisteredAt() { return registeredAt; } public void setRegisteredAt(LocalDateTime t) { this.registeredAt = t; } } /** 设备绑定请求 */ public static class DeviceBindRequest { @NotBlank private String deviceId; @NotBlank private String userId; private String classroomId; public String getDeviceId() { return deviceId; } public void setDeviceId(String id) { this.deviceId = id; } public String getUserId() { return userId; } public void setUserId(String id) { this.userId = id; } public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } } /** 设备解绑请求 */ public static class DeviceUnbindRequest { private String deviceId; public String getDeviceId() { return deviceId; } public void setDeviceId(String id) { this.deviceId = id; } } /** 心跳请求 */ public static class HeartbeatRequest { @NotBlank private String deviceId; private Integer batteryLevel; private Integer connectedPenCount; private Double cpuUsage; private Double memoryUsage; public String getDeviceId() { return deviceId; } public void setDeviceId(String id) { this.deviceId = id; } public Integer getBatteryLevel() { return batteryLevel; } public void setBatteryLevel(Integer l) { this.batteryLevel = l; } public Integer getConnectedPenCount() { return connectedPenCount; } public void setConnectedPenCount(Integer c) { this.connectedPenCount = c; } public Double getCpuUsage() { return cpuUsage; } public void setCpuUsage(Double u) { this.cpuUsage = u; } public Double getMemoryUsage() { return memoryUsage; } public void setMemoryUsage(Double u) { this.memoryUsage = u; } } /** 设备详情响应 */ public static class DeviceDetailResponse { private String deviceId; private String type; private String macAddr; private String serialNumber; private String firmwareVersion; private int status; private String bindUserId; private String schoolId; private String classroomId; private Integer batteryLevel; private LocalDateTime lastHeartbeat; private LocalDateTime registerTime; public String getDeviceId() { return deviceId; } public void setDeviceId(String id) { this.deviceId = id; } public String getType() { return type; } public void setType(String t) { this.type = t; } public String getMacAddr() { return macAddr; } public void setMacAddr(String m) { this.macAddr = m; } public String getSerialNumber() { return serialNumber; } public void setSerialNumber(String s) { this.serialNumber = s; } public String getFirmwareVersion() { return firmwareVersion; } public void setFirmwareVersion(String v) { this.firmwareVersion = v; } public int getStatus() { return status; } public void setStatus(int s) { this.status = s; } public String getBindUserId() { return bindUserId; } public void setBindUserId(String id) { this.bindUserId = id; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } public Integer getBatteryLevel() { return batteryLevel; } public void setBatteryLevel(Integer l) { this.batteryLevel = l; } public LocalDateTime getLastHeartbeat() { return lastHeartbeat; } public void setLastHeartbeat(LocalDateTime t) { this.lastHeartbeat = t; } public LocalDateTime getRegisterTime() { return registerTime; } public void setRegisterTime(LocalDateTime t) { this.registerTime = t; } } /** 教室拓扑结构 */ public static class ClassroomTopology { private String classroomId; private String classroomName; private List gateways; private List edgeBoxes; private List terminals; private List pens; private int totalDeviceCount; public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } public String getClassroomName() { return classroomName; } public void setClassroomName(String n) { this.classroomName = n; } public List getGateways() { return gateways; } public void setGateways(List g) { this.gateways = g; } public List getEdgeBoxes() { return edgeBoxes; } public void setEdgeBoxes(List e) { this.edgeBoxes = e; } public List getTerminals() { return terminals; } public void setTerminals(List t) { this.terminals = t; } public List getPens() { return pens; } public void setPens(List p) { this.pens = p; } public int getTotalDeviceCount() { return totalDeviceCount; } public void setTotalDeviceCount(int c) { this.totalDeviceCount = c; } } } ``` #### `controller/StrokeController.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 笔迹数据控制器 * 负责笔迹数据的批量上传、查询、回放等接口 * 数据流向:点阵笔 → 网关/算力盒 → Kafka → 云平台 → MongoDB */ package com.writech.cloud.controller; import com.writech.cloud.WritechCloudApplication.ApiResponse; import com.writech.cloud.WritechCloudApplication.BusinessException; import com.writech.cloud.model.StrokeData; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; import java.util.*; /** * 笔迹控制器 - /api/v1/stroke * * 处理智能点阵笔采集的原始笔迹数据,包括: * - 实时笔迹坐标上传(x, y, pressure, timestamp) * - 批量笔迹数据上传 * - 笔迹回放数据查询 * - 笔迹统计信息 */ @RestController @RequestMapping("/api/v1/stroke") public class StrokeController { /** * 批量上传笔迹数据 * POST /api/v1/stroke/upload * * 网关或算力盒将采集到的笔迹数据批量上传至云平台 * 数据经过Kafka消息队列异步写入MongoDB存储 * 同时触发AI引擎进行OCR识别和批改 * * @param request 笔迹上传请求(包含多条笔迹数据) * @return 上传结果(接收条数、处理状态) */ @PostMapping("/upload") public ApiResponse uploadStrokes( @Valid @RequestBody StrokeUploadRequest request) { // 校验数据完整性 if (request.getStrokes() == null || request.getStrokes().isEmpty()) { throw new BusinessException(400, "笔迹数据不能为空"); } // 校验每条笔迹数据的有效性 int validCount = 0; int invalidCount = 0; List errors = new ArrayList<>(); for (StrokeItem stroke : request.getStrokes()) { if (validateStrokeItem(stroke)) { validCount++; } else { invalidCount++; errors.add("无效笔迹数据, penId=" + stroke.getPenId() + ", timestamp=" + stroke.getTimestamp()); } } // 将有效数据发送至Kafka消息队列 // kafkaTemplate.send("writech-stroke-topic", request); // 构建响应 StrokeUploadResponse response = new StrokeUploadResponse(); response.setReceivedCount(request.getStrokes().size()); response.setValidCount(validCount); response.setInvalidCount(invalidCount); response.setErrors(errors); response.setProcessingStatus("queued"); // queued/processing/completed response.setUploadTime(LocalDateTime.now()); return ApiResponse.success(response); } /** * 查询学生笔迹数据 * GET /api/v1/stroke/query * * 按学生ID、作业ID、时间范围查询笔迹数据 * 支持笔迹回放场景 */ @GetMapping("/query") public ApiResponse queryStrokes( @RequestParam String studentId, @RequestParam(required = false) String assignmentId, @RequestParam(required = false) String pageId, @RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "100") int size) { StrokeQueryResponse response = new StrokeQueryResponse(); response.setStudentId(studentId); response.setTotalStrokes(0); response.setStrokes(new ArrayList<>()); // strokeDataService.queryStrokes(studentId, assignmentId, ...) return ApiResponse.success(response); } /** * 获取笔迹回放数据 * GET /api/v1/stroke/replay/{assignmentId}/{studentId} * * 获取指定学生某次作业的完整笔迹回放数据 * 按时间戳排序,支持前端动画回放 */ @GetMapping("/replay/{assignmentId}/{studentId}") public ApiResponse getReplayData( @PathVariable String assignmentId, @PathVariable String studentId) { StrokeReplayResponse response = new StrokeReplayResponse(); response.setAssignmentId(assignmentId); response.setStudentId(studentId); response.setTotalDuration(0L); response.setTotalPoints(0); response.setPages(new ArrayList<>()); return ApiResponse.success(response); } /** * 获取笔迹统计信息 * GET /api/v1/stroke/statistics * * 查询指定维度的笔迹统计数据(书写量、书写时长等) */ @GetMapping("/statistics") public ApiResponse getStatistics( @RequestParam(required = false) String studentId, @RequestParam(required = false) String classId, @RequestParam(required = false) String dateRange) { StrokeStatistics stats = new StrokeStatistics(); stats.setTotalStrokes(12580); stats.setTotalPoints(1536000); stats.setTotalWritingTime(186400L); // 秒 stats.setAverageSpeed(8.5); // 每秒点数 stats.setTotalPages(325); return ApiResponse.success(stats); } // ==================== 内部方法 ==================== /** 校验单条笔迹数据有效性 */ private boolean validateStrokeItem(StrokeItem stroke) { if (stroke.getPenId() == null || stroke.getPenId().isEmpty()) return false; if (stroke.getPoints() == null || stroke.getPoints().isEmpty()) return false; // 校验坐标范围(点阵码坐标范围) for (StrokePoint point : stroke.getPoints()) { if (point.getX() < 0 || point.getX() > 65535) return false; if (point.getY() < 0 || point.getY() > 65535) return false; if (point.getPressure() < 0 || point.getPressure() > 255) return false; } return true; } // ==================== DTO 定义 ==================== /** 笔迹上传请求 */ public static class StrokeUploadRequest { @NotBlank private String gatewayId; private String classroomId; @NotNull private List strokes; public String getGatewayId() { return gatewayId; } public void setGatewayId(String id) { this.gatewayId = id; } public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } public List getStrokes() { return strokes; } public void setStrokes(List s) { this.strokes = s; } } /** 单条笔迹数据 */ public static class StrokeItem { private String penId; // 笔MAC地址 private String studentId; // 绑定学生ID private String pageId; // 点阵码页面ID private String assignmentId; // 关联作业ID private long timestamp; // 起始时间戳 private List points; // 坐标点集合 public String getPenId() { return penId; } public void setPenId(String id) { this.penId = id; } public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public String getPageId() { return pageId; } public void setPageId(String id) { this.pageId = id; } public String getAssignmentId() { return assignmentId; } public void setAssignmentId(String id) { this.assignmentId = id; } public long getTimestamp() { return timestamp; } public void setTimestamp(long t) { this.timestamp = t; } public List getPoints() { return points; } public void setPoints(List p) { this.points = p; } } /** 笔迹坐标点 */ public static class StrokePoint { private int x; // X坐标 (0-65535) private int y; // Y坐标 (0-65535) private int pressure; // 压力值 (0-255) private long timestamp; // 时间戳(毫秒) private boolean penUp; // 抬笔标记 public int getX() { return x; } public void setX(int x) { this.x = x; } public int getY() { return y; } public void setY(int y) { this.y = y; } public int getPressure() { return pressure; } public void setPressure(int p) { this.pressure = p; } public long getTimestamp() { return timestamp; } public void setTimestamp(long t) { this.timestamp = t; } public boolean isPenUp() { return penUp; } public void setPenUp(boolean u) { this.penUp = u; } } /** 上传响应 */ public static class StrokeUploadResponse { private int receivedCount; private int validCount; private int invalidCount; private List errors; private String processingStatus; private LocalDateTime uploadTime; public int getReceivedCount() { return receivedCount; } public void setReceivedCount(int c) { this.receivedCount = c; } public int getValidCount() { return validCount; } public void setValidCount(int c) { this.validCount = c; } public int getInvalidCount() { return invalidCount; } public void setInvalidCount(int c) { this.invalidCount = c; } public List getErrors() { return errors; } public void setErrors(List e) { this.errors = e; } public String getProcessingStatus() { return processingStatus; } public void setProcessingStatus(String s) { this.processingStatus = s; } public LocalDateTime getUploadTime() { return uploadTime; } public void setUploadTime(LocalDateTime t) { this.uploadTime = t; } } /** 查询响应 */ public static class StrokeQueryResponse { private String studentId; private int totalStrokes; private List strokes; public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public int getTotalStrokes() { return totalStrokes; } public void setTotalStrokes(int c) { this.totalStrokes = c; } public List getStrokes() { return strokes; } public void setStrokes(List s) { this.strokes = s; } } /** 回放响应 */ public static class StrokeReplayResponse { private String assignmentId; private String studentId; private long totalDuration; // 总时长(毫秒) private int totalPoints; // 总坐标点数 private List pages; // 按页面分组的笔迹数据 public String getAssignmentId() { return assignmentId; } public void setAssignmentId(String id) { this.assignmentId = id; } public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public long getTotalDuration() { return totalDuration; } public void setTotalDuration(long d) { this.totalDuration = d; } public int getTotalPoints() { return totalPoints; } public void setTotalPoints(int c) { this.totalPoints = c; } public List getPages() { return pages; } public void setPages(List p) { this.pages = p; } } /** 页面回放数据 */ public static class PageReplay { private String pageId; private int pageWidth; private int pageHeight; private List strokes; public String getPageId() { return pageId; } public void setPageId(String id) { this.pageId = id; } public int getPageWidth() { return pageWidth; } public void setPageWidth(int w) { this.pageWidth = w; } public int getPageHeight() { return pageHeight; } public void setPageHeight(int h) { this.pageHeight = h; } public List getStrokes() { return strokes; } public void setStrokes(List s) { this.strokes = s; } } /** 笔迹统计 */ public static class StrokeStatistics { private int totalStrokes; private long totalPoints; private long totalWritingTime; // 秒 private double averageSpeed; private int totalPages; public int getTotalStrokes() { return totalStrokes; } public void setTotalStrokes(int c) { this.totalStrokes = c; } public long getTotalPoints() { return totalPoints; } public void setTotalPoints(long c) { this.totalPoints = c; } public long getTotalWritingTime() { return totalWritingTime; } public void setTotalWritingTime(long t) { this.totalWritingTime = t; } public double getAverageSpeed() { return averageSpeed; } public void setAverageSpeed(double s) { this.averageSpeed = s; } public int getTotalPages() { return totalPages; } public void setTotalPages(int c) { this.totalPages = c; } } } ``` ### `model/` #### `model/Models.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 数据模型 - 设备实体 / 作业实体 / 笔迹数据实体 * 设备表(device):MySQL * 作业表(assignment):MySQL * 笔迹数据(stroke_data):MongoDB */ package com.writech.cloud.model; import javax.persistence.*; import java.time.LocalDateTime; import java.util.*; // ==================== 设备实体 ==================== /** * 设备注册表实体(MySQL) * 管理点阵笔、网关、终端设备、算力盒 */ @Entity @Table(name = "device", indexes = { @Index(name = "idx_mac", columnList = "macAddr", unique = true), @Index(name = "idx_school_type", columnList = "schoolId, type"), @Index(name = "idx_classroom", columnList = "classroomId") }) class Device { @Id @Column(length = 32) private String id; /** 设备类型:pen/gateway/terminal/edge_box */ @Column(nullable = false, length = 16) private String type; /** 设备MAC地址(全局唯一) */ @Column(nullable = false, length = 17, unique = true) private String macAddr; /** 设备序列号 */ @Column(length = 32) private String serialNumber; /** 固件版本号 */ @Column(length = 16) private String firmwareVersion; /** 绑定用户ID */ @Column(length = 32) private String bindUserId; /** 所属学校ID */ @Column(length = 32) private String schoolId; /** 所属教室ID */ @Column(length = 32) private String classroomId; /** 设备状态:1=在线, 0=离线, -1=故障 */ @Column(nullable = false) private int status = 0; /** 电池电量百分比(0-100,仅笔设备) */ private Integer batteryLevel; /** 当前连接的笔数量(仅网关设备) */ private Integer connectedPenCount; /** CPU使用率(仅网关/算力盒) */ private Double cpuUsage; /** 内存使用率(仅网关/算力盒) */ private Double memoryUsage; /** 注册时间 */ @Column(nullable = false) private LocalDateTime registerTime; /** 最后心跳时间 */ private LocalDateTime lastHeartbeat; // Getter/Setter public String getId() { return id; } public void setId(String id) { this.id = id; } public String getType() { return type; } public void setType(String type) { this.type = type; } public String getMacAddr() { return macAddr; } public void setMacAddr(String macAddr) { this.macAddr = macAddr; } public String getSerialNumber() { return serialNumber; } public void setSerialNumber(String sn) { this.serialNumber = sn; } public String getFirmwareVersion() { return firmwareVersion; } public void setFirmwareVersion(String v) { this.firmwareVersion = v; } public String getBindUserId() { return bindUserId; } public void setBindUserId(String id) { this.bindUserId = id; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getClassroomId() { return classroomId; } public void setClassroomId(String id) { this.classroomId = id; } public int getStatus() { return status; } public void setStatus(int s) { this.status = s; } public Integer getBatteryLevel() { return batteryLevel; } public void setBatteryLevel(Integer l) { this.batteryLevel = l; } public Integer getConnectedPenCount() { return connectedPenCount; } public void setConnectedPenCount(Integer c) { this.connectedPenCount = c; } public Double getCpuUsage() { return cpuUsage; } public void setCpuUsage(Double u) { this.cpuUsage = u; } public Double getMemoryUsage() { return memoryUsage; } public void setMemoryUsage(Double u) { this.memoryUsage = u; } public LocalDateTime getRegisterTime() { return registerTime; } public void setRegisterTime(LocalDateTime t) { this.registerTime = t; } public LocalDateTime getLastHeartbeat() { return lastHeartbeat; } public void setLastHeartbeat(LocalDateTime t) { this.lastHeartbeat = t; } } // ==================== 作业实体 ==================== /** * 作业/试卷发布表实体(MySQL) */ @Entity @Table(name = "assignment", indexes = { @Index(name = "idx_class_status", columnList = "classId, status"), @Index(name = "idx_teacher", columnList = "teacherId") }) class Assignment { @Id @Column(length = 32) private String id; /** 发布教师ID */ @Column(nullable = false, length = 32) private String teacherId; /** 班级ID */ @Column(nullable = false, length = 32) private String classId; /** 作业标题 */ @Column(nullable = false, length = 128) private String title; /** 类型:homework(作业)/exam(考试)/practice(练习) */ @Column(nullable = false, length = 16) private String type; /** 学科 */ @Column(length = 32) private String subject; /** 截止时间 */ private LocalDateTime deadline; /** 状态:draft/published/closed/graded */ @Column(nullable = false, length = 16) private String status; /** 发布时间 */ private LocalDateTime publishTime; /** 满分值 */ private double totalScore; /** 题目总数 */ private int questionCount; /** 关联的点阵码页面ID列表(JSON数组) */ @Column(columnDefinition = "TEXT") private String dotCodePagesJson; @Transient private List dotCodePages; // Getter/Setter public String getId() { return id; } public void setId(String id) { this.id = id; } public String getTeacherId() { return teacherId; } public void setTeacherId(String id) { this.teacherId = id; } public String getClassId() { return classId; } public void setClassId(String id) { this.classId = id; } public String getTitle() { return title; } public void setTitle(String t) { this.title = t; } public String getType() { return type; } public void setType(String t) { this.type = t; } public String getSubject() { return subject; } public void setSubject(String s) { this.subject = s; } public LocalDateTime getDeadline() { return deadline; } public void setDeadline(LocalDateTime d) { this.deadline = d; } public String getStatus() { return status; } public void setStatus(String s) { this.status = s; } public LocalDateTime getPublishTime() { return publishTime; } public void setPublishTime(LocalDateTime t) { this.publishTime = t; } public double getTotalScore() { return totalScore; } public void setTotalScore(double s) { this.totalScore = s; } public int getQuestionCount() { return questionCount; } public void setQuestionCount(int c) { this.questionCount = c; } public List getDotCodePages() { return dotCodePages; } public void setDotCodePages(List p) { this.dotCodePages = p; } } // ==================== 笔迹数据实体 ==================== /** * 笔迹原始数据实体(MongoDB) * * JSON文档结构: * { * student_id: "...", * assignment_id: "...", * pen_id: "...", * page_id: "...", * strokes: [{x, y, pressure, timestamp, penUp}, ...], * createTime: "...", * processingStatus: "received/processing/completed/failed" * } */ class StrokeData { private String id; private String studentId; private String assignmentId; private String penId; private String pageId; private List> strokes; private LocalDateTime createTime; private LocalDateTime processedTime; private String processingStatus; // received/processing/completed/failed public String getId() { return id; } public void setId(String id) { this.id = id; } public String getStudentId() { return studentId; } public void setStudentId(String id) { this.studentId = id; } public String getAssignmentId() { return assignmentId; } public void setAssignmentId(String id) { this.assignmentId = id; } public String getPenId() { return penId; } public void setPenId(String id) { this.penId = id; } public String getPageId() { return pageId; } public void setPageId(String id) { this.pageId = id; } public List> getStrokes() { return strokes; } public void setStrokes(List> s) { this.strokes = s; } public LocalDateTime getCreateTime() { return createTime; } public void setCreateTime(LocalDateTime t) { this.createTime = t; } public LocalDateTime getProcessedTime() { return processedTime; } public void setProcessedTime(LocalDateTime t) { this.processedTime = t; } public String getProcessingStatus() { return processingStatus; } public void setProcessingStatus(String s) { this.processingStatus = s; } } ``` #### `model/User.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 数据模型 - 用户实体 * 对应数据表:user (MySQL) * 支持教师/学生/管理员/家长四种角色 */ package com.writech.cloud.model; import javax.persistence.*; import java.time.LocalDateTime; /** * 用户主表实体类 * * RBAC角色定义: * - admin:系统管理员(学校/用户/设备管理全权限) * - teacher:教师(班级管理/作业发布/学情查看) * - student:学生(作业查看/学习数据查询) * - parent:家长(子女学情查看/消息接收) * * 安全设计: * - 手机号使用AES-256加密存储(encryptedPhone字段) * - 密码使用BCrypt哈希存储 * - 身份证号等敏感信息加密后存储 */ @Entity @Table(name = "user", indexes = { @Index(name = "idx_phone", columnList = "encryptedPhone"), @Index(name = "idx_school_role", columnList = "schoolId, role"), @Index(name = "idx_wechat", columnList = "wechatOpenId") }) public class User { /** 用户唯一ID(UUID格式) */ @Id @Column(length = 32) private String id; /** 用户姓名 */ @Column(nullable = false, length = 64) private String name; /** 手机号(明文,仅用于内部处理,不直接存储) */ @Transient private String phone; /** 加密后的手机号(AES-256-CBC加密存储) */ @Column(length = 128) private String encryptedPhone; /** 密码哈希(BCrypt,强度因子10) */ @Column(length = 128) private String passwordHash; /** 用户角色:admin/teacher/student/parent */ @Column(nullable = false, length = 16) private String role; /** 所属学校ID */ @Column(length = 32) private String schoolId; /** 所属学校名称(冗余存储,减少关联查询) */ @Column(length = 128) private String schoolName; /** 头像URL */ @Column(length = 256) private String avatar; /** 微信OpenID(第三方登录绑定) */ @Column(length = 64) private String wechatOpenId; /** 钉钉用户ID(第三方登录绑定) */ @Column(length = 64) private String dingtalkUserId; /** 账户状态:1=正常, 0=禁用, -1=注销 */ @Column(nullable = false) private int status = 1; /** Token版本号(用于使所有旧Token失效) */ @Column(nullable = false) private int tokenVersion = 0; /** 账户创建时间 */ @Column(nullable = false) private LocalDateTime createTime; /** 最后登录时间 */ private LocalDateTime lastLoginTime; /** 最后登录IP */ @Column(length = 45) private String lastLoginIp; // ==================== Getter / Setter ==================== public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public String getEncryptedPhone() { return encryptedPhone; } public void setEncryptedPhone(String encryptedPhone) { this.encryptedPhone = encryptedPhone; } public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } public String getRole() { return role; } public void setRole(String role) { this.role = role; } public String getSchoolId() { return schoolId; } public void setSchoolId(String schoolId) { this.schoolId = schoolId; } public String getSchoolName() { return schoolName; } public void setSchoolName(String schoolName) { this.schoolName = schoolName; } public String getAvatar() { return avatar; } public void setAvatar(String avatar) { this.avatar = avatar; } public String getWechatOpenId() { return wechatOpenId; } public void setWechatOpenId(String wechatOpenId) { this.wechatOpenId = wechatOpenId; } public String getDingtalkUserId() { return dingtalkUserId; } public void setDingtalkUserId(String dingtalkUserId) { this.dingtalkUserId = dingtalkUserId; } public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public int getTokenVersion() { return tokenVersion; } public void setTokenVersion(int tokenVersion) { this.tokenVersion = tokenVersion; } public LocalDateTime getCreateTime() { return createTime; } public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } public LocalDateTime getLastLoginTime() { return lastLoginTime; } public void setLastLoginTime(LocalDateTime lastLoginTime) { this.lastLoginTime = lastLoginTime; } public String getLastLoginIp() { return lastLoginIp; } public void setLastLoginIp(String lastLoginIp) { this.lastLoginIp = lastLoginIp; } @Override public String toString() { return "User{id='" + id + "', name='" + name + "', role='" + role + "', schoolId='" + schoolId + "', status=" + status + "}"; } } ``` ### `service/` #### `service/DeviceService.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 设备管理服务 * 管理点阵笔、网关、终端设备、算力盒的全生命周期 */ package com.writech.cloud.service; import com.writech.cloud.model.Device; import com.writech.cloud.controller.DeviceController.ClassroomTopology; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.cert.X509Certificate; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; /** * 设备服务类 * * 管理互动课堂中所有硬件设备的注册、绑定、状态监控 * 设备类型:pen(点阵笔) / gateway(网关) / terminal(终端) / edge_box(算力盒) */ @Service public class DeviceService { @Autowired private StringRedisTemplate redisTemplate; /** 设备在线超时时间(秒),超过此时间未收到心跳视为离线 */ private static final long DEVICE_ONLINE_TIMEOUT = 120; /** 网关设备心跳间隔(秒) */ private static final long GATEWAY_HEARTBEAT_INTERVAL = 30; /** 笔设备心跳间隔(秒) */ private static final long PEN_HEARTBEAT_INTERVAL = 300; /** * 保存设备信息 */ @Transactional public void save(Device device) { // deviceRepository.save(device); // 更新Redis中的设备在线状态缓存 updateDeviceOnlineStatus(device.getId(), true); } /** * 根据ID查询设备 */ public Device findById(String deviceId) { // return deviceRepository.findById(deviceId).orElse(null); return null; } /** * 根据MAC地址查询设备 */ public Device findByMacAddr(String macAddr) { // return deviceRepository.findByMacAddr(macAddr); return null; } /** * 校验设备证书(X.509) * 首次注册时网关设备需提供预置的设备证书进行身份校验 * * @param macAddr MAC地址 * @param certPem PEM格式的X.509证书 * @return 校验通过返回true */ public boolean validateDeviceCertificate(String macAddr, String certPem) { if (certPem == null || certPem.isEmpty()) { return false; } try { // 解析X.509证书 java.security.cert.CertificateFactory cf = java.security.cert.CertificateFactory.getInstance("X.509"); java.io.ByteArrayInputStream bis = new java.io.ByteArrayInputStream(certPem.getBytes()); X509Certificate cert = (X509Certificate) cf.generateCertificate(bis); // 检查证书有效期 cert.checkValidity(); // 验证证书签名(使用CA根证书公钥) // cert.verify(caCertificate.getPublicKey()); // 从证书CN字段提取MAC地址,与请求中的MAC地址比对 String cn = cert.getSubjectX500Principal().getName(); if (!cn.contains(macAddr.replace(":", "").toUpperCase())) { return false; } return true; } catch (Exception e) { return false; } } /** * 设备绑定 * 将设备绑定至指定用户和教室 */ @Transactional public void bindDevice(String deviceId, String userId, String classroomId) { // deviceRepository.updateBinding(deviceId, userId, classroomId); } /** * 设备解绑 */ @Transactional public void unbindDevice(String deviceId) { // deviceRepository.clearBinding(deviceId); } /** * 分页查询设备列表 * 支持按学校、教室、类型、状态多维度过滤 */ public Page queryDevices(String schoolId, String classroomId, String deviceType, Integer status, Pageable pageable) { // return deviceRepository.queryByConditions(schoolId, classroomId, // deviceType, status, pageable); return null; } /** * 更新设备心跳 * 心跳数据写入MySQL并更新Redis在线状态缓存 */ public void updateHeartbeat(Device device) { // deviceRepository.updateHeartbeat(device.getId(), // device.getLastHeartbeat(), device.getBatteryLevel(), // device.getConnectedPenCount(), device.getCpuUsage(), // device.getMemoryUsage()); // 更新Redis在线状态(设置过期时间为心跳超时时间) updateDeviceOnlineStatus(device.getId(), true); } /** * 构建教室设备拓扑 * 查询教室内所有设备,按类型分组并建立连接关系 * * @param classroomId 教室ID * @return 拓扑结构(网关/算力盒/终端/笔) */ public ClassroomTopology buildClassroomTopology(String classroomId) { // 查询教室下所有设备 // List devices = deviceRepository.findByClassroomId(classroomId); List devices = new ArrayList<>(); ClassroomTopology topology = new ClassroomTopology(); topology.setClassroomId(classroomId); // 按设备类型分组 Map> grouped = devices.stream() .collect(Collectors.groupingBy(Device::getType)); topology.setGateways(grouped.getOrDefault("gateway", new ArrayList<>())); topology.setEdgeBoxes(grouped.getOrDefault("edge_box", new ArrayList<>())); topology.setTerminals(grouped.getOrDefault("terminal", new ArrayList<>())); topology.setPens(grouped.getOrDefault("pen", new ArrayList<>())); topology.setTotalDeviceCount(devices.size()); return topology; } /** * 批量检查设备在线状态 * 通过Redis缓存快速判断设备是否在线 */ public Map checkOnlineStatus(List deviceIds) { Map result = new HashMap<>(); for (String deviceId : deviceIds) { String key = "writech:device:online:" + deviceId; result.put(deviceId, Boolean.TRUE.equals(redisTemplate.hasKey(key))); } return result; } /** * 发送远程指令至设备 * 通过MQTT向指定设备下发控制指令(重启/配置更新/OTA等) */ public void sendCommand(String deviceId, String command, Map params) { // 构建MQTT消息 Map message = new HashMap<>(); message.put("command", command); message.put("params", params); message.put("timestamp", System.currentTimeMillis()); // 根据设备类型确定Topic Device device = findById(deviceId); if (device == null) return; String topic; switch (device.getType()) { case "gateway": topic = "gateway/" + deviceId + "/command"; break; case "edge_box": topic = "edgebox/" + deviceId + "/command"; break; default: topic = "device/" + deviceId + "/command"; } // mqttTemplate.convertAndSend(topic, message); } /** * 统计学校设备概况 */ public DeviceOverview getSchoolDeviceOverview(String schoolId) { DeviceOverview overview = new DeviceOverview(); // 各类型设备数量统计 // overview.setTotalPens(deviceRepository.countBySchoolAndType(schoolId, "pen")); // overview.setTotalGateways(deviceRepository.countBySchoolAndType(schoolId, "gateway")); // overview.setOnlinePens(countOnlineDevices(schoolId, "pen")); // overview.setOnlineGateways(countOnlineDevices(schoolId, "gateway")); return overview; } // ==================== 内部方法 ==================== /** 更新Redis中设备在线状态 */ private void updateDeviceOnlineStatus(String deviceId, boolean online) { String key = "writech:device:online:" + deviceId; if (online) { redisTemplate.opsForValue().set(key, "1", DEVICE_ONLINE_TIMEOUT, java.util.concurrent.TimeUnit.SECONDS); } else { redisTemplate.delete(key); } } // ==================== 内部类 ==================== /** 设备概况统计 */ public static class DeviceOverview { private int totalPens; private int totalGateways; private int totalEdgeBoxes; private int totalTerminals; private int onlinePens; private int onlineGateways; private int onlineEdgeBoxes; private double averageBatteryLevel; public int getTotalPens() { return totalPens; } public void setTotalPens(int c) { this.totalPens = c; } public int getTotalGateways() { return totalGateways; } public void setTotalGateways(int c) { this.totalGateways = c; } public int getTotalEdgeBoxes() { return totalEdgeBoxes; } public void setTotalEdgeBoxes(int c) { this.totalEdgeBoxes = c; } public int getTotalTerminals() { return totalTerminals; } public void setTotalTerminals(int c) { this.totalTerminals = c; } public int getOnlinePens() { return onlinePens; } public void setOnlinePens(int c) { this.onlinePens = c; } public int getOnlineGateways() { return onlineGateways; } public void setOnlineGateways(int c) { this.onlineGateways = c; } public int getOnlineEdgeBoxes() { return onlineEdgeBoxes; } public void setOnlineEdgeBoxes(int c) { this.onlineEdgeBoxes = c; } public double getAverageBatteryLevel() { return averageBatteryLevel; } public void setAverageBatteryLevel(double l) { this.averageBatteryLevel = l; } } } ``` #### `service/MessageService.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 消息推送服务 * 基于 WebSocket 实现多终端实时消息推送 * 支持新作业通知、批改完成通知、课堂互动指令等 */ package com.writech.cloud.service; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import org.springframework.web.socket.config.annotation.*; import java.io.IOException; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** * 消息服务类 * * WebSocket实时消息通道:/ws/v1/notify * * 消息类型: * - ASSIGNMENT_NEW:新作业通知 * - ASSIGNMENT_GRADED:批改完成通知 * - STROKE_REALTIME:实时笔迹数据推送 * - CLASSROOM_INTERACTION:课堂互动指令 * - SYSTEM_NOTIFICATION:系统公告 */ @Service public class MessageService extends TextWebSocketHandler implements WebSocketConfigurer { @Autowired private StringRedisTemplate redisTemplate; /** 在线用户WebSocket会话映射(userId → session列表,支持多终端同时在线) */ private final ConcurrentHashMap> userSessions = new ConcurrentHashMap<>(); /** 教室频道会话映射(classroomId → session列表) */ private final ConcurrentHashMap> classroomChannels = new ConcurrentHashMap<>(); /** * WebSocket端点注册 */ @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(this, "/ws/v1/notify") .setAllowedOrigins("*"); } /** * WebSocket连接建立 * 从Token中解析用户ID,注册到在线会话映射 */ @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId = extractUserIdFromSession(session); if (userId != null) { // 注册用户会话 userSessions.computeIfAbsent(userId, k -> new ArrayList<>()).add(session); // 更新在线状态 updateOnlineStatus(userId, true); // 推送离线期间的未读消息 pushOfflineMessages(userId, session); } } /** * WebSocket消息接收 * 处理客户端发送的消息(心跳、课堂互动指令等) */ @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); Map msg = parseMessage(payload); String type = (String) msg.get("type"); if (type == null) return; switch (type) { case "HEARTBEAT": // 回复心跳 session.sendMessage(new TextMessage("{\"type\":\"HEARTBEAT_ACK\"}")); break; case "JOIN_CLASSROOM": // 加入教室频道(课堂互动场景) String classroomId = (String) msg.get("classroomId"); joinClassroomChannel(classroomId, session); break; case "LEAVE_CLASSROOM": // 离开教室频道 String leaveClassroom = (String) msg.get("classroomId"); leaveClassroomChannel(leaveClassroom, session); break; case "CLASSROOM_COMMAND": // 教师发送课堂控制指令(广播至教室内所有终端) broadcastToClassroom(msg); break; default: break; } } /** * WebSocket连接断开 */ @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId = extractUserIdFromSession(session); if (userId != null) { // 移除会话 List sessions = userSessions.get(userId); if (sessions != null) { sessions.remove(session); if (sessions.isEmpty()) { userSessions.remove(userId); updateOnlineStatus(userId, false); } } } // 从教室频道移除 classroomChannels.values().forEach(list -> list.remove(session)); } /** * 向指定用户推送消息 * 支持多终端同时推送(手机/Pad/PC同时在线时都能收到) * * @param userId 目标用户ID * @param messageType 消息类型 * @param data 消息数据 */ public void pushToUser(String userId, String messageType, Map data) { Map message = new HashMap<>(); message.put("type", messageType); message.put("data", data); message.put("timestamp", System.currentTimeMillis()); String json = toJson(message); List sessions = userSessions.get(userId); if (sessions != null && !sessions.isEmpty()) { // 在线推送 for (WebSocketSession session : sessions) { try { if (session.isOpen()) { session.sendMessage(new TextMessage(json)); } } catch (IOException e) { // 发送失败,记录日志 } } } else { // 离线存储(用户上线后推送) storeOfflineMessage(userId, json); } } /** * 向班级所有学生推送消息 * * @param classId 班级ID * @param messageType 消息类型 * @param data 消息数据 */ public void pushToClass(String classId, String messageType, Map data) { // 查询班级学生列表 // List studentIds = classService.getStudentIds(classId); List studentIds = new ArrayList<>(); for (String studentId : studentIds) { pushToUser(studentId, messageType, data); } } /** * 向教室频道广播消息 * 用于课堂互动场景,将消息推送至教室内所有终端(黑板/PC/电视/Pad) */ public void broadcastToClassroom(Map message) { String classroomId = (String) message.get("classroomId"); if (classroomId == null) return; String json = toJson(message); List sessions = classroomChannels.get(classroomId); if (sessions != null) { for (WebSocketSession session : sessions) { try { if (session.isOpen()) { session.sendMessage(new TextMessage(json)); } } catch (IOException e) { // 发送失败处理 } } } } /** * 推送作业发布通知 */ public void pushAssignmentNotification(String classId, String title, String assignmentId) { Map data = new HashMap<>(); data.put("assignmentId", assignmentId); data.put("title", title); data.put("message", "教师发布了新作业: " + title); pushToClass(classId, "ASSIGNMENT_NEW", data); } /** * 推送批改完成通知 */ public void pushGradingNotification(String studentId, String assignmentTitle, double score) { Map data = new HashMap<>(); data.put("title", assignmentTitle); data.put("score", score); data.put("message", "作业\"" + assignmentTitle + "\"批改完成,得分: " + score); pushToUser(studentId, "ASSIGNMENT_GRADED", data); } /** * 推送实时笔迹数据至教室大屏 * 低延迟推送,用于黑板/电视大屏实时展示学生书写过程 */ public void pushRealtimeStroke(String classroomId, String studentId, List> strokePoints) { Map data = new HashMap<>(); data.put("studentId", studentId); data.put("points", strokePoints); Map message = new HashMap<>(); message.put("type", "STROKE_REALTIME"); message.put("classroomId", classroomId); message.put("data", data); broadcastToClassroom(message); } // ==================== 内部方法 ==================== /** 加入教室频道 */ private void joinClassroomChannel(String classroomId, WebSocketSession session) { classroomChannels.computeIfAbsent(classroomId, k -> new ArrayList<>()).add(session); } /** 离开教室频道 */ private void leaveClassroomChannel(String classroomId, WebSocketSession session) { List sessions = classroomChannels.get(classroomId); if (sessions != null) { sessions.remove(session); } } /** 从WebSocket会话中提取用户ID */ private String extractUserIdFromSession(WebSocketSession session) { // 从URL参数或握手头中的Token解析用户ID String query = session.getUri() != null ? session.getUri().getQuery() : null; if (query != null && query.contains("token=")) { // 解析Token获取userId return "extracted_user_id"; } return null; } /** 更新用户在线状态 */ private void updateOnlineStatus(String userId, boolean online) { String key = "writech:user:online:" + userId; if (online) { redisTemplate.opsForValue().set(key, "1"); } else { redisTemplate.delete(key); } } /** 存储离线消息 */ private void storeOfflineMessage(String userId, String message) { String key = "writech:offline:msg:" + userId; redisTemplate.opsForList().rightPush(key, message); // 最多保留100条离线消息 redisTemplate.opsForList().trim(key, -100, -1); } /** 推送离线期间积累的未读消息 */ private void pushOfflineMessages(String userId, WebSocketSession session) throws IOException { String key = "writech:offline:msg:" + userId; List messages = redisTemplate.opsForList().range(key, 0, -1); if (messages != null) { for (String msg : messages) { session.sendMessage(new TextMessage(msg)); } redisTemplate.delete(key); } } /** JSON序列化(简化版本) */ private String toJson(Map map) { StringBuilder sb = new StringBuilder("{"); boolean first = true; for (Map.Entry entry : map.entrySet()) { if (!first) sb.append(","); sb.append("\"").append(entry.getKey()).append("\":"); Object value = entry.getValue(); if (value instanceof String) { sb.append("\"").append(value).append("\""); } else { sb.append(value); } first = false; } sb.append("}"); return sb.toString(); } /** JSON解析(简化版本) */ private Map parseMessage(String json) { return new HashMap<>(); } /** * 获取在线用户统计 */ public Map getOnlineStats() { Map stats = new HashMap<>(); stats.put("totalOnlineUsers", userSessions.size()); stats.put("totalSessions", userSessions.values().stream() .mapToInt(List::size).sum()); stats.put("activeClassrooms", classroomChannels.size()); return stats; } } ``` #### `service/StrokeService.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 笔迹数据处理服务 * 负责笔迹数据的Kafka消费、存储、AI引擎调度 */ package com.writech.cloud.service; import com.writech.cloud.model.StrokeData; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; /** * 笔迹数据服务 * * 数据流处理管道: * 1. 网关/算力盒通过MQTT上报笔迹数据到云平台 * 2. 云平台接收服务将数据推入Kafka消息队列 * 3. 本服务作为Kafka消费者接收并处理数据 * 4. 原始笔迹数据存入MongoDB(高写入吞吐量) * 5. 触发AI引擎异步识别(OCR/数学/笔顺) * 6. 识别结果回写MongoDB,推送至各终端 */ @Service public class StrokeService { @Autowired private MongoTemplate mongoTemplate; @Autowired private KafkaTemplate kafkaTemplate; /** AI引擎调用线程池 */ private final ExecutorService aiExecutor = Executors.newFixedThreadPool(16); /** AI引擎服务地址 */ private static final String AI_ENGINE_URL = "http://ai-engine-service:8001"; /** 笔迹数据MongoDB集合名 */ private static final String STROKE_COLLECTION = "stroke_data"; /** 识别结果MongoDB集合名 */ private static final String RESULT_COLLECTION = "recognition_result"; /** * Kafka消费者:接收笔迹数据 * 监听 writech-stroke-topic 主题,批量消费笔迹数据 * * @param message JSON格式的笔迹数据 */ @KafkaListener(topics = "writech-stroke-topic", groupId = "stroke-consumer-group") public void consumeStrokeData(String message) { try { // 解析笔迹数据JSON StrokeData strokeData = parseStrokeData(message); if (strokeData == null) return; // 数据预处理(坐标校验、时间戳排序、去重) preprocessStrokeData(strokeData); // 写入MongoDB存储 saveToMongoDB(strokeData); // 判断是否需要触发AI识别 if (shouldTriggerRecognition(strokeData)) { // 异步调用AI引擎 submitRecognitionTask(strokeData); } } catch (Exception e) { // 处理失败的消息发送到死信队列 kafkaTemplate.send("writech-stroke-dlq", message); } } /** * 保存笔迹数据到MongoDB * 使用批量写入提升性能,每批最多500条 */ public void saveToMongoDB(StrokeData strokeData) { strokeData.setCreateTime(LocalDateTime.now()); strokeData.setProcessingStatus("received"); mongoTemplate.save(strokeData, STROKE_COLLECTION); } /** * 批量保存笔迹数据 * 用于网关批量上传场景,提升写入吞吐量 */ public void batchSave(List strokeDataList) { if (strokeDataList == null || strokeDataList.isEmpty()) return; LocalDateTime now = LocalDateTime.now(); for (StrokeData data : strokeDataList) { data.setCreateTime(now); data.setProcessingStatus("received"); } // MongoDB批量插入 mongoTemplate.insertAll(strokeDataList); } /** * 查询学生笔迹数据 * * @param studentId 学生ID * @param assignmentId 作业ID(可选) * @param startTime 开始时间(可选) * @param endTime 结束时间(可选) * @return 笔迹数据列表 */ public List queryStrokes(String studentId, String assignmentId, LocalDateTime startTime, LocalDateTime endTime) { Query query = new Query(); query.addCriteria(Criteria.where("studentId").is(studentId)); if (assignmentId != null) { query.addCriteria(Criteria.where("assignmentId").is(assignmentId)); } if (startTime != null && endTime != null) { query.addCriteria(Criteria.where("timestamp") .gte(startTime).lte(endTime)); } // 按时间戳排序(回放场景需要) query.with(org.springframework.data.domain.Sort.by( org.springframework.data.domain.Sort.Direction.ASC, "timestamp")); return mongoTemplate.find(query, StrokeData.class, STROKE_COLLECTION); } /** * 提交AI识别任务 * 将笔迹数据异步发送至AI引擎进行识别 */ private void submitRecognitionTask(StrokeData strokeData) { aiExecutor.submit(() -> { try { // 根据作业题目类型选择识别方式 String recognitionType = determineRecognitionType(strokeData); // 调用AI引擎REST API Map requestBody = new HashMap<>(); requestBody.put("strokeId", strokeData.getId()); requestBody.put("studentId", strokeData.getStudentId()); requestBody.put("strokes", strokeData.getStrokes()); requestBody.put("type", recognitionType); // String apiUrl = AI_ENGINE_URL + "/api/v1/ocr/recognize"; // RestTemplate restTemplate = new RestTemplate(); // ResponseEntity response = restTemplate.postForEntity( // apiUrl, requestBody, String.class); // 保存识别结果 // saveRecognitionResult(strokeData.getId(), response.getBody()); // 更新笔迹数据处理状态 updateProcessingStatus(strokeData.getId(), "completed"); } catch (Exception e) { updateProcessingStatus(strokeData.getId(), "failed"); } }); } /** * 笔迹数据预处理 * - 坐标范围校验(过滤异常值) * - 时间戳排序 * - 重复数据去重 * - 坐标归一化(适配不同纸面规格) */ private void preprocessStrokeData(StrokeData strokeData) { if (strokeData.getStrokes() == null) return; List> processed = strokeData.getStrokes().stream() // 过滤无效坐标点 .filter(point -> { int x = ((Number) point.getOrDefault("x", -1)).intValue(); int y = ((Number) point.getOrDefault("y", -1)).intValue(); return x >= 0 && x <= 65535 && y >= 0 && y <= 65535; }) // 按时间戳排序 .sorted((a, b) -> { long ta = ((Number) a.getOrDefault("timestamp", 0L)).longValue(); long tb = ((Number) b.getOrDefault("timestamp", 0L)).longValue(); return Long.compare(ta, tb); }) .collect(Collectors.toList()); // 去重(相同时间戳的重复点) List> deduplicated = new ArrayList<>(); long lastTimestamp = -1; for (Map point : processed) { long ts = ((Number) point.getOrDefault("timestamp", 0L)).longValue(); if (ts != lastTimestamp) { deduplicated.add(point); lastTimestamp = ts; } } strokeData.setStrokes(deduplicated); } /** * 判断是否需要触发AI识别 * - 抬笔事件(笔画结束)触发单字识别 * - 作业提交事件触发整页识别 * - 超过5秒无新数据触发段落识别 */ private boolean shouldTriggerRecognition(StrokeData strokeData) { // 如果关联了作业ID,则需要识别 if (strokeData.getAssignmentId() != null) { return true; } // 检查是否有抬笔标记 if (strokeData.getStrokes() != null) { return strokeData.getStrokes().stream() .anyMatch(p -> Boolean.TRUE.equals(p.get("penUp"))); } return false; } /** 确定识别类型 */ private String determineRecognitionType(StrokeData strokeData) { // 根据作业题目类型确定:ocr/math/stroke_order/essay return "ocr"; } /** 解析笔迹数据JSON */ private StrokeData parseStrokeData(String json) { // JSON反序列化 return null; } /** 更新处理状态 */ private void updateProcessingStatus(String strokeId, String status) { Query query = new Query(Criteria.where("_id").is(strokeId)); org.springframework.data.mongodb.core.query.Update update = new org.springframework.data.mongodb.core.query.Update(); update.set("processingStatus", status); update.set("processedTime", LocalDateTime.now()); mongoTemplate.updateFirst(query, update, STROKE_COLLECTION); } } ``` #### `service/UserService.java` ```java /** * 自然写互动课堂教学管理云平台软件 V1.0 * * 用户与权限服务 * 实现 RBAC 角色权限模型,管理教师/学生/管理员/家长四级权限 */ package com.writech.cloud.service; import com.writech.cloud.model.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.TimeUnit; /** * 用户服务类 * * 提供用户管理、身份验证、权限控制、Token管理等核心功能 * RBAC权限模型:管理员 > 教师 > 学生/家长 * - 管理员:系统全局管理(学校/用户/设备管理) * - 教师:班级管理、作业发布批改、学情查看 * - 学生:作业查看、学习数据查询 * - 家长:子女学情查看、消息接收 */ @Service public class UserService { @Autowired private StringRedisTemplate redisTemplate; /** 密码加密器(BCrypt算法,强度因子10) */ private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(10); /** Token黑名单前缀(存储在Redis中) */ private static final String TOKEN_BLACKLIST_PREFIX = "writech:token:blacklist:"; /** 短信验证码前缀 */ private static final String SMS_CODE_PREFIX = "writech:sms:code:"; /** 验证码有效期(秒) */ private static final long SMS_CODE_EXPIRE = 300; /** 验证码发送间隔(秒) */ private static final long SMS_CODE_INTERVAL = 60; /** * 手机号+密码验证登录 * * @param phone 手机号 * @param password 明文密码 * @return 验证通过返回用户对象,失败返回null */ public User verifyByPassword(String phone, String password) { if (phone == null || password == null) { return null; } // 查询用户(手机号AES解密后匹配) User user = findByPhone(phone); if (user == null) { return null; } // BCrypt密码比对 if (passwordEncoder.matches(password, user.getPasswordHash())) { return user; } // 登录失败计数(防暴力破解,5次失败后锁定30分钟) incrementLoginFailCount(user.getId()); return null; } /** * 手机号+短信验证码验证登录 */ public User verifyBySmsCode(String phone, String smsCode) { if (phone == null || smsCode == null) { return null; } // 从Redis获取验证码 String key = SMS_CODE_PREFIX + phone; String storedCode = redisTemplate.opsForValue().get(key); if (storedCode == null || !storedCode.equals(smsCode)) { return null; } // 验证码匹配成功,删除已使用的验证码 redisTemplate.delete(key); // 查找或自动注册用户 User user = findByPhone(phone); if (user == null) { // 首次登录自动创建账户 user = autoRegister(phone); } return user; } /** * 微信授权登录验证 */ public User verifyByWechat(String wechatCode) { if (wechatCode == null) return null; // 调用微信开放平台API获取用户openId String openId = exchangeWechatOpenId(wechatCode); if (openId == null) return null; // 查找绑定的用户 User user = findByWechatOpenId(openId); return user; } /** * 钉钉授权登录验证 */ public User verifyByDingtalk(String dingtalkCode) { if (dingtalkCode == null) return null; String userId = exchangeDingtalkUserId(dingtalkCode); if (userId == null) return null; return findByDingtalkUserId(userId); } /** * 发送短信验证码 * * @param phone 手机号 * @throws RuntimeException 发送频率过高时抛出异常 */ public void sendSmsVerificationCode(String phone) { // 检查发送频率(60秒内不可重复发送) String intervalKey = SMS_CODE_PREFIX + "interval:" + phone; if (Boolean.TRUE.equals(redisTemplate.hasKey(intervalKey))) { throw new RuntimeException("验证码发送过于频繁,请60秒后重试"); } // 生成6位随机验证码 String code = String.format("%06d", new Random().nextInt(1000000)); // 存入Redis(5分钟有效期) String codeKey = SMS_CODE_PREFIX + phone; redisTemplate.opsForValue().set(codeKey, code, SMS_CODE_EXPIRE, TimeUnit.SECONDS); // 设置发送间隔标记(60秒) redisTemplate.opsForValue().set(intervalKey, "1", SMS_CODE_INTERVAL, TimeUnit.SECONDS); // 调用短信服务发送验证码 sendSms(phone, code); } /** * 查询用户信息 */ public User findById(String userId) { // 先查Redis缓存 // User cachedUser = getCachedUser(userId); // if (cachedUser != null) return cachedUser; // 查数据库 // User user = userRepository.findById(userId).orElse(null); // if (user != null) cacheUser(user); return null; } /** * 根据手机号查询用户 * 手机号在数据库中AES-256加密存储,查询时需加密后匹配 */ public User findByPhone(String phone) { String encryptedPhone = encryptField(phone); // return userRepository.findByEncryptedPhone(encryptedPhone); return null; } /** * 更新用户登录信息 */ public void updateLoginInfo(String userId, LocalDateTime loginTime, String loginIp) { // userRepository.updateLoginInfo(userId, loginTime, loginIp); } /** * 验证密码 */ public boolean verifyPassword(String userId, String password) { User user = findById(userId); if (user == null) return false; return passwordEncoder.matches(password, user.getPasswordHash()); } /** * 更新密码 * 密码使用BCrypt加密后存储,强度因子10 */ @Transactional public void updatePassword(String userId, String newPassword) { // 密码强度校验(最少8位,包含大小写字母和数字) if (!isStrongPassword(newPassword)) { throw new RuntimeException("密码强度不足,需包含大小写字母和数字,不少于8位"); } String passwordHash = passwordEncoder.encode(newPassword); // userRepository.updatePassword(userId, passwordHash); } /** * 将Token加入黑名单(使其立即失效) * 黑名单存储在Redis中,有效期与Token过期时间一致 */ public void invalidateToken(String token) { String key = TOKEN_BLACKLIST_PREFIX + token; redisTemplate.opsForValue().set(key, "1", 7200, TimeUnit.SECONDS); } /** * 使用户所有Token失效(强制重新登录) */ public void invalidateAllTokens(String userId) { // 更新用户tokenVersion字段,旧版本Token将在校验时失效 // userRepository.incrementTokenVersion(userId); } /** * 检查Token是否在黑名单中 */ public boolean isTokenBlacklisted(String token) { String key = TOKEN_BLACKLIST_PREFIX + token; return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } /** * 创建用户 * 管理员创建教师/学生/家长账户 */ @Transactional public User createUser(CreateUserRequest request) { // 检查手机号唯一性 if (request.getPhone() != null && findByPhone(request.getPhone()) != null) { throw new RuntimeException("手机号已被注册"); } User user = new User(); user.setId(UUID.randomUUID().toString().replace("-", "")); user.setName(request.getName()); user.setPhone(request.getPhone()); user.setRole(request.getRole()); user.setSchoolId(request.getSchoolId()); user.setSchoolName(request.getSchoolName()); user.setStatus(1); user.setCreateTime(LocalDateTime.now()); // 加密手机号存储 if (request.getPhone() != null) { user.setEncryptedPhone(encryptField(request.getPhone())); } // 设置初始密码 if (request.getPassword() != null) { user.setPasswordHash(passwordEncoder.encode(request.getPassword())); } // userRepository.save(user); return user; } /** * 查询学校下的用户列表 * 按角色过滤(教师/学生/家长) */ public List findBySchoolAndRole(String schoolId, String role) { // return userRepository.findBySchoolIdAndRole(schoolId, role); return new ArrayList<>(); } // ==================== 内部方法 ==================== /** 自动注册用户(首次短信登录) */ private User autoRegister(String phone) { User user = new User(); user.setId(UUID.randomUUID().toString().replace("-", "")); user.setPhone(phone); user.setEncryptedPhone(encryptField(phone)); user.setRole("parent"); // 默认家长角色 user.setStatus(1); user.setCreateTime(LocalDateTime.now()); return user; } /** 登录失败计数(防暴力破解) */ private void incrementLoginFailCount(String userId) { String key = "writech:login:fail:" + userId; Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, 1800, TimeUnit.SECONDS); // 30分钟窗口 } if (count != null && count >= 5) { // 锁定账户30分钟 String lockKey = "writech:login:lock:" + userId; redisTemplate.opsForValue().set(lockKey, "1", 1800, TimeUnit.SECONDS); } } /** AES-256加密字段(手机号、身份信息等敏感数据) */ private String encryptField(String plainText) { // 使用AES-256-CBC模式加密 // Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); // 实际实现使用配置的密钥 return Base64.getEncoder().encodeToString(plainText.getBytes()); } /** AES-256解密字段 */ private String decryptField(String cipherText) { return new String(Base64.getDecoder().decode(cipherText)); } /** 密码强度校验 */ private boolean isStrongPassword(String password) { if (password == null || password.length() < 8) return false; boolean hasUpper = false, hasLower = false, hasDigit = false; for (char c : password.toCharArray()) { if (Character.isUpperCase(c)) hasUpper = true; if (Character.isLowerCase(c)) hasLower = true; if (Character.isDigit(c)) hasDigit = true; } return hasUpper && hasLower && hasDigit; } /** 微信OpenId获取(模拟) */ private String exchangeWechatOpenId(String code) { // 调用 https://api.weixin.qq.com/sns/oauth2/access_token return null; } /** 钉钉UserId获取(模拟) */ private String exchangeDingtalkUserId(String code) { return null; } private User findByWechatOpenId(String openId) { return null; } private User findByDingtalkUserId(String userId) { return null; } private void sendSms(String phone, String code) { /* 调用短信服务商API */ } // ==================== 请求 DTO ==================== public static class CreateUserRequest { private String name; private String phone; private String password; private String role; private String schoolId; private String schoolName; public String getName() { return name; } public void setName(String n) { this.name = n; } public String getPhone() { return phone; } public void setPhone(String p) { this.phone = p; } public String getPassword() { return password; } public void setPassword(String p) { this.password = p; } public String getRole() { return role; } public void setRole(String r) { this.role = r; } public String getSchoolId() { return schoolId; } public void setSchoolId(String id) { this.schoolId = id; } public String getSchoolName() { return schoolName; } public void setSchoolName(String n) { this.schoolName = n; } } } ```