Spring Boot + Shiro + CAS 5.2 RESTful协议SSO单点登录实践

发布时间:2026/10/16 14:35:50
Spring Boot + Shiro + CAS 5.2 RESTful协议SSO单点登录实践 先交代一个场景公司某内部平台要把账号体系收编到统一认证中心Spring Boot 应用选的是 Shiro 做权限控制认证中心是 CAS 5.2。按理说最常规的接法是浏览器跳转到 CAS 登录页回调后拿 ticket 回跳Shiro 一套就过了。但真到落地时你会发现凡是涉及第三方客户端、后台服务、移动端、接口对接的登录需求纯跳转模式根本接不进去这时候 CAS 5.2 的 RESTful 协议就成了绕不开的一条路。这篇文章不是讲理论是我在 Spring Boot 2.x Shiro CAS 5.2 的组合里通过 RESTful 协议打通 SSO 单点登录的完整记录。包括 CAS 端怎么开 REST 支持、Shiro 侧认证链路怎么改、ticket 和 TGT 的流转细节、联调阶段踩过的坑以及登录成功之后用户数据和权限怎么落库。适合正在做统一认证、或者被“既要跳转登录又要接口登录”这种双重需求折磨的开发同学参考。1. 为什么是“RESTful 协议”而不是“跳转登录”协议选型的场景依据1.1 标准 CAS 网页登录链路长什么样CAS 最经典的登录流程是浏览器模式。用户在客户端请求受保护资源客户端发现未登录就 302 带到 CAS 的 /login 页面用户在 CAS 上输账号密码CAS 校验通过后生成一个 STService Ticket并带着 ST 和 service 参数重定向回客户端客户端拿 ST 去 CAS 的 serviceValidate 接口验票验票通过才算登录成功。这套流程对于“人在浏览器里操作”的场景非常顺手特别是多个 Web 应用统一用一个登录页体验是自然的。Shiro 官方生态里也有专门的 shiro-cas 模块把 CasFilter、CasRealm 都封装好了只要配好 casServerUrlPrefix 和 loginUrl就能跑通。但它有一个隐含前提登录这件事的入口必须是浏览器。1.2 什么情况下你会想念一个 API 形式的登录入口我这次接入时遇到了两种跳转模式搞不定的情况。第一种是内部定时任务服务。一个批量跑批的模块要在凌晨用系统账号去拉统一用户中心的用户列表这个服务没有界面、没有浏览器它登录 CAS 的方式只能是通过 HTTP 请求提交账号密码CAS 返回票据它再拿着票据访问业务接口。第二种是移动端 App 和前后端分离的前端页面。前端页面不跳 CAS 登录页而是自己弹出一个登录表单把账号密码提交给后端网关网关再代替用户去 CAS 完成认证。这时候如果后端还是用 302 跳转的方式前端拿到的就是一个重定向响应整个认证链路就断了。于是需要一个“非浏览器客户端也能走 CAS 认证”的通道。CAS 5.2 里的 RESTful 协议解决的就是这件事它把登录抽象成了一个普通的 REST 接口调用方 POST 用户名和密码CAS 返回 TGT之后再拿 TGT 换 ST最后用 ST 校验身份。整个过程不依赖 TGC Cookie不依赖浏览器只要是能发 HTTP 请求的客户端都能接入。1.3 架构分层的思路认证中心、客户端、会话管理各管一段把这套方案拆开看责任边界其实很清晰。CAS 5.2 负责统一认证和票据签发它只认“用户名密码对不对“和”这个 service 是否允许认证”不关心你用的是浏览器还是脚本。Spring Boot 应用负责对接用户的登录请求它在收到账号密码后隐藏一个 REST Client 去调 CAS拿到认证结果后再把用户 Session 建起来这层由 Shiro 管理。Shiro 负责本地会话和权限控制。用户是否已登录、有没有某个角色、某个接口允不允许访问这些是 Shiro 的管辖范围。CAS 认证通过后Shiro 拿到一个已经可信的身份信息把它封装成 Subject后续的鉴权都在本地处理不用每次请求都远程验证票据。三层各做各的事SSO 的“单点”体现在多个 Spring Boot 应用接入同一个 CAS任何一端登录成功其他端都可以通过 REST 协议重新用相同账号获取票据或者通过已有的认证结果直接建立会话。这也是“RESTful 协议”在这套架构里的核心价值——它不是一个新的登录系统而是把原有的 CAS 能力暴露成了 API。2. CAS 5.2 端开启 REST 认证从依赖到接口验证2.1 添加 REST 支持的依赖与配置CAS 5.2 的 RESTful 协议不是我随手调一个开关就能用的它本身是一个扩展模块官方叫 cas-server-support-rest。在 Overlay 方式部署 CAS 时需要把它打进构建里。既然是基于 Overlay 的部署方式大多数团队都只是构建叠加了几个模块结构比较统一。我当时处理的是 5.2.x 的版本依赖大致要加到 CAS 的重叠配置里dependency groupIdorg.apereo.cas/groupId artifactIdcas-server-support-rest/artifactId version${cas.version}/version /dependency加完依赖后需要在 CAS 的配置里确认 REST 认证开关是打开的。5.2 版本里常见配置项是cas.restful.authentication.starttrue这段配置的含义是开启 RESTful 认证入口让 /cas/v1/tickets 这个接口能够接收账号密码请求。如果没开你会看到接口返回 404极容易让人误以为是路由没配。建议配置里同时检查一下 service 注册情况。REST 协议换取 ST 时service 参数必须指向一个 CAS 中已注册的合法服务。5.2 默认的 Registered Services 存储可以是 JSON也可以落在数据库里。我这里就是典型的 JSON 注册方式{ class: org.apereo.cas.services.RegexRegisteredService, serviceId: ^https://app\\.server\\.local:8443/casclient/.*, name: casclient, id: 1, evaluationOrder: 1 }serviceId 是一项正则表达式只有当请求里带的 service 能匹配上这串正则时CAS 才愿意为它颁发 ST。这也是我后来排查“服务被拒”类问题时的重点检查对象。2.2 三个核心接口的含义与调用顺序CAS 5.2 REST 协议要盯住的接口其实就三个我整理成了一张对照表步骤接口作用关键参数1. 认证并获取 TGTPOST /cas/v1/tickets校验账号密码返回 TGTusername、password、service可选2. 换取 STPOST /cas/v1/tickets/{TGT}拿着 TGT 换取某个服务的 STservice必填3. 校验 STGET /cas/serviceValidate校验 ST 是否有效返回用户身份service、ticket第一步最像“登录”。调用方把账号密码 POST 给 /cas/v1/tickets如果 CAS 认证通过HTTP 响应码是 201并且响应头 Location 会带着新生成的 TGT 地址比如 /cas/v1/tickets/TGT-xxxxx。这个 TGT 就相当于停车场给你的“总凭证”证明你确实开着车进来了。第二步是把总凭证一次性消费成“某个门禁的临时通行码”也就是 ST。调用方拿着 TGT 再 POST 一次请求体里带上业务系统的 service 地址CAS 返回的响应体就是 ST。ST 是一次性票据用完就销毁而且有效期非常短默认通常是 10 秒以内。第三步是业务系统自己校验 ST。它拿着 ST 和 service 去访问 serviceValidateCAS 返回一段 XML里面有 username 和 authentication-success 节点业务系统拿到之后就可以确认这个用户是 CAS 信任的允许建立本地登录态。用生活类比来说TGT 是你在车站换票时的身份凭证ST 是进站口那张实名车票serviceValidate 是检票员你给他车票他核对了身份没问题才放你上车。2.3 用简单命令先验证服务端是否畅通服务端配没配好写代码之前先用 curl 验一遍是最快的。我在联调初期就是这么干的# 第一步提交账号密码拿 TGT curl -k -X POST -d usernamecasuserpasswordpasswordservicehttps%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2Flogin \ https://cas.server.local/cas/v1/tickets # 如果返回201Location 里就是 TGT 地址 # 第二步用 TGT 换 ST curl -k -X POST -d servicehttps%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2Flogin \ https://cas.server.local/cas/v1/tickets/TGT-xxxxx # 第三步校验 ST curl -k https://cas.server.local/cas/serviceValidate?servicehttps%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2FloginticketST-xxxxx这三条命令走通了就说明 CAS 端的 REST 协议没问题可以放心去改业务系统。第一次跑这条命令时我最常碰到的问题反而是 service 没编码、被 302 拦截、或者 HTTPS 证书不信任。curl 加 -k 能绕开证书问题但业务代码里还是得把证书链处理好不能图省事关掉证书校验。3. Shiro 侧的自定义认证器把 CAS REST 拉进 Realm3.1 为什么不用现成的 shiro-casShiro 官方提供的 shiro-cas 模块很好用但它面向的是网页跳转模式。它里面的 CasFilter 会主动把未登录请求重定向到 CAS 登录页CasRealm 里的 doGetAuthenticationInfo 接收的是一个 CasToken这个 token 是通过回调地址带回来的 ST 构造的。但本文的 RESTful 场景完全不同。登录入口是 Spring Boot 自己提供的 /login 接口前端把账号密码提交过来后端要做的是拿着账号密码去向 CAS 的 /cas/v1/tickets 发请求全程不经过浏览器跳转。这种情况下如果硬套 shiro-cas你会发现它根本没有提供“拿用户名密码换 TGT”这一步的逻辑。所以我的做法是保留 Shiro 的 Realm 机制自写一个认证过滤器和一个 Realm把“调用 CAS REST 接口完成认证”的逻辑放进 Realm。自定义认证器本身把 Shiro 和 CAS 黏合起来。3.2 认证器设计与代码骨架先定义一个自定义 Token用于携带 SSO 场景下的信息。REST 方式登录时前端可能直接提交账号密码也可能提交 CAS 回调带来的 ticket。所以 token 上既要能放用户名密码也要能放 ST。public class CasRestToken implements AuthenticationToken { private final String username; private final String password; private final String service; private final String ticket; public CasRestToken(String username, String password, String service, String ticket) { this.username username; this.password password; this.service service; this.ticket ticket; } Override public Object getPrincipal() { return username; } Override public Object getCredentials() { // 如果走ticket方式这里放ticket走账号密码方式则放密码 return ticket ! null ? ticket : password; } // getter / setter 略 }然后自定义一个 Realm。核心逻辑在 doGetAuthenticationInfo 里调用 CAS REST 接口完成认证CAS 通过后再把用户身份放给 Shiro。public class CasRestRealm extends AuthorizingRealm { private String casServerPrefix; private String callbackService; Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { CasRestToken casToken (CasRestToken) token; // 第一步去 CAS 端认证账号密码拿到 TGT String tgt requestTgt(casToken.getUsername(), casToken.getPassword(), callbackService); // 第二步用 TGT 换取 ST String st requestSt(tgt, callbackService); if (st null || st.isEmpty()) { throw new AuthenticationException(CAS 票据换取失败); } // 第三步拿 ST 到 serviceValidate 校验返回的是 CAS 用户名 String casUser validateSt(st, callbackService); // 这里可以进一步查询本地用户做用户信息承载 SimpleAuthenticationInfo info new SimpleAuthenticationInfo( casUser, st, getName() ); return info; } // requestTgt 内部实现POST /cas/v1/tickets // requestSt 内部实现POST /cas/v1/tickets/{tgt}带 service 参数 // validateSt 内部实现GET /cas/serviceValidate解析 XML 里的 user 节点 }这段代码有三个关键点。第一获取 TGT 的 HTTP 调用必须捕获 401 状态。如果用户名密码出错CAS 返回的不是 201而是 401 Unauthorized。这块要映射成 Shiro 的 AuthenticationException否则前端会收到一个莫名其妙的 500。第二换取 ST 时service 参数要和注册服务完全一致协议、域名、端口、路径差一个字符都不行。比如注册时写的是 https请求里传了 http校验时就会失败。第三doGetAuthenticationInfo 返回的 credentials 是 ST而不是密码。因为密码在换 TGT 时已经交给 CAS 了本地不再保存明文密码。后续如果 Shiro 要做 credentials 匹配得保证不会拿用户输入的密码和 ST 比对所以我把 token 的 credentials 和 Realm 的 credentials 都设成 ST保证同源。3.3 Shiro 配置里 Filter 的挂载顺序认证器写完之后要把自定义的过滤器挂到 Shiro 的过滤器链上。Shiro 5.x 时代的配置可能走 shiro-spring-boot-starter也可能走原生 shiro.ini我这边用的是 Java Config 方式。Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, CasRestAuthenticationFilter casRestFilter) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); MapString, Filter filters new HashMap(); filters.put(casRest, casRestFilter); factoryBean.setFilters(filters); MapString, String chain new LinkedHashMap(); chain.put(/login, anon); chain.put(/logout, logout); chain.put(/**, casRest); factoryBean.setFilterChainDefinitionMap(chain); return factoryBean; }过滤器链的顺序不要乱。登录接口本身必须是匿名可访问的否则就死循环了。受保护资源统一挂自定义的 casRest 过滤器这个过滤器要先判断当前 Subject 是否已经认证过认证过就直接放行没有认证的才继续走登录逻辑。还有一个容易搞错的点REST 场景下未必每个请求都会带账号密码。业务方更多是把受保护的接口访问作为一个触发点用户如果还没登录过滤器会返回 401 并提示前端去登录前端拿到登录表单后再把账号密码 POST 过来。所以过滤器里的 isAccessAllowed 和 onAccessDenied 必须分开处理不能把“未登录”和“登录失败”混成一种响应。4. 登录时序中的小细节service 参数、ST 时效与回跳4.1 一次完整登录的时序细节把服务端和客户端都准备好之后一次完整的 REST 方式登录流程是这样的用户打开前端页面前端发现 401 或者某个接口返回未登录于是展示登录表单。用户输入账号密码前端发起 POST /system/login请求体包含 username、password、service。Spring Boot 应用的自定义过滤器解析出账号密码构造 CasRestToken。Realm 调用 CAS 的 /cas/v1/ticketsCAS 返回 201 和 TGT。Realm 拿 TGT 重新调 /cas/v1/tickets/{TGT}换取 ST。Realm 拿 ST 调 /cas/serviceValidate校验通过得到 CAS 用户名。Realm 返回 SimpleAuthenticationInfoShiro 创建 Subject并在 Session 中记录登录态。系统返回 token 或 sessionId 给前端后续请求带上就会直接命中已认证的 Subject。这里第 4、5、6 步分别对应 CAS 协议里“认证”“授权票据”“验票”三个动作。业务系统没有直接拿账号密码去访问某个数据库或第三方接口去验证而是 CAS 统一发了通行证和入场券所有接入系统只需要认 CAS 的签名这就是 SSO 的协作方式。4.2 service 参数参与校验的机制service 参数在整个流程里不只是“回调地址”那么简单的含义它还参与了 ST 的绑定。CAS 在签发 ST 时会把 service 作为票据的一部分写进去业务系统拿 ST 来验票时必须带上同一个 service。只要 service 不同token 就是无效的。因为这个机制我通常会要求各个接入应用把 service 写成“只属于自己应用”的一个稳定地址不要直接用根域名。更不建议两个应用共用同一个 service 正则否则 A 应用签发的 ST 可以被 B 应用拿去验而且 A、B 两个应用会把登录态混掉。举一个实际例子我这边两个接入应用一个叫业务后台service 固定是https://admin.server.local:8443/admin/login另一个叫开放平台service 固定是https://open.server.local:8443/open/login。CAS 里各注册一个正则互不干扰。4.3 ST 时效带来的并发控制问题CAS 的 ST 有效期默认只有几十秒而且是一次性的。这在正常流程里没问题但联调时如果哪一步网络慢了或者代码里做了重试就会踩到“第一次验票失败第二次再验显示票已经被用过了”的问题。我踩过的一个场景是业务系统校验 ST 时第一次因为 SSL 证书握手失败抛了异常代码里的重试机制自动又跑了一次结果第二轮拿着同一个 ST 去验CAS 直接返回 INVALID_TICKET。原因很简单ST 被第一轮请求消费了虽然第一轮异常了但 CAS 那边已经把它标记为已使用。处理方式也比较朴素校验 ST 的代码不要写成无条件重试至少不能对同一张 ST 重试。如果第一次失败是因为网络抖动正确的做法是重新走一遍“获取 TGT - 换取 ST - 验票”的完整流程而不是复用同一个 ST 去重试。另外 Redis 或分布式缓存在这种 RPC 调用频繁的场景里也比较有用。我后来在 TGT 和 ST 的获取结果上加了短暂缓存避免用户的同一个请求在校验环节因为网络重放出多个调用。不过缓存时间一定要严格控制ST 存活时间本来就短缓存里放超过几十秒就失去意义了。5. 联调排错记录高概率踩雷点与排查手段5.1 401/403 与 TGT 获取失败的排查最常见的一组问题是第一步都走不通POST /cas/v1/tickets 返回 401 而不是 201。第一步先确认账号密码本身是否正确。CAS 默认账号体系是它内部的用户存储可以是 LDAP、数据库、或者默认内存用户。如果直接拿着业务系统的用户表去 CAS 验CAS 当然说我 401。第二确认用户名密码参数名拼写。REST 协议里就是 username、password不要自己改成 userName 或者 passwd。第三确认请求有没有被 Web 层拦截。有的环境里CAS 前端可能还有一层网关或 Apache导致 /cas/v1/tickets 被当作普通页面请求做了过滤。比如有些团队在 Nginx 里只允许 /cas/login 通过其他请求一律拦截结果 curl 测接口时死活不通。再看 403。403 一般不是认证失败而是权限或 IP 白名单的问题。CAS 5.2 里如果开启了“仅允许来自特定 IP 的 REST 调用”之类的策略业务服务器 IP 不在白名单里就会被拒绝。还有一种情况是 CSRF 或 SOP 策略但这通常只影响浏览器场景不影响 Postman 和 curl如果 curl 能通过但前端 fetch 不过多半要检查 CORS。5.2 serviceValidate 返回 INVALID_TICKET 的常见原因第二步、第三步报 INVALID_TICKET 的坑更隐蔽。我列一下实际遇到过的原因ticket 参数被 URL 转义对了但 ST 是空字符串。用 TGT 换 ST 时返回的 201 响应体里才是 ST 值如果代码里是拿响应头去解析就会得到空值。验票时用的 service 和换取 ST 时的 service 不一致。大小写差异、路径多一个斜杠、协议从 https 变成 http都不行。ST 过期或已经消费过一次。CAS 日志里能看到对应记录去 CAS 服务端的 logs 目录搜 ST 值能直接看到被标记成 invalid 的原因。验票地址用错。5.2 兼容 /serviceValidate 和 /p3/serviceValidate但有的版本里旧接口返回的 XML 结构可能缺少某些属性建议统一使用 CAS 协议版本对应的验票端点。排查这类问题我的习惯是先开 CAS 服务端的 DEBUG 日志或者去看 cas.log 审计记录。CAS 有个专门的 Authentication Audit 日志票据生成、校验、消费都会留痕比在业务系统一行行打日志高效得多。5.3 一处容易忽略的 SSL 与编码问题这个话题看起来基础实际在团队协作时踩得最多。Spring Boot 调用 CAS 时如果 CAS 是 HTTPS就要求本地 JVM 信任 CAS 的证书链。开发环境很多是自签名证书调试的时候可以在代码里临时忽略证书但测试和生产环境绝不能这么干。我见过一次线上事故就是因为测试环境为了省事把所有 SSL 校验全关掉了结果换到生产环境后HTTPS 握手失败所有登录请求在第一步就全军覆没。正确做法是把 CAS 站点证书导入应用服务器的信任库或者让 CAS 链路上层使用已被信任的正式证书。编码问题主要出在中文用户名和带特殊符号的密码上。REST 请求构造时username 和 password 建议用表单编码的格式提交参数的 URLEncoder 编码要显式声明字符集。我曾经遇到一个用户名里带 符号的用户因为没有编码后端按 Query 参数拼接结果 CAS 把 后面的一段当成额外参数解析认证直接失败。6. 落地后的一些工程化补充用户落库、会话处理、单点退出6.1 认证与授权信息的落库策略登录过程中CAS 只负责回答“这个用户是谁”也就是身份认证。至于“这个用户在业务系统里拥有哪些角色”“能访问哪些菜单”CAS 不管Shiro 也不直接管跨系统的权限。所以我们在登录成功拿到用户名后通常要本地查一遍用户表。用户第一次登录时根据 CAS 返回的用户名自动创建一条本地用户记录老用户则直接用本地记录关联角色和权限。这块我踩过一个姿势上的坑把用户信息同步放在“每次登录都全量查询”里。如果 CAS 用户数达到几千上万每来一次登录都做一次全表扫描键查询性能明显下降。更好的方式是走异步任务从 CAS 端拉取用户变更事件或增量用户列表提前同步到本地用户表登录时只做索引查询。权限方面Shiro 的 AuthorizationInfo 实现里角色和权限建议从本地关联表中加载。不要尝试每次授权都远程请求 CASCAS 不是权限中心它也管不了业务系统内部五花八门的权限模型。6.2 会话保持与单点退出Shiro 默认的 Session 是存在 JVM 内存里的。开发调试够用但如果 Spring Boot 应用做了多实例部署用户随机打到不同节点Session 就在各节点间互不认账。用户可能在 A 节点登录了下一次请求被负载均衡发到 B 节点B 节点发现没有这个 Session又踢去重新认证。所以只要部署实例数大于 1就建议把 Shiro 的 SessionDAO 换成 Redis 实现让多个实例共享同一个 Session。CAS 回跳和 REST 认证这套流程才可以真正在集群环境下顺畅跑起来。单点退出也是一个容易被忽略的点。REST 协议下CAS 的登出入口是 /cas/logout业务系统登出要走两步先销毁本地 Shiro Session再调用 CAS 的 logout 接口。如果不调 CAS 登出那 CAS 那边的 TGC 还是有效状态用户下次访问其他系统可能又被直接登录了这就不算真正的单点退出。业务系统里一般是在 /logout 接口的过滤器或 Kontroller 里做两件事clear 当前 Subject同时向 CAS 发送一个 logout 请求。发送不能阻塞太长时间可以用异步方式发。6.3 安全收尾密码相关安全部分建议整个 RESTful 流程里账号密码在业务系统和 CAS 之间传输如果这条链路走的是明文 HTTP账号密码就等于裸奔。生产环境必须保证 CAS 对外提供的 /cas/v1/tickets 接口走 HTTPS。应用内部记录日志时也要注意不要把密码打进日志。换票据时 CAS 返回的响应体只有 TGT 或 ST这几个票据型东西可以和业务参数一起打印但 username、password 这类登录表单参数不要无脑日志输出。我见过有同事把整个请求参数对象直接 toString然后密码就安安静静躺在日志文件里这种习惯迟早出问题。补充一个细节如果系统还开了“记住我”功能REST 登录方式下不建议把账号密码长期缓存在前端更不建议在后端把密码字段长期存到 Session 或 Redis。记住我正确的做法是签发短时访问令牌而不是把原始凭证囤在会话里。这套 CAS Shiro 的 RESTful 单点登录方案从协议选型到服务端开启再到 Shiro 的 Realm 改造再到联调踩坑整体链路看上去不长但每一层都有自己的细节。我自己的体会是这类集成的核心难点通常不在某一个单独的技术点上而在“协议本身假设的使用场景”和“你实际的使用场景”之间的落差。CAS 默认假设你是浏览器REST 协议把它拉回到了接口世界而你要做的就是把这个接口世界的桥接逻辑在 Shiro 里补齐。照着这个思路去排查问题基本就不会跑偏。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询