Apache Shiro 统一化实现多端登录(PC端移动端)

news2025/4/1 20:02:36

Apache Shiro 是一个强大且易用的Java安全框架,提供了身份验证、授权、密码学和会话管理等功能。它被广泛用于保护各种类型的应用程序,包括Web应用、桌面应用、RESTful服务、移动端应用大型企业级应用

需求背景

在当今数字化浪潮的推动下,我们共同见证了互联网从萌芽到繁荣的辉煌历程,随后移动互联网的异军突起,更是将信息的触角延伸至社会的每一个角落。这一系列的变革,不仅重塑了人们的生活方式,也极大地丰富了我们的数字生活体验。如今,用户接入数字世界的终端类型呈现出前所未有的多样化态势,从传统的Web端,到便捷的APP端,再到轻量级的小程序端,每一种终端都承载着不同的使用场景与用户需求,共同编织起一张错综复杂的数字网络。

面对如此多元且复杂的终端环境,登录与注销作为用户进入和退出系统的出入口,看似简单,实则不然。从单一终端的视角审视,登录与注销功能或许显得简单直接,无非是输入凭证、验证身份、完成会话的建立或终止。然而,当我们转换视角,从多端协同的宏观层面去考量时,就会发现这一功能的实现远非想象中那般轻松。

根据以往经验,面对此问题,我们可能是这样做的:

  • Web端:开发一套接口,单独维护;
  • 移动端:开发一套接口,单独维护;

当前方案功能实现无碍,但后续维护成本高昂。需求变更时需同步维护多套关联接口并开展回归测试,既耗时又易因版本差异引发非预期故障,影响系统稳定性。

需求分析

我们再来分析一下,多端会话ID在交互方式上呈现的差异化特征:

  • Web端:在常规应用场景中,会话 ID 一般会被存储在 Cookie 里,随后借助 Cookie 机制来实现交互操作。
  • 移动端:在与服务端进行交互时,采用 RESTful 接口的方式,而会话 ID 一般会被放置在请求的 Header 中,以此来实现会话的标识与传递。

若期望通过一套代码来达成多端需求的统一实现,其核心思路在于将各类相关特征进行有机整合,并依据请求类型的差异,动态地启用相应的处理机制。这一理念在逻辑上清晰明了,然而令人遗憾的是,Apache Shiro 框架原生并不支持这种实现方式

我曾查遍 Apache Shiro 官网文档,竟找不出任何关于支持移动端的任何描述。但方法总比困难多,通过阅读源码,逐步Debug,梳理出一条清晰的脉络,可以实现上述的思路。这就是开源的好处呀!

不得不说,Apache Shrio 的设计哲学真的太棒了,原理易懂,模块划分合理,它以简洁高效之姿,展现出独特的魅力与实用价值。

搞清楚这一点,你便会恍然大悟:Apache Shiro 凭借其强大的功能特性,可应用于各种需要认证与鉴权的场景,无论是传统的 Web 端,还是当下流行的移动端,亦或是便捷实用的小程序端,皆在其适用范畴之内。

建议:大家有时间,真的可以把Shiro源码跟着Debug阅读一下,必将大有脾益。那时,你不仅会愈发倾心于Apache Shiro的精巧设计,更能参透其蕴含的安全哲学。彼时,或许便能体会我此刻按捺不住的分享热忱。

相信此刻的你,已经迫不及待啦!我们一起揭开这神秘的面纱吧!从实战角度,一步步达成目标。

知晓了原理,代码实现很简单。

实战环境

  • Spring Boot 3
  • JDK 17
  • Redis

关于 Spring Boot 3 如何集成 Apache Shiro 可以参考这篇文章,本次实战,以此为基础。

SpringBoot3 集成 Shirohttps://blog.csdn.net/li277967151/article/details/140927139

实战

注:此次实战,仅展示核心Code。

完整代码,大家可以访问这个开源项目,直接Running,更有Feel

TyFast: 基于SpringBoot+Shiro搭建的快速开发平台https://gitee.com/tommycloud/TyFast

1、Yaml配置

#Shiro配置
shiro:
  loginUrl: /login
  successUrl: /index
  unauthorizedUrl: /error/401.html
  logoutUrl: /logout
  userNativeSessionManager: true #false:表示基于Servlet容器 实现Session(即HttpSession)
  sessionManager:
    cookie:
      name: tysid
      path: /

2、SpringBoot 自动装配类

package com.ty.web.spring.config;

import at.pollux.thymeleaf.shiro.dialect.ShiroDialect;
import com.ty.web.shiro.AuthenticationFilter;
import com.ty.web.shiro.AuthorizationFilter;
import com.ty.web.shiro.CookieLogoutFilter;
import com.ty.web.shiro.DistributedSessionDao;
import com.ty.web.shiro.TyWebSessionManager;
import com.ty.web.shiro.realm.NormalRealm;
import com.ty.web.shiro.realm.WithoutPasswordRealm;
import com.ty.web.spring.config.properties.ShiroProperties;
import jakarta.servlet.DispatcherType;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.config.Ini;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.SessionDAO;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.AbstractShiroWebConfiguration;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.util.CollectionUtils;
import org.apache.shiro.web.config.IniFilterChainResolverFactory;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.servlet.AbstractShiroFilter;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

import static com.ty.cm.constant.ShiroConstant.SESSION_TIMEOUT;
import static org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration.FILTER_NAME;
import static org.apache.shiro.spring.config.web.autoconfigure.ShiroWebFilterConfiguration.REGISTRATION_BEAN_NAME;

/**
 * Shiro配置
 *
 * @Author Tommy
 * @Date 2022/1/27
 */
@Configuration
@EnableConfigurationProperties(ShiroProperties.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(name = "shiro.web.enabled", matchIfMissing = true)
@Slf4j
public class ShiroConfig extends AbstractShiroWebConfiguration {

    @Value("#{ @environment['shiro.sessionManager.cookie.name'] ?: 'x-auth-token'}")
    private String sessionIdHeader;

    /**
     * Shiro 常规认证Realm
     */
    @Bean
    public Realm authenticationRealm() {
        return new NormalRealm();
    }

    /**
     * Shiro 免密认证Realm
     */
    @Bean
    public Realm withoutPasswordRealm() {
        return new WithoutPasswordRealm();
    }

    /**
     * 分布式Session Dao
     */
    @Bean
    public SessionDAO sessionDAO() {
        return new DistributedSessionDao();
    }

    /**
     * Shiro Session Manager
     */
    @Bean
    public SessionManager sessionManager() {
        return super.sessionManager();
    }

    /**
     * Shiro 核心过滤器
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroProperties shiroProperties) {

        final ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        filterFactoryBean.setSecurityManager(securityManager); // Shiro的核心安全接口,这个属性是必须的

        // 各URL参数
        filterFactoryBean.setLoginUrl(shiroProperties.getLoginUrl()); // 登录URL,非必须的属性
        filterFactoryBean.setSuccessUrl(shiroProperties.getSuccessUrl()); // 登录成功后要跳转的URL
        filterFactoryBean.setUnauthorizedUrl(shiroProperties.getUnauthorizedUrl()); // 访问未经授权的资源时,转到的URL

        // 替换Shiro默认的Filter(ShiroFilter 集成了过滤器filterchain 模式,所以Shiro内部Filter不要通过SpringBoot实例化,否则就会成为全局Filter,拦截异常)
        filterFactoryBean.getFilters().put("authc", authenticationFilter());
        filterFactoryBean.getFilters().put("perms", authorizationFilter());
        filterFactoryBean.getFilters().put("logout", cookieLogoutFilter());

        // 设置鉴权规则
        filterFactoryBean.setFilterChainDefinitionMap(shiroFilterChainDefinition(shiroProperties));

        // 设置Session有效期(只有自己实现Session DAO时,才需要设置此项)
        // 基于Servlet容器的 Shiro Session,有效期同 HttpSession
        DefaultWebSecurityManager webSecurityManager = (DefaultWebSecurityManager) securityManager;
        ((DefaultWebSessionManager) webSecurityManager.getSessionManager()).setGlobalSessionTimeout(SESSION_TIMEOUT * 1000); // 单位:毫秒

        // 设置Shiro工具类,便于获取相关对象
        SecurityUtils.setSecurityManager(securityManager);

        log.info("Apache Shiro :: 初始化完成!");
        return filterFactoryBean;
    }

    /**
     * 手动配置 Shiro 核心过滤器 (建议手动配置,否则可能因SpringBoot问题,无法初始化)
     */
    @Bean(name = REGISTRATION_BEAN_NAME)
    public FilterRegistrationBean<AbstractShiroFilter> filterShiroFilterRegistrationBean(ShiroFilterFactoryBean shiroFilterFactoryBean) throws Exception {

        FilterRegistrationBean<AbstractShiroFilter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ERROR);
        filterRegistrationBean.setFilter((AbstractShiroFilter)shiroFilterFactoryBean.getObject());
        filterRegistrationBean.setName(FILTER_NAME);
        filterRegistrationBean.setOrder(1);
        return filterRegistrationBean;
    }

    /**
     * Shiro连接约束配置,即过滤链的定义
     * <b>
     *  <p> anon: 匿名访问</p>
     *	<p> authc:认证访问</p>
     *	<p> perms:授权访问</p>
     *	<p> logout:注销访问</p>
     * </b>
     */
    private Map<String, String> shiroFilterChainDefinition(ShiroProperties shiroProperties) {

        final DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        if (StringUtils.isNotBlank(shiroProperties.getLoginUrl())) { // 登录
            chainDefinition.addPathDefinition(shiroProperties.getLoginUrl(), "authc");
        }
        if (StringUtils.isNotBlank(shiroProperties.getLogoutUrl())) { // 注销
            chainDefinition.addPathDefinition(shiroProperties.getLogoutUrl(), "logout");
        }

        // 设置无需鉴权的URL
        shiroProperties.getIgnoreUrls().stream().filter(url -> StringUtils.isNotBlank(url)).forEach(url -> chainDefinition.addPathDefinition(url, "anon"));

        // 读取鉴权配置信息
        if (StringUtils.isNotBlank(shiroProperties.getRules())) {
            final Ini ini = new Ini();
            ini.load(shiroProperties.getRules());
            Ini.Section section = ini.getSection(IniFilterChainResolverFactory.URLS);
            if (CollectionUtils.isEmpty(section)) {
                section = ini.getSection(Ini.DEFAULT_SECTION_NAME);
            }
            chainDefinition.addPathDefinitions(section);
        }

        log.info("Shiro::鉴权规则初始化完毕::DefaultShiroFilterChainDefinition! --> " + chainDefinition.getFilterChainMap());
        return chainDefinition.getFilterChainMap();
    }

    /**
     * 替换Shiro默认的Filter实现:认证过滤器
     */
    @Bean
    public AuthenticationFilter authenticationFilter() {
        AuthenticationFilter authcFilter = new AuthenticationFilter();
        authcFilter.setUsernameParam("loginName");
        return authcFilter;
    }

    /**
     * 替换Shiro默认的Filter实现:鉴权过滤器
     */
    private AuthorizationFilter authorizationFilter() {
        return new AuthorizationFilter();
    }

    /**
     * 替换Shiro默认的Filter实现:Logout过滤器
     */
    private CookieLogoutFilter cookieLogoutFilter() {
        return new CookieLogoutFilter();
    }

    /**
     * 替换Shiro默认的 Native Session Manager
     */
    @Override
    protected SessionManager nativeSessionManager() {
        TyWebSessionManager webSessionManager = new TyWebSessionManager();
        webSessionManager.setSessionIdCookieEnabled(this.sessionIdCookieEnabled);
        webSessionManager.setSessionIdUrlRewritingEnabled(this.sessionIdUrlRewritingEnabled);
        webSessionManager.setSessionIdHeader(this.sessionIdHeader);
        webSessionManager.setSessionIdCookie(this.sessionCookieTemplate());
        webSessionManager.setSessionFactory(this.sessionFactory());
        webSessionManager.setSessionDAO(this.sessionDAO());
        webSessionManager.setDeleteInvalidSessions(this.sessionManagerDeleteInvalidSessions);
        return webSessionManager;
    }

    /**
     * Thymeleaf 与 Shiro 整合
     */
    @Bean
    public ShiroDialect shiroDialect() {
        return new ShiroDialect();
    }
}

关于实现此需求,这个装配类的核心代码就2点

  • 此类需继承:AbstractShiroWebConfiguration
  • 替换Shiro默认的 Native Session Manager
    /**
     * 替换Shiro默认的 Native Session Manager
     */
    @Override
    protected SessionManager nativeSessionManager() {
        TyWebSessionManager webSessionManager = new TyWebSessionManager();
        webSessionManager.setSessionIdCookieEnabled(this.sessionIdCookieEnabled);
        webSessionManager.setSessionIdUrlRewritingEnabled(this.sessionIdUrlRewritingEnabled);
        webSessionManager.setSessionIdHeader(this.sessionIdHeader);
        webSessionManager.setSessionIdCookie(this.sessionCookieTemplate());
        webSessionManager.setSessionFactory(this.sessionFactory());
        webSessionManager.setSessionDAO(this.sessionDAO());
        webSessionManager.setDeleteInvalidSessions(this.sessionManagerDeleteInvalidSessions);
        return webSessionManager;
    }

注:为什么这两个是关键点,说来话长,这里不做阐述,若你Debug一下源码,自然分晓。

3、实现自己的认证接口Filter

package com.ty.web.shiro;

import com.ty.api.log.service.LoginAuditLogService;
import com.ty.api.model.log.LoginAuditLog;
import com.ty.api.model.system.SysUser;
import com.ty.api.system.service.SysUserService;
import com.ty.cm.model.AjaxResult;
import com.ty.cm.utils.URLUtils;
import com.ty.web.push.TPush;
import com.ty.web.spring.SpringContextHolder;
import com.ty.web.utils.WebIpUtil;
import com.ty.web.utils.WebUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;

import java.io.IOException;
import java.util.Date;

import static com.ty.cm.constant.Numbers.ONE;
import static com.ty.cm.constant.ShiroConstant.DEFAULT_CAPTCHA_PARAM;

/**
 * Shiro认证服务
 *
 * @Author Tommy
 * @Date 2022/1/27
 */
@Slf4j
public class AuthenticationFilter extends FormAuthenticationFilter {

    /** 账户业务接口 **/
    @Autowired
    @Lazy
    private SysUserService sysUserService;

    /** 登录日志接口 **/
    @Autowired
    @Lazy
    private LoginAuditLogService loginAuditLogService;

    /** TPush消息推送 **/
    @Autowired
    @Lazy
    private TPush tpush;

    /** "验证码"参数名称 */
    private String captchaParam = DEFAULT_CAPTCHA_PARAM;

    /**
     * 创建令牌
     */
    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {

        String username = getUsername(request);
        String password = getPassword(request);
        boolean rememberMe = isRememberMe(request);
        String host = getHost(request);
        String captcha = getCaptcha(request);
        return new com.ty.web.shiro.AuthenticationToken(username, password, rememberMe, host, captcha);
    }

    /**
     * 未经认证时访问系统在此拦截
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        final boolean isLoginRequest = super.isLoginRequest(request, response);
        if (!isLoginRequest && WebUtil.isAjax()) { // 登录URL不能拦截
            WebUtil.sendError(WebUtils.toHttp(response), HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        return super.onAccessDenied(request, response);
    }

    /**
     * 登录失败的回调函数
     */
    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException ex, ServletRequest request, ServletResponse response) {

        final boolean isAjax = WebUtil.isAjax();

        /*
         * 转换标准异常为自定义异常(因框架架构设计问题,只在多Realm情况下,才需要此操作)
         */
        if (token instanceof com.ty.web.shiro.AuthenticationToken) {
            com.ty.web.shiro.AuthenticationToken authenticationToken = (com.ty.web.shiro.AuthenticationToken) token;
            ex = null != authenticationToken.getAex()? authenticationToken.getAex() : ex;
        }

        try {
            WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.warn(SpringContextHolder.getMessage(ex.getMessage())));
        } catch (IOException ioe) {
            log.error(ioe.getMessage(), ioe);
        } finally {
            log.warn("登录校验失败::" + (isAjax? "异步":"同步") + "::" + ex.getMessage());
        }
        return !isAjax;
    }

    /**
     * 登录成功的回调函数
     */
    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {

        final boolean isAjax = WebUtil.isAjax();

        // 进入系统前的业务处理
        this.postHandle(subject, isAjax, request, response);

        // 输出成功信息
        try {
            WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success(subject.getSession().getId()));
        } catch (IOException ioe) {
            log.error(ioe.getMessage(), ioe);
        }
        return !isAjax? super.onLoginSuccess(token, subject, request, response) : !isAjax;
    }

    /**
     * 获取验证码
     *
     * @param request
     * @return 验证码
     */
    protected String getCaptcha(ServletRequest request) {
        return WebUtils.getCleanParam(request, captchaParam);
    }

    /**
     * 认证后,进入系统前的处理
     *
     * @param subject
     * @param isAjax
     * @throws Exception
     */
    public void postHandle(Subject subject, boolean isAjax, ServletRequest request, ServletResponse response) throws Exception {
        String loginIp = WebIpUtil.getClientIP();
        String domain = URLUtils.getPrimaryDomain(WebUtil.getDomain(), true);

        final SysUser account = (SysUser) subject.getPrincipal();
        log.info(account.getLoginName() + " 登录成功::" + (isAjax? "异步":"同步") + " :: From " + loginIp);

        // 此处可写业务代码
        // 如:获取员工信息等,可在账户表中,添加辅助字段,用于存储业务数据
        // ......

        // 更新用户的登录信息(IP & 登录时间)
        SysUser sysUser = new SysUser();
        sysUser.setUserId(account.getUserId());
        sysUser.setLoginTime(new Date());
        sysUser.setLoginIp(loginIp);
        sysUserService.update(sysUser);

        // 记录登录日志
        loginAuditLogService.save(new LoginAuditLog(account.getLoginName(), loginIp, WebUtil.getUserAgent(), ONE));

        // 实现登录互踢
        boolean result = sysUserService.kickOut(account, subject.getSession().getId().toString());
        if (result) { // 将下线消息通知到同账户的其它客户端
            tpush.kickOut(account.getLoginName());
        }
    }
}

关于实现此需求,核心代码如下:

    /**
     * 登录成功的回调函数
     */
    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {

        final boolean isAjax = WebUtil.isAjax();

        // 进入系统前的业务处理
        this.postHandle(subject, isAjax, request, response);

        // 输出成功信息
        try {
            WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success(subject.getSession().getId()));
        } catch (IOException ioe) {
            log.error(ioe.getMessage(), ioe);
        }
        return !isAjax? super.onLoginSuccess(token, subject, request, response) : !isAjax;
    }

此段代码,当移动端以异步请求登录成功后,服务端会将Session ID返回。而Web端登录成功后,走Shiro原生逻辑。

4、实现自己的Logout Filter

package com.ty.web.shiro;

import com.ty.cm.model.AjaxResult;
import com.ty.cm.utils.URLUtils;
import com.ty.web.utils.WebUtil;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.shiro.web.filter.authc.LogoutFilter;
import org.apache.shiro.web.util.WebUtils;

/**
 * 基于Cookie机制的注销登录服务
 *
 * @Author Tommy
 * @Date 2022/1/27
 */
public class CookieLogoutFilter extends LogoutFilter {

    /**
     * 注销登录业务逻辑处理
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response)
            throws Exception {

        getSubject(request, response).logout(); // Shiro内部实现

        // Cookie 登出处理
        String domain = URLUtils.getPrimaryDomain(WebUtil.getDomain(), true);
        WebUtil.removeAllCookie((HttpServletRequest) request, (HttpServletResponse) response, domain);

        // 登出后的前端交互
        if (WebUtil.isAjax()) {
            WebUtil.writeJSON(WebUtils.toHttp(response), AjaxResult.success());
        } else {
            issueRedirect(request, response, getRedirectUrl());
        }
        return false;
    }
}

5、【核心】实现自己的Web Session Manager

package com.ty.web.shiro;

import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;

import java.io.Serializable;

/**
 * 增强 Web Session Manager,支持从Header中获取Session ID
 *
 * @Author Tommy
 * @Date 2025/3/11
 */
@Data
public class TyWebSessionManager extends DefaultWebSessionManager {

    private String sessionIdHeader;

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        Serializable id = super.getSessionId(request, response);

        // 若 Shiro 原生获取不到SessionID,则从Header中尝试获取
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String sessionId = httpRequest.getHeader(sessionIdHeader);
        if (StringUtils.isNotBlank(sessionId)) {
            id = sessionId;
        }
        return id;
    }
}

若Debug源码后,你会发现,这段代码实现,其实是对原生Shiro的补充,以支持移动端场景。

经过上述的5个步骤,我们的代码工作就完成了,是不是很简单呢。若你想知晓,为什么是这么写,那就只能Debug源码喽!因为这个事情,真的不太好通过Blog文字的方式,讲清楚呢!

测试

1、移动端

  • 登录接口测试

  • 调用数据接口测试

  • 注销接口测试

2、Web端

  • 登录接口测试

  • 注销接口测试

结论

通过上述测试可知,我们通过统一的接口,完美同时支持Web端与移动端,成功达成了一套代码适配多平台的高效解决方案。

此刻,你是否如拨云见日般,心中豁然开朗?是否恍然发觉,这看似棘手的难题,实则并不繁杂。只要思路如灵动的丝线般清晰穿梭,Coding便会如行云流水般简单自然。

 

至此分享结束!

Enjoy It! 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2323523.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

NAT—地址转换(实战篇)

一、实验拓扑&#xff1a; 二、实验需求&#xff1a; 1.实现内网主机访问外网 2.实现外网客户端能够访问内网服务器 三、实验思路 1.配置NAT地址池实现内网地址转换成公网地址&#xff0c;实现内网主机能够访问外网。 2.配置NAT Sever实现公网地址映射内网服务器地址&…

用HTML和CSS生成炫光动画卡片

这个效果结合了渐变、旋转和悬浮效果的炫酷动画示例&#xff0c;使用HTML和CSS实现。 一、效果 二、实现 代码如下&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport&quo…

FPGA_YOLO(三)

上一篇讲的是完全映射&#xff0c;也就是block中的所包含的所有的卷积以及归一&#xff0c;池化卷积 举例总共6个等都在pl侧进行处理&#xff08;写一个top 顶层 里面conv 1 bn1 relu1 pool1 conv1*1 conv 2 bn2 relu2 pool2 conv1*1 ....总共6个 &#xff09;&#xff0c;…

旅游CMS选型:WordPress、Joomla与Drupal对比

内容概要 在旅游行业数字化转型进程中&#xff0c;内容管理系统&#xff08;CMS&#xff09;的选择直接影响网站运营效率与用户体验。WordPress、Joomla和Drupal作为全球主流的开源CMS平台&#xff0c;其功能特性与行业适配性存在显著差异。本文将从旅游企业核心需求出发&…

全面适配iOS 18.4!通付盾加固产品全面升级,护航App安全上架

引言&#xff1a; 苹果官方新规落地&#xff01; 自2025年4月24日起&#xff0c;所有提交至App Store Connect的应用必须使用Xcode 16或更高版本构建&#xff0c;否则将面临审核驳回风险&#xff01;Beta版iOS 18.4、iPadOS 18.4现已推出&#xff0c;通付盾iOS加固产品率先完成…

一台电脑最多能接几个硬盘?

在使用电脑时&#xff0c;硬盘空间不够是许多用户都会遇到的问题。无论是摄影师、剪辑师等需要大量存储空间的专业人士&#xff0c;还是游戏玩家、数据备份爱好者&#xff0c;都可能希望通过增加硬盘来扩展存储容量。然而&#xff0c;一台电脑究竟最多能接多少个硬盘&#xff1…

【玩转全栈】---- Django 基于 Websocket 实现群聊(解决channel连接不了)

学习视频&#xff1a; 14-11 群聊&#xff08;一&#xff09;_哔哩哔哩_bilibili 目录 Websocket 连接不了&#xff1f; 收发数据 断开连接 完整代码 聊天室的实现 聊天室一 聊天室二 settings 配置 consumer 配置 多聊天室 Websocket 连接不了&#xff1f; 基于这篇博客&…

如何快速解决django报错:cx_Oracle.DatabaseError: ORA-00942: table or view does not exist

我们在使用django连接oracle进行编程时&#xff0c;使用model进行表映射对接oracle数据时&#xff0c;默认表名组成结构为&#xff1a;应用名_类名&#xff08;如&#xff1a;OracleModel_test&#xff09;&#xff0c;故即使我们库中存在表test&#xff0c;运行查询时候&#…

本地安装git

下载git 通过官网 下载 &#xff1a;Git - Downloading Package 若此页面无法直达&#xff0c;请删掉download/win尝试 2.双击运行安装 选择安装目录&#xff1a; 选择配置&#xff0c;默认不动 git安装目录名 默认即可 Git 的默认编辑器&#xff0c;建议使用默认的 Vim 编辑器…

小程序内表格合并功能实现—行合并

功能介绍&#xff1a;支付宝小程序手写表格实现行内合并&#xff0c;依据动态数据自动计算每次需求合并的值&#xff0c;本次记录行内合并&#xff0c;如果列内合并&#xff0c;同理即可实现 前端技术&#xff1a;grid布局 display&#xff1a;grid 先看实现效果: axml&…

SSE协议介绍和python实现

概述&#xff1a; SSE&#xff08;Server-Sent Events&#xff09;协议是一种允许服务器向客户端实时推送更新的技术&#xff0c;基于HTTP协议&#xff0c;常用于实时数据推送特点&#xff1a; 单向通信&#xff1a;服务器向客户端推送数据&#xff0c;客户端无法发送数据。基…

甘肃旅游服务平台+论文源码视频演示

4 系统设计 4.1系统概要设计 甘肃旅游服务平台并没有使用C/S结构&#xff0c;而是基于网络浏览器的方式去访问服务器&#xff0c;进而获取需要的数据信息&#xff0c;这种依靠浏览器进行数据访问的模式就是现在用得比较广泛的适用于广域网并且没有网速限制要求的小程序结构&am…

WebRTC中音视频服务质量QoS之FEC+NACK调用流程

WebRTC中音视频服务质量QoS之FECNACK调用流程 WebRTC中音视频服务质量QoS之FECNACK调用流程 WebRTC中音视频服务质量QoS之FECNACK调用流程前言一、WebRTC中FEC基础原理1. FEC基础操作 异或操作XOR2、 FEC中 行向和纵向 计算3、 WebRTC中 媒体包分组和生成FEC的包数① kFecRateT…

神经网络知识点整理

目录 ​一、深度学习基础与流程 二、神经网络基础组件 三、卷积神经网络&#xff08;CNN&#xff09;​编辑 四、循环神经网络&#xff08;RNN&#xff09;与LSTM 五、优化技巧与调参 六、应用场景与前沿​编辑 七、总结与展望​编辑 一、深度学习基础与流程 机器学习流…

远程办公新体验:用触屏手机流畅操作电脑桌面

在数字化浪潮的推动下&#xff0c;远程办公已从“应急选项”转变为职场常态。无论是居家隔离、差旅途中&#xff0c;还是咖啡厅临时办公&#xff0c;高效连接公司电脑的需求从未如此迫切。然而&#xff0c;传统的远程控制软件常因操作复杂、画面卡顿或功能限制而影响效率。如今…

【面试八股】:常见的锁策略

常见的锁策略 synchronized &#xff08;标准库的锁不够你用了&#xff09;锁策略和 Java 不强相关&#xff0c;其他语言涉及到锁&#xff0c;也有这样的锁策略。 1. 悲观锁&#xff0c;乐观锁&#xff08;描述的加锁时遇到的场景&#xff09; 悲观锁&#xff1a;预测接下来…

【python】OpenCV—Hand Detection

文章目录 1、功能描述2、代码实现3、效果展示4、完整代码5、参考6、其它手部检测和手势识别的方案 更多有趣的代码示例&#xff0c;可参考【Programming】 1、功能描述 基于 opencv-python 和 mediapipe 进行手部检测 2、代码实现 导入必要的库函数 import cv2 import media…

Flink中聚合算子介绍

前言 在flink api中&#xff0c;聚合算子是非常常用的。所谓的聚合就是在分组的基础上做比较计算的操作。下面通过几个简单案例来说明聚合算子的用法和注意事项。 聚合算子案例 因为flink的api操作流程比较固定&#xff0c;从获取执行环境》获取数据源》执行数据转换操作》输…

【基础】Windows 中通过 VSCode 使用 GCC 编译调试 C++

准备 安装 VSCode 及 C 插件。通过 MSYS2 安装 MinGW-w64 工具链&#xff0c;为您提供必要的工具来编译代码、调试代码并配置它以使用IntelliSense。参考&#xff1a;Windows 中的 Linux 开发工具链 验证安装&#xff1a; gcc --version g --version gdb --version三个核心配…

知识就是力量——物联网应用技术

基础知识篇 一、常用电子元器件1——USB Type C 接口引脚详解特点接口定义作用主从设备关于6P引脚的简介 2——常用通信芯片CH343P概述特点引脚定义 CH340概述特点封装 3——蜂鸣器概述类型驱动电路原文链接 二、常用封装介绍贴片电阻电容封装介绍封装尺寸与功率关系&#xff1…