software copyright
This commit is contained in:
@@ -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));
|
||||
|
||||
// 存入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<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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user