简介:这是一套面向Java全栈学习与毕业设计场景的即时通讯管理系统,采用前后端分离架构:后端基于SpringBoot整合Mybatis/MybatisPlus搭建业务接口,结合Redis缓存与JWT登录鉴权,并通过WebSocket实现消息实时推送;前端由Vue+ElementUI完成页面交互,覆盖登录注册、会话管理、通讯录、好友管理、收藏、个人资料、文本/图片/文件消息收发、文件下载与图片预览等功能。资源包共1889个文件,约74.55MB,其中111个Java源码与136个XML配置对应业务实现和Mybatis映射,205个JS、689个CSS及290个Less负责前端交互与样式,175个PNG图片为界面素材,并附带SQL数据库备份及前后端完整工程目录。代码中Controller层、Service实现、Redis数据访问、WebSocket服务端、JWT工具类等模块分层清晰,便于直接导入开发工具运行与二次扩展。当前已有619人学习浏览,适合正在做毕业设计,或希望了解WebSocket实时通信、Redis缓存、JWT鉴权等完整落地写法的开发者参考。
1. 即时通讯管理系统到底在解决什么问题
很多人一看到“即时通讯管理系统”就以为是个带聊天窗口的Demo,把用户列表拉出来、点一下能发消息就算完工。但真正落到企业或校园内部场景时,核心诉求根本不是“能聊天”,而是消息可追溯、状态可感知、权限可控制——谁在什么时间给谁发了什么、对方看了没有、哪些人属于哪些组、敏感词有没有被拦截,这些才是管理侧真正要的东西。这套基于Java+SpringBoot+vue+elementui的即时通讯管理系统,做的就是“聊天能力 + 后台管控”的合体:前端用vue和elementui撑起会话界面和管理界面,后端用SpringBoot提供REST接口、WebSocket长连接和消息持久化。适合拿来当毕业设计、课程设计,也适合中小团队做内部沟通工具的原型,或者作为你简历上一个完整的全栈项目。下面我按自己落地这类系统时的完整思路,把架构、代码、参数和坑一次讲透。
2. 系统的整体技术选型与数据模型设计
2.1 为什么是 SpringBoot + vue + elementui 这套组合
先聊选型理由。SpringBoot在这个场景里的价值在于起步快、生态全、资料多。WebSocket原生支持、MyBatis-Plus分页插件、全局异常处理、拦截器注册,这些在SpringBoot里都是配置类加注解的事儿,不用像SSH时代那样写一堆XML。Vue 2 + Element UI的组合在管理系统领域至今仍是主流梯队,Element UI的表格、表单、对话框、分页组件对“后台管理”这种密集的CRUD界面极其友好;它的表格支持多选、排序、列固定,直接决定管理端的开发速度。如果你是做毕设或课设,这套技术栈还有一个隐性优势:答辩时每个环节几乎都能找到对应的问题点——SpringBoot的自动配置原理、Vue的响应式机制、Element UI的组件通信,全是高频考点。
整体架构上,我一般这样划分:
- 应用端(面向普通用户):登录、好友列表、单聊、群聊、消息记录、在线状态。
- 管理端(面向管理员):用户管理、群组管理、聊天记录审计、敏感词统计、系统数据概览。
两端共用同一个后端服务,但通过路由和权限区分入口。前端是标准的Vue SPA,后端只暴露REST接口和WebSocket端点。
2.2 数据库表结构设计:这几张表是核心
即时通讯系统的表设计说复杂也复杂,说简单也简单。我落地的核心表一般是这么几张:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| t_user | 用户表 | user_id, username, password, nickname, avatar, status, create_time |
| t_friend | 好友关系表 | id, user_id, friend_id, remark, status |
| t_group | 群组表 | group_id, group_name, owner_id, create_time |
| t_group_member | 群成员表 | id, group_id, user_id, role |
| t_message | 消息表 | msg_id, from_id, to_id, msg_type, content, is_read, create_time |
| t_offline_message | 离线消息表 | id, user_id, msg_id, is_pushed |
最需要注意的是t_message表的设计。单聊时to_id存接收人ID;群聊时to_id存群组ID,并通过msg_type字段区分(1表示单聊,2表示群聊)。为什么要把单聊和群聊放同一张表?因为管理端审计聊天记录时,大概率要按时间线混合查询,拆成两张表会让“全局搜索”变得很痛苦。另一个关键点是is_read字段,它支撑的是“已读未读”功能,存布尔值即可,但查询时要和create_time做联合索引,否则消息量上来后列表接口会很慢。
CREATE TABLE t_message ( msg_id BIGINT AUTO_INCREMENT PRIMARY KEY, from_id BIGINT NOT NULL COMMENT '发送人ID', to_id BIGINT NOT NULL COMMENT '接收人ID(用户或群组)', msg_type TINYINT NOT NULL DEFAULT 1 COMMENT '1单聊 2群聊', content VARCHAR(2000) NOT NULL COMMENT '消息内容', is_read TINYINT NOT NULL DEFAULT 0 COMMENT '0未读 1已读', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_to_user_time (to_id, is_read, create_time), INDEX idx_from_time (from_id, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;消息内容用utf8mb4而不是utf8,否则用户发个Emoji表情直接报 "Incorrect string value" 错误,这种错误在答辩现场出现一次就很尴尬。
2.3 后端项目骨架与前端工程结构
后端我用标准的SpringBoot分层结构。controller层只做参数接收和结果封装,具体逻辑下沉到service,数据库操作走mapper。WebSocket相关的类单独放进websocket包,不要跟HTTP接口混在一起。这里有一个习惯性建议:消息推送的Service不要直接操作Mapper,而是通过一个独立的MessageService统一收口,这样后续加敏感词过滤、加消息撤回、加已读回执时,都只改一个地方。
com.example.im ├── config // WebSocket配置、CORS配置、MyBatis-Plus配置 ├── controller // 登录、好友、群组、消息历史、管理端接口 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus Mapper接口 ├── entity // 数据库实体 ├── websocket // WebSocketHandler、握手拦截器、消息模型 └── common // 统一返回结果、异常处理、JWT工具前端工程用vue-cli创建。src/views下按页面拆:Login.vue、Chat.vue、AdminUser.vue、AdminMessage.vue;src/utils放axios封装和WebSocket封装;src/store用Vuex存当前用户信息和全局连接状态。Element UI按需引入,不要全量引入,否则首屏体积会大得离谱。
3. WebSocket 接入与后端消息链路实现
3.1 用 SpringBoot 原生 WebSocket 做长连接:配置类与握手拦截器
SpringBoot集成WebSocket不用引第三方库,spring-boot-starter-websocket就够了。常见的坑是:握手拦截器里取不到登录态,因为WebSocket握手不走SpringMVC的拦截器链,必须单独写HandshakeInterceptor。下面这段配置是我项目里的标准写法:
@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler(), "/ws/chat") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOrigins("*"); } @Bean public WebSocketHandler chatWebSocketHandler() { return new ChatWebSocketHandler(); } }public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { // 从请求参数中取 token,解析出 userId 放进 attributes String token = request.getURI().getQuery() != null ? UriComponentsBuilder.fromUri(request.getURI()).build() .getQueryParams().getFirst("token") : null; if (token == null || !JwtUtil.validate(token)) { return false; // 握手失败,前端会触发 onerror } Integer userId = JwtUtil.getUserId(token); attributes.put("userId", userId); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { } }这两个类的分工很明确:WebSocketConfig负责注册端点和拦截器,AuthHandshakeInterceptor负责在握手阶段校验身份并把userId塞进attributes。后面处理器从WebSocketSession.getAttributes()里取userId就拿到了当前连接的用户身份。注意setAllowedOrigins("*")是开发环境的配置,线上部署时要把*换成实际域名,否则有跨域风险。
3.2 消息处理器的核心逻辑:连接管理、心跳保活、消息分发
消息处理器是整个后端的心跳。它要维护一张“在线用户ID到WebSocketSession”的映射表,并处理用户上线、下线、消息转发。这里有一个非常容易踩的并发坑:WebSocketSession不是线程安全的,多线程同时 sendMessage 会抛异常。我用ConcurrentHashMap存session,发送时对每个session单独加锁。
@Component public class ChatWebSocketHandler extends TextWebSocketHandler { // 在线用户:userId -> WebSocketSession private final Map<Integer, WebSocketSession> onlineSessions = new ConcurrentHashMap<>(); private final Map<Integer, Object> sessionLocks = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { Integer userId = (Integer) session.getAttributes().get("userId"); onlineSessions.put(userId, session); sessionLocks.put(userId, new Object()); // 广播上线事件 broadcast(null, buildMessage("ONLINE", userId, null, "用户上线了")); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { JSONObject json = JSONObject.parseObject(message.getPayload()); String type = json.getString("type"); if ("PING".equals(type)) { // 心跳响应 session.sendMessage(new TextMessage("{\"type\":\"PONG\"}")); return; } if ("CHAT".equals(type)) { Integer fromId = json.getInteger("fromId"); Integer toId = json.getInteger("toId"); Integer msgType = json.getInteger("msgType"); // 1单聊 2群聊 String content = json.getString("content"); // 统一走消息服务:落库 + 实时转发 + 离线补偿 messageService.sendAndDispatch(fromId, toId, msgType, content); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { Integer userId = (Integer) session.getAttributes().get("userId"); onlineSessions.remove(userId); sessionLocks.remove(userId); broadcast(null, buildMessage("OFFLINE", userId, null, "用户下线了")); } public void sendToUser(Integer userId, String payload) { WebSocketSession session = onlineSessions.get(userId); if (session != null && session.isOpen()) { synchronized (sessionLocks.get(userId)) { session.sendMessage(new TextMessage(payload)); } } } }handleTextMessage是消息入口,afterConnectionClosed是清理入口。这里的sendAndDispatch是Service层的方法,它做的事是:先落库,再查目标用户是否在线,在线则直接推,不在线则写离线消息表。心跳方面我一般让前端30秒发一次PING,连续两次收不到(即60秒无消息)就判定掉线,关闭session并清理映射表。前端心跳间隔可以调,但不要低于15秒,不然高并发下PING包会占掉大量带宽。
3.3 离线消息补偿:用户上线后怎么把漏掉的消息补回来
用户断线期间别人发的消息不能丢,这是即时通讯的底线。我的做法是:发送方在sendAndDispatch里先查目标用户是否在onlineSessions里,不在就把消息ID和用户ID写入t_offline_message;用户上线时,afterConnectionEstablished里触发一个补拉动作。
@Service public class MessageService { @Autowired private MessageMapper messageMapper; @Autowired private OfflineMessageMapper offlineMessageMapper; @Autowired private ChatWebSocketHandler webSocketHandler; public void sendAndDispatch(Integer fromId, Integer toId, Integer msgType, String content) { // 1. 落库 Message msg = new Message(); msg.setFromId(fromId); msg.setToId(toId); msg.setMsgType(msgType); msg.setContent(content); msg.setIsRead(false); messageMapper.insert(msg); // 2. 判断目标是否在线,在线直接推,不在线写离线表 JSONObject payload = new JSONObject(); payload.put("type", "CHAT"); payload.put("msgId", msg.getMsgId()); payload.put("fromId", fromId); payload.put("toId", toId); payload.put("content", content); if (msgType == 1) { // 单聊:推给 toId if (!webSocketHandler.isOnline(toId)) { offlineMessageMapper.insert(new OfflineMessage(toId, msg.getMsgId())); } else { webSocketHandler.sendToUser(toId, payload.toJSONString()); } } else { // 群聊:查群成员列表,逐个推送,不在线的写离线表 List<Integer> memberIds = groupMemberMapper.selectUserIdsByGroupId(toId); for (Integer uid : memberIds) { if (uid.equals(fromId)) continue; // 不发给自己 if (!webSocketHandler.isOnline(uid)) { offlineMessageMapper.insert(new OfflineMessage(uid, msg.getMsgId())); } else { webSocketHandler.sendToUser(uid, payload.toJSONString()); } } } } public List<Message> pullOfflineMessages(Integer userId) { // 查出离线消息关联的原始消息,并标记已读 return offlineMessageMapper.selectMessagesByUserId(userId); } }注意给群成员发消息时 “不发给自己” 这个条件是必须的,否则前端会收到自己发出去的消息再渲染一遍,界面闪一下很难受。离线消息的补拉时机很关键:必须等前端把历史消息列表加载完再补拉,否则会出现“新消息出现在旧消息上面”的错乱。我通常的做法是:前端先调用GET /api/message/history?beforeId=xxx&limit=20加载最近20条,加载完成后再建立WebSocket连接,连接建立后服务端自动把离线消息推过来。
4. Vue + Element UI 前端:从登录到管理端的落地
4.1 vue 工程环境配置与 WebSocket 代理
前端工程的环境配置是新手最容易卡壳的地方。用vue create创建工程后,第一件事是安装依赖:npm install element-ui axios vuex vue-router。Vue 2 配 Element UI 没问题,千万别在 Vue 3 里装 Element UI(那是Element Plus的活),版本匹配错误是控制台报错的重灾区。
开发环境下有个绕不开的点:WebSocket的代理和HTTP接口的代理是两套配置。vue.config.js里只配proxy不够,WS协议需要单独配ws: true。
// vue.config.js module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true }, '/ws': { target: 'ws://localhost:8080', ws: true, changeOrigin: true } } } };配好了之后,前端所有接口请求都用/api开头,WebSocket连接地址用ws://localhost:8081/ws/chat。为什么要走8081的代理而不是直接连8080?因为浏览器同源策略会拦截跨域WebSocket,虽然服务端配了setAllowedOrigins("*"),但开发时走代理最省心,避免同时处理CORS和CSRF两堆问题。
4.2 登录逻辑、路由守卫与用户状态管理
登录页的逻辑不复杂,但要注意把token存到sessionStorage而不是localStorage——关闭浏览器标签页就失效,更符合管理系统的安全习惯。路由守卫用beforeEach拦一下:
// router/index.js router.beforeEach((to, from, next) => { const token = sessionStorage.getItem('token'); if (to.path === '/login') { next(); } else if (!token) { next('/login'); } else { next(); } });用户信息(userId、nickname、avatar)在登录成功时由后端返回,前端存到Vuex里。为什么不在刷新页面时再调一次接口拿用户信息?因为刷新时WebSocket会断开重连,重连握手里需要userId,从Vuex取比异步调接口更可靠。我一般在App.vue的mounted里执行“先取Vuex用户信息、再建立WebSocket连接”这个顺序。
4.3 聊天界面的消息渲染与历史分页加载
聊天界面是即时通讯的门面,UI上要搞定“会话列表 + 消息区 + 输入框”三段式布局。消息区滚动条要始终吸附在底部,有新消息时自动滚下去,但用户往上翻历史记录时不能强制跳动。我用的方案是:监听滚动事件,滚动位置距顶部小于50px时触发历史消息加载,加载完把新数据unshift进消息数组并保持原滚动位置。
<template> <div class="chat-panel"> <div class="message-list" ref="messageList" @scroll="onScroll"> <div v-for="msg in messages" :key="msg.msgId" class="message-item"> <span class="username">{{ msg.fromName }}</span> <span class="content">{{ msg.content }}</span> </div> </div> <div class="input-area"> <el-input v-model="draft" type="textarea" :rows="3" @keydown.enter.native="sendMessage" /> <el-button type="primary" @click="sendMessage">发送</el-button> </div> </div> </template>注意绑定消息内容用的是{{ msg.content }}插值而不是v-html。即时通讯的消息内容天然是不可信的,用户完全可以发<img src=x onerror=alert(1)>,用v-html渲染等于给XSS攻击开了一扇大门。这一点在管理端的聊天审计页面同样适用。
methods: { loadHistory() { const beforeId = this.messages.length > 0 ? this.messages[0].msgId : null; axios.get('/api/message/history', { params: { beforeId: beforeId, limit: 20 } }).then(res => { const oldHeight = this.$refs.messageList.scrollHeight; this.messages.unshift(...res.data); this.$nextTick(() => { this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight - oldHeight; }); }); }, onScroll() { if (this.$refs.messageList.scrollTop < 50) { this.loadHistory(); // 触顶加载更多 } } }beforeId是消息分页的核心参数。我不用传统的pageNum/pageSize分页方式,因为聊天记录是逆序加载的——最新的在最下面,往上翻才加载更早的消息。用msgId做游标,天然避开“新增消息导致分页数据位移”的问题。这个思路同样适用于管理端查看聊天记录时的场景。
4.4 Element UI 搭建管理端:用户管理与聊天审计
管理端的核心是表格。用户管理页面用el-table+el-pagination+el-input搜索框,走的是标准CRUD。这里要重点提醒:**el-table开启多选时必须设置row-key**,而且这个key必须是数据里的唯一字段(比如userId),不是数组下标。
<el-table :data="userList" row-key="userId" @selection-change="handleSelectionChange"> <el-table-column type="selection" width="55" /> <el-table-column prop="username" label="用户名" /> <el-table-column prop="nickname" label="昵称" /> <el-table-column prop="status" label="状态" /> <el-table-column label="操作"> <template slot-scope="scope"> <el-button type="text" @click="resetPassword(scope.row.userId)">重置密码</el-button> <el-button type="text" class="danger-text" @click="banUser(scope.row.userId)">禁用</el-button> </template> </el-table-column> </el-table>row-key不设或设成index,在回显选中状态时会错乱——比如翻页后重新加载数据,之前选的项会消失或选错行。Element UI官方中文文档里这个坑写得比较隐晦,实际踩过一次就记住了。
管理端的聊天审计页面用el-tabs区分“单聊记录”和“群聊记录”,核心字段是发送人、接收人、消息内容、时间。后端在controller写一个多条件查询接口,接收fromId、toId、keyword、startTime、endTime五个可选参数,用MyBatis-Plus的LambdaQueryWrapper动态拼条件。这里要注意:群聊和单聊的to_id含义不同,查询时要加上msg_type条件,否则会把群聊记录当成某个用户的单聊记录展示出来。
5. 避坑:即时通讯系统最常见的五个翻车现场
5.1 WebSocket握手404,后端控制台没有任何日志
现象:前端new WebSocket("ws://localhost:8081/ws/chat")直接报404,但HTTP接口访问正常。
原因:这是开发环境的经典问题——vue.config.js里只配了/api的代理,没配/ws的代理。浏览器请求的是8081端口,后端在8080,没代理自然404。还有一种可能是后端写成了@ServerEndpoint("/ws/chat")注解方式而不是实现WebSocketConfigurer,两种方式注册路径的优先级不同,混用时根路径会被覆盖。
解决:先在vue.config.js里给/ws单独加代理并设ws: true。如果后端确认是WebSocketConfigurer方式,顺手检查/ws/chat有没有被Shiro或Spring Security拦截——WebSocket握手请求不会携带传统的session信息,过滤器如果强制要求登录态会把握手直接踢掉。我在项目里对/ws/**路径统一放行,登录校验全部放到AuthHandshakeInterceptor里做。
5.2 消息发不出去,前端WebSocket已连接但sendMessage没反应
现象:浏览器Network面板看到WS连接状态是Open,控制台执行ws.send()也不报错,但对方收不到消息。
原因:大概率是“连接建立成功,但握手时身份信息没拿到”。我在beforeHandshake里解析token失败时,拦截器返回false会导致连接直接关闭——但如果前端没有监听onclose,页面表现就是“看起来连接正常,一发送就石沉大海”。还有一种情况是用户在前端手动调用ws.close()后没用onclose回调重新建立连接,二次登录后session还是旧连接。
解决:前端一定要写onclose回调并做重连,重连前重新从store里取token,不要用缓存里的旧token。我踩过一次就是用户改密码后token失效,WebSocket没重新握手,所有消息全部丢失。重连的次数上限建议设为5次,间隔2秒递增,超过之后要求用户刷新页面,避免死循环刷爆服务器。
5.3 Element UI表格选择框回显后“全选错误”
现象:用户管理页面选了三个人,翻页再回来,勾选的变成另外三个人;或者点表头的全选框,选中的数量对不上。
原因:我在4.4里提到了row-key。这其实是最普遍的误用:开发者看到el-table-column type="selection"就以为选中逻辑是组件管理的,忽略了row-key的语义。row-key的作用是告诉Element UI“哪一列是这一行的唯一ID”,省略或设置成重复值时,选中状态的内部Map会串行。
解决:必须在<el-table>上显式设置row-key="userId",且userId不能有重复。另外注意:如果接口返回的数据结构里主键不叫userId而叫id,要写row-key="id",别想当然。这个问题的后果不只是显示错误——调selection-change拿到的selectedRows数组也是错的,批量禁用用户时可能误伤无辜账号。
5.4 消息排序错乱:先发的消息出现在后发的下面
现象:聊天记录里偶发消息顺序颠倒,特别是同一个会话里两个用户几乎同时发消息时。
原因:我用create_time排序。MySQL的DATETIME默认精度是秒,同一秒内的消息无法区分先后。更隐蔽的问题是:WebSocket推送是异步的,消息A已经落库但还没推送,消息B先到达前端渲染出来,等A推送过来后直接push到数组尾部,就“看起来顺序错了”。
解决:排序一律用msg_id,因为Auto Increment保证严格递增。前端渲染时不要直接push,而是先判断新消息的msgId是否大于当前数组最后一条的msgId,如果是才追加,否则插入到正确位置。历史消息加载用beforeId=xx查询,自然也不会出现重复或跳动。
receiveMessage(newMsg) { const lastMsg = this.messages[this.messages.length - 1]; if (!lastMsg || newMsg.msgId > lastMsg.msgId) { this.messages.push(newMsg); } else { // 按 msgId 插到正确位置 const idx = this.messages.findIndex(m => newMsg.msgId < m.msgId); this.messages.splice(idx, 0, newMsg); } }5.5 全局过滤器拦截了上传PDF,却把正常接口也打挂了
现象:后端加了一个全局XSS过滤器处理上传文件,结果普通的发消息接口也报错,甚至JSON解析直接失败。
原因:常见做法是写一个OncePerRequestFilter或HandlerInterceptor,对请求体做统一处理。但有的过滤器对Content-Type: application/json的请求也用读取getInputStream()的方式改写body,导致SpringMVC在后面无法再次读取,直接抛出HttpMessageNotReadableException。
解决:过滤器里先判断Content-Type,只处理multipart/form-data或application/x-www-form-urlencoded类型;JSON请求不做流改写,而是加一个RequestBodyAdvice做专门的JSON解析和转义。而且XSS过滤要区分场景:消息内容需要保留原始格式,比如用户发的<br>或/u003c转义,要在存储时保留、展示时转义,而不是存入前直接抹掉。
6. 往上走一步:已读回执、离线文件与操作日志
系统跑通后,想让它从“课程设计”变成“可以演示完整闭环的产品”,我会优先加三个能力:已读回执、图片/文件传输、管理端操作日志。
已读回执的做法是:消息列表接口里查出is_read = 0且to_id = 当前用户的条数,用来在会话列表渲染未读角标;前端打开某个会话并渲染完消息区后,调PUT /api/message/read接口传入toId和msgType,后端批量把is_read置为1,并给发送方推一条READ事件。注意不要每条消息一个更新请求,20条消息就发20个HTTP请求,管理端和移动端都会很卡。
图片和文件传输不建议塞进WebSocket的文本消息里。我的约定是:msg_type = 3表示图片,msg_type = 4表示文件,消息内容存的是文件URL。前端先把文件传到POST /api/file/upload,拿到URL后再走普通消息链路发出去。文件存储先本地磁盘就够用,application.yml里配一个upload-dir,按日期分目录存。如果后续真上生产,再把文件迁移到MinIO或OSS,接口路径不变,只换存储实现。
管理端操作日志是答辩时的加分项。管理员禁用用户、重置密码、导出聊天记录时,写一条t_admin_log记录操作人、操作时间、操作内容和IP。这块不复杂,一个AOP切面注解就能搞定,但它在“管理系统”的定位里非常出彩——它让“管理”本身也变得可审计。
最后说一个我在项目里坚持了三年多的习惯:每次改完消息链路,先开两个浏览器窗口用不同账号对发,再关掉一个窗口发消息验证离线补偿,最后用管理端查聊天记录确认落库完整。这套手工冒烟流程十分钟不到,但能拦住绝大多数的低级回归。希望帮到你。
本文还有配套的精品资源,点击获取