Bladeren bron

feat(017-oauth): LINE 新增服务端回调 /auth/line/callback(唤起 LINE App 场景)

原设计要求前端自取 code 再 POST /oauthLogin;但用户走「唤起 LINE App / 系统浏览器」
授权时,LINE 把重定向发给后端 redirect_uri,uniapp 无法在中间截获 code。新增服务端回调
与原 /oauthLogin 并存,复用同一 verify + tempKey/oauthBindPhone 后续。

- 新增 LineCallbackController: GET /auth/line/callback (@Anonymous)
  接 code → verify("line",code) 换 token/取 userId → 已绑定签 JWT / 未绑定生成 tempKey
  → 302 跳 App scheme oauth.line.app-redirect(?token= / ?needPhone=1&tempKey= / ?error=)
  刻意不复用 InfoUserController 私有方法(避免改已上线代码),同款 buildOauthToken
- application.yml: oauth.line.redirect-uri 改 api.awayqtw.com(须与 Console 白名单一致),
  新增 oauth.line.app-redirect=com.twanmsdyh.app://oauthLogin
- spec.md: FR-009

约束:redirect_uri 三方一致性(前端 authorize / 后端换 token / Console 白名单)。

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 1 maand geleden
bovenliggende
commit
f058368a65

+ 165 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java

@@ -0,0 +1,165 @@
+package com.ruoyi.app.user;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.ruoyi.app.utils.oauth.OAuthVerifyService;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.common.constant.CacheConstants;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.model.LoginUserDto;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.ServletUtils;
+import com.ruoyi.common.utils.ip.AddressUtils;
+import com.ruoyi.common.utils.ip.IpUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.utils.JwtUtil;
+import eu.bitwalker.useragentutils.UserAgent;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * LINE 登录服务端回调(017-oauth-login 增量,2026-08-05)。
+ *
+ * <p><b>为什么需要它</b>:当用户走「唤起 LINE App / 系统浏览器」授权时,LINE 把重定向发给
+ * 注册的 redirect_uri(后端 https URL),uniapp(另一个 App)无法在中间截获 code。这时必须由
+ * 后端接住回调、换 token、完成登录,再把结果 302 跳回 App(自定义 scheme)。原 {@code /oauthLogin}
+ * 保留不动,覆盖「前端自己拿到 code 再 POST 给后端」的另一种场景(H5 / webview / SDK)。
+ *
+ * <p><b>流程</b>:LINE → {@code GET /auth/line/callback?code=...&state=...}
+ * → {@link OAuthVerifyService#verify verify("line", code)}(code 换 token 取 userId)
+ * → 查 info_user_oauth:已绑定→签 JWT token;未绑定→生成 tempKey(needPhone)
+ * → 302 跳到 {@code oauth.line.app-redirect}(如 com.twanmsdyh.app://oauthLogin)带结果:
+ * <ul>
+ *   <li>已绑定:{@code <app-redirect>?token=xxx}</li>
+ *   <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
+ *   <li>异常:{@code <app-redirect>?error=xxx}</li>
+ * </ul>
+ *
+ * <p><b>redirect_uri 三方一致性</b>:LINE 要求「前端 authorize 的 redirect_uri」「后端换 token 的
+ * redirect_uri」「LINE Console 回调白名单」三者完全一致,否则回 400 redirect_uri_mismatch。
+ * 故 application.yml 的 {@code oauth.line.redirect-uri} 必须与 Console 一致(= 本端点地址)。
+ *
+ * @author foodie
+ * @date 2026-08-05
+ */
+@RestController
+@RequestMapping("/auth/line")
+public class LineCallbackController extends BaseController {
+
+    private static final Logger log = LoggerFactory.getLogger(LineCallbackController.class);
+
+    /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
+    private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+
+    @Autowired
+    private OAuthVerifyService oauthVerifyService;
+    @Autowired
+    private IInfoUserService infoUserService;
+    @Autowired
+    private InfoUserOauthMapper infoUserOauthMapper;
+    @Autowired
+    private RedisCache redisCache;
+
+    /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
+    @Value("${oauth.line.app-redirect}")
+    private String appRedirect;
+
+    /**
+     * LINE 服务端回调:接 code → 换 token → 登录 → 302 回 App。
+     * 用 @Anonymous 放行(LINE 以浏览器/App 身份回调,无 token)。
+     */
+    @Anonymous
+    @GetMapping("/callback")
+    public void callback(@RequestParam(value = "code", required = false) String code,
+                         @RequestParam(value = "state", required = false) String state,
+                         HttpServletResponse response) throws IOException {
+        try {
+            if (code == null || code.isEmpty()) {
+                log.warn("[OAuth][LINE] callback 缺 code 参数, state={}", state);
+                redirectToApp(response, "error", "no_code");
+                return;
+            }
+            log.info("[OAuth][LINE] callback 收到 code(len={}), state={}", code.length(), state);
+
+            // code → userId(OAuthVerifyService.verifyLine:code 换 access_token 再取 profile)
+            String providerUid = oauthVerifyService.verify("line", code);
+
+            InfoUserOauth bind = infoUserOauthMapper.selectOne(
+                    new LambdaQueryWrapper<InfoUserOauth>()
+                            .eq(InfoUserOauth::getProvider, "line")
+                            .eq(InfoUserOauth::getProviderUid, providerUid));
+            if (bind != null) {
+                InfoUser u = infoUserService.getOne(new QueryWrapper<InfoUser>()
+                        .eq("user_id", bind.getUserId()).eq("status", 0).eq("del_flag", "0"));
+                if (u == null) {
+                    log.warn("[OAuth][LINE] callback 已绑定但账号已停用 userId={}", bind.getUserId());
+                    redirectToApp(response, "error", "user_stopped");
+                    return;
+                }
+                String token = buildOauthToken(u, "line");
+                log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
+                response.sendRedirect(appRedirect + "?token=" + enc(token));
+                return;
+            }
+
+            // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
+            String tempKey = UUID.randomUUID().toString().replace("-", "");
+            redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
+            log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
+            response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
+        } catch (Exception e) {
+            log.error("[OAuth][LINE] callback 异常", e);
+            redirectToApp(response, "error", e.getMessage() == null ? "fail" : e.getMessage());
+        }
+    }
+
+    /**
+     * 签发三方登录 token(与 InfoUserController.issueOauthToken 同款,仅返回 token 串不包 AjaxResult)。
+     * 此处刻意不复用 InfoUserController 的私有方法,避免改动已上线代码;两处逻辑保持一致。
+     */
+    private String buildOauthToken(InfoUser user, String provider) {
+        redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + user.getUserId() + ":" + "*");
+        LoginUserDto dto = new LoginUserDto();
+        dto.setUserId(user.getUserId());
+        dto.setUserName(user.getPhone());
+        dto.setProvider(provider);
+        fillLoginUserInfo(dto);
+        return JwtUtil.setToken(CacheConstants.USER_TOKEN_KEY, dto);
+    }
+
+    /** 填充登录设备/网络信息(IP、地点、浏览器、OS、登录时间),与 InfoUserController.fillLoginUserInfo 一致。 */
+    private void fillLoginUserInfo(LoginUserDto userDto) {
+        String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
+        userDto.setIpaddr(ip);
+        userDto.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
+        UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
+        userDto.setBrowser(userAgent.getBrowser().getName());
+        userDto.setOs(userAgent.getOperatingSystem().getName());
+        userDto.setLoginTime(System.currentTimeMillis());
+    }
+
+    /** 302 跳回 App:app-redirect + ?<key>=<value>(value 做 URL 编码)。 */
+    private void redirectToApp(HttpServletResponse response, String key, String value) throws IOException {
+        response.sendRedirect(appRedirect + "?" + key + "=" + enc(value));
+    }
+
+    private static String enc(String s) {
+        return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
+    }
+}

+ 4 - 2
ruoyi-admin/src/main/resources/application.yml

@@ -77,8 +77,10 @@ oauth:
     client-id: "2010911071"
     # Channel Secret(换 token 用,勿泄露)
     client-secret: "880fb850c17a3399d207100144a1cb67"
-    # 授权回调地址(须与 LINE Console 回调白名单 + 前端拿 code 时一致;LINE 仅校验一致性,不实际回调)
-    redirect-uri: https://foodieapi.waimai-paotui.com/auth/line/callback
+    # 授权回调地址(须与 LINE Console 回调白名单 + 前端 authorize 一致;GET /auth/line/callback 接住换 token 登录)
+    redirect-uri: https://api.awayqtw.com/auth/line/callback
+    # 后端回调登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/needPhone/error)
+    app-redirect: com.twanmsdyh.app://oauthLogin
     # 用 code 换 access_token 的端点(一般不改)
     token-url: https://api.line.me/oauth2/v2.1/token
     # 用 access_token 换用户信息的端点(一般不改)

+ 1 - 0
specs/017-oauth-login/spec.md

@@ -32,6 +32,7 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 - **FR-006**: token claim 增加 `provider` 字段(apple/google/line;手机号登录为 phone),供登录渠道统计或「未绑手机限制」类约束使用。
 - **FR-007**: 登录端点 `@Anonymous` 放行;受保护接口继续走 `@Auth`,零额外接入。
 - **FR-008**: 凭证校验失败 / tempKey 过期 / 短信码错误 → 明确错误提示,不签发 token、不建账号。
+- **FR-009**(2026-08-05 增量): LINE「唤起 LINE App / 系统浏览器」流程下前端拿不到 code,新增服务端回调 `GET /auth/line/callback`(@Anonymous,LineCallbackController):接 LINE 重定向的 code → 复用 verify("line",code) 换 token 取 userId → 已绑定签 token / 未绑定生成 tempKey → 302 跳回 App scheme `oauth.line.app-redirect`(`?token=` / `?needPhone=1&tempKey=` / `?error=`,未绑定时 App 再调 /infouser/user/oauthBindPhone)。原 /oauthLogin 保留,覆盖前端自取 code 场景(H5/webview/SDK)。约束:oauth.line.redirect-uri 须与 LINE Console 回调白名单一致(否则 400 redirect_uri_mismatch)。
 
 ## Key Entities