2 Commits 9b517be734 ... b16f9717c8

Author SHA1 Message Date
  qmj b16f9717c8 修改line 文件配置 1 month ago
  qmj f058368a65 feat(017-oauth): LINE 新增服务端回调 /auth/line/callback(唤起 LINE App 场景) 1 month ago

File diff suppressed because it is too large
+ 135 - 0
.claude/homunculus/observations.jsonl


+ 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);
+    }
+}

+ 3 - 1
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 仅校验一致性,不实际回调)
+    # 授权回调地址(须与 LINE Console 回调白名单 + 前端 authorize 一致;GET /auth/line/callback 接住换 token 登录)
     redirect-uri: https://foodieapi.waimai-paotui.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 换用户信息的端点(一般不改)

+ 206 - 0
specs/017-oauth-login/line-callback-frontend.md

@@ -0,0 +1,206 @@
+# LINE 登录回调 — 前端(uniapp)接入说明
+
+> 配套后端:`LineCallbackController` → `GET /auth/line/callback`(2026-08-05 增量,017-oauth-login FR-009)。
+> 适用场景:**「唤起 LINE App / 系统浏览器」** 授权 —— 这种方式前端拿不到 code,由后端接住 LINE 的重定向、完成登录后,再 302 跳回 App。
+>
+> 若你的场景是「H5 / 自家 webview / LINE SDK」(前端能自己拿到 code),**不走本回调**,直接调 `POST /infouser/user/oauthLogin {provider:"line", credential:code}`。两条流程并存。
+
+---
+
+## 1. 整体流程
+
+```
+① uniapp 打开 LINE 授权页(用 redirect_uri = https://api.awayqtw.com/auth/line/callback)
+② 用户在 LINE App 里一键同意
+③ LINE 跳转:GET https://api.awayqtw.com/auth/line/callback?code=xxx&state=xxx
+④ 后端:code → 换 token → 取 userId → 查绑定 → 签 JWT 或 生成 tempKey
+⑤ 后端 302 跳回 App scheme:
+     com.twanmsdyh.app://oauthLogin?<参数>
+⑥ uniapp 被 scheme 拉起,读参数,按下面三种情况处理
+```
+
+**关键:** 前端不碰 code,只接收后端 302 回来的 scheme URL 并按参数分流。
+
+---
+
+## 2. scheme 注册(manifest.json)
+
+App 端要能被 `com.twanmsdyh.app://...` 拉起,需在 `manifest.json` 注册该 URL Scheme(HBuilderX:App 模块配置 → App 常用其它设置 / 各平台):
+
+```json
+{
+  "app-plus": {
+    "distribute": {
+      "android": { "schemes": "com.twanmsdyh.app" },
+      "ios": { "urltypes": "com.twanmsdyh.app" }
+    }
+  }
+}
+```
+
+> iOS 的 `urltypes` 填的是 scheme 名(不含 `://`)。注册后系统遇到 `com.twanmsdyh.app://...` 就会拉起你的 App 并把完整 URL 传进来。
+
+---
+
+## 3. 打开 LINE 授权页
+
+前端用系统/LINE 打开如下 URL(`redirect_uri` **必须**与 LINE Console 白名单、后端 `application.yml` 三方一致):
+
+```
+https://access.line.me/oauth2/v2.1/authorize
+  ?response_type=code
+  &client_id=2010911071
+  &redirect_uri=https%3A%2F%2Fapi.awayqtw.com%2Fauth%2Fline%2Fcallback
+  &scope=profile%20openid
+  &state=<前端生成的随机串>
+```
+
+打开方式(uniapp App 端):`plus.runtime.openURL(authorizeUrl)` 或 `plus.share.launchLaunch` / 系统 webview,交由 LINE App / 系统浏览器接管。
+
+- `scope=profile openid`:后端 `v2/profile` 取 userId 要 `profile` 权限,**不能少**。
+- `state`:前端随机生成,用于防 CSRF(后端当前**未强校验** state,但建议传,后续会加)。
+
+---
+
+## 4. 接收 302 回跳的 scheme URL
+
+uniapp 在 `App.vue` 里读取拉起 App 的 URL:
+
+```js
+// App.vue
+export default {
+  onLaunch() {
+    // #ifdef APP-PLUS
+    // 冷启动:URL 落在 plus.runtime.arguments
+    this.handleLineCallback(plus.runtime.arguments)
+    // 热启动(App 已开,LINE 回跳再拉起):监听 newargs
+    plus.runtime.addEventListener('newargs', (e) => {
+      this.handleLineCallback(e.args)
+    })
+    // #endif
+  },
+  methods: {
+    handleLineCallback(url) {
+      if (!url || !url.startsWith('com.twanmsdyh.app://oauthLogin')) return
+      const queryStr = url.split('?')[1] || ''
+      const params = this.parseQuery(queryStr)
+      routeByLineParams(params)
+    },
+    parseQuery(q) {
+      const o = {}
+      q.split('&').forEach(kv => {
+        const [k, v] = kv.split('=')
+        if (k) o[k] = decodeURIComponent(v || '')
+      })
+      return o
+    }
+  }
+}
+```
+
+> `plus.runtime.arguments` 的具体形态随 uniapp 版本略有差异(可能是完整 URL,也可能只是 query 段),联调时打印一次确认即可。建议先 `console.log('LINE cb url=', url)` 看真实值再解析。
+
+---
+
+## 5. 三种返回值处理(核心)
+
+后端 302 回的 URL 形如 `com.twanmsdyh.app://oauthLogin?<参数>`,参数只有下列三种组合之一:
+
+| 情况 | 参数 | 含义 | 前端动作 |
+|------|------|------|----------|
+| ✅ 已绑定、登录成功 | `?token=<JWT>` | 该 LINE 已绑过手机号账号,已签发 token | 存 token → 跳首页 |
+| 🟡 未绑定、需绑手机 | `?needPhone=1&tempKey=<32位>` | 首次用该 LINE 登录,要绑手机号 | 跳「绑手机」页 → 收手机号+短信码 → 调 `/oauthBindPhone` |
+| ❌ 失败 | `?error=<原因>` | 换 token 失败 / 账号停用 / 缺 code 等 | 提示并回登录页 |
+
+### 5.1 `token` —— 登录成功
+
+```js
+function routeByLineParams(params) {
+  if (params.token) {
+    // 与现有手机号登录一致:token 存本地
+    uni.setStorageSync('token', params.token)
+    // 也可顺手把 cid/deviceToken 上报一次(后端回调时没有设备信息)
+    reportDeviceInfo()
+    uni.reLaunch({ url: '/pages/index/index' })
+    return
+  }
+  // ...
+}
+```
+
+- `token` 是后端签的 JWT(`Authorization` / `token` header 带它即可),与 `/lodeing` 返回的 token 同款,**直接复用现有请求拦截器**。
+- 后端回调发生在服务端,**拿不到 cid/deviceToken**,故设备信息要么这里补报、要么等首次业务请求时按原流程更新。
+
+### 5.2 `needPhone` + `tempKey` —— 需要绑手机
+
+```js
+if (params.needPhone === '1' && params.tempKey) {
+  // tempKey 5 分钟有效,跳到绑手机页把它带上
+  uni.navigateTo({ url: '/pages/login/bindPhone?tempKey=' + params.tempKey })
+  return
+}
+```
+
+绑手机页:输入手机号 → 调 `GET /infouser/user/getcode?phone=<手机号>` 发短信 → 填验证码 → 调下面接口完成绑定+登录:
+
+### 5.3 `error` —— 失败
+
+```js
+if (params.error) {
+  const map = {
+    no_code: '回调缺少授权码,请重试',
+    user_stopped: '该账号已被停用',
+    fail: 'LINE 登录失败,请重试'
+  }
+  uni.showModal({ title: '登录失败', content: map[params.error] || decodeURIComponent(params.error), showCancel: false })
+  uni.reLaunch({ url: '/pages/login/login' })
+  return
+}
+```
+
+- `error` 可能是固定码(`no_code` / `user_stopped`),也可能是后端异常文案(已 URL 编码,`decodeURIComponent` 解开)。
+
+---
+
+## 6. 绑定接口契约:`POST /infouser/user/oauthBindPhone`
+
+`needPhone` 分支收集完手机号 + 短信码后调用。
+
+**请求体(JSON):**
+```json
+{
+  "tempKey": "5.2 拿到的 tempKey",
+  "phone":   "+8869xxxxxxxx",
+  "code":    "短信验证码(万能码 8888 可用,仅测试)",
+  "cid":         "推送 cid(个推/uni 推送)",
+  "cidType":     "推送类型,与现有登录一致(本项目一般传 \"\")",
+  "deviceToken": "APNs deviceToken(iOS)",
+  "voIPToken":   "VoIP push token(iOS)"
+}
+```
+
+**响应:**
+- 成功(已注册关联 / 未注册新建 + 写绑定):
+  ```json
+  {
+    "code": 200,
+    "msg":  "登录成功",
+    "data": { ...用户信息... },
+    "token": "<JWT>"
+  }
+  ```
+  → 存 `token` → 跳首页(同 5.1)。
+- 失败:`code != 200`,常见 `msg`:短信码错误(`no.user.jcaptcha.error`)、账号停用(`no.user.stop`)、tempKey 过期(`no.oauth.tempkey.expired`,需重新走一遍 LINE 登录拿新 tempKey)。
+
+> 绑定成功后 `info_user_oauth(user_id, provider="line", provider_uid=<LINE userId>)` 已写入;**下次同一 LINE 登录就走 5.1 的 `token` 分支**,不再要绑手机。
+
+---
+
+## 7. 注意事项
+
+1. **redirect_uri 三方一致铁律**:前端 authorize 里的 `redirect_uri`、后端 `application.yml` 的 `oauth.line.redirect-uri`、LINE Console 回调白名单,三者必须**完全相同**(当前 = `https://api.awayqtw.com/auth/line/callback`),否则 LINE 回 `400 redirect_uri_mismatch`。
+2. **tempKey 有效期 5 分钟**:超时后 `/oauthBindPhone` 回 `tempkey.expired`,需重新点 LINE 登录拿新的。
+3. **302 到自定义 scheme 的兼容性**:标准系统浏览器会跟随 302 到 `com.twanmsdyh.app://...` 拉起 App。若联调发现 **LINE 内置浏览器**不跟随 302(App 没被拉起),后端可改为返回 HTML(`meta refresh` + 手动「打开 App」链接)兜底 —— 这是待联调确认项,目前是 302。
+4. **设备信息**:后端回调链路没有 App 上下文,`cid` / `deviceToken` / `voIPToken` 在回调时更新不到;绑手机走 `/oauthBindPhone` 时会带上,已绑定的老用户建议 5.1 拿到 token 后补报一次(或复用既有设备上报逻辑)。
+5. **state 暂未校验**:后端目前不验证 `state`,前端仍建议生成并传,后续后端会加 CSRF 校验。
+6. **与 `/oauthLogin` 并存**:如果某端(如 H5)前端能自己拿到 code,直接 `POST /infouser/user/oauthLogin {provider:"line", credential:code, ...}`,不必走本回调;返回值结构与本回调的 `token` / `needPhone+tempKey` 对应一致。

+ 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
 

Some files were not shown because too many files changed in this diff