<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
公司開發的系統原先的使用者資訊是基於shiro session 進行管理,但是session不適用於app端,並且伺服器重啟後需要重新登入。需要改造將shiro和jwt進行整合,實現通過token登入。
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.2.0</version> </dependency>
import org.apache.shiro.authc.AuthenticationToken; public class JWTToken implements AuthenticationToken { // 金鑰 private String token; public JWTToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; public Object getCredentials() { }
import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; import com.hxkg.datafusion.controller.FlieController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.io.UnsupportedEncodingException; import java.util.Date; /** * JWT token 工具類 * * @author yangfeng * @Date 2019-09-17 */ @ConfigurationProperties(prefix = "jwt") @Component public class JWTUtil { private static Logger LOG = LoggerFactory.getLogger(FlieController.class); //過期時間 private static Long expire; // 祕鑰 private static String secret; /** * 校驗token是否正確 * * @param token 金鑰 * @return 是否正確 */ public static boolean verify(String token, String userName) { try { //根據密碼生成JWT效驗器 Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .withClaim("userName", userName) .build(); //效驗TOKEN verifier.verify(token); return true; } catch (UnsupportedEncodingException e) { return false; } } * 獲得token中的資訊無需secret解密也能獲得 * @return token中包含的使用者名稱 public static String getUsername(String token) { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim("userName").asString(); } catch (JWTDecodeException e) { return null; * 獲取使用者id * @param token * @return public static String getUserId(String token) { return jwt.getClaim("userId").asString(); * 生成簽名 * @param userName 使用者名稱 * @param userId * @return 加密的token public static String sign(String userName, String userId) { Date date = new Date(System.currentTimeMillis() + expire * 1000); // 附帶username資訊 return JWT.create() .withClaim("userId", userId) .withExpiresAt(date) .sign(algorithm); } catch (Exception e) { public static Long getExpire() { return expire; public static void setExpire(Long expire) { JWTUtil.expire = expire; public static String getSecret() { return secret; public static void setSecret(String secret) { JWTUtil.secret = secret; }
application.properties中增加:
#jwt token # token有效時長,7天,單位秒 jwt.expire=604800 jwt.secret=JWT_TOKEN_SHIRO
注意:jwt加解密的私鑰使用設定的字串而不使用使用者登入密碼的好處是防止使用者密碼被其他人修改後,使用者作業系統此時再去校驗token會發生token解析對不上的情況。
import org.apache.shiro.authz.UnauthorizedException; import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.RequestMethod; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; /** * jwt過濾器 * * @Author yangfeng * @Description preHandle->isAccessAllowed->isLoginAttempt->executeLogin * @Date 2019-09-18 * @Time 12:36 */ public class JWTFilter extends BasicHttpAuthenticationFilter { private Logger logger = LoggerFactory.getLogger(this.getClass()); /** * 如果帶有 token,則對 token 進行檢查,否則直接通過 */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException { //判斷請求的請求頭是否帶上 "Token" if (isLoginAttempt(request, response)) { //如果存在,則進入 executeLogin 方法執行登入,檢查 token 是否正確 try { executeLogin(request, response); return true; } catch (Exception e) { //token 錯誤 responseError(response, e.getMessage()); return false; //產生異常則阻止請求的繼續執行 } } //如果請求頭不存在 Token,則可能是執行登陸操作或者是遊客狀態存取,無需檢查 token,直接返回 true return true; } /** * 判斷使用者是否想要登入。 * 檢測 header 裡面是否包含 Token */ @Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { HttpServletRequest req = (HttpServletRequest) request; String token = req.getHeader("Authorization"); return token != null; } /** * 執行登陸操作 */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String token = httpServletRequest.getHeader("Authorization"); JWTToken jwtToken = new JWTToken(token); // 提交給realm進行登入,如果錯誤他會丟擲異常並被捕獲 getSubject(request, response).login(jwtToken); // 如果沒有丟擲異常則代表登入成功,返回true return true; } /** * 對跨域提供支援 */ @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; httpServletResponse.setHeader("Access-control-Allow-Origin", "*"); httpServletResponse.setHeader("Access-Control-Allow-Methods", "*"); httpServletResponse.setHeader("Access-Control-Allow-Headers", "*"); // 跨域時會首先傳送一個option請求,這裡我們給option請求直接返回正常狀態 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } /** * 請求異常跳轉 */ private void responseError(ServletResponse response, String message) { try { HttpServletResponse httpServletResponse = (HttpServletResponse) response; //設定編碼,否則中文字元在重定向時會變為空字串 message = URLEncoder.encode(message, "UTF-8"); httpServletResponse.sendRedirect("/pc/login/noLogin?message=" + message); } catch (IOException e) { logger.error(e.getMessage()); } } }
executeLogin()
方法中的getSubject(request, response).login(jwtToken)
就是觸發Shiro 登入操作。
import com.auth0.jwt.exceptions.SignatureVerificationException; import com.auth0.jwt.exceptions.TokenExpiredException; import com.hxkg.datafusion.common.jwt.JWTToken; import com.hxkg.datafusion.common.jwt.JWTUtil; import com.hxkg.datafusion.entity.customized.RoleAO; import com.hxkg.datafusion.entity.customized.UserAO; import com.hxkg.datafusion.entity.customized.VUserPrivilegeAO; import com.hxkg.datafusion.service.IRoleService; import com.hxkg.datafusion.service.IUserService; import com.hxkg.datafusion.service.IVUserPrivilegeService; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.authz.SimpleAuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import javax.annotation.Resource; import java.util.List; /** * 登入授權realm * * @author yangfeng * @date 2019.6.21 */ @Service public class ShiroRealm extends AuthorizingRealm { @Resource private IRoleService roleService; @Resource private IVUserPrivilegeService vUserPrivilegeService; @Resource private IUserService userService; @Override public boolean supports(AuthenticationToken token) { return token instanceof JWTToken; } /** * 為當前登入使用者授予角色和許可權 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = JWTUtil.getUsername(principals.getPrimaryPrincipal().toString()); SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); // 根據登入名獲取登使用者資訊 if (!StringUtils.isEmpty(username)) { //設定使用者角色 ServiceResult<List<RoleAO>> rolesResult = roleService.getRolesByUserName(username); if (rolesResult != null && rolesResult.isSucceed() && !CollectionUtils.isEmpty(rolesResult.getData())) { for (RoleAO role : rolesResult.getData()) { authorizationInfo.addRole(role.getName()); } } //設定許可權 List<VUserPrivilegeAO> privileges = vUserPrivilegeService.queryPrivilegeByUserName(username); if (!CollectionUtils.isEmpty(privileges)) { for (VUserPrivilegeAO privilege : privileges) { if (privilege == null || StringUtils.isEmpty(privilege.getPrivilegecode())) { continue; } //許可權操作程式碼 authorizationInfo.addStringPermission(privilege.getPrivilegecode()); } } return authorizationInfo; } return null; } /** * 驗證當前登入的使用者 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { String token = (String) auth.getCredentials(); // 解密獲得username,用於和資料庫進行對比 String username = JWTUtil.getUsername(token); if (StringUtils.isEmpty(username)) { throw new AuthenticationException("token錯誤!"); } UserAO user = userService.getUserByName(username); if (user == null) { throw new AuthenticationException("使用者不存在!"); } if (Constant.DISABLE.equals(user.getEnabled())) { throw new AuthenticationException("賬號已被禁用!"); } try { if (JWTUtil.verify(token, username)) { return new SimpleAuthenticationInfo(token, token, getName()); } else { throw new AuthenticationException("token認證失敗!"); } } catch (TokenExpiredException e) { throw new AuthenticationException("token已過期!"); } catch (SignatureVerificationException e) { throw new AuthenticationException("密碼不正確!"); } } /** * 清除登陸使用者授權資訊快取. */ public void clearCached() { this.clearCachedAuthorizationInfo(SecurityUtils.getSubject().getPrincipals()); } /** * 超級管理員不做許可權的判斷,自動擁有所有許可權 * * @param principals * @param permission * @return */ @Override public boolean isPermitted(PrincipalCollection principals, String permission) { String username = JWTUtil.getUsername(principals.getPrimaryPrincipal().toString()); //Constant常數檔案設定: //public static String SYSTEM_SUPER_ADMIN = "admin";系統超級管理員 return Constant.SYSTEM_SUPER_ADMIN.equals(username) || super.isPermitted(principals, permission); } @Override public boolean hasRole(PrincipalCollection principals, String roleIdentifier) { String username = JWTUtil.getUsername(principals.getPrimaryPrincipal().toString()); return Constant.SYSTEM_SUPER_ADMIN.equals(username) || super.hasRole(principals, roleIdentifier); } }
import com.hxkg.datafusion.common.jwt.JWTFilter; import com.hxkg.datafusion.util.ShiroRealm; import org.apache.shiro.mgt.DefaultSessionStorageEvaluator; import org.apache.shiro.mgt.DefaultSubjectDAO; import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import javax.servlet.Filter; import java.util.HashMap; import java.util.Map; /** * shiro設定 * * @author yangfeng * @date 2019.7.14 */ @Configuration public class ShiroConfig { @Bean("securityManager") public DefaultWebSecurityManager securityManager(ShiroRealm shiroRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // 使用自己的realm securityManager.setRealm(shiroRealm); /* * 關閉shiro自帶的session,詳情見檔案 * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29 */ DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); return securityManager; } @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); // 新增自己的過濾器並且取名為jwt Map<String, Filter> filterMap = new HashMap<>(); filterMap.put("jwt", new JWTFilter()); factoryBean.setFilters(filterMap); factoryBean.setSecurityManager(securityManager); factoryBean.setUnauthorizedUrl("/401"); /* * 自定義url規則 * http://shiro.apache.org/web.html#urls- */ Map<String, String> filterRuleMap = new HashMap<>(); filterRuleMap.put("/pc/login/doLogin", "anon"); filterRuleMap.put("/pc/login/logout", "anon"); filterRuleMap.put("/pc/login/noLogin", "anon"); filterRuleMap.put("/**", "authc"); // 所有請求通過我們自己的JWT Filter filterRuleMap.put("/**", "jwt"); factoryBean.setLoginUrl("/pc/login/noLogin");//沒有登入的使用者請求需要登入的資源時自動跳轉到該路徑 factoryBean.setUnauthorizedUrl("/pc/login/unauthorized");//沒有許可權預設跳轉 factoryBean.setFilterChainDefinitionMap(filterRuleMap); return factoryBean; } /** * 開啟Shiro的註解(如@RequiresRoles,@RequiresPermissions),需藉助SpringAOP掃描使用Shiro註解的類,並在必要時進行安全邏輯驗證 * @param securityManager * @return */ /** * 下面的程式碼是新增註解支援 */ @Bean @DependsOn("lifecycleBeanPostProcessor") public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); defaultAdvisorAutoProxyCreator.setProxyTargetClass(true); return defaultAdvisorAutoProxyCreator; } @Bean public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } } 除了登入doLogin、退出logout、沒有登入跳轉登入noLogin方法外,其他所有請求通過我們自己的JWT Filter。
import com.hxkg.datafusion.common.jwt.JWTUtil; import com.hxkg.datafusion.entity.customized.UserAO; import com.hxkg.datafusion.entity.customized.VUserPrivilegeAO; import com.hxkg.datafusion.service.IUserService; import com.hxkg.datafusion.service.IVUserPrivilegeService; import com.hxkg.datafusion.util.*; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Date; import java.util.List; /** * 登入 * * @author yangfeng * @date 2019-07-11 16:02 */ @RestController @RequestMapping(value = "pc/login") public class LoginController { private static Logger LOG = LoggerFactory.getLogger(LoginController.class); @Resource private IUserService userService; @Resource private IVUserPrivilegeService vUserPrivilegeService; /** * 未登陸認證 * * @param message * @return */ @RequestMapping(value = "noLogin", method = {RequestMethod.GET, RequestMethod.POST}) @ResponseBody public Object noLogin(String message) { ServiceResult<String> genResult = ServiceResultHelper.genResult(false, Constant.NO_AUTHENTICATION, StringUtils.isNotEmpty(message) ? message : "使用者登入資訊已失效,請重新登入後再試。", null); return genResult; } /** * 未授權 * * @return */ @RequestMapping(value = "unauthorized", method = {RequestMethod.GET, RequestMethod.POST}) public Object unauthorized() { return ServiceResultHelper.genResult(false, Constant.ErrorCode.PERMISSION_DENIED_CODE, Constant.ErrorCode.PERMISSION_DENIED_MSG, null); } /** * 登入操作 * * @param username * @param password * @param request * @param response * @return */ @RequestMapping(value = "doLogin", method = {RequestMethod.GET, RequestMethod.POST}) public Object doLogin(String username, String password, Boolean rememberMe, HttpServletRequest request, HttpServletResponse response) { ServiceResult<UserAO> genResult; // 獲取鹽值 String salt; UserAO loginUser = userService.getUserByName(username); if (loginUser == null) { genResult = ServiceResultHelper.genResult(false, Constant.ErrorCode.USER_NOT_EXIST_ERROR, Constant.ErrorCode.USER_NOT_EXIST_ERROR_MSG, null); return genResult; } if (Constant.DISABLE.equals(loginUser.getEnabled())) { genResult = ServiceResultHelper.genResult(false, Constant.ErrorCode.USER_DISABLE_ERROR, Constant.ErrorCode.USER_DISABLE_ERROR_MSG, null); return genResult; } salt = loginUser.getSalt(); password = MD5Util.MD5Encode(password + salt); UserAO user = userService.getUserByNameAndPwd(username, password); if (user != null) { genResult = ServiceResultHelper.genResult(true, Constant.SUCCESS, "登入成功", user); //獲取許可權 List<VUserPrivilegeAO> userPrivileges = vUserPrivilegeService.queryPrivilegeByUserName(username); user.setPrivileges(userPrivileges); //更新登入資訊 user.setLastLoginTime(DateTimeUtil.format(new Date(), DateTimeUtil.YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)); user.setLastLoginIp(IPUtil.getClientIp(request)); userService.saveOrUpdate(user); genResult.setAdditionalProperties("token", JWTUtil.sign(username, user.getId())); } else { genResult = ServiceResultHelper.genResult(false, Constant.ErrorCode.PASSWORD_ERROR, Constant.ErrorCode.PASSWORD_ERROR_MSG, null); } return genResult; } /** * 退出登入 * * @param request * @param response * @return */ @RequestMapping(value = "logout", method = {RequestMethod.GET, RequestMethod.POST}) public Object logout(HttpServletRequest request, HttpServletResponse response) { Subject currentUser = SecurityUtils.getSubject(); currentUser.logout(); CookieUtil cookieUtil = new CookieUtil(request, response, 0); cookieUtil.deleteCookie(Constant.SESSION_CURRENT_USER); cookieUtil.deleteCookie("JSESSIONID"); ServiceResult<Object> ret = new ServiceResult<Object>(); ret.setMsg("退出登入成功"); ret.setCode(Constant.SUCCESS); ret.setData(null); ret.setSucceed(true); return ret; } }
登入完成一系列的檢查,成功後建立jwt token。
import com.hxkg.datafusion.util.Constant; import com.hxkg.datafusion.util.ServiceResultHelper; import org.apache.shiro.authz.UnauthenticatedException; import org.apache.shiro.authz.UnauthorizedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * 例外處理 * * @author yangfeng * @date 2019-09-11 */ @ControllerAdvice public class WebExceptionHandler { private static Logger LOG = LoggerFactory.getLogger(WebExceptionHandler.class); @ExceptionHandler(UnauthorizedException.class) @ResponseBody public Object handleUnauthorizedException(Exception ex, HttpServletRequest request, HttpServletResponse response) { LOG.error("{}", ex.getMessage()); return ServiceResultHelper.genResult(false, Constant.ErrorCode.PERMISSION_DENIED_CODE, Constant.ErrorCode.PERMISSION_DENIED_MSG, null); } @ExceptionHandler(UnauthenticatedException.class) @ResponseBody public Object handleUnauthenticatedException(Exception ex, HttpServletRequest request, HttpServletResponse response) { LOG.error("{}", ex.getMessage()); return ServiceResultHelper.genResult(false, Constant.ErrorCode.INVALID_LOGIN_CODE, Constant.ErrorCode.INVALID_LOGIN_MSG, null); } @ExceptionHandler(Exception.class) @ResponseBody public Object handleException(Exception ex, HttpServletRequest request, HttpServletResponse response) { LOG.error("{}", ex.getMessage()); return ServiceResultHelper.genResult(false, Constant.ErrorCode.SERVER_ERROR_CODE, Constant.ErrorCode.SERVER_ERROR_MSG, null); } }
處理未登入、未授權等異常,返回相應的程式碼,方便前端捕獲跳轉登入頁面,或者作出提示,而不會直接丟擲伺服器異常導致提示不夠明確。
到此後端已經全部寫完。接下來是vue部分。
localStorage.setItem('token', token);
//封裝請求 function xAxios(options) { let opts = {...options}; let token = localStorage.getItem('token') if (token) { opts.headers['Authorization'] = token }
因為後端jetFilter登入異常會進行重定向,所以nginx需要加上前端的代理設定。
到此這篇關於vue+springboot+shiro+jwt實現登入的文章就介紹到這了,更多相關springboot jwt實現登入內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45