基本介紹
相信大家對二維碼都不陌生,生活中到處充斥著掃碼登錄的場景,如登錄網頁版微信、支付寶等,最近學習了一下掃碼登錄的原理,感覺蠻有趣的,于是自己實作了一個簡易版掃碼登錄的 Demo,以此記錄一下學習程序,
實際上是面試的時候被問到了  ̄△ ̄!
原理決議
1. 身份認證機制
在介紹掃碼登錄的原理之前,我們先聊一聊服務端的身份認證機制,以普通的 賬號 + 密碼 登錄方式為例,服務端收到用戶的登錄請求后,首先驗證賬號、密碼的合法性,如果驗證通過,那么服務端會為用戶分配一個 token,該 token 與用戶的身份資訊相關聯,可作為用戶的登錄憑證,之后 PC 端再次發送請求時,需要在請求的 Header 或者 Query 引數中攜帶 token,服務端根據 token 便可識別出當前用戶,token 的優點是更加方便、安全,它降低了賬號密碼被劫持的風險,而且用戶不需要重復地輸入賬號和密碼,PC 端通過賬號和密碼登錄的程序如下:

掃碼登錄本質上也是一種身份認證方式,賬號 + 密碼 登錄與掃碼登錄的區別在于,前者是利用 PC 端的賬號和密碼為 PC 端申請一個 token,后者是利用 手機端的 token + 設備資訊 為 PC 端申請一個 token,這兩種登錄方式的目的相同,都是為了使 PC 端獲得服務端的 "授權",在為 PC 端申請 token 之前,二者都需要向服務端證明自己的身份,也就是必須讓服務端知道當前用戶是誰,這樣服務端才能為其生成 PC 端 token,由于掃碼前手機端一定是處于已登錄狀態的,因此手機端本身已經保存了一個 token,該 token 可用于服務端的身份識別,那么為什么手機端在驗證身份時還需要設備資訊呢?實際上,手機端的身份認證和 PC 端略有不同:
-
手機端在登錄前也需要輸入賬號和密碼,但登錄請求中除了賬號密碼外還包含著設備資訊,例如設備型別、設備 id 等,
-
接收到登錄請求后,服務端會驗證賬號和密碼,驗證通過后,將用戶資訊與設備資訊關聯起來,也就是將它們存盤在一個資料結構 structure 中,
-
服務端為手機端生成一個 token,并將 token 與用戶資訊、設備資訊關聯起來,即以 token 為 key,structure 為 value,將該鍵值對持久化保存到本地,之后將 token 回傳給手機端,
-
手機端發送請求,攜帶 token 和設備資訊,服務端根據 token 查詢出 structure,并驗證 structure 中的設備資訊和手機端的設備資訊是否相同,以此判斷用戶的有效性,
我們在 PC 端登錄成功后,可以短時間內正常瀏覽網頁,但之后訪問網站時就要重新登陸了,這是因為 token 是有過期時間的,較長的有效時間會增大 token 被劫持的風險,但是,手機端好像很少有這種問題,例如微信登錄成功后可以一直使用,即使關閉微信或重啟手機,這是因為設備資訊具有唯一性,即使 token 被劫持了,由于設備資訊不同,攻擊者也無法向服務端證明自己的身份,這樣大大提高了安全系數,因此 token 可以長久使用,手機端通過賬號密碼登錄的程序如下:

2. 流程概述
了解了服務端的身份認證機制后,我們再聊一聊掃碼登錄的整個流程,以網頁版微信為例,我們在 PC 端點擊二維碼登錄后,瀏覽器頁面會彈出二維碼圖片,此時打開手機微信掃描二維碼,PC 端隨即顯示 "正在掃碼",手機端點擊確認登錄后,PC 端就會顯示 "登陸成功" 了,
上述程序中,服務端可以根據手機端的操作來回應 PC 端,那么服務端是如何將二者關聯起來的呢?答案就是通過 "二維碼",嚴格來說是通過二維碼中的內容,使用二維碼解碼器掃描網頁版微信的二維碼,可以得到如下內容:

由上圖我們得知,二維碼中包含的其實是一個網址,手機掃描二維碼后,會根據該網址向服務端發送請求,接著,我們打開 PC 端瀏覽器的開發者工具:

可見,在顯示出二維碼之后,PC 端一直都沒有 "閑著",它通過輪詢的方式不斷向服務端發送請求,以獲知手機端操作的結果,這里我們注意到,PC 端發送的 URL 中有一個引數 uuid,值為 "Adv-NP1FYw==",該 uuid 也存在于二維碼包含的網址中,由此我們可以推斷,服務端在生成二維碼之前會先生成一個二維碼 id,二維碼 id 與二維碼的狀態、過期時間等資訊系結在一起,一同存盤在服務端,手機端可以根據二維碼 id 操作服務端二維碼的狀態,PC 端可以根據二維碼 id 向服務端詢問二維碼的狀態,
二維碼最初為 "待掃描" 狀態,手機端掃碼后服務端將其狀態改為 "待確認" 狀態,此時 PC 端的輪詢請求到達,服務端向其回傳 "待確認" 的回應,手機端確認登錄后,二維碼變成 "已確認" 狀態,服務端為 PC 端生成用于身份認證的 token,PC 端再次詢問時,就可以得到這個 token,整個掃碼登錄的流程如下圖所示:

-
PC 端發送 "掃碼登錄" 請求,服務端生成二維碼 id,并存盤二維碼的過期時間、狀態等資訊,
-
PC 端獲取二維碼并顯示,
-
PC 端開始輪詢檢查二維碼的狀態,二維碼最初為 "待掃描" 狀態,
-
手機端掃描二維碼,獲取二維碼 id,
-
手機端向服務端發送 "掃碼" 請求,請求中攜帶二維碼 id、手機端 token 以及設備資訊,
-
服務端驗證手機端用戶的合法性,驗證通過后將二維碼狀態置為 "待確認",并將用戶資訊與二維碼關聯在一起,之后為手機端生成一個一次性 token,該 token 用作確認登錄的憑證,
-
PC 端輪詢時檢測到二維碼狀態為 "待確認",
-
手機端向服務端發送 "確認登錄" 請求,請求中攜帶著二維碼 id、一次性 token 以及設備資訊,
-
服務端驗證一次性 token,驗證通過后將二維碼狀態置為 "已確認",并為 PC 端生成 PC 端 token,
-
PC 端輪詢時檢測到二維碼狀態為 "已確認",并獲取到了 PC 端 token,之后 PC 端不再輪詢,
-
PC 端通過 PC 端 token 訪問服務端,
上述程序中,我們注意到,手機端掃碼后服務端會回傳一個一次性 token,該 token 也是一種身份憑證,但它只能使用一次,一次性 token 的作用是確保 "掃碼請求" 與 "確認登錄" 請求由同一個手機端發出,也就是說,手機端用戶不能 "幫其他用戶確認登錄",
關于一次性 token 的知識本人也不是很了解,但可以推測,在服務端的快取中,一次性 token 映射的 value 應該包含 "掃碼" 請求傳入的二維碼資訊、設備資訊以及用戶資訊,
代碼實作
1. 環境準備
-
JDK 1.8:專案使用 Java 語言撰寫,
-
Maven:依賴管理,
-
Redis:Redis 既作為資料庫存盤用戶的身份資訊(為了簡化操作未使用 MySQL),也作為快取存盤二維碼資訊、token 資訊等,
2. 主要依賴
-
SpringBoot:專案基本環境,
-
Hutool:開源工具類,其中的 QrCodeUtil 可用于生成二維碼圖片,
-
Thymeleaf:模板引擎,用于頁面渲染,
3. 生成二維碼
二維碼的生成以及二維碼狀態的保存邏輯如下:
@RequestMapping(path = "/getQrCodeImg", method = RequestMethod.GET)
public String createQrCodeImg(Model model) {
String uuid = loginService.createQrImg();
String qrCode = Base64.encodeBase64String(QrCodeUtil.generatePng("http://127.0.0.1:8080/login/uuid=" + uuid, 300, 300));
model.addAttribute("uuid", uuid);
model.addAttribute("QrCode", qrCode);
return "login";
}
PC 端訪問 "登錄" 請求時,服務端呼叫 createQrImg 方法,生成一個 uuid 和一個 LoginTicket 物件,LoginTicket 物件中封裝了用戶的 userId 和二維碼的狀態,然后服務端將 uuid 作為 key,LoginTicket 物件作為 value 存入到 Redis 服務器中,并設定有效時間為 5 分鐘(二維碼的有效時間),createQrImg 方法的邏輯如下:
public String createQrImg() {
// uuid
String uuid = CommonUtil.generateUUID();
LoginTicket loginTicket = new LoginTicket();
// 二維碼最初為 WAITING 狀態
loginTicket.setStatus(QrCodeStatusEnum.WAITING.getStatus());
// 存入 redis
String ticketKey = CommonUtil.buildTicketKey(uuid);
cacheStore.put(ticketKey, loginTicket, LoginConstant.WAIT_EXPIRED_SECONDS, TimeUnit.SECONDS);
return uuid;
}
我們在前一節中提到,手機端的操作主要影響二維碼的狀態,PC 端輪詢時也是查看二維碼的狀態,那么為什么還要在 LoginTicket 物件中封裝 userId 呢?這樣做是為了將二維碼與用戶進行關聯,想象一下我們登錄網頁版微信的場景,手機端掃碼后,PC 端就會顯示用戶的頭像,雖然手機端并未確認登錄,但 PC 端輪詢時已經獲取到了當前掃碼的用戶(僅頭像資訊),因此手機端掃碼后,需要將二維碼與用戶系結在一起,使用 LoginTicket 物件只是一種實作方式,二維碼生成后,我們將其狀態置為 "待掃描" 狀態,userId 不做處理,默認為 null,
4. 掃描二維碼
手機端發送 "掃碼" 請求時,Query 引數中攜帶著 uuid,服務端接收到請求后,呼叫 scanQrCodeImg 方法,根據 uuid 查詢出二維碼并將其狀態置為 "待確認" 狀態,操作完成后服務端向手機端回傳 "掃碼成功" 或 "二維碼已失效" 的資訊:
@RequestMapping(path = "/scan", method = RequestMethod.POST)
@ResponseBody
public Response scanQrCodeImg(@RequestParam String uuid) {
JSONObject data = https://www.cnblogs.com/johnlearning/archive/2022/04/30/loginService.scanQrCodeImg(uuid);
if (data.getBoolean("valid")) {
return Response.createResponse("掃碼成功", data);
}
return Response.createErrorResponse("二維碼已失效");
}
scanQrCodeImg 方法的主要邏輯如下:
public JSONObject scanQrCodeImg(String uuid) {
// 避免多個移動端同時掃描同一個二維碼
lock.lock();
JSONObject data = https://www.cnblogs.com/johnlearning/archive/2022/04/30/new JSONObject();
try {
String ticketKey = CommonUtil.buildTicketKey(uuid);
LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
// redis 中 key 過期后也可能不會立即洗掉
Long expired = cacheStore.getExpireForSeconds(ticketKey);
boolean valid = loginTicket != null &&
QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.WAITING &&
expired != null &&
expired >= 0;
if (valid) {
User user = hostHolder.getUser();
if (user == null) {
throw new RuntimeException("用戶未登錄");
}
// 修改掃碼狀態
loginTicket.setStatus(QrCodeStatusEnum.SCANNED.getStatus());
Condition condition = CONDITION_CONTAINER.get(uuid);
if (condition != null) {
condition.signal();
CONDITION_CONTAINER.remove(uuid);
}
// 將二維碼與用戶進行關聯
loginTicket.setUserId(user.getUserId());
cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS);
// 生成一次性 token, 用于之后的確認請求
String onceToken = CommonUtil.generateUUID();
cacheStore.put(CommonUtil.buildOnceTokenKey(onceToken), uuid, LoginConstant.ONCE_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
data.put("once_token", onceToken);
}
data.put("valid", valid);
return data;
} finally {
lock.unlock();
}
}
-
首先根據 uuid 查詢 Redis 中存盤的 LoginTicket 物件,然后檢查二維碼的狀態是否為 "待掃描" 狀態,如果是,那么將二維碼的狀態改為 "待確認" 狀態,如果不是,那么該二維碼已被掃描過,服務端提示用戶 "二維碼已失效",我們規定,只允許第一個手機端能夠掃描成功,加鎖的目的是為了保證
查詢 + 修改操作的原子性,避免兩個手機端同時掃碼,且同時檢測到二維碼的狀態為 "待掃描", -
上一步操作成功后,服務端將 LoginTicket 物件中的 userId 置為當前用戶(掃碼用戶)的 userId,也就是將二維碼與用戶資訊系結在一起,由于掃碼請求是由手機端發送的,因此該請求一定來自于一個有效的用戶,我們在專案中配置一個攔截器(也可以是過濾器),當攔截到 "掃碼" 請求后,根據請求中的 token(手機端發送請求時一定會攜帶 token)查詢出用戶資訊,并將其存盤到 ThreadLocal 容器(hostHolder)中,之后系結資訊時就可以從 ThreadLocal 容器將用戶資訊提取出來,注意,這里的 token 指的手機端 token,實際中應該還有設備資訊,但為了簡化操作,我們忽略掉設備資訊,
-
用戶資訊與二維碼資訊關聯在一起后,服務端為手機端生成一個一次性 token,并存盤到 Redis 服務器,其中 key 為一次性 token 的值,value 為 uuid,一次性 token 會回傳給手機端,作為 "確認登錄" 請求的憑證,
上述代碼中,當二維碼的狀態被修改后,我們喚醒了在 condition 中阻塞的執行緒,這一步的目的是為了實作長輪詢操作,下文中會介紹長輪詢的設計思路,
5. 確認登錄
手機端發送 "確認登錄" 請求時,Query 引數中攜帶著 uuid,且 Header 中攜帶著一次性 token,服務端接收到請求后,首先驗證一次性 token 的有效性,即檢查一次性 token 對應的 uuid 與 Query 引數中的 uuid 是否相同,以確保掃碼操作和確認操作來自于同一個手機端,該驗證程序可在攔截器中配置,驗證通過后,服務端呼叫 confirmLogin 方法,將二維碼的狀態置為 "已確認":
@RequestMapping(path = "/confirm", method = RequestMethod.POST)
@ResponseBody
public Response confirmLogin(@RequestParam String uuid) {
boolean logged = loginService.confirmLogin(uuid);
String msg = logged ? "登錄成功!" : "二維碼已失效!";
return Response.createResponse(msg, logged);
}
confirmLogin 方法的主要邏輯如下:
public boolean confirmLogin(String uuid) {
String ticketKey = CommonUtil.buildTicketKey(uuid);
LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
boolean logged = true;
Long expired = cacheStore.getExpireForSeconds(ticketKey);
if (loginTicket == null || expired == null || expired == 0) {
logged = false;
} else {
lock.lock();
try {
loginTicket.setStatus(QrCodeStatusEnum.CONFIRMED.getStatus());
Condition condition = CONDITION_CONTAINER.get(uuid);
if (condition != null) {
condition.signal();
CONDITION_CONTAINER.remove(uuid);
}
cacheStore.put(ticketKey, loginTicket, expired, TimeUnit.SECONDS);
} finally {
lock.unlock();
}
}
return logged;
}
該方法會根據 uuid 查詢二維碼是否已經過期,如果未過期,那么就修改二維碼的狀態,
6. PC 端輪詢
輪詢操作指的是前端重復多次向后端發送相同的請求,以獲知資料的變化,輪詢分為長輪詢和短輪詢:
-
長輪詢:服務端收到請求后,如果有資料,那么就立即回傳,否則執行緒進入等待狀態,直到有資料到達或超時,瀏覽器收到回應后立即重新發送相同的請求,
-
短輪詢:服務端收到請求后無論是否有資料都立即回傳,瀏覽器收到回應后間隔一段時間后重新發送相同的請求,
由于長輪詢相比短輪詢能夠得到實時的回應,且更加節約資源,因此專案中我們考慮使用 ReentrantLock 來實作長輪詢,輪詢的目的是為了查看二維碼狀態的變化:
@RequestMapping(path = "/getQrCodeStatus", method = RequestMethod.GET)
@ResponseBody
public Response getQrCodeStatus(@RequestParam String uuid, @RequestParam int currentStatus) throws InterruptedException {
JSONObject data = https://www.cnblogs.com/johnlearning/archive/2022/04/30/loginService.getQrCodeStatus(uuid, currentStatus);
return Response.createResponse(null, data);
}
getQrCodeStatus 方法的主要邏輯如下:
public JSONObject getQrCodeStatus(String uuid, int currentStatus) throws InterruptedException {
lock.lock();
try {
JSONObject data = https://www.cnblogs.com/johnlearning/archive/2022/04/30/new JSONObject();
String ticketKey = CommonUtil.buildTicketKey(uuid);
LoginTicket loginTicket = (LoginTicket) cacheStore.get(ticketKey);
QrCodeStatusEnum statusEnum = loginTicket == null || QrCodeStatusEnum.parse(loginTicket.getStatus()) == QrCodeStatusEnum.INVALID ?
QrCodeStatusEnum.INVALID : QrCodeStatusEnum.parse(loginTicket.getStatus());
if (currentStatus == statusEnum.getStatus()) {
Condition condition = CONDITION_CONTAINER.get(uuid);
if (condition == null) {
condition = lock.newCondition();
CONDITION_CONTAINER.put(uuid, condition);
}
condition.await(LoginConstant.POLL_WAIT_TIME, TimeUnit.SECONDS);
}
// 用戶掃碼后向 PC 端回傳頭像資訊
if (statusEnum == QrCodeStatusEnum.SCANNED) {
User user = userService.getCurrentUser(loginTicket.getUserId());
data.put("avatar", user.getAvatar());
}
// 用戶確認后為 PC 端生成 access_token
if (statusEnum == QrCodeStatusEnum.CONFIRMED) {
String accessToken = CommonUtil.generateUUID();
cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), loginTicket.getUserId(), LoginConstant.ACCESS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
data.put("access_token", accessToken);
}
data.put("status", statusEnum.getStatus());
data.put("message", statusEnum.getMessage());
return data;
} finally {
lock.unlock();
}
}
該方法接收兩個引數,即 uuid 和 currentStatus,其中 uuid 用于查詢二維碼,currentStatus 用于確認二維碼狀態是否發生了變化,如果是,那么需要立即向 PC 端反饋,我們規定 PC 端在輪詢時,請求的引數中需要攜帶二維碼當前的狀態,
-
首先根據 uuid 查詢出二維碼的最新狀態,并比較其是否與 currentStatus 相同,如果相同,那么當前執行緒進入阻塞狀態,直到被喚醒或者超時,
-
如果二維碼狀態為 "待確認",那么服務端向 PC 端回傳掃碼用戶的頭像資訊(處于 "待確認" 狀態時,二維碼已與用戶資訊系結在一起,因此可以查詢出用戶的頭像),
-
如果二維碼狀態為 "已確認",那么服務端為 PC 端生成一個 token,在之后的請求中,PC 端可通過該 token 表明自己的身份,
上述代碼中的加鎖操作是為了能夠令當前處理請求的執行緒進入阻塞狀態,當二維碼的狀態發生變化時,我們再將其喚醒,因此上文中的掃碼操作和確認登錄操作完成后,還會有一個喚醒執行緒的程序,
實際上,加鎖操作設計得不太合理,因為我們只設定了一把鎖,因此對不同二維碼的查詢或修改操作都會搶占同一把鎖,按理來說,不同二維碼的操作之間應該是相互獨立的,即使加鎖,也應該是為每個二維碼均配一把鎖,但這樣做代碼會更加復雜,或許有其它更好的實作長輪詢的方式?或者干脆直接短輪詢,當然,也可以使用 WebSocket 實作長連接,
7. 攔截器配置
專案中配置了兩個攔截器,一個用于確認用戶的身份,即驗證 token 是否有效:
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Autowired
private CacheStore cacheStore;
@Autowired
private UserService userService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String accessToken = request.getHeader("access_token");
// access_token 存在
if (StringUtils.isNotEmpty(accessToken)) {
String userId = (String) cacheStore.get(CommonUtil.buildAccessTokenKey(accessToken));
User user = userService.getCurrentUser(userId);
hostHolder.setUser(user);
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
}
}
如果 token 有效,那么服務端根據 token 獲取用戶的資訊,并將用戶資訊存盤到 ThreadLocal 容器,手機端和 PC 端的請求都由該攔截器處理,如 PC 端的 "查詢用戶資訊" 請求,手機端的 "掃碼" 請求,由于我們忽略了手機端驗證時所需要的的設備資訊,因此 PC 端和手機端 token 可以使用同一套驗證邏輯,
另一個攔截器用于攔截 "確認登錄" 請求,即驗證一次性 token 是否有效:
@Component
public class ConfirmInterceptor implements HandlerInterceptor {
@Autowired
private CacheStore cacheStore;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String onceToken = request.getHeader("once_token");
if (StringUtils.isEmpty(onceToken)) {
return false;
}
if (StringUtils.isNoneEmpty(onceToken)) {
String onceTokenKey = CommonUtil.buildOnceTokenKey(onceToken);
String uuidFromCache = (String) cacheStore.get(onceTokenKey);
String uuidFromRequest = request.getParameter("uuid");
if (!StringUtils.equals(uuidFromCache, uuidFromRequest)) {
throw new RuntimeException("非法的一次性 token");
}
// 一次性 token 檢查完成后將其洗掉
cacheStore.delete(onceTokenKey);
}
return true;
}
}
該攔截器主要攔截 "確認登錄" 請求,需要注意的是,一次性 token 驗證通過后要立即將其洗掉,
編碼程序中,我們簡化了許多操作,例如:1. 忽略掉了手機端的設備資訊;2. 手機端確認登錄后并沒有直接為用戶生成 PC 端 token,而是在輪詢時生成,
效果演示
1. 工具準備
-
瀏覽器:PC 端操作
-
Postman:模仿手機端操作,
2. 資料準備
由于我們沒有實作真實的手機端掃碼的功能,因此使用 Postman 模仿手機端向服務端發送請求,首先我們需要確保服務端存盤著用戶的資訊,即在 Test 類中執行如下代碼:
@Test
void insertUser() {
User user = new User();
user.setUserId("1");
user.setUserName("John同學");
user.setAvatar("/avatar.jpg");
cacheStore.put("user:1", user);
}
手機端發送請求時需要攜帶手機端 token,這里我們為 useId 為 "1" 的用戶生成一個 token(手機端 token):
@Test
void loginByPhone() {
String accessToken = CommonUtil.generateUUID();
System.out.println(accessToken);
cacheStore.put(CommonUtil.buildAccessTokenKey(accessToken), "1");
}
手機端 token(accessToken)為 "aae466837d0246d486f644a3bcfaa9e1"(隨機值),之后發送 "掃碼" 請求時需要攜帶這個 token,
3. 掃碼登錄流程展示
啟動專案,訪問 localhost:8080/index:

點擊登錄,并在開發者工具中找到二維碼 id(uuid):

打開 Postman,發送 localhost:8080/login/scan 請求,Query 引數中攜帶 uuid,Header 中攜帶手機端 token:

上述請求回傳 "掃碼成功" 的回應,同時還回傳了一次性 token,此時 PC 端顯示出掃碼用戶的頭像:

在 Postman 中發送 localhost:8080/login/confirm 請求,Query 引數中攜帶 uuid,Header 中攜帶一次性 token:

"確認登錄" 請求發送完成后,PC 端隨即獲取到 PC 端 token,并成功查詢用戶資訊:

結語
本文主要介紹了掃碼登錄的原理,并實作了一個簡易版掃碼登錄的 Demo,關于原理部分的理解錯誤以及代碼中的不足之處歡迎大家批評指正(⌒.-),原始碼見掃碼登錄,如果覺得有識訓的話給個 Star 吧~,
好文推薦:
[1]. https://juejin.cn/post/6940976355097985032#heading-1
[2]. https://juejin.cn/post/6844904111398191117?utm_source=gold_browser_extension
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/468117.html
標籤:其他
上一篇:java創建目錄的示例分享
下一篇:無法除錯Blazorwasm
