基于SpringBoot+Vue的即时通讯管理系统:从WebSocket到管理端实战

发布时间:2026/10/2 20:07:57
基于SpringBoot+Vue的即时通讯管理系统:从WebSocket到管理端实战 简介这是一套面向Java全栈学习与毕业设计场景的即时通讯管理系统采用前后端分离架构后端基于SpringBoot整合Mybatis/MybatisPlus搭建业务接口结合Redis缓存与JWT登录鉴权并通过WebSocket实现消息实时推送前端由VueElementUI完成页面交互覆盖登录注册、会话管理、通讯录、好友管理、收藏、个人资料、文本/图片/文件消息收发、文件下载与图片预览等功能。资源包共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把用户列表拉出来、点一下能发消息就算完工。但真正落到企业或校园内部场景时核心诉求根本不是“能聊天”而是消息可追溯、状态可感知、权限可控制——谁在什么时间给谁发了什么、对方看了没有、哪些人属于哪些组、敏感词有没有被拦截这些才是管理侧真正要的东西。这套基于JavaSpringBootvueelementui的即时通讯管理系统做的就是“聊天能力 后台管控”的合体前端用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_timet_friend好友关系表id, user_id, friend_id, remark, statust_group群组表group_id, group_name, owner_id, create_timet_group_member群成员表id, group_id, user_id, rolet_message消息表msg_id, from_id, to_id, msg_type, content, is_read, create_timet_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;消息内容用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.vuesrc/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, MapString, 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 MapInteger, WebSocketSession onlineSessions new ConcurrentHashMap(); private final MapInteger, 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 { // 群聊查群成员列表逐个推送不在线的写离线表 ListInteger 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 ListMessage pullOfflineMessages(Integer userId) { // 查出离线消息关联的原始消息并标记已读 return offlineMessageMapper.selectMessagesByUserId(userId); } }注意给群成员发消息时 “不发给自己” 这个条件是必须的否则前端会收到自己发出去的消息再渲染一遍界面闪一下很难受。离线消息的补拉时机很关键必须等前端把历史消息列表加载完再补拉否则会出现“新消息出现在旧消息上面”的错乱。我通常的做法是前端先调用GET /api/message/history?beforeIdxxxlimit20加载最近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 classchat-panel div classmessage-list refmessageList scrollonScroll div v-formsg in messages :keymsg.msgId classmessage-item span classusername{{ msg.fromName }}/span span classcontent{{ msg.content }}/span /div /div div classinput-area el-input v-modeldraft typetextarea :rows3 keydown.enter.nativesendMessage / el-button typeprimary clicksendMessage发送/el-button /div /div /template注意绑定消息内容用的是{{ msg.content }}插值而不是v-html。即时通讯的消息内容天然是不可信的用户完全可以发img srcx onerroralert(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-tableel-paginationel-input搜索框走的是标准CRUD。这里要重点提醒**el-table开启多选时必须设置row-key**而且这个key必须是数据里的唯一字段比如userId不是数组下标。el-table :datauserList row-keyuserId selection-changehandleSelectionChange el-table-column typeselection width55 / el-table-column propusername label用户名 / el-table-column propnickname label昵称 / el-table-column propstatus label状态 / el-table-column label操作 template slot-scopescope el-button typetext clickresetPassword(scope.row.userId)重置密码/el-button el-button typetext classdanger-text clickbanUser(scope.row.userId)禁用/el-button /template /el-table-column /el-tablerow-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 typeselection就以为选中逻辑是组件管理的忽略了row-key的语义。row-key的作用是告诉Element UI“哪一列是这一行的唯一ID”省略或设置成重复值时选中状态的内部Map会串行。解决必须在el-table上显式设置row-keyuserId且userId不能有重复。另外注意如果接口返回的数据结构里主键不叫userId而叫id要写row-keyid别想当然。这个问题的后果不只是显示错误——调selection-change拿到的selectedRows数组也是错的批量禁用用户时可能误伤无辜账号。5.4 消息排序错乱先发的消息出现在后发的下面现象聊天记录里偶发消息顺序颠倒特别是同一个会话里两个用户几乎同时发消息时。原因我用create_time排序。MySQL的DATETIME默认精度是秒同一秒内的消息无法区分先后。更隐蔽的问题是WebSocket推送是异步的消息A已经落库但还没推送消息B先到达前端渲染出来等A推送过来后直接push到数组尾部就“看起来顺序错了”。解决排序一律用msg_id因为Auto Increment保证严格递增。前端渲染时不要直接push而是先判断新消息的msgId是否大于当前数组最后一条的msgId如果是才追加否则插入到正确位置。历史消息加载用beforeIdxx查询自然也不会出现重复或跳动。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切面注解就能搞定但它在“管理系统”的定位里非常出彩——它让“管理”本身也变得可审计。最后说一个我在项目里坚持了三年多的习惯每次改完消息链路先开两个浏览器窗口用不同账号对发再关掉一个窗口发消息验证离线补偿最后用管理端查聊天记录确认落库完整。这套手工冒烟流程十分钟不到但能拦住绝大多数的低级回归。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询