Эх сурвалжийг харах

feat(backend): 桌面端扫码登录改用小程序码(getwxacodeunlimit),新增 POST /scan 确认接口,移除 GET /callback 与网站应用凭据

liaoxg 1 өдөр өмнө
parent
commit
6c073c2c65

+ 0 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -43,7 +43,6 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/direct-register",
         "/api/auth/register-with-invite",
         "/api/auth/wechat-web/qr",
-        "/api/auth/wechat-web/callback",
         "/api/auth/wechat-web/poll",
         "/api/internal/",
         "/api/product/list",

+ 0 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -103,7 +103,6 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
                 "/api/auth/wechat-web/qr",
-                "/api/auth/wechat-web/callback",
                 "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
@@ -149,7 +148,6 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
                 "/api/auth/wechat-web/qr",
-                "/api/auth/wechat-web/callback",
                 "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",

+ 37 - 56
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java

@@ -1,39 +1,34 @@
 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.RequestAttribute;
 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)。
+ * 微信「小程序码」扫码登录(桌面端 SSO)。
  *
- * <p>流程:桌面端 POST /qr 拿 state+url → 用户在浏览器扫码 → 微信回调 GET /callback(带 code+state)
- * → 服务端换 unionid 匹配 users.unionid,写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (从 scene 取 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认 →
+ * 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
  *
- * <p>注意:/callback 是微信服务器跳转的浏览器端点,必须用 GET——这是本项目
- * 「统一 @PostMapping,禁止 @GetMapping」规范的唯一例外。
+ * <p>全部为 POST:/qr、/poll 公开(桌面端尚未持有令牌),/scan 需小程序 JWT
+ * (扫码者即小程序已登录用户,据此完成与小程序账号的 SSO)。
  */
-@Tag(name = "微信网站应用登录", description = "桌面端微信扫码登录,与小程序账号 SSO")
+@Tag(name = "微信小程序扫码登录", description = "桌面端小程序码扫码登录,与小程序账号 SSO")
 @RestController
 @Slf4j
 @RequestMapping("/api/auth/wechat-web")
@@ -42,50 +37,50 @@ public class WechatWebAuthController {
     @Resource
     private WechatWebLoginService wechatWebLoginService;
 
-    @Resource
-    private UserMapper userMapper;
-
     @Resource
     private UserService userService;
 
-    @Operation(summary = "获取扫码登录二维码链接")
+    @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);
+        try {
+            String base64 = wechatWebLoginService.generateQrImage(state);
+            data.put("qrImage", base64 == null || base64.isEmpty() ? "" : "data:image/png;base64," + base64);
+        } catch (RuntimeException e) {
+            log.error("生成登录小程序码失败: state={}", state, e);
+            wechatWebLoginService.removeState(state);
+            return Result.error("生成二维码失败:" + e.getMessage());
+        }
         return Result.success(data);
     }
 
-    @Operation(summary = "微信扫码回调(微信服务器跳转,GET)")
-    @GetMapping("/callback")
-    public void callback(@RequestParam("code") String code,
-                         @RequestParam("state") String state,
-                         HttpServletResponse response) throws IOException {
+    @Operation(summary = "小程序扫码确认登录(小程序端调用,携带小程序 JWT)")
+    @PostMapping("/scan")
+    public Result<Map<String, Object>> scan(@RequestBody Map<String, String> body,
+                                            @RequestAttribute("userId") Long userId) {
+        String state = body != null ? body.get("state") : null;
         if (state == null || state.trim().isEmpty()) {
-            writeHtml(response, "登录失败", "缺少 state 参数");
-            return;
+            return Result.error("缺少 state");
         }
-        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;
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            return Result.error("二维码已过期,请重新获取");
+        }
+        if (!"P".equals(stored.toString())) {
+            return Result.error("二维码已被使用或已过期");
         }
-        User user = userMapper.selectOne(
-                new LambdaQueryWrapper<User>().eq(User::getUnionid, unionid).last("LIMIT 1"));
-        if (user == null) {
-            wechatWebLoginService.putState(state, "N");
-            writeHtml(response, "未找到账号", "该微信尚未绑定小程序账号,请先在小程序登录后再试");
-            return;
+        if (userId == null) {
+            return Result.error("请先登录小程序");
         }
-        wechatWebLoginService.putState(state, "U" + user.getId());
-        log.info("微信网站应用扫码成功: state={}, userId={}", state, user.getId());
-        writeHtml(response, "扫码成功", "已登录,请返回客户端继续");
+        wechatWebLoginService.putState(state, "U" + userId);
+        log.info("小程序扫码确认登录: state={}, userId={}", state, userId);
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "ok");
+        return Result.success(data);
     }
 
     @Operation(summary = "轮询扫码登录结果")
@@ -122,7 +117,7 @@ public class WechatWebAuthController {
                 data.put("role", login.getRole());
                 data.put("nickname", login.getNickname());
             } catch (RuntimeException e) {
-                log.warn("网站应用扫码换取 JWT 失败: state={}, error={}", state, e.getMessage());
+                log.warn("小程序扫码换取 JWT 失败: state={}, error={}", state, e.getMessage());
                 data.put("status", "error");
                 data.put("message", e.getMessage() != null ? e.getMessage() : "登录失败");
             }
@@ -132,18 +127,4 @@ public class WechatWebAuthController {
         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();
-    }
 }

+ 21 - 72
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java

@@ -1,116 +1,65 @@
 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。
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (落地页从 scene 取出 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认
+ * → 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>采用小程序 appid/secret + {@code getwxacodeunlimit},无需「网站应用」凭据,
+ * 也不产生任何 GET 回调;扫码者身份即小程序已登录用户,天然与小程序账号 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;
 
+    /** 扫码落地的小程序页面路径(须已在 pages.json 注册) */
+    @Value("${wechat.wxacode-login-page:pages/login/scan-login}")
+    private String scanPage;
+
     @Resource
     private RedisTemplate<String, Object> redisTemplate;
 
-    private final RestTemplate restTemplate = createRestTemplate();
+    @Resource
+    private WechatService wechatService;
 
     /** 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。
+     * 生成扫码登录用的小程序码(PNG 的 base64 编码,不含 data URI 前缀)。
+     * scene 直接放入 state(32 位十六进制,符合微信「最大 32 个可见字符」限制)。
+     * 测试模式返回空串(无真实小程序码,桌面端展示提示即可)。
      */
-    public Map<String, String> exchangeCode(String code) {
+    public String generateQrImage(String state) {
         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;
+            log.info("测试模式:跳过小程序码生成(state={})", state);
+            return "";
         }
+        return wechatService.generateWxacode(state, scanPage);
     }
 
-    /** 写入扫码状态:P=待扫码 / U{userId}=已匹配 / N=未绑定 */
+    /** 写入扫码状态:P=待扫码 / U{userId}=已确认 / N=未绑定 */
     public void putState(String state, String value) {
         try {
             redisTemplate.opsForValue().set(STATE_KEY_PREFIX + state, value,

+ 4 - 7
cfc-backend/src/main/resources/application.yml

@@ -83,7 +83,7 @@ zkxz:
 
 wechat:
   appid: wx5ba8038ef16fb245
-  secret: ${WECHAT_SECRET:dev-only-wechat-secret}
+  secret: ${WECHAT_SECRET:3dae525c6f5e010cc034f59abbd1b1cc}
   mch-id: 1703255039
   mch-key: ${WECHAT_MCH_KEY:dev-only-mch-key}
   api-v3-key: ${WECHAT_API_V3_KEY:dev-only-api-v3-key}
@@ -103,12 +103,9 @@ wechat:
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}
   # 小程序码环境:dev=development / test=trial / prod=release
   env-version: ${WECHAT_ENV_VERSION:release}
-  # 网站应用(微信开放平台“网站应用”)——桌面端扫码登录 SSO。
-  # 前置:该网站应用必须与小程序(appid=wx5ba8038ef16fb245)绑定同一开放平台账号,否则 unionid 不一致导致匹配失败。
-  web:
-    appid: ${WECHAT_WEB_APPID:}
-    secret: ${WECHAT_WEB_SECRET:}
-    redirect-uri: ${WECHAT_WEB_REDIRECT_URI:https://cfc.cf-club.com/api/auth/wechat-web/callback}
+  # 桌面端「小程序码」扫码登录:扫码落地的小程序页面路径(须已在 cfc-frontend/pages.json 注册)。
+  # 复用上面的 wechat.appid/secret 调 getwxacodeunlimit,无需「网站应用」凭据,也不产生 GET 回调。
+  wxacode-login-page: ${WECHAT_WXACODE_LOGIN_PAGE:pages/login/scan-login}
 
 dify:
   base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}