From e18463279b598e1dbdd53a4626931d3ab8b3d575 Mon Sep 17 00:00:00 2001
From: dzq <dzq@ys.com>
Date: Fri, 11 Apr 2025 11:01:05 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95):=20?=
 =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95=E7=9B=B8?=
 =?UTF-8?q?=E5=85=B3=E6=8E=A5=E5=8F=A3=E5=8F=8A=E6=96=87=E6=A1=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

添加微信登录控制器,实现获取微信登录二维码、临时token、短信验证码发送与验证、用户退出登录等功能。同时新增相关接口文档,详细描述各接口的请求与响应格式。
---
 .../api/controller/WxLoginController.java     |  94 +++++++
 .../domain/ab98/api/Ab98ApiUtil.java          | 234 ++++++++++++++++++
 doc/汇邦数字身份平台登录接口文档.md           | 232 +++++++++++++++++
 3 files changed, 560 insertions(+)
 create mode 100644 agileboot-api/src/main/java/com/agileboot/api/controller/WxLoginController.java
 create mode 100644 agileboot-domain/src/main/java/com/agileboot/domain/ab98/api/Ab98ApiUtil.java
 create mode 100644 doc/汇邦数字身份平台登录接口文档.md

diff --git a/agileboot-api/src/main/java/com/agileboot/api/controller/WxLoginController.java b/agileboot-api/src/main/java/com/agileboot/api/controller/WxLoginController.java
new file mode 100644
index 0000000..29fe6b1
--- /dev/null
+++ b/agileboot-api/src/main/java/com/agileboot/api/controller/WxLoginController.java
@@ -0,0 +1,94 @@
+package com.agileboot.api.controller;
+
+import com.agileboot.common.core.dto.ResponseDTO;
+import com.agileboot.common.exception.ApiException;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import com.agileboot.domain.ab98.api.Ab98ApiUtil;
+
+import javax.validation.constraints.NotBlank;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/wx/login")
+@CrossOrigin(origins = "*", allowedHeaders = "*")
+@RequiredArgsConstructor
+@Api(tags = "微信登录接口")
+public class WxLoginController {
+
+    @PostMapping("/logout")
+    @ApiOperation(value = "用户退出登录")
+    public ResponseDTO<Ab98ApiUtil.LogoutResponse> logout(@RequestParam @NotBlank String token) {
+        try {
+            return ResponseDTO.ok(Ab98ApiUtil.doLogout(token));
+        } catch (ApiException e) {
+            return ResponseDTO.fail(e);
+        }
+    }
+
+    /**
+     * 获取微信登录二维码链接
+     */
+    @GetMapping("/wechat/qrcode")
+    public ResponseDTO<String> getWechatQrCode(@RequestParam @NotBlank String token) {
+        try {
+            return ResponseDTO.ok(Ab98ApiUtil.generateWechatLoginUrl(token));
+        } catch (ApiException e) {
+            return ResponseDTO.fail(e);
+        }
+    }
+
+
+    /**
+     * 获取临时token
+     */
+    @GetMapping("/getToken")
+    @ApiOperation(value = "获取临时令牌", notes = "用于后续登录流程")
+    public ResponseDTO<Ab98ApiUtil.TokenResponse> getToken(@RequestParam String appName) {
+        try {
+            return ResponseDTO.ok(Ab98ApiUtil.getToken(appName));
+        } catch (ApiException e) {
+            return ResponseDTO.fail(e);
+        }
+    }
+
+    /**
+     * 发送短信验证码
+     */
+    @PostMapping("/sendSms")
+    public ResponseDTO<Ab98ApiUtil.SmsSendResponse> sendSms(
+        @RequestParam String token,
+        @RequestParam String tel) {
+        try {
+            return ResponseDTO.ok(Ab98ApiUtil.sendLoginSms(token, tel));
+        } catch (ApiException e) {
+            return ResponseDTO.fail(e);
+        }
+    }
+
+    /**
+     * 验证短信验证码
+     */
+    @PostMapping("/verifySms")
+    public ResponseDTO<Ab98ApiUtil.LoginData> verifySms(
+        @RequestParam String token,
+        @RequestParam String tel,
+        @RequestParam String vcode) {
+        try {
+            Ab98ApiUtil.LoginResponse loginResponse = Ab98ApiUtil.verifySmsCode(token, tel, vcode);
+            Ab98ApiUtil.LoginData data = new Ab98ApiUtil.LoginData();
+            data.setFace_img(loginResponse.getOutputData().getFace_img());
+            data.setSuccess(loginResponse.getOutputData().isSuccess());
+            data.setSex(loginResponse.getOutputData().getSex());
+            data.setName(loginResponse.getOutputData().getName());
+            data.setUserid(loginResponse.getOutputData().getUserid());
+            data.setRegistered(loginResponse.getOutputData().isRegistered());
+            data.setTel(loginResponse.getOutputData().getTel());
+            return ResponseDTO.ok(data);
+        } catch (ApiException e) {
+            return ResponseDTO.fail(e);
+        }
+    }
+}
diff --git a/agileboot-domain/src/main/java/com/agileboot/domain/ab98/api/Ab98ApiUtil.java b/agileboot-domain/src/main/java/com/agileboot/domain/ab98/api/Ab98ApiUtil.java
new file mode 100644
index 0000000..36d8c93
--- /dev/null
+++ b/agileboot-domain/src/main/java/com/agileboot/domain/ab98/api/Ab98ApiUtil.java
@@ -0,0 +1,234 @@
+package com.agileboot.domain.ab98.api;
+
+import cn.hutool.http.HttpUtil;
+import cn.hutool.json.JSONUtil;
+import com.agileboot.common.exception.ApiException;
+import com.agileboot.common.exception.error.ErrorCode;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+public class Ab98ApiUtil {
+
+    private static final String BASE_URL = "https://www.ab98.cn/api/doInterface";
+    private static final String WEBSOCKET_URL = "wss://www.ab98.cn/login.ws/";
+
+    /**
+     * 短信登录(发送验证码)
+     */
+    /**
+     * 发送短信验证码(短信登录)
+     * @param token 通过getToken获取的临时令牌(有效期5分钟)
+     * @param tel 接收验证码的手机号码(需符合格式:11位数字)
+     * @param nobind "true"表示不绑定手机号(固定值)
+     * @param for_login "true"表示用于登录(固定值)
+     * @param from 来源渠道(固定值"jt")
+     */
+    public static SmsSendResponse sendLoginSms(String token, String tel) {
+        String url = BASE_URL + "?code=doSendSms";
+        
+        Map<String, Object> paramMap = new HashMap<String, Object>() {{
+            put("token", token);
+            put("tel", tel);
+            put("nobind", "true");
+            put("for_login", "true");
+            put("from", "jt");
+        }};
+
+        String response = HttpUtil.createPost(url)
+            .body(JSONUtil.toJsonStr(paramMap))
+            .header("noSign", "true")
+            .header("source", "api")
+            .execute().body();
+        log.info("短信发送响应: {}", response);
+
+        SmsSendResponse resp = JSONUtil.toBean(response, SmsSendResponse.class);
+        checkApiResponse(resp);
+        return resp;
+    }
+
+    /**
+     * 验证短信验证码
+     */
+    /**
+     * 验证短信验证码(短信登录)
+     * @param token 通过getToken获取的临时令牌
+     * @param tel 接收验证码的手机号码
+     * @param vcode 用户输入的6位验证码(有效期5分钟)
+     * @return 登录结果,包含用户身份信息
+     * @throws ApiException 当验证失败或接口返回异常时抛出
+     */
+    public static LoginResponse verifySmsCode(String token, String tel, String vcode) {
+        String url = BASE_URL + "?code=doCheckSmsCode";
+
+        Map<String, Object> paramMap = new HashMap<String, Object>() {{
+            put("token", token);
+            put("tel", tel);
+            put("vcode", vcode);
+        }};
+
+        String response = HttpUtil.createPost(url)
+            .body(JSONUtil.toJsonStr(paramMap))
+            .header("noSign", "true")
+            .header("source", "api")
+            .execute().body();
+        log.info("短信验证响应: {}", response);
+
+        LoginResponse resp = JSONUtil.toBean(response, LoginResponse.class);
+        checkApiResponse(resp);
+        return handleLoginResult(resp);
+    }
+
+    /**
+     * 处理登录结果
+     */
+    private static LoginResponse handleLoginResult(LoginResponse response) {
+        if (response.getOutputData() == null || !response.getOutputData().isSuccess()) {
+            log.error("登录失败: {}", response);
+            throw new ApiException(ErrorCode.FAILED, "登录验证失败");
+        }
+        return response;
+    }
+
+    /**
+     * 生成微信扫码登录URL
+     */
+    public static String generateWechatLoginUrl(String token) {
+        return "https://www.ab98.cn/online/index.html?content=doLogin%60" + token;
+    }
+
+    /**
+     * 监听扫码登录websocket
+     */
+    /*public static void listenLoginWebsocket(String token, WebSocketListener listener) {
+        String url = WEBSOCKET_URL + token;
+        HttpUtil.createWebSocket(url, listener).connect();
+    }*/
+
+    /**
+     * 获取登录token
+     */
+    /**
+     * 获取登录token
+     * @param appName 应用英文名(需在汇邦数字平台登记)
+     * @return outputData.token 临时令牌(后续接口需携带)
+     * @return outputData.takeFace 是否需要人脸验证(true需要刷脸)
+     */
+    public static TokenResponse getToken(String appName) {
+//        String url = BASE_URL + "?code=doGetToken&from=jt&app=" + appName;
+        String url = BASE_URL + "?code=doGetToken&from=jt";
+        
+        String response = HttpUtil.createGet(url)
+            .header("noSign", "true")
+            .header("source", "api")
+            .execute().body();
+        log.info("获取token响应: {}", response);
+        
+        TokenResponse tokenResponse = JSONUtil.toBean(response, TokenResponse.class);
+        checkApiResponse(tokenResponse);
+        return tokenResponse;
+    }
+
+    /**
+     * 构建带参数的请求URL
+     */
+
+
+    /**
+     * 检查接口响应状态
+     */
+    private static void checkApiResponse(BaseResponse response) {
+        if (response.getStateCode() != 200 || !"ok".equals(response.getState())) {
+            log.error("接口调用失败: {}", response);
+            throw new ApiException(ErrorCode.FAILED, "第三方接口调用失败");
+        }
+    }
+
+    // 基础响应对象
+    @Data
+    public static class BaseResponse {
+        private String state;
+        private Integer stateCode;
+        private Object outputData;
+    }
+
+    @Data
+    public static class SmsSendResponse extends BaseResponse {
+        private SmsResult outputData;
+    }
+
+    @Data
+    public static class SmsResult {
+        private boolean success;
+        private String errMsg;
+    }
+
+    @Data
+    public static class LoginResponse extends BaseResponse {
+        private LoginData outputData;
+    }
+
+    @Data
+    public static class LoginData {
+        private String idcard_back;   // 身份证背面照片地址
+        private String face_img;      // 人脸照片地址
+        private String address;       // 身份证登记地址
+        private String nation;        // 民族(接口文档显示可能为null)
+        private boolean success;      // 验证是否成功
+        private String sex;           // 性别(男/女)
+        private String name;          // 真实姓名
+        private String userid;        // 用户在平台的唯一ID
+        private boolean registered;   // 是否已注册
+        private String tel;           // 手机号码
+        private String idnum;         // 身份证号码
+        private String idcard_front;  // 身份证正面照片地址
+    }
+
+    
+    /**
+     * 用户退出接口
+     */
+    public static LogoutResponse doLogout(String token) {
+        String url = BASE_URL + "?code=doLogout";
+        
+        Map<String, Object> paramMap = new HashMap<String, Object>() {{
+            put("token", token);
+        }};
+
+        String response = HttpUtil.createPost(url)
+            .body(JSONUtil.toJsonStr(paramMap))
+            .header("noSign", "true")
+            .header("source", "api")
+            .execute().body();
+        log.info("用户退出响应: {}", response);
+
+        LogoutResponse resp = JSONUtil.toBean(response, LogoutResponse.class);
+        checkApiResponse(resp);
+        return resp;
+    }
+
+    @Data
+    public static class LogoutResponse extends BaseResponse {
+        private LogoutData outputData; // 包含success和logoutTime(登出时间)
+    }
+
+    @Data
+    public static class LogoutData {
+        private boolean success;
+        private String logoutTime;
+    }
+
+    // Token响应对象
+    @Data
+    public static class TokenResponse extends BaseResponse {
+        private TokenData outputData;
+    }
+
+    @Data
+    public static class TokenData {
+        private String token;
+        private boolean takeFace;
+    }
+}
\ No newline at end of file
diff --git a/doc/汇邦数字身份平台登录接口文档.md b/doc/汇邦数字身份平台登录接口文档.md
new file mode 100644
index 0000000..2b41270
--- /dev/null
+++ b/doc/汇邦数字身份平台登录接口文档.md
@@ -0,0 +1,232 @@
+## 请求概述
+### 请求header
+所有请求必须包含以下两个header:
+*   `noSign`: `true`
+*   `source`: `api`
+获取登录token后,也可以在header中添加:
+*   `token`: `${token}`
+### 登录校验结果
+接口返回结果为json格式,如果包含`code`字段,则说明登录校验失败。
+*   `code`: `0001`  - 登录状态已失效
+*   `code`: `0002`  - 在cookie、Get参数、Header中均未检测到token
+## 登录接口
+### 2.1 获取token
+*   **API**: `https://www.ab98.cn/api/doInterface?code=doGetToken&from=jt&app=${appname}`
+*   **请求方式**: GET
+*   **请求参数**:
+    *   `code`: `doGetToken` (必传)
+    *   `from`: `jt` (可选)
+    *   `app`: 调用该接口的应用在汇邦数字平台登记的应用英文名 (可选)
+**成功返回**:
+```json
+{
+    "outputData": {
+        "token": "1a6ea39e84c406283839856640e3aa66",
+        "takeFace": true
+    },
+    "state": "ok"
+}
+```
+**失败返回**:
+```json
+{
+    "outputData": "错误信息",
+    "stateCode": 0,
+    "state": "fail"
+}
+```
+### 2.2 短信登录
+#### 2.2.1 发送短信
+*   **API**: `https://www.ab98.cn/api/doInterface?code=doSendSms`
+*   **请求方式**: POST
+*   **Content-Type**: application/json
+*   **请求BODY**:
+```json
+{
+    "token":"358900e1005c33a1dd059b07042ceec3", //必传
+    "tel":"137xxxxxxxx", //必传
+    "nobind":"true",      //必传
+    "for_login":"true",    //必传
+    "from":"jt"         //必传
+}
+```
+**成功返回**:
+```json
+{
+    "outputData": {
+        "success": true
+    },
+    "state": "ok"
+}
+```
+**失败返回**:
+```json
+{
+    "outputData": {
+        "success": false,
+        "errMsg": "失败原因"
+    },
+    "state": "ok"
+}
+```
+#### 2.2.2 验证短信验证码
+*   **API**: `https://www.ab98.cn/api/doInterface?code=doCheckSmsCode`
+*   **请求方式**: POST
+*   **Content-Type**: application/json
+*   **请求BODY**:
+```json
+{
+    "token":"358900e1005c33a1dd059b07042ceec3",
+    "tel":"137xxxxxxxx",
+    "vcode":"123456"
+}
+```
+**成功返回**:
+```json
+{
+    "outputData": {
+        "idcard_back": "https://www.ab98.cn/upload/temp/images/202102/xxx.jpeg",
+        "face_img": "https://www.ab98.cn/upload/temp/images/202102/yyy.jpeg",
+        "address": "身份证上的地址",
+        "nation": null,
+        "success": true,
+        "sex": "男",
+        "name": "xxx",
+        "userid": 123,
+        "registered": true,
+        "tel": "137xxxxxxxx",
+        "idnum": "450981xxxxxxxxxxxx",
+        "idcard_front": "https://www.ab98.cn/upload/temp/images/202102/zzz.jpeg"
+    },
+    "state": "ok"
+}
+```
+**失败返回**:
+```json
+{
+    "outputData": {
+        "success": false
+    },
+    "state": "ok"
+}
+```
+### 2.3 微信扫码登录
+*   **API**: `https://www.ab98.cn/online/index.html?content=doLogin%60`
+#### 2.3.1 二维码
+假设获取到的token为`358900e1005c33a1dd059b07042ceec3`,则构造的最终链接为:
+`https://www.ab98.cn/online/index.html?content=doLogin%60358900e1005c33a1dd059b07042ceec3`
+使用该链接生成二维码图片供用户使用微信“扫一扫”。
+#### 2.3.2 监听websocket
+*   **监听地址**: `wss://www.ab98.cn/login.ws/${token}`
+*   **登录失败**: 
+```json
+{
+"outputData": "登录失败原因",
+"state": "fail"
+}
+```
+*   **登录成功**:
+```json
+{
+"outputData": "登录成功",
+"state": "ok",
+"username": "扫码者姓名",
+"sex": "扫码者性别",
+"head_img": "扫码者头像",
+"idnum": "身份证号码",
+"userid": "人员记录ID",
+"tel": "手机号码"
+}
+```
+### 2.4 刷脸登录
+*   **API**: `https://www.ab98.cn/api/doInterface?code=doStrongFaceLogin`
+*   **请求方式**: POST
+*   **Content-Type**: application/json
+*   **请求BODY**:
+```json
+{
+"token":"358900e1005c33a1dd059b07042ceec3", //必传
+"check_code":"1234", //必传
+"imgBase64":"/9j/4AAQS…" //必传
+}
+```
+**成功返回**:
+```json
+{
+"state": "ok",
+"outputData": "登录成功",
+"openid": "oGfFD1jBEZ2sq4PhOc8zKKejHA9E",
+"head_img": "https://www.ab98.cn/… ",
+"sex": "男",
+"userid": 1,
+"token": "dbe11ef64cd6350c5d935d531db765c0",
+"username": "xxx",
+"tel": "xxx"
+}
+```
+### 2.5 动态码登录
+1.  **发起websocket监听**:
+`wss://www.ab98.cn/login.ws/{监听token}`
+其中 `{监听token}` 由客户端自定义,由数字和英文字母组成,保证多用户同时监听时 `{监听token}` 互不相同即可,否则会接收到错误消息。
+2.  **主动发送消息**:
+```json
+{
+"type": "mfa",
+"do": "checkCode",
+"token": "xxx",
+"code": "000000"
+}
+```
+**服务器响应消息**:
+1.  **登录失败**:
+```json
+{
+"state": "fail",
+"msg": "失败原因"
+}
+```
+2.  **动态码校验通过,等待移动端授权登录**:
+```json
+{
+"state": "ok",
+"step": "0"
+}
+```
+3.  **等待移动端授权超时**:
+```json
+{
+"state": "ok",
+"step": "1"
+}
+```
+4.  **移动端已授权登录**:
+```json
+{
+"state": "ok",
+"step": "2",
+"openid": "xxx",
+"head_img": "/xxx",
+"sex": "男/女",
+"company": "xxx",
+"userid": 123,
+"idnum": "xxx",
+"username": "xxx",
+"tel": "xxx"
+}
+```
+### 2.6 退出登录
+*   **API**: `https://www.ab98.cn/api/doInterface?code=doLogout`
+*   **请求方式**: POST
+*   **Content-Type**: application/json
+*   **请求BODY**:
+```json
+{
+"token":"358900e1005c33a1dd059b07042ceec3"
+}
+```
+**成功返回**:
+```json
+{
+"state": "ok"
+}
+```
\ No newline at end of file