software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,280 @@
/**
* 自然写互动课堂教学管理云平台软件 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<Device> 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<Device> devices = deviceRepository.findByClassroomId(classroomId);
List<Device> devices = new ArrayList<>();
ClassroomTopology topology = new ClassroomTopology();
topology.setClassroomId(classroomId);
// 按设备类型分组
Map<String, List<Device>> 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<String, Boolean> checkOnlineStatus(List<String> deviceIds) {
Map<String, Boolean> 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<String, Object> params) {
// 构建MQTT消息
Map<String, Object> 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; }
}
}
@@ -0,0 +1,339 @@
/**
* 自然写互动课堂教学管理云平台软件 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<String, List<WebSocketSession>> userSessions =
new ConcurrentHashMap<>();
/** 教室频道会话映射(classroomId → session列表) */
private final ConcurrentHashMap<String, List<WebSocketSession>> 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<String, Object> 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<WebSocketSession> 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<String, Object> data) {
Map<String, Object> message = new HashMap<>();
message.put("type", messageType);
message.put("data", data);
message.put("timestamp", System.currentTimeMillis());
String json = toJson(message);
List<WebSocketSession> 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<String, Object> data) {
// 查询班级学生列表
// List<String> studentIds = classService.getStudentIds(classId);
List<String> studentIds = new ArrayList<>();
for (String studentId : studentIds) {
pushToUser(studentId, messageType, data);
}
}
/**
* 向教室频道广播消息
* 用于课堂互动场景,将消息推送至教室内所有终端(黑板/PC/电视/Pad)
*/
public void broadcastToClassroom(Map<String, Object> message) {
String classroomId = (String) message.get("classroomId");
if (classroomId == null) return;
String json = toJson(message);
List<WebSocketSession> 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<String, Object> 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<String, Object> 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<Map<String, Object>> strokePoints) {
Map<String, Object> data = new HashMap<>();
data.put("studentId", studentId);
data.put("points", strokePoints);
Map<String, Object> 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<WebSocketSession> 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<String> 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<String, Object> map) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> 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<String, Object> parseMessage(String json) {
return new HashMap<>();
}
/**
* 获取在线用户统计
*/
public Map<String, Integer> getOnlineStats() {
Map<String, Integer> 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;
}
}
@@ -0,0 +1,256 @@
/**
* 自然写互动课堂教学管理云平台软件 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<String, String> 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<StrokeData> 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<StrokeData> 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<String, Object> 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<String> 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<Map<String, Object>> 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<Map<String, Object>> deduplicated = new ArrayList<>();
long lastTimestamp = -1;
for (Map<String, Object> 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);
}
}
@@ -0,0 +1,375 @@
/**
* 自然写互动课堂教学管理云平台软件 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));
// 存入Redis5分钟有效期)
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<User> 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; }
}
}