Spring Secuirty 使用手机号、短信验证码登录(身份认证)
前言
对于登录方式,PC 端后台一般会选择账号密码登录,如果是 ToC 的服务,也会支持手机号+短信验证码的登录方式,甚至如果有 APP,还可以支持扫码登录。当然还有腾讯系、阿里系的应用喜欢的三方授权登录。
移动端(国内)通常的应用都会优先选择手机号+短信验证码的方式,即使这样商家会有额外的短信成本负担。比如现在的微信手机号快速认证(所谓“一键登录”)组件已经开始堂而皇之的收费了,跟短信收费也差不太多,一条 3 分钱。说是该组件手机号认证也是有费用的,现在转移到商家头上。支付宝暂时没有发现这种情况。关键是,对于共享共电宝这种快速响应的场景,你还不得不使用它的快速认证服务,不然要先手机号获取验证码,步骤会多,用户体验会差。当然也是提供了手机号+验证码的登录方式就是了,用不用看用户自己。
Redis 实现
其实手机号+短信验证码登录之前做过一次,只不过实现方式不太一样。资产项目因为使用了微服务,有独立的用户中心。对于微服务不太了解,用户中心的代码也没有办法说改就改。所以当时就想着绕过现有的用户认证体系,再建一套独立的认证,最好还要简单一点的。资产 ERP 项目使用的是 华夏 ERP,里面刚好有一套账号认证以及 redis 管理 token,认证身份的方法。
管伊佳ERP(华夏ERP) UserController.java
@Resource
private UserService userService;
...
@Resource
private RedisService redisService;
private static String SUCCESS = "操作成功";
private static String ERROR = "操作失败";
@PostMapping(value = "/login")
@ApiOperation(value = "登录")
public BaseResponseInfo login(@RequestBody UserEx userParam, HttpServletRequest request)throws Exception {
BaseResponseInfo res = new BaseResponseInfo();
try {
userService.validateCaptcha(userParam.getCode(), userParam.getUuid());
Map<String, Object> data = userService.login(userParam.getLoginName().trim(), userParam.getPassword().trim(), request);
res.code = 200;
res.data = data;
} catch (BusinessRunTimeException e) {
throw new BusinessRunTimeException(e.getCode(), e.getMessage());
} catch(Exception e){
logger.error(e.getMessage(), e);
res.code = 500;
res.data = "用户登录失败";
}
return res;
}
主要需要关注两个服务 UserService
和 RedisService
。UserService
主要实现登录的逻辑,RedisService
则是负责 token、captcha 等内容的管理(CRUD)。
接入存在的问题是,redis 配置序列化可能会重复或被覆盖(RedisService
中设置了序列化器)。如果已配置,可以直接注释掉序列化器的设置部分。
Spring Security 实现
在若依前后端分离版本里没有找到手机号、短信验证码登录的集成方法,所以只能求助于百度。
【Spring Security系列】如何用Spring Security集成手机验证码登录?五分钟搞定!
再结合百度 AI 给出的结果,主要需要新增、修改以下几个文件:
- xxxAuthenticationToken
- xxxAuthenticationProvider
- SecurityConfig
- xxxAuthenticationFilter
最后的 xxxAuthenticationFilter
是走过滤器处理,而非正常的控制器。有点类似 Laravel 框架中的中间件,拦截特定的请求做判断处理。也就是说,在这里可以使用过滤器做登录处理,也可以走正常请求控制器处理。
首先是创建 SmsAuthenticationProvider
- sms 身份认证方法提供者 和 SmsAuthenticationToken
- 身份令牌信息(这里对应手机号、短信验证码)。
SmsAuthenticationProvider.java
package com.ruoyi.framework.manager.provider;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.StringUtils;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
public class SmsAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final RedisCache redisCache;
public SmsAuthenticationProvider(UserDetailsService userDetailsService, RedisCache redisCache) {
this.userDetailsService = userDetailsService;
this.redisCache = redisCache;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// SmsAuthenticationToken token = (SmsAuthenticationToken) authentication;
String phoneNumber = (String) authentication.getPrincipal(); // 手机号
String code = (String) authentication.getCredentials(); // 验证码
// 验证验证码是否正确
Integer codeValid = validateSmsCode(phoneNumber, code);
if (codeValid == 1) {
throw new BadCredentialsException("验证码已过期.");
}
if (codeValid == 2) {
throw new BadCredentialsException("验证码错误.");
}
// 加载用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(phoneNumber);
if (userDetails == null) {
throw new BadCredentialsException("未找到对应的用户,请联系平台工作人员");
}
// 创建已认证的Authentication
return new SmsAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
public Integer validateSmsCode(String phoneNumber, String code)
{
String verifyKey = CacheConstants.SMS_CODE_KEY + StringUtils.nvl(phoneNumber, "");
String smsCode = redisCache.getCacheObject(verifyKey);
if (smsCode == null)
{
return 1;
}
if (!code.equalsIgnoreCase(smsCode))
{
return 2;
}
redisCache.deleteObject(verifyKey);
return 0;
}
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
SmsAuthenticationToken.java
package com.ruoyi.framework.manager.provider;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal; // 手机号
private Object credentials; // 验证码
public SmsAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
this.setAuthenticated(false);
}
public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
两个类一个继承抽象类,一个实现接口,所以需要重写一些必要的方法。
之后就是将 SmsAuthenticationProvider
实例配置到 SecurityConfig
中的 ProviderManager
- 认证提供者的管理器中。
SecurityConfig.java
package com.ruoyi.framework.config;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.framework.manager.provider.SmsAuthenticationProvider;
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
import com.ruoyi.framework.security.filter.SmsAuthenticationFilter;
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
import com.ruoyi.framework.security.handle.SmsAuthenticationFailureHandler;
import com.ruoyi.framework.security.handle.SmsAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.filter.CorsFilter;
/**
* spring security配置
*
* @author ruoyi
*/
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* Sms 认证失败处理累
*/
@Autowired
private SmsAuthenticationFailureHandler smsAuthenticationFailureHandler;
/**
* Sms 认证成功处理累
*/
@Autowired
private SmsAuthenticationSuccessHandler smsAuthenticationSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 跨域过滤器
*/
@Autowired
private CorsFilter corsFilter;
/**
* 允许匿名访问的地址
*/
@Autowired
private PermitAllUrlProperties permitAllUrl;
@Autowired
private RedisCache redisCache;
/**
* 身份验证实现
*/
@Bean
public AuthenticationManager authenticationManager()
{
// 账号密码 认证
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
// Sms 认证
SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider(userDetailsService, redisCache);
return new ProviderManager(smsAuthenticationProvider, daoAuthenticationProvider);
}
// @Bean
// public SmsAuthenticationFilter smsAuthenticationFilter() throws Exception {
// SmsAuthenticationFilter filter = new SmsAuthenticationFilter();
// filter.setAuthenticationManager(authenticationManager()); // 设置认证管理器
// filter.setAuthenticationSuccessHandler(smsAuthenticationSuccessHandler); // 设置成功处理器
// filter.setAuthenticationFailureHandler(smsAuthenticationFailureHandler); // 设置失败处理器
// return filter;
// }
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Bean
protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception
{
return httpSecurity
// CSRF禁用,因为不使用session
.csrf(csrf -> csrf.disable())
// 禁用HTTP响应标头
.headers((headersCustomizer) -> {
headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
})
// 认证失败处理类
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
// 基于token,所以不需要session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 注解标记允许匿名访问的url
.authorizeHttpRequests((requests) -> {
permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
requests.antMatchers("/login", "/register", "/captchaImage", "/api/worker/login", "/api/worker/sendSms").permitAll()
// 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
.antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
})
// 添加Logout filter
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
// 添加JWT filter
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
// .addFilterBefore(smsAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// 添加CORS filter
.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
.addFilterBefore(corsFilter, LogoutFilter.class)
.build();
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
}
这是若依框架已有的安全配置,通过 ProviderManager
初始化需要的认证提供者,添加 authenticationManager
bean。
当然,百度 AI 中的是通过继承 WebSecurityConfigurerAdapter
重写 configure
方法,来添加认证提供者:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private SmsAuthenticationProvider authenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(authenticationProvider);
}
// 其他配置...
}
两者添加的方式稍微有点不同,并且 WebSecurityConfigurerAdapter
已经被标注为弃用了,所以优先考虑若依中的安全配置写法。
最后就是控制器、服务调用 authenticationManager
。
使用控制器
控制器:
/**
* 登录方法
*
* @param loginBody 登录信息
* @return 结果
*/
@PostMapping("/login")
@ApiOperation(value = "登录方法", notes = "登录方法")
@RepeatSubmit
public R<String> login(@RequestBody WorkerLoginBody loginBody)
{
// 生成令牌
String token = loginService.workerLogin(loginBody.getPhoneNumber(), loginBody.getCode());
return R.ok(token);
}
服务:
/**
* 登录验证
*
* @param phoneNumber 用户名
* @param code 验证码
* @return 结果
*/
public String workerLogin(String phoneNumber, String code)
{
// 获取 username
SysUser user = userService.selectUserByPhoneNumber(phoneNumber);
if (user == null) {
throw new RuntimeException("账号不存在!");
}
// 验证码校验
validateSmsCode(phoneNumber, code);
String username = user.getUserName();
// 用户验证
Authentication authentication = null;
try
{
// 加载用户详情
SmsAuthenticationToken authenticationToken = new SmsAuthenticationToken(phoneNumber, code);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());
// 生成token
return tokenService.createToken(loginUser);
}
authenticationManager
会根据传入的令牌信息类型,判断调用哪一个认证提供者的 authenticate()
方法。对于返回的 authentication 处理、登录日志、生成 token 都是套用的若依框架中的账号密码登录的默认处理。
附上发送验证码的方法:
public void sendSmsCode(String phoneNumber) {
// 获取 username
SysUser user = userService.selectUserByPhoneNumber(phoneNumber);
if (user == null) {
throw new RuntimeException("账号不存在!");
}
String smsTemplate = "您的验证码为${code},10分钟内有效。";
String sign = "【xxx】"; //短信签名
String code = RandomUtils.randomInt(4);
Map<String, String> valuesMap = new HashMap();
valuesMap.put("code", StringUtils.isNotEmpty(code) ? code : "8888");//手机验证码
StringSubstitutor sub = new StringSubstitutor(valuesMap);
String content = sub.replace(smsTemplate);
try {
// 发送短信
smsService.sendSms(phoneNumber, content, "xx", sign);
} catch (Exception e) {
throw new RuntimeException("发送失败!");
} finally {
// 增加发送日志
}
// 存储验证码
String verifyKey = CacheConstants.SMS_CODE_KEY + StringUtils.nvl(phoneNumber, "");
redisCache.setCacheObject(verifyKey, code, 10 * 60, TimeUnit.SECONDS);
}
短信模版字符串使用了字符变量替换方法,需要 commons-text
依赖包,如果没有,也可以直接字符串拼接。
使用过滤器
如果想要使用过滤器来处理,就需要将上面安全配置中关于 filter 部分注释去掉。.addFilterBefore(smsAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
是将 sms 过滤器添加到用户名密码认证过滤器前面,不过测试发现,不加也不影响两种不同路由请求的正确处理。
SmsAuthenticationFilter.java
package com.ruoyi.framework.security.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.framework.manager.provider.SmsAuthenticationToken;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String PHONE_KEY = "phoneNumber"; // 手机号字段
public static final String CAPTCHA_KEY = "code"; // 验证码字段
private boolean postOnly = true;
private final ObjectMapper objectMapper = new ObjectMapper();
public SmsAuthenticationFilter() {
super("/api/worker/login"); // 拦截短信验证码登录请求
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String phone;
String captcha;
try {
// 读取请求体中的 JSON 数据并解析
Map<String, String> requestBody = objectMapper.readValue(request.getInputStream(), Map.class);
phone = requestBody.get(PHONE_KEY); // 获取手机号
captcha = requestBody.get(CAPTCHA_KEY); // 获取验证码
} catch (IOException e) {
throw new AuthenticationServiceException("Failed to parse authentication request body", e);
}
if (phone == null) {
phone = "";
}
if (captcha == null) {
captcha = "";
}
phone = phone.trim();
// 创建验证请求的 Token
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, captcha);
return this.getAuthenticationManager().authenticate(authRequest);
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
此外,还有认证成功和认证失败的处理类。如果不加,失败会抛出异常会走到公共的认证失败处理类,成功会返回 /
请求内容。
{
"timestamp": "2024-09-25T15:57:32.185+08:00",
"status": 401,
"error": "Unauthorized",
"message": "Unauthorized",
"path": "/api/worker/login"
}
SmsAuthenticationSuccessHandler.java
package com.ruoyi.framework.security.handle;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.web.service.TokenService;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;
@Component
public class SmsAuthenticationSuccessHandler implements AuthenticationSuccessHandler, Serializable {
private static final long serialVersionUID = 1L;
@Autowired
private TokenService tokenService;
@Autowired
private ISysUserService userService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
AsyncManager.me().execute(AsyncFactory.recordLogininfor(loginUser.getUsername(), Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
recordLoginInfo(loginUser.getUserId());
// 生成token
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("success", tokenService.createToken(loginUser))));
}
/**
* 记录登录信息
*
* @param userId 用户ID
*/
public void recordLoginInfo(Long userId)
{
SysUser sysUser = new SysUser();
sysUser.setUserId(userId);
sysUser.setLoginIp(IpUtils.getIpAddr());
sysUser.setLoginDate(DateUtils.getNowDate());
userService.updateUserProfile(sysUser);
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
AuthenticationSuccessHandler.super.onAuthenticationSuccess(request, response, chain, authentication);
}
}
SmsAuthenticationFailureHandler.java
package com.ruoyi.framework.security.handle;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.user.CaptchaException;
import com.ruoyi.common.utils.ServletUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;
/**
* 认证失败处理类 返回未授权
*
* @author ruoyi
*/
@Component
public class SmsAuthenticationFailureHandler implements AuthenticationFailureHandler, Serializable
{
private static final long serialVersionUID = 1L;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
int code = HttpStatus.ERROR;
String msg = exception.getMessage();
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
}
}
当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »