Shiro整合JWT实现前后端分离无状态认证实战指南

发布时间:2026/10/13 11:41:58
Shiro整合JWT实现前后端分离无状态认证实战指南 在前后端分离项目里做登录认证shiro和jwt这一对组合大概率是绕不开的。前年我接手一个内部管理系统改版后端是单体Spring Boot应用前端从JSP这种服务端渲染正在往Vue独立部署迁移第一个绕不过去的就是登录态怎么维持。老方案用的Session加Cookie一换成跨域部署和接口化之后Session缺失、跨域带Cookie这些事轮流折磨人。后来定了shiro整合jwt的方案把权限模型继续留在Shiro上登录态改用JWT这种无状态令牌承载项目才真正跑顺。这套组合在线上稳定运行了三个多月期间整理了不少笔记这篇就按“怎么设计、怎么搭、会踩哪些坑”的路线一次讲清楚适合正在做前后端分离改造、或者准备自建轻量级认证体系的Java后端同学参考。1. 先从两个名词说起shiro管什么jwt管什么1.1 Shiro在项目里扮演的角色Shiro在Java安全领域属于老牌框架功能模块拆开看就是认证、授权、会话管理、加密这四块。用在Web项目里它要解决的是“你是谁、你能干什么、你的登录态暂存在哪、密码怎么安全比对”这几个基础问题。很多团队一直用它而不是Spring Security原因很实际Shiro学习曲线比Spring Security缓很多配置量更少对中小型项目来说性价比非常高不存在“为了一个简单后台系统背一整套安全规范”的负担。Shiro内部三个核心对象Subject、SecurityManager、Realm。Subject可以理解成当前用户的一个抽象句柄你在Controller里能通过SecurityUtils.getSubject()拿到它SecurityManager是整个安全框架的总入口认证、授权、会话管理都被它串起来Realm则是安全数据和业务数据的桥梁Shiro不关心你的用户存在MySQL还是Redis它只负责把Realm返回的用户信息和权限信息规整成一个安全上下文。实际的认证流程就是用户把账号密码交给SubjectSubject把凭证转给SecurityManagerSecurityManager让认证器去找匹配的RealmRealm查到用户后做凭据比对比对成功就创建带角色和权限的Subject失败就抛认证异常。1.2 JWT的定位与优势JWT全称JSON Web Token格式看起来就是一串三段式字符串三段之间用点号隔开Header、Payload、Signature。Header里写明签名算法Payload里塞业务声明比如用户ID、签发时间、过期时间这些key-value对Signature则用服务端密钥对前两段做签名。它最大的两个特点是自包含与无状态服务端不需要保存任何会话记录拿到Token验一下签名和有效期通过就认为是合法请求。类比一下这张票更好理解Session方案类似商场存包柜顾客把包存进去服务端把一个柜号夹在手里下次凭柜号取包但柜子本身不记录顾客是谁且只能在存包的那个商场用。JWT像一张盖了骑缝章的入场券入场时服务端验章章是真的就直接放行票面上还印着入场人名字和有效期。这个特性在前后端分离、接口集群部署时尤其值钱任何一个服务节点都能独立完成用户身份验证不需要共享Session也就没有Session同步和粘性会话这类架构包袱。1.3 为什么非得“整合”既然Shiro功能完整、JWT又简单易用为什么不直接用其中一个原因在于两者的边界不重叠。Shiro默认的登录态是绑定Session的而JWT本身又完全没有权限模型不解决“用户有什么角色、哪些接口能碰”这件事。它们刚好互补Shiro管“权限结构”JWT管“无状态身份”。所谓整合不是让Shiro去理解JWT格式而是做两个切入点。第一把Shiro获取用户信息的地方从Session改成解析JWTRealm收到Token后解析出用户名再去数据库查用户和权限。第二把Shiro从请求中读取登录态的地方改成从HTTP请求头读取Bearer Token自定义Filter把请求头里的Token取出交给Subject完成登录。权限判断继续用Shiro的注解和URL规则改动被约束在这两个点上项目原有的角色继承、权限注解、接口拦截图腾都不会被破坏这就是整合方案的核心理由。2. 整合设计的核心思路把登录的“状态”从会话挪到令牌2.1 传统Session方案的痛点做这套整合之前系统里Web登录就是标准Session流程登录成功后在服务端内存创建Session把JSESSIONID写进浏览器Cookie后续请求凭Cookie找到对应Session。单机跑没有任何问题但项目一旦拆成多实例部署麻烦立刻出现。负载均衡把同一个用户的两次请求分到不同节点A节点存的Session在B节点上根本不存在用户马上被判定未登录。解决方案要么引入Redis集中化Session要么做粘性会话两种方式都增加了运维和架构复杂度。前后端分离之后问题更明确。前端部署在另一个域名或端口跨域请求要带Cookie不是简单在Ajax里加个withCredentials就行还牵扯CSRF防护、SameSite策略、新浏览器对第三方Cookie的限制等等。移动端调用同一个后端接口时Cookie机制和浏览器的行为还不完全一致。一个登录态方案要同时适配Web、H5、AppSession这套就显得有些笨重无状态令牌几乎是接口化改造必然被推到台前的选择。2.2 无状态认证的请求链路整合完成后的认证链路我习惯画成六步记忆。第一步用户登录拿用户名和密码访问登录接口服务端校验通过后生成JWT并返回给前端。第二步前端把Token存在本地存储或内存中每次请求在请求头加Authorization的Bearer串。第三步后端自定义的JwtFilter在请求进入业务Controller之前完成拦截从请求头解析出Token。第四步JwtFilter调用Subject.login()把Token包装成自定义Token传给Shiro。第五步Shiro把Token交给JwtRealmRealm负责验签、查用户、组装权限信息。第六步登录成功放行Controller层再通过Subject或注解拿到当前用户身份做业务处理。这六步里最核心的是第四、第五步。不要另起炉灶在Filter里直接解析Token然后ThreadLocal存用户那样等于把Shiro架空了后续所有权限注解和URL拦截都失去作用。正确姿势是把Token解析后迅速交还给Shiro的认证流程让Shiro基于解析结果建立Subject这样后续Shiro的授权能力才能继续生效。2.3 方案选型继承式还是替换式做整合时有两个方向可选。方向一是在Shiro的Filter链中间插入一个自定义Token过滤器继承BasicHttpAuthenticationFilter或OncePerRequestFilter拦截所有请求后强制走Subject登录。方向二是绕开Shiro的Filter链在网关或者Spring Interceptor层统一解析JWTShiro只用来做后台RBAC校验。第二种解耦更彻底但用起来你会发现Shiro的URL拦截配置、注解拦截这些基础能力基本用不上等于退化成一个纯工具库。我建议中小型项目首选方向一。Shiro的Filter链在设计的时候本来就考虑了自定义认证逻辑的扩展把JWT解析放进这个位置代码量小、侵入性低前端后端都不需要感知太多改动。如果项目前面还有网关网关侧做一次轻量级JWT校验进入服务后再由Shiro Filter验一次两边互不冲突只是多一次验签的开销。安全相关的系统多一次校验一般可以接受。3. 实操落地从零搭建shiro整合jwt完整项目3.1 第一步引入依赖版本搭配别踩坑我用的技术栈是Spring Boot 2.7.x加Shiro 1.11.0加jjwt 0.11.5。Maven依赖这样加dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring/artifactId version1.11.0/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency这里有个容易忽略的细节jjwt从0.11.x开始拆成了API包和实现包编译代码只依赖jjwt-api但运行期必须把jjwt-impl和jjwt-jackson一起引入否则报错提示非常隐晦像是缺少某个签名相关类。有人喜欢用java-jwt或者hutool的JWTUtil代替都行我习惯jjwt的原因是它的Builder和Parser链式调用写起来规整自定义Claims、过期时间处理顺手遇到问题搜索资料也多一些。选择依赖版本时还要注意Shiro和Spring Boot之间的兼容性。Shiro 1.x比较稳跟着Spring Boot 2.x使用没有大坑如果项目用到Spring Boot 3.x建议直接选Shiro 2.x或者考虑其他框架不然Servlet API不兼容会让整个Filter链启动时报错排查起来很费劲。3.2 第二步JWT工具类与自定义Token类JWT工具类负责三个职责生成Token、解析Token、校验Token。Secret、过期时间、签发者都放在application.yml里方便不同环境切换。基本实现如下Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expiration}) private Long expiration; Value(${jwt.issuer}) private String issuer; public String generateToken(String username, Long userId) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuer(issuer) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expiration * 1000)) .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } public boolean isTokenExpired(String token) { return parseToken(token).getExpiration().before(new Date()); } }生成Token时我把用户名放在Subject里把用户ID放在自定义Claim里这样接口里要取用户ID或用户名都很方便。Secret引用的字符串长度不能低于32字节这是HS256算法的硬性要求配置短了会在签名时直接抛异常所以我会用一个UUID类型的随机字符串写进配置文件。过期时间我习惯设置2小时配合后面会提到的刷新机制使用。自定义Token类也不能缺它的作用是把字符串Token包装成Shiro认识的AuthenticationToken。为了让Shiro正确识别还不破坏JWT的“无密码”特性getPrincipal和getCredentials都返回Token字符串public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String token) { this.token token; } Override public Object getPrincipal() { return token; } Override public Object getCredentials() { return token; } }3.3 第三步编写JwtRealm认证与授权都在这Realm是Shiro连接业务数据的桥梁整合JWT后doGetAuthenticationInfo方法变成“解析Token并查用户”doGetAuthorizationInfo方法负责“组装权限集合”。代码如下public class JwtRealm extends AuthorizingRealm { Autowired private JwtUtil jwtUtil; Autowired private UserMapper userMapper; Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); SetString roles new HashSet(); SetString permissions new HashSet(); // 真实项目在这里从数据库或权限服务查询 roles.add(admin); permissions.add(user:list); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(permissions); return info; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String jwt (String) token.getPrincipal(); try { Claims claims jwtUtil.parseToken(jwt); String username claims.getSubject(); User user userMapper.selectByUsername(username); if (user null || user.getStatus() 0) { throw new UnknownAccountException(用户不存在或已禁用); } return new SimpleAuthenticationInfo(username, jwt, getName()); } catch (ExpiredJwtException e) { throw new AuthenticationException(token已过期); } catch (Exception e) { throw new AuthenticationException(token解析失败); } } }supports()这条方法特别值得留意。当系统存在多个Realm时Shiro的认证器需要靠它判断Token应该交给哪个Realm。以前直接把所有Realm都配置进去不加区分就会出现账号密码的认证器尝试处理JWT Token的情况报错极难排查。另外SimpleAuthenticationInfo的第三个参数是Realm的名字getName()默认返回当前Realm的类名如果你重写了Realm的getName第三个参数也要保持一致。3.4 第四步自定义JwtFilter认证过滤器过滤器是整合链路里承上启下的组件上承HTTP请求头里的Token下启Shiro的Subject登录流程。我继承BasicHttpAuthenticationFilter重写isLoginAttempt和isAccessAllowedpublic class JwtFilter extends BasicHttpAuthenticationFilter { Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { String authzHeader getAuthzHeader(request); return authzHeader ! null authzHeader.startsWith(Bearer ); } Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isLoginAttempt(request, response)) { try { executeLogin(request, response); return true; } catch (Exception e) { writeUnauthorizedResponse(response); return false; } } // 没有携带Bearer Token的请求直接放行由后续Shiro权限判断决定能否访问 return true; } Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { String authzHeader getAuthzHeader(request); String token authzHeader.substring(7); JwtToken jwtToken new JwtToken(token); getSubject(request, response).login(jwtToken); return true; } }isLoginAttempt的作用是判断这次请求是不是一次“登录尝试”如果请求头里根本没有Bearer Token就把它当成匿名请求放行Shiro会再通过anon和jwt这些规则决定它能访问什么。这种设计的好处是登录接口、静态资源、白名单接口不需要额外接入自定义逻辑规则在配置层就能搞定。这里必须强调一点isLoginAttempt只检查前缀不负责Token内容是否合法。Token合法性的校验在executeLogin和Realm里完成。所以子串截取时authzHeader.substring(7)这个“7”是Bearer加空格的固定长度如果前端传的格式不规范后面解析一定会报错在日志里看到类似SignatureException的时候就该检查请求头格式了。3.5 第五步ShiroConfig配置与拦截规则配置类要把Realm、SecurityManager、ShiroFilterFactoryBean全部串起来。整合JWT时还多一步显式关闭Shiro自带的Session存储因为登录态已经交给JWT了继续创建Session属于画蛇添足Configuration public class ShiroConfig { Bean public JwtRealm jwtRealm() { return new JwtRealm(); } Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(jwtRealm()); DefaultSubjectDAO subjectDAO new DefaultSubjectDAO(); DefaultSessionStorageEvaluator sessionStorageEvaluator new DefaultSessionStorageEvaluator(); sessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator); manager.setSubjectDAO(subjectDAO); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean filterFactoryBean new ShiroFilterFactoryBean(); filterFactoryBean.setSecurityManager(securityManager); filterFactoryBean.setFilters(Collections.singletonMap(jwt, new JwtFilter())); MapString, String filterChainDefinitionMap new LinkedHashMap(); filterChainDefinitionMap.put(/login, anon); filterChainDefinitionMap.put(/favicon.ico, anon); filterChainDefinitionMap.put(/**, jwt); filterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); filterFactoryBean.setUnauthorizedUrl(/unauthorized); return filterFactoryBean; } }拦截规则这里必须用LinkedHashMap因为Shiro按顺序从上到下匹配URL第一条命中就生效。我习惯把需要放行的anon规则全部写在前面把/**这种通配规则放在最后。以前排过一个低级问题有人把/**放在第一位导致登录接口也被jwt过滤器拦截用户永远登录不了这类问题定位很容易被误导。关闭Session存储这一步不要忽略。如果不清掉Session管理器Shiro在某些请求里还是会尝试创建Session甚至写Cookie无状态方案就名存实亡。很多网上文章只给Realm和Filter的代码这个细节往往被跳过了但它直接影响方案是否够纯粹。3.6 第六步登录接口与Controller获取用户信息登录接口实现相对简单密码校验我放在Service层完成。注意JWT模式下Shiro不会主动帮忙做密码比对因为JwtToken的credentials和principal都是Token字符串Shiro无法从Token里拿到原始密码所以登录接口自己负责校验账号密码校验通过后签发TokenRestController public class AuthController { Autowired private UserService userService; Autowired private JwtUtil jwtUtil; PostMapping(/login) public Result login(RequestBody LoginRequest request) { User user userService.authenticate(request.getUsername(), request.getPassword()); if (user null) { return Result.error(账号或密码错误); } String token jwtUtil.generateToken(user.getUsername(), user.getId()); return Result.success(new TokenResponse(token)); } }业务接口里获取当前用户的方式有两种。第一种是直接在方法里通过SecurityUtils.getSubject().getPrincipal()拿到用户名然后查用户信息。第二种是配合Shiro的注解做权限控制比如在查询列表接口上加RequiresPermissions(user:list)这个注解需要开启代理支持。AuthorizationAttributeSourceAdvisor也必须注册成Bean否则注解不生效。我把它一并配在ShiroConfig里Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }前端对接时的请求头格式是固定的Authorization: Bearer 。这个格式看起来简单但Bearer和Token之间那个空格非常容易在拼接时漏掉尤其是当前端随手从文档复制拼接代码时。如果你发现“登录了但拿不到用户信息”这样的症状第一个要检查的就是这个空格和Header名字大小写。4. 常见问题与排查技巧实录方案落地三个月整理出几份排查笔记下面这些是按出现频率排的实战问题有的在网上一搜一堆答案但真正能对号入座的细节不多。4.1 问题一所有请求都被401拦下症状登录成功后带着Token访问业务接口依然返回401。排查步骤是有顺序的先用Postman或curl发请求看是否能排除前端缓存干扰再在JwtFilter的isAccessAllowed里打断点确认过滤器是否进入最后看Nginx或网关是否把Authorization请求头剥离了。两个高频原因值得重点说。第一Filter链配置顺序不对/**的通配规则排在白名单前面导致登录接口也被拦截。解决办法是把anon规则统一放在最前。第二请求头格式出错业务请求没有按“Bearer空格Token”的格式带AuthorizationFilter的isLoginAttempt判断会认为这不是登录尝试直接放行然后业务代码因为没有登录主体而返回401。这类问题定位时优先看请求和响应头用浏览器开发者工具比对一次请求头问题基本上就浮出水面了。4.2 问题二Filter被注册两次Token被重复解析症状一个请求进入JwtFilter两次代码里的日志重复打印业务上表现为Token解析执行了两遍效率下降。原因通常是把自定义JwtFilter既交给了ShiroFilterFactoryBean的setFilters管理又用WebFilter或者FilterRegistrationBean在Spring Boot注册了一次。两个容器都持有这个Filter请求自然被过滤两遍。解法是把自定义JwtFilter上的Component或WebFilter注解去掉不让Spring容器额外接管只通过ShiroFilterFactoryBean维护。如果项目里确实需要同时对JwtFilter做排序或URL匹配优先用FilterRegistrationBean并在里面setEnabled(false)来避免重复注册。检查代码时先全局搜索一下JwtFilter的实例创建基本一眼就能看出是否被重复引用了。4.3 问题三Token过期时返回的是HTML而不是友好JSONShiro在认证失败时默认跳转登录页或返回未授权视图这在接口项目里很尴尬前端收到一段HTML之后直接蒙圈。我的做法是在JwtFilter里统一处理异常所有认证失败场景都输出固定结构的JSON响应private void writeUnauthorizedResponse(ServletResponse response) throws IOException { response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); MapString, Object body new HashMap(); body.put(code, 401); body.put(message, 登录状态已过期或非法); response.getWriter().write(new ObjectMapper().writeValueAsString(body)); }这里有个小坑是写响应时得用response.getWriter()不要用response.getOutputStream()否则在部分Web容器里会和Shiro后续的返回状态处理产生冲突响应体写不完整。另外可以顺手在ShiroConfig里设置setLoginUrl和setUnauthorizedUrl让这些URL返回JSON视图但如果你已经在Filter里统一写JSON了它们的作用就变成兜底了。4.4 问题四跨域配置不生效前后端分离项目跨域是标配后端配置了CorsFilter却总感觉没生效这通常是Filter执行顺序的问题。Shiro的Filter链默认排在最前它的过滤行为可能发生在CorsFilter之前预检的OPTIONS请求还没到CorsFilter就被Shiro拦截了自然返回不了CORS相关响应头。解决办法是把CorsFilter的注册顺序调到最前面FilterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE)同时确保ShiroFilter的order在它后面。另外跨域的请求头要覆盖Authorization这个自定义Header如果你在CORS配置里只写了Content-Type和X-Requested-With请求带Authorization时依然过不了预检。写CORS配置时把allowedHeaders直接设为*或者显式包含Authorization能少很多折腾。4.5 问题五用户权限更新后仍然有旧权限这个问题的表现是管理员改了某个用户的角色或权限该用户下一次请求还是按老权限判断。如果项目给Shiro配了Redis作为缓存管理器授权信息可能在Redis里存了缓存key没被同步失效用户就拿着缓存权限访问到当前已撤销的接口。处理建议分两级。第一优先级是在修改角色权限的代码里主动清理该用户的授权缓存调用Subject的principals.clearCache()或者把缓存Manager切回本地并设置极短过期时间。第二优先级是权限敏感度高的系统不要依赖Shiro的AuthorizationCache直接在doGetAuthorizationInfo里查库。简单来说权限这种数据宁可让每次请求多查一次库也不要让用户拿到过期的“已撤销权限”。4.6 问题六无权限访问时返回了302重定向Shiro默认在授权失败时会重定向到未授权页面这在纯接口服务里是灾难。浏览器收到302跳到登录页或一个不存在的页面前端根本无法感知具体是什么权限错误。配置了setUnauthorizedUrl以后重定向行为依然存在只是目标地址换了。推荐的做法是自定义一个统一的授权异常处理。结合Shiro的注解方案在全局异常处理器里捕获AuthorizationException这种授权异常统一返回403和错误信息。同时配合JwtFilter里已经写好的401逻辑认证失败401、授权失败403状态码清晰前端拦截器可以根据状态码做跳转或提示联调体验会舒服很多。需要注意全局异常处理器对AuthorizationException的捕获不能只拦截RuntimeException否则Shiro抛出的授权异常会漏出去并变成500错误前端看到的就是服务器内部错误而不是清晰的权限不足提示。5. 尾声一点个人体会这套shiro整合jwt的方案开发起来并不复杂难的是“能跑通”和“能稳定地跑”之间的距离。Session方案里只要会话没失效请求过来基本畅通无阻换成JWT方案后时间戳偏差、密钥不一致、请求头格式不标准、过滤器顺序不对任何一环出问题都是联调杀手。我踩过几次坑之后的经验是先统一前后端约定Authorization格式和过期时间这类契约尽早定下来比事后排查要省事十倍。最后分享一个我实际在用的扩展思路在JWT的Payload里加一个“登录版本号”字段用户改密码或管理员踢人时把版本号加一Realm解析Token后拿版本号和数据库当前值比对版本不一致直接拒绝。这等于给无状态的JWT补了一块近实时的吊销能力比单纯依赖过期时间可靠得多。再往上做还可以引入Refresh Token的双Token机制一个短时效的Access Token负责业务请求一个长效Refresh Token负责续签体验和安全性可以同时兼顾。这个方向值得每一个做了基础整合的朋友继续深挖。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询