Procházet zdrojové kódy

feat(oauth): 商家/骑手三方登录改为账号密码绑定

qmj před 1 týdnem
rodič
revize
77b23e8321

+ 159 - 0
docs/oauth-business-login-app-api.md

@@ -0,0 +1,159 @@
+# 商家 / 骑手 App 三方登录接入文档(Apple / Google / LINE)
+
+> 依据 `specs/017-oauth-login`(含 2026-10-08 业务端账号密码绑定增量)整理。
+> 适用端:骑手 App、商家 App(含商家子账号)。用户端 App 的手机号+短信流程不在本文范围。
+
+## 1. 渠道与 provider
+
+| 端 | Apple | Google | LINE |
+|---|---|---|---|
+| 骑手 | `apple_rider` | `google_rider` | `line_rider` |
+| 商家(含子账号) | `apple_merchant` | `google_merchant` | `line_merchant` |
+
+- 三方账号只做**已有业务账号的快捷登录入口**:骑手渠道只能绑已有有效骑手账号,商家渠道只能绑已有有效商家/子账号,**不会自动创建业务账号**。
+- 一个三方账号在同一 provider 下只能绑定一个业务账号;换绑需联系平台处理。
+
+## 2. 总体流程
+
+```
+① App 取三方凭证
+   Apple  :Sign in with Apple SDK → identityToken
+   Google :Google Sign-In SDK → idToken
+   LINE   :先调 /auth/line/authorize 拿授权地址和 state,打开授权页
+② POST /infouser/user/oauthLogin 提交 provider + credential
+   ├─ 已绑定 → 直接返回 { data: 用户信息, token },登录完成
+   └─ 未绑定 → 返回 { data: { status:"needPhone", tempKey } }
+③ 首次绑定:App 弹【账号 + 密码】表单
+   POST /infouser/user/oauthBindPhone  提交 tempKey + userName + password(RSA密文)
+   → 成功返回 { data: 用户信息, token },绑定并登录完成
+```
+
+- `token` 与现有账号密码登录(`syslodeing` / `shanglodeing`)签发的是同类会话 token(骑手端 / 商家端各自独立),后续请求照旧放 `token` 请求头即可,无需其它接入。
+- LINE 走服务端回调时第②步由后端回调完成,App 从回跳 scheme 参数里直接拿到 token 或 tempKey,见第 6 节。
+
+## 3. 接口:签发 LINE 授权地址 `POST /auth/line/authorize`(免登录)
+
+业务端 LINE 登录**必须**先从服务端拿授权地址(内含服务端签发的 state,5 分钟有效、单次消费),不要自行拼接 LINE 授权 URL。
+
+**请求体:**
+
+```json
+{ "provider": "line_rider" }   // 商家端传 line_merchant
+```
+
+**响应:**
+
+```json
+{
+  "code": 200,
+  "data": {
+    "authorizationUrl": "https://access.line.me/oauth2/v2.1/authorize?...",
+    "state": "32位hex"
+  }
+}
+```
+
+App 打开 `authorizationUrl`(系统浏览器或 WebView),并**保存 state** 用于回跳比对。
+
+## 4. 接口:三方登录第一步 `POST /infouser/user/oauthLogin`(免登录)
+
+**请求体:**
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `provider` | 是 | 见第 1 节 |
+| `credential` | 是 | Apple=identityToken;Google=idToken;LINE=授权 code |
+| `state` | LINE 直连提交 code 时必填 | `/auth/line/authorize` 返回的 state;Apple/Google 不传 |
+| `cid` `cidType` `deviceToken` `voIPToken` | 否 | 推送字段,与现有登录一致 |
+| `fullName` | 否 | 仅 Apple:首次授权时用户共享的姓名,从 ASAuthorization 凭证取;后续登录不再传 |
+
+**响应 A(已绑定,直接登录):**
+
+```json
+{ "code": 200, "msg": "...", "data": { ...用户信息... }, "token": "xxx" }
+```
+
+**响应 B(未绑定,首次):**
+
+```json
+{ "code": 200, "msg": "...", "data": { "status": "needPhone", "tempKey": "xxx" } }
+```
+
+- `tempKey` **5 分钟有效、一次性**:过期或任何一步失败,都要重新从 `oauthLogin` 开始,不能重试旧 tempKey。
+- 注意:字段名 `needPhone` 是历史契约(用户端为手机号+短信),**业务端拿到该状态后展示的是「账号 + 密码」表单**,不走短信。
+- 响应 `data` 不含三方渠道字段(骑手返回账号实体、商家返回 `MerchantLoginView`,均只有账号资料);登录渠道 App 自己发起时已知,需要展示时按第 7 节查询。
+
+**失败:** `code != 200`(凭证无效/过期、provider 不支持、账号已停用、state 无效等),直接提示 `msg`。
+
+## 5. 接口:首次绑定 `POST /infouser/user/oauthBindPhone`(免登录)
+
+业务端绑定 = 校验业务账号密码后写入三方绑定,成功即登录。
+
+**请求体:**
+
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `tempKey` | 是 | `oauthLogin` 返回 |
+| `userName` | 是 | 业务账号登录名(与 `syslodeing` / `shanglodeing` 登录用的账号一致) |
+| `password` | 是 | **RSA 公钥加密后的密码密文**,加密方式与现有账号密码登录完全一致(同一公钥、同一格式,直接复用现有加密函数) |
+| `cid` `cidType` `deviceToken` `voIPToken` | 否 | 推送字段 |
+
+业务端**不传** `phone` / `code`(那是用户端 App 的短信绑定参数)。
+
+**响应(成功):**
+
+```json
+{ "code": 200, "msg": "...", "data": { ...用户信息... }, "token": "xxx" }
+```
+
+绑定成功后即完成登录,无需再调登录接口;该三方账号下次登录走 `oauthLogin` 直接拿 token。
+
+**失败(不写绑定、不签 token):**
+
+| 场景 | 处理建议 |
+|---|---|
+| tempKey 缺失/过期 | 重新发起 `oauthLogin` |
+| 缺 userName / password | 表单校验拦截 |
+| 账号不存在 | 提示 `msg`(账号名或角色不符、已删除均按不存在处理) |
+| 密码错误 | 提示 `msg`,可重输 |
+| 账号停用 / 子账号被禁用或归属失效 | 提示 `msg`,联系平台 |
+
+## 6. LINE 服务端回调 `GET /auth/line/callback`(LINE 服务器调用,App 不直接请求)
+
+LINE 授权完成后,后端处理回调并 **302 跳回对应 App 的 scheme**,App 按查询参数分流:
+
+| 回跳参数 | 含义 | App 动作 |
+|---|---|---|
+| `?token=...&state=...` | 已绑定,登录成功 | 比对 state 后保存 token,进入首页 |
+| `?needPhone=1&tempKey=...&state=...` | 未绑定,首次 | 比对 state 后弹**账号+密码**表单 → 调 `oauthBindPhone` |
+| `?error=no_code\|user_stopped\|fail&state=...` | 失败 | 提示失败,可重试 |
+
+- App 必须比对回跳 `state` 与第 3 节保存的发起值,不一致视为异常放弃本次登录。
+- 若业务端在 H5/webview 内能自己拿到授权 code,也可不走回调,改为直接调 `oauthLogin` 提交 `provider + credential(code) + state`。
+
+## 7. 登录渠道与第三方名称展示(与用户端同一契约)
+
+与用户端 App 一致:登录接口响应不带渠道字段,需要展示「本次登录渠道 + 第三方名称」时调用:
+
+### `GET /infouser/user/getuserinfo`(`token` 请求头;骑手/商家 OAuth 登录签发的 token 均可直接调用)
+
+响应 `data` 中与渠道相关的两个字段(017 2026-09-24 增量,三端共用同一契约):
+
+| 字段 | 说明 |
+|---|---|
+| `oauthProvider` | 本次会话登录渠道:`apple_rider` / `google_rider` / `line_rider` / `apple_merchant` / `google_merchant` / `line_merchant`;账号密码登录的会话该字段为空 |
+| `providerDisplayName` | 该渠道的第三方显示名称(LINE 的 displayName / Apple 首次授权共享的 fullName),**可空** |
+
+**展示规则(与用户端一致):**
+
+- 第三方名称与账号昵称 `nickName` 分开显示;`providerDisplayName` 为空时只展示渠道(如「Apple 登录」),不要用昵称冒充第三方名称。
+- LINE 名称由服务端每次成功登录自动刷新,App 无需额外操作。
+- 这两个字段反映的是**本次会话**的登录方式,不是账号维度的「最后一次登录方式」。
+
+## 8. 注意事项汇总
+
+1. Apple / Google:App 用各自 SDK 在本地取凭证后直接调 `oauthLogin`,无需服务端授权地址。
+2. LINE:必须走 `/auth/line/authorize` 拿授权地址与 state(服务端校验单次消费),state 过期需重新获取。
+3. `tempKey` 一次性、5 分钟 TTL;绑定任何失败都从 `oauthLogin` 重来。
+4. 密码只接受 RSA 密文,与现有登录接口同公钥同格式。
+5. 各端 audience/clientId 独立:Apple/Google 凭证必须用对应业务端 App 的 bundleId/clientId 获取,用户端凭证在业务端会被拒绝。

+ 101 - 47
ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java

@@ -1235,58 +1235,39 @@ public class InfoUserController extends BaseController {
             return issueOauthToken(u, provider, providerUid);
         }
 
-        // 用户端保留既有校验;业务账号绑定必须通过真实短信验证码。
-        String phone = dto.getPhone();
-        if (phone == null || phone.isBlank()) {
-            return error(MessageUtils.message("no.oauth.phone.blank"));
-        }
-        String xcode = redisCache.getCacheObject(phone.trim().replaceAll("\\+", ""));
         boolean businessAccount = accountType != null && !accountType.allowCreate();
-        boolean codeOk = (xcode != null && xcode.equals(dto.getCode()))
-                || (!businessAccount && "8888".equals(dto.getCode()));
-        if (!codeOk) {
-            log.warn("[OAuth] 短信验证码错误 phone={}", maskPhone(phone));
-            return error(MessageUtils.message("no.user.jcaptcha.error"));
-        }
-
-        // apple/google 用户端(accountType 为空)同样按会员渠道查询:
-        // getuser 不带 userType 过滤,同手机号命中商家/骑手账号会错绑或多行异常。
-        OAuthAccountType bindingChannel = accountType != null ? accountType : OAuthAccountType.USER;
-        InfoUser user = findOauthBindingUser(bindingChannel, phone);
-        if (accountType != null && !accountType.allowCreate()) {
-            if (user == null) {
-                return error(MessageUtils.message("no.user.not.exist"));
+        InfoUser user;
+        if (businessAccount) {
+            // 017 2026-10-08 增量:业务端(骑手/商家/子账号)改用账号密码绑定。
+            // 子账号等业务账号不强制登记 tel_phone,短信流程对其不可达;userName+密码是业务端唯一且全覆盖的登录因子。
+            if (dto.getUserName() == null || dto.getUserName().isBlank()) {
+                return error(MessageUtils.message("no.oauth.username.blank"));
             }
-            if (!canOauthLogin(accountType, user)) {
-                return error(MessageUtils.message("no.user.stop"));
+            if (dto.getPassword() == null || dto.getPassword().isBlank()) {
+                return error(MessageUtils.message("no.user.password.not.null"));
             }
-        }
-        if (user != null && !"0".equals(user.getStatus())) {
-            // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
-            log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
-            return error(MessageUtils.message("no.user.stop"));
-        }
-        if (user == null && (accountType == null || accountType.allowCreate())) {
-            // 新建:昵称=手机号(与 createUser 一致)
-            InfoUser info = new InfoUser();
-            info.setPhone(phone);
-            info.setNickName(phone);
-            info.setCid(dto.getCid());
-            info.setCidType(dto.getCidType());
-            info.setDeviceToken(dto.getDeviceToken());
-            info.setVoIPToken(dto.getVoIPToken());
-            info.setUserType("0");
-            info.setMycode(new UUIDUtil().get8UUID());
-            if (!infoUserService.saveOrUpdate(info)) {
-                throw new ServiceException(MessageUtils.message("no.system.error"));
+            // 已删除(del_flag!=0)或渠道角色不符的账号一律按不存在拒绝,与 shanglodeing 一致;
+            // userName 与登录路径同样按原串精确匹配(不 trim);不用 getOne,多行时给友好提示而非 500
+            List<InfoUser> matches = infoUserService.list(new LambdaQueryWrapper<InfoUser>()
+                    .eq(InfoUser::getUserName, dto.getUserName())
+                    .eq(InfoUser::getDelFlag, "0")
+                    .in(InfoUser::getUserType, accountType.userTypes()));
+            if (matches.size() > 1) {
+                log.warn("[OAuth] 业务账号名关联多个账号 userName={}", dto.getUserName());
+                return error(MessageUtils.message("no.oauth.username.duplicate"));
             }
-            user = findOauthBindingUser(bindingChannel, phone);
+            user = matches.isEmpty() ? null : matches.get(0);
             if (user == null) {
+                log.warn("[OAuth] 业务绑定账号不存在 userName={}", dto.getUserName());
                 return error(MessageUtils.message("no.user.not.exist"));
             }
-            createUserWallet(user.getUserId());
-            log.info("[OAuth] 新建账号 userId={}", user.getUserId());
-        } else {
+            if (!verifyBusinessBindPassword(dto.getPassword(), user.getPassword())) {
+                log.warn("[OAuth] 业务绑定密码错误 userName={}", dto.getUserName());
+                return error(MessageUtils.message("no.user.password.error"));
+            }
+            if (!canOauthLogin(accountType, user)) {
+                return error(MessageUtils.message("no.user.stop"));
+            }
             user.setCid(dto.getCid());
             user.setCidType(dto.getCidType());
             user.setDeviceToken(dto.getDeviceToken());
@@ -1294,7 +1275,60 @@ public class InfoUserController extends BaseController {
             if (!infoUserService.saveOrUpdate(user)) {
                 throw new ServiceException(MessageUtils.message("no.system.error"));
             }
-            log.info("[OAuth] 关联已有账号 userId={}", user.getUserId());
+            log.info("[OAuth] 业务账号密码绑定 userName={}", dto.getUserName());
+        } else {
+            // 用户端保留既有校验:手机号 + 短信验证码(含万能码 8888 兼容)。
+            String phone = dto.getPhone();
+            if (phone == null || phone.isBlank()) {
+                return error(MessageUtils.message("no.oauth.phone.blank"));
+            }
+            String xcode = redisCache.getCacheObject(phone.trim().replaceAll("\\+", ""));
+            boolean codeOk = (xcode != null && xcode.equals(dto.getCode()))
+                    || "8888".equals(dto.getCode());
+            if (!codeOk) {
+                log.warn("[OAuth] 短信验证码错误 phone={}", maskPhone(phone));
+                return error(MessageUtils.message("no.user.jcaptcha.error"));
+            }
+
+            // apple/google 用户端(accountType 为空)同样按会员渠道查询:
+            // getuser 不带 userType 过滤,同手机号命中商家/骑手账号会错绑或多行异常。
+            OAuthAccountType bindingChannel = accountType != null ? accountType : OAuthAccountType.USER;
+            user = findOauthBindingUser(bindingChannel, phone);
+            if (user != null && !"0".equals(user.getStatus())) {
+                // 停用账号不允许绑定三方(与 lodeing 一致,防止绕过停用)
+                log.warn("[OAuth] 账号已停用 phone={}", maskPhone(phone));
+                return error(MessageUtils.message("no.user.stop"));
+            }
+            if (user == null) {
+                // 新建:昵称=手机号(与 createUser 一致)
+                InfoUser info = new InfoUser();
+                info.setPhone(phone);
+                info.setNickName(phone);
+                info.setCid(dto.getCid());
+                info.setCidType(dto.getCidType());
+                info.setDeviceToken(dto.getDeviceToken());
+                info.setVoIPToken(dto.getVoIPToken());
+                info.setUserType("0");
+                info.setMycode(new UUIDUtil().get8UUID());
+                if (!infoUserService.saveOrUpdate(info)) {
+                    throw new ServiceException(MessageUtils.message("no.system.error"));
+                }
+                user = findOauthBindingUser(bindingChannel, phone);
+                if (user == null) {
+                    return error(MessageUtils.message("no.user.not.exist"));
+                }
+                createUserWallet(user.getUserId());
+                log.info("[OAuth] 新建账号 userId={}", user.getUserId());
+            } else {
+                user.setCid(dto.getCid());
+                user.setCidType(dto.getCidType());
+                user.setDeviceToken(dto.getDeviceToken());
+                user.setVoIPToken(dto.getVoIPToken());
+                if (!infoUserService.saveOrUpdate(user)) {
+                    throw new ServiceException(MessageUtils.message("no.system.error"));
+                }
+                log.info("[OAuth] 关联已有账号 userId={}", user.getUserId());
+            }
         }
 
         // 写三方绑定
@@ -1310,7 +1344,11 @@ public class InfoUserController extends BaseController {
         log.info("[OAuth] 写入绑定 provider={}, userId={}", provider, user.getUserId());
 
         redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
-        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
+        // 029 设备信任仅用户端适用:业务端绑定只验了密码(共享凭证场景)、未验手机号,
+        // 写信任会让共享设备后续经用户端 deviceConfirm 冒用登记手机号,故不记录
+        if (!businessAccount) {
+            deviceTrustService.recordLogin(dto.getDeviceId(), dto.getPhone(), user.getUserId());
+        }
         return issueOauthToken(user, provider, providerUid);
     }
 
@@ -1469,6 +1507,22 @@ public class InfoUserController extends BaseController {
         }
     }
 
+    /**
+     * 017 2026-10-08 增量:业务端绑定密码比对,复用 shanglodeing/syslodeing 的 RSA 解密比较。
+     * 解密异常或比对不一致一律按密码错误拒绝,不向客户端区分失败原因。
+     */
+    private boolean verifyBusinessBindPassword(String submitted, String stored) {
+        try {
+            RsaMima rsa = new RsaMima();
+            String wmima = rsa.decryptByPrivateKey(submitted);
+            String nmima = rsa.decryptByPrivateKey(stored);
+            return wmima != null && wmima.equals(nmima);
+        } catch (Exception e) {
+            log.warn("[OAuth] 业务绑定密码解密失败 type={}", e.getClass().getSimpleName());
+            return false;
+        }
+    }
+
     /**
      * 按渠道查待绑定账号。不能用 getOne:会员/商家/骑手共用 info_user 表,
      * 同手机号可能命中多行(如一号注册多个商家账号),getOne 多行会抛底层异常导致接口 500;

+ 8 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java

@@ -14,12 +14,18 @@ public class OAuthBindDto {
     /** oauthLogin 返回的临时凭证 */
     private String tempKey;
 
-    /** 手机号 */
+    /** 手机号(用户端绑定用) */
     private String phone;
 
-    /** 短信验证码 */
+    /** 短信验证码(用户端绑定用) */
     private String code;
 
+    /** 业务账号登录名(骑手/商家/子账号绑定用,017 2026-10-08 增量) */
+    private String userName;
+
+    /** 业务账号密码(RSA 密文,与 shanglodeing/syslodeing 提交格式一致,017 2026-10-08 增量) */
+    private String password;
+
     /** 推送字段(与现有登录一致) */
     private String cid;
     private String cidType;

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -197,6 +197,8 @@ no.oauth.needphone=首次登入請驗證手機號
 no.oauth.tempkey.missing=登入憑證缺失,請重新登入
 no.oauth.tempkey.expired=登入憑證已過期,請重新登入
 no.oauth.phone.blank=手機號碼不可為空
+no.oauth.username.blank=帳號不能為空
+no.oauth.username.duplicate=該帳號名關聯多個帳號,請聯繫平台
 no.oauth.line.config.invalid=LINE登入設定不完整
 no.oauth.client.config.invalid={0}登入設定不完整
 no.oauth.state.invalid=登入授權已失效,請重新發起登入

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -192,6 +192,8 @@ no.oauth.needphone=Phone verification is required for first login
 no.oauth.tempkey.missing=Login credential is missing, please log in again
 no.oauth.tempkey.expired=Login credential expired, please log in again
 no.oauth.phone.blank=Phone number is required
+no.oauth.username.blank=Account is required
+no.oauth.username.duplicate=This account name is linked to multiple accounts. Please contact the platform
 no.oauth.line.config.invalid=LINE login configuration is incomplete
 no.oauth.client.config.invalid={0} login configuration is incomplete
 no.oauth.state.invalid=Login authorization has expired. Please start sign-in again.

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -366,6 +366,8 @@ no.message.push.merchant.delivery.start=ร้านค้าเริ่มจ
 no.oauth.client.config.invalid=การตั้งค่าการเข้าสู่ระบบ {0} ไม่ถูกต้อง
 no.oauth.line.config.invalid=การตั้งค่าการเข้าสู่ระบบ LINE ไม่ถูกต้อง
 no.oauth.phone.blank=กรุณาระบุหมายเลขโทรศัพท์
+no.oauth.username.blank=กรุณาระบุบัญชีผู้ใช้
+no.oauth.username.duplicate=ชื่อบัญชีนี้เชื่อมโยงกับหลายบัญชี กรุณาติดต่อแพลตฟอร์ม
 no.oauth.state.invalid=ข้อมูลการเข้าสู่ระบบหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง
 no.order.not.found=ไม่พบออเดอร์
 no.order.selfdelivery.required=ออเดอร์นี้ไม่ใช่ออเดอร์จัดส่งด้วยตนเองของร้านค้า

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -192,6 +192,8 @@ no.oauth.needphone=首次登录请验证手机号
 no.oauth.tempkey.missing=登录凭证缺失,请重新登录
 no.oauth.tempkey.expired=登录凭证已过期,请重新登录
 no.oauth.phone.blank=手机号不能为空
+no.oauth.username.blank=账号不能为空
+no.oauth.username.duplicate=该账号名关联多个账号,请联系平台
 no.oauth.line.config.invalid=LINE登录配置不完整
 no.oauth.client.config.invalid={0}登录配置不完整
 no.oauth.state.invalid=登录授权已失效,请重新发起登录

+ 2 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -192,6 +192,8 @@ no.oauth.needphone=首次登入請驗證手機號
 no.oauth.tempkey.missing=登入憑證缺失,請重新登入
 no.oauth.tempkey.expired=登入憑證已過期,請重新登入
 no.oauth.phone.blank=手機號碼不可為空
+no.oauth.username.blank=帳號不能為空
+no.oauth.username.duplicate=該帳號名關聯多個帳號,請聯繫平台
 no.oauth.line.config.invalid=LINE登入設定不完整
 no.oauth.client.config.invalid={0}登入設定不完整
 no.oauth.state.invalid=登入授權已失效,請重新發起登入

+ 91 - 21
ruoyi-admin/src/test/java/com/ruoyi/app/user/InfoUserControllerTest.java

@@ -83,6 +83,7 @@ class InfoUserControllerTest {
     private BusinessPhoneService businessPhoneService;
     private OAuthVerifyService oauthVerifyService;
     private InfoUserOauthMapper infoUserOauthMapper;
+    private InfoUserDeviceMapper deviceMapper;
     private RedisCache redisCache;
     private static ConfigurableListableBeanFactory originalBeanFactory;
 
@@ -104,6 +105,11 @@ class InfoUserControllerTest {
         messageSource.addMessage("merchant.subaccount.platform.managed", Locale.getDefault(),
                 "分管账号只能通过专用入口管理");
         messageSource.addMessage("no.oauth.phone.blank", Locale.getDefault(), "手机号不能为空");
+        messageSource.addMessage("no.oauth.username.blank", Locale.getDefault(), "账号不能为空");
+        messageSource.addMessage("no.oauth.username.duplicate", Locale.getDefault(),
+                "该账号名关联多个账号,请联系平台");
+        messageSource.addMessage("no.user.password.not.null", Locale.getDefault(), "密码不能为空");
+        messageSource.addMessage("no.user.password.error", Locale.getDefault(), "密码错误");
         messageSource.addMessage("no.oauth.needphone", Locale.getDefault(), "请绑定手机号");
         messageSource.addMessage("no.user.login.success", Locale.getDefault(), "登录成功");
         messageSource.addMessage("no.user.stop", Locale.getDefault(), "账号已停用");
@@ -148,8 +154,9 @@ class InfoUserControllerTest {
         ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
         ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
         ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        deviceMapper = mock(InfoUserDeviceMapper.class);
         ReflectionTestUtils.setField(controller, "deviceTrustService",
-                new DeviceTrustService(mock(InfoUserDeviceMapper.class)));
+                new DeviceTrustService(deviceMapper));
         when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1);
     }
 
@@ -563,15 +570,26 @@ class InfoUserControllerTest {
     @ParameterizedTest
     @CsvSource({"apple_rider,2,qtw_tokens:qs:", "google_rider,2,qtw_tokens:qs:",
             "apple_merchant,1,qtw_tokens:sh:app:", "google_merchant,3,qtw_tokens:sh:app:"})
-    void businessOauthBindsExistingBusinessPhoneAndIssuesItsOwnSession(
+    void businessOauthBindsWithAccountPasswordAndIssuesItsOwnSession(
             String provider, String role, String tokenPrefix) {
         OAuthBindDto request = oauthRequest(provider);
+        // 业务端不再依赖手机号/短信(子账号可不登记 tel_phone),只提交账号密码
+        request.setPhone(null);
+        request.setCode(null);
+        request.setUserName("store-owner");
+        request.setPassword("encrypted-password");
         InfoUser business = activeUser(72L, role);
-        business.setTelPhone(request.getPhone());
+        business.setUserName("store-owner");
+        // 存储密文与提交密文不同(RSA 每次加密结果不同),防止实现退化为密文直接比对
+        business.setPassword("stored-encrypted-password");
         when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(business));
         when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
 
-        AjaxResult result = controller.oauthBindPhone(request);
+        AjaxResult result;
+        try (MockedConstruction<RsaMima> ignored = mockConstruction(RsaMima.class,
+                (rsa, context) -> when(rsa.decryptByPrivateKey(any())).thenReturn("password"))) {
+            result = controller.oauthBindPhone(request);
+        }
 
         assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
         String token = (String) result.get("token");
@@ -584,13 +602,19 @@ class InfoUserControllerTest {
         verify(infoUserService, never()).getuser(any());
         ArgumentCaptor<Wrapper<InfoUser>> query = ArgumentCaptor.forClass(Wrapper.class);
         verify(infoUserService).list(query.capture());
-        assertTrue(query.getValue().getSqlSegment().contains("tel_phone"));
+        assertTrue(query.getValue().getSqlSegment().contains("user_name"));
+        // 业务端绑定未经手机验证,不写设备信任(029 仅用户端适用)
+        org.mockito.Mockito.verifyNoInteractions(deviceMapper);
     }
 
     @ParameterizedTest
     @CsvSource({"apple_rider", "google_rider", "apple_merchant", "google_merchant"})
     void businessOauthCannotCreateAnAccount(String provider) {
-        AjaxResult result = controller.oauthBindPhone(oauthRequest(provider));
+        OAuthBindDto request = oauthRequest(provider);
+        request.setUserName("ghost-account");
+        request.setPassword("encrypted-password");
+
+        AjaxResult result = controller.oauthBindPhone(request);
 
         assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
         assertEquals("账号不存在", result.get(AjaxResult.MSG_TAG));
@@ -890,43 +914,89 @@ class InfoUserControllerTest {
 
     @ParameterizedTest
     @CsvSource({"apple_rider", "google_merchant", "line_rider", "line_merchant"})
-    void businessBindingRequiresAnActualSmsCode(String provider) {
+    void businessOauthBindingRejectsWrongPassword(String provider) {
         OAuthBindDto request = oauthRequest(provider);
-        request.setCode("8888");
+        request.setUserName("store-owner");
+        request.setPassword("encrypted-password");
         InfoUser user = activeUser(72L, provider.endsWith("rider") ? "2" : "1");
-        when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user);
-        when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true);
+        user.setPassword("stored-cipher");
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(user));
+
+        try (MockedConstruction<RsaMima> ignored = mockConstruction(RsaMima.class,
+                (rsa, context) -> when(rsa.decryptByPrivateKey(any()))
+                        .thenReturn("submitted-password", "stored-password"))) {
+            AjaxResult result = controller.oauthBindPhone(request);
+            assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+            assertEquals("密码错误", result.get(AjaxResult.MSG_TAG));
+        }
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+    }
+
+    @Test
+    void businessOauthBindingRejectsDuplicateUserName() {
+        OAuthBindDto request = oauthRequest("apple_merchant");
+        request.setUserName("duplicated-name");
+        request.setPassword("encrypted-password");
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(
+                activeUser(71L, "1"), activeUser(72L, "1")));
+
         AjaxResult result = controller.oauthBindPhone(request);
+
         assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
-        assertEquals("验证码错误", result.get(AjaxResult.MSG_TAG));
+        assertEquals("该账号名关联多个账号,请联系平台", result.get(AjaxResult.MSG_TAG));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
+    }
+
+    @ParameterizedTest
+    @CsvSource({"true,false", "false,true"})
+    void businessOauthBindingRequiresUserNameAndPassword(boolean withName, boolean withPassword) {
+        OAuthBindDto request = oauthRequest("line_merchant");
+        request.setUserName(withName ? "store-owner" : null);
+        request.setPassword(withPassword ? "encrypted-password" : null);
+
+        AjaxResult result = controller.oauthBindPhone(request);
+
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
+        assertEquals(withName ? "密码不能为空" : "账号不能为空", result.get(AjaxResult.MSG_TAG));
         verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
     }
 
     @Test
-    void oauthBindPhoneRejectsPhoneLinkedToMultipleAccounts() {
-        OAuthBindDto request = oauthRequest("apple");
-        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(
-                activeUser(71L, "0"), activeUser(72L, "0")));
+    void businessOauthBindingRejectsDeletedAccount() {
+        OAuthBindDto request = oauthRequest("apple_merchant");
+        request.setUserName("deleted-owner");
+        request.setPassword("encrypted-password");
+        InfoUser deleted = activeUser(72L, "1");
+        deleted.setUserName("deleted-owner");
+        deleted.setPassword("encrypted-password");
+        deleted.setDelFlag("2");
+        when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(deleted));
 
-        ServiceException exception = assertThrows(ServiceException.class,
-                () -> controller.oauthBindPhone(request));
+        AjaxResult result;
+        try (MockedConstruction<RsaMima> ignored = mockConstruction(RsaMima.class,
+                (rsa, context) -> when(rsa.decryptByPrivateKey(any())).thenReturn("password"))) {
+            result = controller.oauthBindPhone(request);
+        }
 
-        assertEquals("该手机号关联多个账号,请联系平台", exception.getMessage());
+        assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
         verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
         verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
     }
 
     @Test
-    void merchantOauthBindPhoneRejectsPhoneLinkedToMultipleStores() {
-        OAuthBindDto request = oauthRequest("line_merchant");
+    void oauthBindPhoneRejectsPhoneLinkedToMultipleAccounts() {
+        OAuthBindDto request = oauthRequest("apple");
         when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(
-                activeUser(71L, "1"), activeUser(72L, "1")));
+                activeUser(71L, "0"), activeUser(72L, "0")));
 
         ServiceException exception = assertThrows(ServiceException.class,
                 () -> controller.oauthBindPhone(request));
 
         assertEquals("该手机号关联多个账号,请联系平台", exception.getMessage());
         verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+        verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class));
     }
 
     private OAuthBindDto oauthRequest(String provider) {

+ 24 - 0
specs/017-oauth-login/plan.md

@@ -147,3 +147,27 @@ cid, cidType, deviceToken, voIPToken
 4. 验证 LINE 名称变化、Apple 首次有名/无名及历史空值、同一用户多渠道切换、不同 `user_id` 不串名,并联调 App 展示。
 
 2026-09-28 实施完成(test-202609v2,验证记录见 tasks.md)。对 App 的契约:`/oauthLogin` 新增可选 `fullName`(Apple 首次授权提交);JWT 新增 `providerUid` claim(gettoken 续签保真,App 无需处理);`/getuserinfo` 返回新增 `oauthProvider`(本次登录渠道,手机号会话为空)与 `providerDisplayName`(该渠道第三方名称,可空——空时 App 只展示渠道,不用昵称冒充)。LINE 名称由服务端在每次成功登录自动刷新,App 无额外操作。
+
+## 2026-10-08 增量:业务端改用账号密码绑定
+
+### 设计
+
+- 动机:业务账号(尤其子账号)不强制登记 `tel_phone`,按手机号+短信的绑定流程对无手机号账号不可达;`user_name`+密码是业务端唯一且全覆盖的登录因子。
+- `oauthBindPhone` 内按 `accountType` 分流:
+  - **业务渠道**(`allowCreate=false`):要求 `userName` + `password`(RSA 密文);按 `user_name + user_type IN (渠道角色) + del_flag=0` 用 `list()` 查账号(按原串精确匹配、不 trim;多行命中返回 `no.oauth.username.duplicate` 友好提示而非 getOne 抛 500——平台 edit 改名无唯一复查,存在重名可能);`RsaMima` 解密比对,异常或比对失败按 `no.user.password.error` 拒绝;再走 `canOauthLogin`(停用/子账号状态/归属)。不查短信、不要求 `phone`。
+  - **用户渠道**(`apple`/`google`/`line`/`line_user`):原手机号+短信逻辑逐行保留。
+- 绑定写入、tempKey 原子消费、竞态容错、`issueOauthToken` 分端签发全部复用现状。
+- **业务端绑定不写 029 设备信任**:绑定只验密码未验手机号,共享设备若写入信任,后续用户端 deviceConfirm 会冒用登记手机号一键绑定/建号;`recordLogin` 仅用户渠道调用。
+- LINE 回调 `needPhone=1` 契约不动,业务 App 侧渲染账号密码表单(外部待办)。
+
+### 受影响文件
+
+- `ruoyi-admin/.../user/dto/OAuthBindDto.java`:新增 `userName`、`password` 字段。
+- `ruoyi-admin/.../user/InfoUserController.java`:`oauthBindPhone` 业务分支改为密码绑定;手机号/短信校验移入用户分支。
+- `ruoyi-admin/src/main/resources/i18n/messages*.properties`(5 份):新增 `no.oauth.username.blank`。
+- `specs/017-oauth-login/*`:本增量。
+
+### 验证
+
+- `InfoUserControllerTest`:业务密码绑定成功(免手机号)、密码错误拒绝、缺账号/缺密码拒绝、账号不存在不自动创建、用户端短信绑定回归。
+- JDK 21 定向测试 + `ruoyi-admin` 模块回归与编译;真实 App 端表单联调为外部待办。

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

@@ -75,3 +75,13 @@ C 端 app 现仅支持「手机号 + 短信验证码」登录(`/infouser/user/
 - LINE:每次成功登录且取得带 `profile` 权限的资料时,读取 `displayName` 并刷新对应绑定记录;获取失败时保留已保存的名称,不影响登录。
 - Apple:首次授权取得用户共享的 `fullName` 时保存到对应绑定记录;后续登录不要求再次取得名称。历史账号未保存过 Apple 名称或用户未共享名称时,该名称允许为空,用户端仅展示“Apple 登录”及原有昵称,不伪造 Apple 名称。
 - 2026-09-28 后端实施完成(实施记录见 tasks.md):App 端展示联调与真机验证待 App 源码接入,数据库 SQL 待手动执行。
+
+## 2026-10-08 增量:业务端(骑手/商家/子账号)改用账号密码绑定
+
+**背景**:子账号等业务账号不强制登记 `tel_phone`,而短信绑定流程按 `tel_phone` 查账号——无手机号的账号永远查不到,LINE/Apple 登录对其实际不可用。业务账号的 `user_name` + 密码是其唯一且 100% 覆盖的日常登录因子(`shanglodeing`/`syslodeing` 均为账号密码登录),以密码作为绑定凭证与业务账号既有安全等级一致。
+
+- **FR-016**: 业务渠道(`apple_rider`/`google_rider`/`line_rider`/`apple_merchant`/`google_merchant`/`line_merchant`)首次绑定改为**账号 + 密码**:`oauthBindPhone` 业务分支不再要求手机号/短信码,改为提交 `userName` + RSA 加密 `password`;后端按 `user_name + user_type IN (渠道角色) + del_flag=0` 查账号,RSA 解密比对密码(复用 `shanglodeing` 比对逻辑),通过后走 `canOauthLogin`(含停用拦截、子账号启用状态与归属校验)再写绑定并按端签发 token。
+- **FR-017**: 用户端绑定完全不变:仍为手机号 + 短信验证码(含万能码 8888 兼容);`apple`/`google`/`line`/`line_user` 行为不变。本增量同时移除业务端的短信绑定路径——所有业务账号必有 `user_name`+密码,密码绑定即全覆盖,不保留双路径。
+- **FR-018**: 业务端绑定失败(缺账号/缺密码/账号不存在/密码错误/账号停用)不写绑定、不签 token;提示复用现有 key(`no.user.password.not.null` / `no.user.not.exist` / `no.user.password.error` / `no.user.stop`),仅新增 `no.oauth.username.blank`。
+- **FR-019**: LINE 回调契约不变:业务线未绑定仍 302 `needPhone=1&tempKey=`,业务 App 将该页面渲染为账号密码表单(App 端外部待办,后端不改回跳参数)。
+- `oauthLogin`、`oauthDeviceConfirm`、已绑定快捷登录、绑定竞态容错分支均不受本增量影响;设备信任免短信确认继续仅限用户端。

+ 26 - 0
specs/017-oauth-login/tasks.md

@@ -76,3 +76,29 @@
 - [x] T33 编写并运行渠道切换、跨账号隔离、LINE 名称更新、Apple 首次/历史空值及 App 展示的定向验证。
 
 > 实施记录(2026-09-28,test-202609v2):后端全部完成——`OAuthIdentity` 随凭证校验带回 displayName,`OAuthLoginDto.fullName` 承载 Apple 首次授权姓名,tempKey 缓存值三段式 `provider@providerUid@displayName`(空名称保持两段式,兼容旧值);JWT 新增 `providerUid` claim(gettoken 续签保真);getuserinfo 按会话渠道 + userId+providerUid 精确绑定回填 `oauthProvider`/`providerDisplayName`,旧会话无 providerUid 只展示渠道不任意挑绑定;LINE 直连与回调两条入口均在成功登录时刷新 displayName(空/未变保留旧值,失败不阻断)。定向测试 100 通过 + 全模块回归 759 tests 0 failures/0 errors。SQL 未执行(updatesql/sql.md 待手动执行);App 端展示联调待 App 源码接入(与 T28 同为外部待办)。
+
+## 2026-10-08 增量:业务端改用账号密码绑定
+
+- [x] T34 `OAuthBindDto` 新增 `userName`、`password` 字段(无校验注解)。
+- [x] T35 `oauthBindPhone` 业务分支(`*_rider`/`*_merchant`)改为账号密码绑定:`user_name + user_type + del_flag` 查账号、RSA 解密比对、`canOauthLogin` 校验后写绑定;移除业务端短信路径;用户端手机号+短信逻辑逐行保留。
+- [x] T36 新增 i18n key `no.oauth.username.blank` 写入 5 份 properties;其余提示复用现有 key。
+- [x] T37 定向测试(先红后绿):业务密码绑定成功(免手机号)、密码错误/缺账号/缺密码拒绝、账号不存在不自动创建、已删除账号拒绝、用户端短信绑定回归;移除业务短信绑定与 tel_phone 多账号旧用例。
+- [x] T38 JDK 21 定向测试与 `ruoyi-admin` 模块回归、编译验证;App 端账号密码表单与 LINE 回跳页联调为外部待办。
+
+> 实施记录(2026-10-08,test-202609v2):TDD 先红后绿——RED 时 66 tests 中 10 个新/改用例如期失败(旧短信路径返回"账号不存在"等),实现后 66/66 通过;全模块回归 `mvn -pl ruoyi-admin -am test` 849 tests 0 failures/0 errors(含 `BackendI18nBundleContractTest` key 一致性契约)。已删除账号由查询 `del_flag='0'` 过滤按"账号不存在"拒绝,`canOauthLogin` 二次兜底(delFlag 异常数据返回"账号已停用")。本增量无数据库结构变更,无 SQL 待执行。App 端(骑手/商家)首次绑定页改为账号密码表单、LINE 回跳 `needPhone=1` 页面同改,为外部待办。
+
+### 代码评审修复 (2026-10-08)
+
+评审(6 维度核实)发现并修复,第二轮 TDD(RED 9 失败 → GREEN),修复后 `InfoUserControllerTest` 67/67、全模块回归 850 tests 0 failures/0 errors:
+
+- [x] **业务端绑定不写设备信任**:原实现 `recordLogin` 会把"设备 → 登记手机号"写入信任表,但业务端只验了密码(共享凭证)未验手机号;共享平板后续可经用户端 deviceConfirm 免短信冒用该手机号绑定/自动建会员账号(含钱包)。现仅用户渠道调用 `recordLogin`,业务绑定不写(成功用例断言 `verifyNoInteractions(deviceMapper)`)。
+- [x] **userName 查询改 `list()` + 多行友好提示**:原 `getOne` 在重名(平台 `edit()` 改名无唯一复查,库无唯一索引)时抛 TooManyResults 500 且已消耗 tempKey;现多行返回新 key `no.oauth.username.duplicate`(已加 5 份 properties)。
+- [x] **去掉 userName 的 `trim()`**:与 `shanglodeing`/`syslodeing` 及建号路径的原串精确匹配一致,避免"带空格账号名日常可登录但绑定查不到"。
+- [x] **测试有效性**:成功用例提交密文与存储密文改为不同字符串(RSA 每次加密结果不同),防止实现退化为"密文直接相等比对"时测试仍然通过。
+
+评审确认但不改(既有问题/产品决策,待定夺):
+- 平台 `add()`/`edit()` 存密码为客户端密文原样入库(`add()` 内 `System.out.println("加密:"...)` 处疑似遗留未做服务端加密);存量密码若非 RSA 密文则密码绑定永远失败(日常密码登录同样失败,属存量数据问题)。
+- 业务端绑定成为无速率限制的密码尝试入口(`@Anonymous`、tempKey 可随意发起换取);绑定永久且无解绑路径。建议后续加失败计数锁定或解绑能力(017 后续增量)。
+- 骑手日常登录 `syslodeing` 不校验 `status`,停用骑手可密码登录但三方绑定被拒(两端口径不一,既有行为)。
+- OAuth 绑定/登录不刷新 `last_login_at`(既有行为,影响"最近登录"展示)。
+- RSA 比对逻辑已有 4 份拷贝(登录×2/改密/绑定),建议后续抽公共服务。