Browse Source

feat(auth): 微信网站应用扫码登录(桌面端 SSO)后端

liaoxg 1 ngày trước cách đây
mục cha
commit
1906f5a8e3

+ 149 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java

@@ -0,0 +1,149 @@
+package com.etotem.cfc.controller.auth;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.WechatWebLoginService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 微信开放平台「网站应用」扫码登录(桌面端 SSO)。
+ *
+ * <p>流程:桌面端 POST /qr 拿 state+url → 用户在浏览器扫码 → 微信回调 GET /callback(带 code+state)
+ * → 服务端换 unionid 匹配 users.unionid,写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>注意:/callback 是微信服务器跳转的浏览器端点,必须用 GET——这是本项目
+ * 「统一 @PostMapping,禁止 @GetMapping」规范的唯一例外。
+ */
+@Tag(name = "微信网站应用登录", description = "桌面端微信扫码登录,与小程序账号 SSO")
+@RestController
+@Slf4j
+@RequestMapping("/api/auth/wechat-web")
+public class WechatWebAuthController {
+
+    @Resource
+    private WechatWebLoginService wechatWebLoginService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private UserService userService;
+
+    @Operation(summary = "获取扫码登录二维码链接")
+    @PostMapping("/qr")
+    public Result<Map<String, Object>> qr() {
+        String state = UUID.randomUUID().toString().replace("-", "");
+        wechatWebLoginService.putState(state, "P");
+        String url = wechatWebLoginService.buildQrConnectUrl(state);
+        Map<String, Object> data = new HashMap<>();
+        data.put("state", state);
+        data.put("url", url);
+        return Result.success(data);
+    }
+
+    @Operation(summary = "微信扫码回调(微信服务器跳转,GET)")
+    @GetMapping("/callback")
+    public void callback(@RequestParam("code") String code,
+                         @RequestParam("state") String state,
+                         HttpServletResponse response) throws IOException {
+        if (state == null || state.trim().isEmpty()) {
+            writeHtml(response, "登录失败", "缺少 state 参数");
+            return;
+        }
+        Map<String, String> info = wechatWebLoginService.exchangeCode(code);
+        String unionid = info != null ? info.get("unionid") : null;
+        if (unionid == null || unionid.trim().isEmpty()) {
+            wechatWebLoginService.putState(state, "N");
+            writeHtml(response, "登录失败", "未获取到微信 unionid(请确认网站应用与小程序已绑定同一开放平台账号)");
+            return;
+        }
+        User user = userMapper.selectOne(
+                new LambdaQueryWrapper<User>().eq(User::getUnionid, unionid).last("LIMIT 1"));
+        if (user == null) {
+            wechatWebLoginService.putState(state, "N");
+            writeHtml(response, "未找到账号", "该微信尚未绑定小程序账号,请先在小程序登录后再试");
+            return;
+        }
+        wechatWebLoginService.putState(state, "U" + user.getId());
+        log.info("微信网站应用扫码成功: state={}, userId={}", state, user.getId());
+        writeHtml(response, "扫码成功", "已登录,请返回客户端继续");
+    }
+
+    @Operation(summary = "轮询扫码登录结果")
+    @PostMapping("/poll")
+    public Result<Map<String, Object>> poll(@RequestBody Map<String, String> body) {
+        String state = body != null ? body.get("state") : null;
+        Map<String, Object> data = new HashMap<>();
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            data.put("status", "expired");
+            return Result.success(data);
+        }
+        String value = stored.toString();
+        if ("P".equals(value)) {
+            data.put("status", "pending");
+            return Result.success(data);
+        }
+        if ("N".equals(value)) {
+            wechatWebLoginService.removeState(state);
+            data.put("status", "notfound");
+            return Result.success(data);
+        }
+        if (value.startsWith("U")) {
+            wechatWebLoginService.removeState(state);
+            Long userId = Long.valueOf(value.substring(1));
+            try {
+                LoginResultDTO login = userService.ssoLogin(userId);
+                data.put("status", "success");
+                data.put("token", login.getToken());
+                data.put("userId", login.getUserId());
+                data.put("role", login.getRole());
+                data.put("nickname", login.getNickname());
+            } catch (RuntimeException e) {
+                log.warn("网站应用扫码换取 JWT 失败: state={}, error={}", state, e.getMessage());
+                data.put("status", "error");
+                data.put("message", e.getMessage() != null ? e.getMessage() : "登录失败");
+            }
+            return Result.success(data);
+        }
+        data.put("status", "error");
+        data.put("message", "未知状态");
+        return Result.success(data);
+    }
+
+    /** 输出极简结果页(微信内置浏览器/桌面浏览器展示) */
+    private void writeHtml(HttpServletResponse response, String title, String html) throws IOException {
+        response.setContentType("text/html;charset=UTF-8");
+        String page = "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">"
+                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+                + "<title>" + title + "</title></head>"
+                + "<body style=\"font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;"
+                + "text-align:center;padding-top:80px;color:#333\">"
+                + "<h2>" + title + "</h2>"
+                + "<p style=\"color:#888\">" + html + "</p></body></html>";
+        response.getWriter().write(page);
+        response.getWriter().flush();
+    }
+}

+ 155 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java

@@ -0,0 +1,155 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 微信开放平台「网站应用」扫码登录(scope=snsapi_login)。
+ *
+ * <p>与小程序共用同一微信开放平台账号时,扫码拿到的 unionid 与小程序一致,
+ * 可据此匹配 {@code users.unionid} 完成桌面端与小程序账号的 SSO。
+ */
+@Service
+public class WechatWebLoginService {
+
+    private static final Logger log = LoggerFactory.getLogger(WechatWebLoginService.class);
+
+    private static final String QRCONNECT_URL = "https://open.weixin.qq.com/connect/qrconnect";
+    private static final String OAUTH2_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
+    private static final String STATE_KEY_PREFIX = "wechat:web:login:";
+    /** state 有效期(秒),与二维码展示时长一致 */
+    private static final long STATE_TTL_SECONDS = 300;
+
+    @Value("${wechat.web.appid:}")
+    private String webAppid;
+
+    @Value("${wechat.web.secret:}")
+    private String webSecret;
+
+    @Value("${wechat.web.redirect-uri:}")
+    private String redirectUri;
+
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    private final RestTemplate restTemplate = createRestTemplate();
+
+    /** Redis 不可用时的内存兜底:state -> [value, 写入时间(ms)] */
+    private final Map<String, String[]> memory = new ConcurrentHashMap<>();
+
+    private static RestTemplate createRestTemplate() {
+        org.springframework.http.client.SimpleClientHttpRequestFactory factory =
+                new org.springframework.http.client.SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(3000);
+        factory.setReadTimeout(5000);
+        return new RestTemplate(factory);
+    }
+
+    /**
+     * 构造扫码登录授权 URL(桌面端用系统浏览器打开此链接)。
+     * 测试模式:直接回调本服务并带回模拟 code,便于本地联调。
+     */
+    public String buildQrConnectUrl(String state) {
+        if (testMode) {
+            return redirectUri + (redirectUri.contains("?") ? "&" : "?")
+                    + "code=test_wxweb_code&state=" + state;
+        }
+        String encodedUri = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8);
+        return String.format(
+                "%s?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect",
+                QRCONNECT_URL, webAppid, encodedUri, state);
+    }
+
+    /**
+     * 用 code 换取 openid / unionid(网站应用 OAuth2)。
+     * 失败返回 null。
+     */
+    public Map<String, String> exchangeCode(String code) {
+        if (testMode) {
+            log.info("测试模式:模拟网站应用 OAuth2 返回");
+            Map<String, String> result = new HashMap<>();
+            result.put("openid", "test_wxweb_openid");
+            result.put("unionid", "test_wxweb_unionid");
+            return result;
+        }
+        try {
+            String url = String.format("%s?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
+                    OAUTH2_TOKEN_URL, webAppid, webSecret, code);
+            ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class);
+            JSONObject json = JSON.parseObject(resp.getBody());
+            if (json != null && json.containsKey("openid")) {
+                Map<String, String> result = new HashMap<>();
+                result.put("openid", json.getString("openid"));
+                result.put("unionid", json.getString("unionid"));
+                result.put("access_token", json.getString("access_token"));
+                return result;
+            }
+            log.error("网站应用 OAuth2 获取 openid 失败: {}", json == null ? null : json.toJSONString());
+            return null;
+        } catch (Exception e) {
+            log.error("网站应用 OAuth2 获取 openid 异常", e);
+            return null;
+        }
+    }
+
+    /** 写入扫码状态:P=待扫码 / U{userId}=已匹配 / N=未绑定 */
+    public void putState(String state, String value) {
+        try {
+            redisTemplate.opsForValue().set(STATE_KEY_PREFIX + state, value,
+                    Duration.ofSeconds(STATE_TTL_SECONDS));
+        } catch (Exception e) {
+            log.warn("写入 state 到 Redis 失败,改用内存兜底: {}", e.getMessage());
+            memory.put(state, new String[]{value, String.valueOf(System.currentTimeMillis())});
+        }
+    }
+
+    /** 读取扫码状态;不存在或已过期返回 null */
+    public Object getState(String state) {
+        try {
+            Object v = redisTemplate.opsForValue().get(STATE_KEY_PREFIX + state);
+            if (v != null) {
+                return v;
+            }
+        } catch (Exception e) {
+            log.warn("读取 state 从 Redis 失败,改用内存兜底: {}", e.getMessage());
+        }
+        String[] entry = memory.get(state);
+        if (entry == null) {
+            return null;
+        }
+        long ts = Long.parseLong(entry[1]);
+        if (System.currentTimeMillis() - ts > STATE_TTL_SECONDS * 1000) {
+            memory.remove(state);
+            return null;
+        }
+        return entry[0];
+    }
+
+    /** 清除扫码状态(登录成功后调用,防止重放) */
+    public void removeState(String state) {
+        try {
+            redisTemplate.delete(STATE_KEY_PREFIX + state);
+        } catch (Exception e) {
+            log.warn("删除 state 从 Redis 失败: {}", e.getMessage());
+        }
+        memory.remove(state);
+    }
+}