Przeglądaj źródła

feat: 同设备三方登录免绑手机号(029 设备信任)

- 新表 info_user_device:设备最近一次验证通过的手机号(SQL 已追加至 updatesql/sql.md 待手动执行)
- lodeing/oauthLogin/oauthBindPhone 支持可选 deviceId;四种登录成功点 upsert 设备信任
- oauthLogin 未绑定且设备已信任时返回 deviceConfirm 分支(脱敏手机号只读一键确认,免短信)
- 新接口 /infouser/user/oauthDeviceConfirm:校验 tempKey+deviceId 匹配后绑定并签发 token
- LINE 回调 state 改传 deviceId,回跳新增 deviceConfirm 分支;已绑定路径记录设备信任
- i18n 六语言文件新增 no.oauth.device.confirm / no.oauth.device.mismatch
- 测试 26 个全绿(TDD);spec-kit 文档位于 specs/029-device-trust-phone-bind/
qmj 6 godzin temu
rodzic
commit
cb8721bf4d
29 zmienionych plików z 1717 dodań i 5 usunięć
  1. 1 1
      .specify/feature.json
  2. 3 3
      CLAUDE.md
  3. 101 1
      ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java
  4. 17 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java
  5. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java
  6. 21 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java
  7. 3 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java
  8. 77 0
      ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java
  9. 2 0
      ruoyi-admin/src/main/resources/i18n/messages.properties
  10. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_en_US.properties
  11. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties
  12. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_vi.properties
  13. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties
  14. 2 0
      ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties
  15. 393 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java
  16. 161 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java
  17. 137 0
      ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java
  18. 43 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java
  19. 2 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java
  20. 13 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java
  21. 35 0
      specs/029-device-trust-phone-bind/checklists/requirements.md
  22. 90 0
      specs/029-device-trust-phone-bind/contracts/api-contract.md
  23. 68 0
      specs/029-device-trust-phone-bind/data-model.md
  24. 98 0
      specs/029-device-trust-phone-bind/plan.md
  25. 82 0
      specs/029-device-trust-phone-bind/quickstart.md
  26. 59 0
      specs/029-device-trust-phone-bind/research.md
  27. 110 0
      specs/029-device-trust-phone-bind/spec.md
  28. 174 0
      specs/029-device-trust-phone-bind/tasks.md
  29. 14 0
      updatesql/sql.md

+ 1 - 1
.specify/feature.json

@@ -1,3 +1,3 @@
 {
-  "feature_directory": "specs/028-flash-delivery-push"
+  "feature_directory": "specs/029-device-trust-phone-bind"
 }

+ 3 - 3
CLAUDE.md

@@ -1,6 +1,6 @@
-# foodie_server Development Guidelines
+# foodie_server Development Guidelines
 
-Auto-generated from all feature plans. Last updated: 2026-08-18
+Auto-generated from all feature plans. Last updated: 2026-09-20
 
 ## Tech Stack
 
@@ -204,5 +204,5 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan: `specs/028-flash-delivery-push/plan.md`
+shell commands, and other important information, read the current plan: `specs/029-device-trust-phone-bind/plan.md`
 <!-- SPECKIT END -->

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

@@ -40,7 +40,10 @@ import com.ruoyi.system.utils.AuthContext;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.MobileSMS;
 import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
 import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.system.domain.InfoUserDevice;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.system.mapper.InfoUserOauthMapper;
 
@@ -110,8 +113,12 @@ public class InfoUserController extends BaseController {
     private ISysDictDataService dictDataService;
     @Autowired
     private IRiderPositionService riderPositionService;
+    @Autowired
+    private DeviceTrustService deviceTrustService;
     /** 三方登录首登临时凭证 Redis 前缀 */
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+    /** 设备确认临时凭证 Redis 前缀:值为 deviceId,与 OAUTH_TEMP_PREFIX 同 TTL 成对写入(029) */
+    private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
 
 
     @Anonymous
@@ -987,6 +994,7 @@ public class InfoUserController extends BaseController {
         infoUserService.saveOrUpdate(info);
         InfoUser inus = infoUserService.getuser(userDTO.getPhone());
         createUserWallet(inus.getUserId());
+        deviceTrustService.recordLogin(userDTO.getDeviceId(), userDTO.getPhone(), inus.getUserId());
         redisCache.deleteKeys(CacheConstants.USER_TOKEN_KEY + inus.getUserId() + ":" + "*");
         // 补充登录用户信息
         LoginUserDto userDto = new LoginUserDto();
@@ -1018,6 +1026,7 @@ public class InfoUserController extends BaseController {
         user.setDeviceToken(userDTO.getDeviceToken());
         user.setVoIPToken(userDTO.getVoIPToken());
         infoUserService.saveOrUpdate(user);
+        deviceTrustService.recordLogin(userDTO.getDeviceId(), user.getPhone(), user.getUserId());
         QueryWrapper<VipUser> queryWrapper = new QueryWrapper<>();
         queryWrapper.eq("user_id", infoUser.getUserId());
         VipUser vipUser = vipUserService.getOne(queryWrapper);
@@ -1075,14 +1084,27 @@ public class InfoUserController extends BaseController {
             u.setDeviceToken(dto.getDeviceToken());
             u.setVoIPToken(dto.getVoIPToken());
             infoUserService.saveOrUpdate(u);
+            deviceTrustService.recordLogin(dto.getDeviceId(), u.getPhone(), u.getUserId());
             return issueOauthToken(u, dto.getProvider());
         }
 
         // 未绑定:缓存 {provider, providerUid},返回 needPhone + tempKey
-        log.info("[OAuth] 未绑定,返回 needPhone provider={}, providerUid={}", dto.getProvider(), providerUid);
+        // (029:同设备已验证过手机号 → 免短信一键确认 deviceConfirm 分支)
         String tempKey = UUID.randomUUID().toString().replace("-", "");
         redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey,
                 dto.getProvider() + "@" + providerUid, 5, TimeUnit.MINUTES);
+        String trustedPhone = deviceTrustService.getTrustedPhone(dto.getDeviceId());
+        if (trustedPhone != null) {
+            redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, dto.getDeviceId(), 5, TimeUnit.MINUTES);
+            log.info("[OAuth] 设备已信任,返回 deviceConfirm provider={}, deviceId={}, phone={}",
+                    dto.getProvider(), dto.getDeviceId(), DeviceTrustService.maskPhone(trustedPhone));
+            Map<String, Object> data = new HashMap<>();
+            data.put("status", "deviceConfirm");
+            data.put("tempKey", tempKey);
+            data.put("maskedPhone", DeviceTrustService.maskPhone(trustedPhone));
+            return AjaxResult.success(MessageUtils.message("no.oauth.device.confirm"), data);
+        }
+        log.info("[OAuth] 未绑定,返回 needPhone provider={}, providerUid={}", dto.getProvider(), providerUid);
         Map<String, Object> data = new HashMap<>();
         data.put("status", "needPhone");
         data.put("tempKey", tempKey);
@@ -1170,6 +1192,84 @@ public class InfoUserController extends BaseController {
         log.info("[OAuth] 写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
 
         redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
+        return issueOauthToken(user, provider);
+    }
+
+    /**
+     * 设备信任免绑一键确认(029-device-trust-phone-bind):oauthLogin 返回 deviceConfirm 后,
+     * App 弹只读脱敏手机号确认框,用户点确认调本接口免短信完成绑定登录。
+     */
+    @Anonymous
+    @PostMapping("/oauthDeviceConfirm")
+    public AjaxResult oauthDeviceConfirm(@RequestBody OAuthDeviceConfirmDto dto) {
+        if (dto == null || dto.getTempKey() == null || dto.getTempKey().isEmpty()) {
+            log.warn("[OAuth] oauthDeviceConfirm 缺 tempKey");
+            return error(MessageUtils.message("no.oauth.tempkey.missing"));
+        }
+        if (dto.getDeviceId() == null || dto.getDeviceId().isEmpty()) {
+            log.warn("[OAuth] oauthDeviceConfirm 缺 deviceId tempKey={}", dto.getTempKey());
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        }
+        String cached = redisCache.getCacheObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        if (cached == null) {
+            log.warn("[OAuth] 设备确认 tempKey 已过期/不存在 tempKey={}", dto.getTempKey());
+            return error(MessageUtils.message("no.oauth.tempkey.expired"));
+        }
+        // 凭证与设备必须匹配:dev 键由 oauthLogin 的 deviceConfirm 分支写入,换设备重放即拒绝
+        String boundDevice = redisCache.getCacheObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        InfoUserDevice trust = deviceTrustService.getTrust(dto.getDeviceId());
+        if (boundDevice == null || !boundDevice.equals(dto.getDeviceId()) || trust == null) {
+            log.warn("[OAuth] 设备确认凭证与设备不匹配 tempKey={}, deviceId={}", dto.getTempKey(), dto.getDeviceId());
+            return error(MessageUtils.message("no.oauth.device.mismatch"));
+        }
+        int at = cached.indexOf('@');
+        String provider = cached.substring(0, at);
+        String providerUid = cached.substring(at + 1);
+        log.info("[OAuth] 设备确认流程 provider={}, providerUid={}, deviceId={}", provider, providerUid, dto.getDeviceId());
+
+        // 容错:若期间已被绑定,直接登录(与 oauthBindPhone 一致)
+        InfoUserOauth exist = infoUserOauthMapper.selectOne(
+                new LambdaQueryWrapper<InfoUserOauth>()
+                        .eq(InfoUserOauth::getProvider, provider)
+                        .eq(InfoUserOauth::getProviderUid, providerUid));
+        if (exist != null) {
+            log.info("[OAuth] 设备确认期间已被绑定,直接登录 userId={}", exist.getUserId());
+            InfoUser u = infoUserService.getById(exist.getUserId());
+            return issueOauthToken(u, provider);
+        }
+
+        // 信任手机号即账号主键:已注册校验停用、未注册自动创建(与 oauthBindPhone 一致,防绕过停用)
+        String phone = trust.getPhone();
+        InfoUser user = infoUserService.getuser(phone);
+        if (user != null && !"0".equals(user.getStatus())) {
+            log.warn("[OAuth] 设备确认账号已停用 phone={}", DeviceTrustService.maskPhone(phone));
+            return error(MessageUtils.message("no.user.stop"));
+        }
+        if (user == null) {
+            InfoUser info = new InfoUser();
+            info.setPhone(phone);
+            info.setNickName(phone);
+            info.setUserType("0");
+            info.setMycode(new UUIDUtil().get8UUID());
+            infoUserService.saveOrUpdate(info);
+            user = infoUserService.getuser(phone);
+            createUserWallet(user.getUserId());
+            log.info("[OAuth] 设备确认新建账号 userId={}", user.getUserId());
+        }
+
+        // 写三方绑定(一键确认即免短信绑定)
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(user.getUserId());
+        bind.setProvider(provider);
+        bind.setProviderUid(providerUid);
+        bind.setCreateTime(new Date());
+        infoUserOauthMapper.insert(bind);
+        log.info("[OAuth] 设备确认写入绑定 provider={}, providerUid={}, userId={}", provider, providerUid, user.getUserId());
+
+        redisCache.deleteObject(OAUTH_TEMP_PREFIX + dto.getTempKey());
+        redisCache.deleteObject(OAUTH_TEMP_DEVICE_PREFIX + dto.getTempKey());
+        deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId());
         return issueOauthToken(user, provider);
     }
 

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

@@ -2,6 +2,7 @@ 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.user.service.DeviceTrustService;
 import com.ruoyi.app.utils.oauth.OAuthVerifyService;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.common.constant.CacheConstants;
@@ -48,6 +49,7 @@ import java.util.concurrent.TimeUnit;
  * <ul>
  *   <li>已绑定:{@code <app-redirect>?token=xxx}</li>
  *   <li>未绑定:{@code <app-redirect>?needPhone=1&tempKey=xxx}(App 弹手机号+短信码 UI 后调 /infouser/user/oauthBindPhone)</li>
+ *   <li>未绑定且设备已信任(029):{@code <app-redirect>?deviceConfirm=1&tempKey=xxx&maskedPhone=xxx}(App 弹只读确认框后调 /infouser/user/oauthDeviceConfirm)</li>
  *   <li>异常:{@code <app-redirect>?error=xxx}</li>
  * </ul>
  *
@@ -66,6 +68,8 @@ public class LineCallbackController extends BaseController {
 
     /** 与 InfoUserController.OAUTH_TEMP_PREFIX 一致:未绑定临时凭证 Redis 前缀(oauthBindPhone 消费) */
     private static final String OAUTH_TEMP_PREFIX = "oauth:bind:";
+    /** 与 InfoUserController.OAUTH_TEMP_DEVICE_PREFIX 一致:设备确认临时凭证 Redis 前缀(029) */
+    private static final String OAUTH_TEMP_DEVICE_PREFIX = "oauth:bind:dev:";
 
     @Autowired
     private OAuthVerifyService oauthVerifyService;
@@ -75,6 +79,8 @@ public class LineCallbackController extends BaseController {
     private InfoUserOauthMapper infoUserOauthMapper;
     @Autowired
     private RedisCache redisCache;
+    @Autowired
+    private DeviceTrustService deviceTrustService;
 
     /** 后端登录后 302 跳回 App 的 scheme(App 注册该 scheme 接收 token/tempKey/error) */
     @Value("${oauth.line.app-redirect}")
@@ -113,14 +119,25 @@ public class LineCallbackController extends BaseController {
                     return;
                 }
                 String token = buildOauthToken(u, "line");
+                deviceTrustService.recordLogin(state, u.getPhone(), u.getUserId());
                 log.info("[OAuth][LINE] callback 已绑定登录成功 userId={}", u.getUserId());
                 response.sendRedirect(appRedirect + "?token=" + enc(token));
                 return;
             }
 
             // 未绑定:缓存 {line, providerUid},返回 needPhone + tempKey(与 oauthLogin 同款,oauthBindPhone 消费)
+            // 029:state 由前端传 deviceId(契约见 specs/029/contracts/api-contract.md §5),设备已信任则走免绑分支
             String tempKey = UUID.randomUUID().toString().replace("-", "");
             redisCache.setCacheObject(OAUTH_TEMP_PREFIX + tempKey, "line@" + providerUid, 5, TimeUnit.MINUTES);
+            String trustedPhone = deviceTrustService.getTrustedPhone(state);
+            if (trustedPhone != null) {
+                redisCache.setCacheObject(OAUTH_TEMP_DEVICE_PREFIX + tempKey, state, 5, TimeUnit.MINUTES);
+                log.info("[OAuth][LINE] callback 设备已信任,返回 deviceConfirm providerUid={}, phone={}",
+                        providerUid, DeviceTrustService.maskPhone(trustedPhone));
+                response.sendRedirect(appRedirect + "?deviceConfirm=1&tempKey=" + enc(tempKey)
+                        + "&maskedPhone=" + enc(DeviceTrustService.maskPhone(trustedPhone)));
+                return;
+            }
             log.info("[OAuth][LINE] callback 未绑定,返回 needPhone providerUid={}", providerUid);
             response.sendRedirect(appRedirect + "?needPhone=1&tempKey=" + enc(tempKey));
         } catch (Exception e) {

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthBindDto.java

@@ -25,4 +25,7 @@ public class OAuthBindDto {
     private String cidType;
     private String deviceToken;
     private String voIPToken;
+
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 21 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java

@@ -0,0 +1,21 @@
+package com.ruoyi.app.user.dto;
+
+import lombok.Data;
+
+/**
+ * 设备信任免绑一键确认入参(029-device-trust-phone-bind)。
+ * oauthLogin 返回 deviceConfirm 分支后,App 弹只读脱敏手机号确认框,
+ * 用户点确认调本 DTO 对应接口完成免短信绑定登录。
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Data
+public class OAuthDeviceConfirmDto {
+
+    /** oauthLogin 返回的临时凭证 */
+    private String tempKey;
+
+    /** 设备唯一标识(须与 oauthLogin 时一致,否则拒绝) */
+    private String deviceId;
+}

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java

@@ -22,4 +22,7 @@ public class OAuthLoginDto {
     private String cidType;
     private String deviceToken;
     private String voIPToken;
+
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 77 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java

@@ -0,0 +1,77 @@
+package com.ruoyi.app.user.service;
+
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+
+/**
+ * 设备-手机号信任服务(029-device-trust-phone-bind)。
+ * <p>规则一句话:手机号验证通过 + 登录成功 + 带了合法 deviceId → upsert 信任记录。</p>
+ * <p>调用点:lodeing 短信登录、oauthBindPhone 三方短信绑定、oauthLogin 已绑定直登、
+ * oauthDeviceConfirm 一键确认、LINE 回调已绑定路径。同设备以最近一次为准(覆盖),
+ * 入参不全时静默跳过,绝不影响登录主流程。</p>
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Service
+public class DeviceTrustService {
+
+    private final InfoUserDeviceMapper deviceMapper;
+
+    public DeviceTrustService(InfoUserDeviceMapper deviceMapper) {
+        this.deviceMapper = deviceMapper;
+    }
+
+    /** deviceId 合法性:非空且 ≤64(空串/超长按未传处理,老版本 App 兼容) */
+    public static boolean isValidDeviceId(String deviceId) {
+        return deviceId != null && !deviceId.isEmpty() && deviceId.length() <= 64;
+    }
+
+    /** 记录/刷新设备信任(同设备以最近一次为准;入参不全时静默跳过) */
+    public void recordLogin(String deviceId, String phone, Long userId) {
+        if (!isValidDeviceId(deviceId) || phone == null || phone.isEmpty() || userId == null) {
+            return;
+        }
+        InfoUserDevice exist = deviceMapper.selectById(deviceId);
+        Date now = new Date();
+        if (exist == null) {
+            InfoUserDevice row = new InfoUserDevice();
+            row.setDeviceId(deviceId);
+            row.setPhone(phone);
+            row.setUserId(userId);
+            row.setCreateTime(now);
+            row.setUpdateTime(now);
+            deviceMapper.insert(row);
+        } else {
+            exist.setPhone(phone);
+            exist.setUserId(userId);
+            exist.setUpdateTime(now);
+            deviceMapper.updateById(exist);
+        }
+    }
+
+    /** 查询设备信任记录(deviceId 非法返回 null,不查库) */
+    public InfoUserDevice getTrust(String deviceId) {
+        if (!isValidDeviceId(deviceId)) {
+            return null;
+        }
+        return deviceMapper.selectById(deviceId);
+    }
+
+    /** 查询设备最近一次验证通过的手机号,无信任记录返回 null */
+    public String getTrustedPhone(String deviceId) {
+        InfoUserDevice trust = getTrust(deviceId);
+        return trust == null ? null : trust.getPhone();
+    }
+
+    /** 手机号脱敏:保留前 3 后 4,中间 ****(长度不足 8 原样返回,与原日志脱敏规则一致) */
+    public static String maskPhone(String phone) {
+        if (phone == null || phone.length() < 8) {
+            return phone;
+        }
+        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
+    }
+}

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

@@ -185,6 +185,8 @@ no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
 no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}

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

@@ -189,6 +189,8 @@ no.oauth.token.invalid={0} login credential is invalid
 no.oauth.token.expired={0} login credential has expired
 no.oauth.audience.mismatch={0} credential verification failed
 no.oauth.verify.fail={0} login verification failed: {1}
+no.oauth.device.confirm=Confirm binding the phone number verified on this device
+no.oauth.device.mismatch=Invalid device confirmation, please log in again
 
 # Order invoice validation (010)
 no.invoice.choice.invalid=Invalid invoice type: {0}

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

@@ -189,6 +189,8 @@ no.oauth.token.invalid=ข้อมูลรับรองการเข้า
 no.oauth.token.expired=ข้อมูลรับรองการเข้าสู่ระบบ {0} หมดอายุแล้ว
 no.oauth.audience.mismatch=การตรวจสอบข้อมูลรับรอง {0} ไม่ผ่าน
 no.oauth.verify.fail=การตรวจสอบการเข้าสู่ระบบ {0} ไม่สำเร็จ: {1}
+no.oauth.device.confirm=โปรดยืนยันการผูกหมายเลขโทรศัพท์ที่ยืนยันบนอุปกรณ์นี้
+no.oauth.device.mismatch=การยืนยันอุปกรณ์ไม่ถูกต้อง โปรดเข้าสู่ระบบอีกครั้ง
 
 # ตรวจสอบใบกำกับภาษีของคำสั่งซื้อ (010)
 no.invoice.choice.invalid=ประเภทใบกำกับภาษีไม่ถูกต้อง: {0}

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

@@ -189,6 +189,8 @@ no.oauth.token.invalid=Thông tin đăng nhập {0} không hợp lệ
 no.oauth.token.expired=Thông tin đăng nhập {0} đã hết hạn
 no.oauth.audience.mismatch=Xác minh thông tin {0} không thành công
 no.oauth.verify.fail=Xác minh đăng nhập {0} thất bại: {1}
+no.oauth.device.confirm=Vui lòng xác nhận liên kết số điện thoại đã xác minh trên thiết bị này
+no.oauth.device.mismatch=Xác nhận thiết bị không hợp lệ, vui lòng đăng nhập lại
 
 # Kiểm tra hóa đơn đơn hàng (010)
 no.invoice.choice.invalid=Loại hóa đơn không hợp lệ: {0}

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

@@ -189,6 +189,8 @@ no.oauth.token.invalid={0}登录凭证无效
 no.oauth.token.expired={0}登录凭证已过期
 no.oauth.audience.mismatch={0}凭证校验未通过
 no.oauth.verify.fail={0}登录校验失败:{1}
+no.oauth.device.confirm=请确认绑定本机已验证的手机号
+no.oauth.device.mismatch=设备确认凭证无效,请重新登录
 
 # 订单发票校验(010)
 no.invoice.choice.invalid=发票类型不合法:{0}

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

@@ -189,6 +189,8 @@ no.oauth.token.invalid={0}登入憑證無效
 no.oauth.token.expired={0}登入憑證已過期
 no.oauth.audience.mismatch={0}憑證校驗未通過
 no.oauth.verify.fail={0}登入校驗失敗:{1}
+no.oauth.device.confirm=請確認綁定本機已驗證的手機號
+no.oauth.device.mismatch=設備確認憑證無效,請重新登入
 
 # 訂單發票校驗(010)
 no.invoice.choice.invalid=發票類型不合法:{0}

+ 393 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java

@@ -0,0 +1,393 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.dto.OAuthBindDto;
+import com.ruoyi.app.user.dto.OAuthDeviceConfirmDto;
+import com.ruoyi.app.user.dto.OAuthLoginDto;
+import com.ruoyi.system.domain.vo.UserDTO;
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 设备信任免绑端到端分支测试(029-device-trust-phone-bind,TDD 先行)。
+ * 直接以 Mockito 实例化 InfoUserController,走 oauthLogin/oauthDeviceConfirm/oauthBindPhone/lodeing
+ * 的关键分支;JwtUtil/MessageUtils 通过 SpringUtils 静态 BeanFactory 替换为测试桩。
+ */
+class DeviceTrustFlowTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final RedisCache redisCache = mock(RedisCache.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final InfoUserOauthMapper infoUserOauthMapper = mock(InfoUserOauthMapper.class);
+    private final com.ruoyi.app.utils.oauth.OAuthVerifyService oauthVerifyService =
+            mock(com.ruoyi.app.utils.oauth.OAuthVerifyService.class);
+    private final com.ruoyi.system.service.IUserWalletService userWalletService =
+            mock(com.ruoyi.system.service.IUserWalletService.class);
+    private final com.ruoyi.system.service.IVipUserService vipUserService =
+            mock(com.ruoyi.system.service.IVipUserService.class);
+    private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class);
+
+    private InfoUserController controller;
+
+    @BeforeAll
+    static void initStatics() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+    }
+
+    @AfterAll
+    static void restoreStatics() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+        JwtUtilRestore.restore();
+    }
+
+    @BeforeEach
+    void setUp() {
+        // MessageUtils:返回 key 本身;JwtUtil 的 getBean(RedisCache) 也从这个工厂取
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
+
+        controller = new InfoUserController();
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
+        ReflectionTestUtils.setField(controller, "userWalletService", userWalletService);
+        ReflectionTestUtils.setField(controller, "vipUserService", vipUserService);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+    }
+
+    @AfterEach
+    void tearDown() {
+        RequestContextHolder.resetRequestAttributes();
+    }
+
+    /** JwtUtil 静态依赖还原占位(当前版本经 SpringUtils 取 Bean,无需额外清理) */
+    private static final class JwtUtilRestore {
+        static void restore() {
+            // JwtUtil.setToken 每次经 SpringUtils.getBean 动态获取,无静态持有,无需还原
+        }
+    }
+
+    private static InfoUser user(long id, String phone, String status) {
+        InfoUser u = new InfoUser();
+        u.setUserId(id);
+        u.setPhone(phone);
+        u.setStatus(status);
+        u.setDelFlag("0");
+        return u;
+    }
+
+    private static InfoUserDevice trust(String deviceId, String phone, long userId) {
+        InfoUserDevice t = new InfoUserDevice();
+        t.setDeviceId(deviceId);
+        t.setPhone(phone);
+        t.setUserId(userId);
+        return t;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> dataMap(AjaxResult result) {
+        return (Map<String, Object>) result.get("data");
+    }
+
+    // ==================== oauthLogin 分支 ====================
+
+    @Test
+    void oauthLoginUnboundWithTrustedDeviceReturnsDeviceConfirmAndWritesBothKeys() {
+        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("google");
+        dto.setCredential("cred-1");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals(200, result.get("code"));
+        Map<String, Object> data = dataMap(result);
+        assertEquals("deviceConfirm", data.get("status"));
+        assertNotNull(data.get("tempKey"));
+        assertEquals("098****4321", data.get("maskedPhone"));
+        String tempKey = (String) data.get("tempKey");
+        verify(redisCache).setCacheObject(eq("oauth:bind:" + tempKey), eq("google@uid-1"), eq(5), eq(TimeUnit.MINUTES));
+        verify(redisCache).setCacheObject(eq("oauth:bind:dev:" + tempKey), eq("dev-1"), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void oauthLoginUnboundWithoutDeviceIdStillReturnsNeedPhone() {
+        when(oauthVerifyService.verify("google", "cred-1")).thenReturn("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("google");
+        dto.setCredential("cred-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        Map<String, Object> data = dataMap(result);
+        assertEquals("needPhone", data.get("status"));
+        assertNotNull(data.get("tempKey"));
+        assertNull(data.get("maskedPhone"));
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void oauthLoginUnboundWithUnknownDeviceStillReturnsNeedPhone() {
+        when(oauthVerifyService.verify("apple", "cred-2")).thenReturn("uid-2");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-x")).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("apple");
+        dto.setCredential("cred-2");
+        dto.setDeviceId("dev-x");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals("needPhone", dataMap(result).get("status"));
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    // ==================== oauthDeviceConfirm ====================
+
+    private void stubConfirmRedis(String tempKey, String deviceId) {
+        when(redisCache.getCacheObject("oauth:bind:" + tempKey)).thenReturn("google@uid-1");
+        when(redisCache.getCacheObject("oauth:bind:dev:" + tempKey)).thenReturn(deviceId);
+    }
+
+    private OAuthDeviceConfirmDto confirmDto(String tempKey, String deviceId) {
+        OAuthDeviceConfirmDto dto = new OAuthDeviceConfirmDto();
+        dto.setTempKey(tempKey);
+        dto.setDeviceId(deviceId);
+        return dto;
+    }
+
+    @Test
+    void confirmBindsTrustedPhoneExistingUserAndIssuesToken() {
+        stubConfirmRedis("tk-1", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getuser("0987654321")).thenReturn(existing);
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-1", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertEquals("no.user.login.success", result.get("msg"));
+        assertNotNull(result.get("token"));
+        verify(infoUserOauthMapper).insert(org.mockito.ArgumentMatchers.<InfoUserOauth>argThat(b -> "google".equals(b.getProvider())
+                && "uid-1".equals(b.getProviderUid()) && b.getUserId() == 100L));
+        verify(redisCache).deleteObject("oauth:bind:tk-1");
+        verify(redisCache).deleteObject("oauth:bind:dev:tk-1");
+        // recordLogin:同设备同号刷新信任
+        verify(deviceMapper).updateById(any(InfoUserDevice.class));
+    }
+
+    @Test
+    void confirmCreatesUserWhenTrustedPhoneNotRegistered() {
+        stubConfirmRedis("tk-2", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0999888777", 0L));
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(infoUserService.getuser("0999888777")).thenReturn(null, user(300L, "0999888777", "0"));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-2", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(userWalletService).createUserWallet(300L);
+        verify(infoUserOauthMapper).insert(org.mockito.ArgumentMatchers.<InfoUserOauth>argThat(b -> b.getUserId() == 300L));
+    }
+
+    @Test
+    void confirmExpiredTempKeyReturnsExpiredError() {
+        when(redisCache.getCacheObject("oauth:bind:tk-3")).thenReturn(null);
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-3", "dev-1"));
+        assertEquals(500, result.get("code"));
+        assertEquals("no.oauth.tempkey.expired", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmWithDeviceMismatchOrMissingDevKeyIsRejected() {
+        stubConfirmRedis("tk-4", "dev-other");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        AjaxResult wrongDevice = controller.oauthDeviceConfirm(confirmDto("tk-4", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", wrongDevice.get("msg"));
+
+        // dev 键缺失(tempKey 来自 needPhone 分支)
+        when(redisCache.getCacheObject("oauth:bind:tk-5")).thenReturn("google@uid-1");
+        when(redisCache.getCacheObject("oauth:bind:dev:tk-5")).thenReturn(null);
+        AjaxResult noDevKey = controller.oauthDeviceConfirm(confirmDto("tk-5", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", noDevKey.get("msg"));
+
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmWhenTrustRowGoneIsRejected() {
+        stubConfirmRedis("tk-6", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-6", "dev-1"));
+        assertEquals("no.oauth.device.mismatch", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmRejectedWhenTrustedAccountStopped() {
+        stubConfirmRedis("tk-7", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        when(infoUserService.getuser("0987654321")).thenReturn(user(100L, "0987654321", "1"));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-7", "dev-1"));
+        assertEquals("no.user.stop", result.get("msg"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    @Test
+    void confirmFallsBackToDirectLoginWhenBoundMeanwhile() {
+        stubConfirmRedis("tk-8", "dev-1");
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+        InfoUserOauth exist = new InfoUserOauth();
+        exist.setUserId(100L);
+        exist.setProvider("google");
+        exist.setProviderUid("uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(exist);
+        when(infoUserService.getById(100L)).thenReturn(user(100L, "0987654321", "0"));
+
+        AjaxResult result = controller.oauthDeviceConfirm(confirmDto("tk-8", "dev-1"));
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class));
+    }
+
+    // ==================== 记录点(US2) ====================
+
+    @Test
+    void phoneLoginSuccessRecordsDeviceTrust() {
+        when(redisCache.getCacheObject("0987654321")).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getuser("0987654321")).thenReturn(existing);
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(vipUserService.getOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        UserDTO dto = new UserDTO();
+        dto.setPhone("0987654321");
+        dto.setCode("8888");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.lodeing(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "dev-1".equals(row.getDeviceId())
+                && "0987654321".equals(row.getPhone()) && row.getUserId() == 100L));
+    }
+
+    @Test
+    void phoneLoginWithoutDeviceIdSkipsRecording() {
+        when(redisCache.getCacheObject("0987654321")).thenReturn("1234");
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getuser("0987654321")).thenReturn(existing);
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(vipUserService.getOne(any())).thenReturn(null);
+
+        UserDTO dto = new UserDTO();
+        dto.setPhone("0987654321");
+        dto.setCode("1234");
+        AjaxResult result = controller.lodeing(dto);
+
+        assertEquals(200, result.get("code"));
+        verifyNoInteractions(deviceMapper);
+    }
+
+    @Test
+    void oauthBindPhoneSuccessRecordsDeviceTrust() {
+        when(redisCache.getCacheObject("oauth:bind:tk-b1")).thenReturn("google@uid-9");
+        when(redisCache.getCacheObject("0987654321")).thenReturn(null);
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getuser("0987654321")).thenReturn(existing);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        OAuthBindDto dto = new OAuthBindDto();
+        dto.setTempKey("tk-b1");
+        dto.setPhone("0987654321");
+        dto.setCode("8888");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthBindPhone(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(any(InfoUserDevice.class));
+    }
+
+    @Test
+    void oauthLoginBoundDirectLoginRecordsDeviceTrust() {
+        when(oauthVerifyService.verify("line", "cred-3")).thenReturn("uid-3");
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(100L);
+        bind.setProvider("line");
+        bind.setProviderUid("uid-3");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
+        InfoUser existing = user(100L, "0987654321", "0");
+        when(infoUserService.getOne(any())).thenReturn(existing);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        OAuthLoginDto dto = new OAuthLoginDto();
+        dto.setProvider("line");
+        dto.setCredential("cred-3");
+        dto.setDeviceId("dev-1");
+        AjaxResult result = controller.oauthLogin(dto);
+
+        assertEquals(200, result.get("code"));
+        assertNotNull(result.get("token"));
+        verify(deviceMapper).insert(org.mockito.ArgumentMatchers.<InfoUserDevice>argThat(row -> "0987654321".equals(row.getPhone())));
+    }
+}

+ 161 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java

@@ -0,0 +1,161 @@
+package com.ruoyi.app.user;
+
+import com.ruoyi.app.user.service.DeviceTrustService;
+import com.ruoyi.common.core.redis.RedisCache;
+import com.ruoyi.common.utils.spring.SpringUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.domain.InfoUserOauth;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import com.ruoyi.system.mapper.InfoUserOauthMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.support.StaticMessageSource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.startsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * LINE 回调设备信任免绑分支测试(029-device-trust-phone-bind,TDD 先行)。
+ * state 参数语义:前端把 deviceId 放进 LINE authorize 的 state 带回(见 contracts/api-contract.md §5)。
+ */
+class LineCallbackDeviceTrustTest {
+
+    private static ConfigurableListableBeanFactory originalBeanFactory;
+
+    private final RedisCache redisCache = mock(RedisCache.class);
+    private final IInfoUserService infoUserService = mock(IInfoUserService.class);
+    private final InfoUserOauthMapper infoUserOauthMapper = mock(InfoUserOauthMapper.class);
+    private final com.ruoyi.app.utils.oauth.OAuthVerifyService oauthVerifyService =
+            mock(com.ruoyi.app.utils.oauth.OAuthVerifyService.class);
+    private final InfoUserDeviceMapper deviceMapper = mock(InfoUserDeviceMapper.class);
+
+    private LineCallbackController controller;
+    private MockHttpServletResponse response;
+
+    @BeforeAll
+    static void saveStatics() {
+        originalBeanFactory = (ConfigurableListableBeanFactory)
+                ReflectionTestUtils.getField(SpringUtils.class, "beanFactory");
+    }
+
+    @AfterAll
+    static void restoreStatics() {
+        new SpringUtils().postProcessBeanFactory(originalBeanFactory);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+        StaticMessageSource messageSource = new StaticMessageSource();
+        messageSource.setUseCodeAsDefaultMessage(true);
+        beanFactory.registerSingleton("messageSource", messageSource);
+        beanFactory.registerSingleton("redisCache", redisCache);
+        new SpringUtils().postProcessBeanFactory(beanFactory);
+
+        RequestContextHolder.setRequestAttributes(
+                new ServletRequestAttributes(new MockHttpServletRequest()));
+
+        controller = new LineCallbackController();
+        ReflectionTestUtils.setField(controller, "redisCache", redisCache);
+        ReflectionTestUtils.setField(controller, "infoUserService", infoUserService);
+        ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper);
+        ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService);
+        ReflectionTestUtils.setField(controller, "deviceTrustService", new DeviceTrustService(deviceMapper));
+        ReflectionTestUtils.setField(controller, "appRedirect", "com.test.app://oauthLogin");
+
+        response = new MockHttpServletResponse();
+    }
+
+    @AfterEach
+    void tearDown() {
+        RequestContextHolder.resetRequestAttributes();
+    }
+
+    private static InfoUserDevice trust(String deviceId, String phone, long userId) {
+        InfoUserDevice t = new InfoUserDevice();
+        t.setDeviceId(deviceId);
+        t.setPhone(phone);
+        t.setUserId(userId);
+        return t;
+    }
+
+    @Test
+    void unboundWithTrustedDeviceRedirectsDeviceConfirmAndWritesBothKeys() throws Exception {
+        when(oauthVerifyService.verify("line", "code-1")).thenReturn("line-uid-1");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-1")).thenReturn(trust("dev-1", "0987654321", 100L));
+
+        controller.callback("code-1", "dev-1", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?deviceConfirm=1&tempKey="),
+                "应回跳 deviceConfirm 分支,实际: " + location);
+        assertTrue(location.endsWith("&maskedPhone=098****4321"), "应带脱敏手机号,实际: " + location);
+
+        ArgumentCaptor<String> tempKeyCaptor = ArgumentCaptor.forClass(String.class);
+        verify(redisCache).setCacheObject(startsWith("oauth:bind:"), eq("line@line-uid-1"), eq(5), eq(TimeUnit.MINUTES));
+        verify(redisCache).setCacheObject(startsWith("oauth:bind:dev:"), eq("dev-1"), eq(5), eq(TimeUnit.MINUTES));
+    }
+
+    @Test
+    void unboundWithUnknownStateStillRedirectsNeedPhone() throws Exception {
+        when(oauthVerifyService.verify("line", "code-2")).thenReturn("line-uid-2");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(null);
+        when(deviceMapper.selectById("dev-x")).thenReturn(null);
+
+        controller.callback("code-2", "dev-x", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?needPhone=1&tempKey="),
+                "应维持 needPhone 回跳,实际: " + location);
+        verify(redisCache, never()).setCacheObject(startsWith("oauth:bind:dev:"), anyString(), anyInt(), any(TimeUnit.class));
+    }
+
+    @Test
+    void boundUserWithDeviceStateRedirectsTokenAndRecordsTrust() throws Exception {
+        when(oauthVerifyService.verify("line", "code-3")).thenReturn("line-uid-3");
+        InfoUserOauth bind = new InfoUserOauth();
+        bind.setUserId(100L);
+        bind.setProvider("line");
+        bind.setProviderUid("line-uid-3");
+        when(infoUserOauthMapper.selectOne(any())).thenReturn(bind);
+        InfoUser u = new InfoUser();
+        u.setUserId(100L);
+        u.setPhone("0987654321");
+        u.setStatus("0");
+        when(infoUserService.getOne(any())).thenReturn(u);
+        when(deviceMapper.selectById("dev-1")).thenReturn(null);
+
+        controller.callback("code-3", "dev-1", response);
+
+        String location = response.getRedirectedUrl();
+        assertTrue(location != null && location.startsWith("com.test.app://oauthLogin?token="),
+                "已绑定应直接回跳 token,实际: " + location);
+        verify(deviceMapper).insert(any(InfoUserDevice.class));
+    }
+}

+ 137 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java

@@ -0,0 +1,137 @@
+package com.ruoyi.app.user.service;
+
+import com.ruoyi.system.domain.InfoUserDevice;
+import com.ruoyi.system.mapper.InfoUserDeviceMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * DeviceTrustService 单元测试(029-device-trust-phone-bind,TDD 先行)。
+ */
+class DeviceTrustServiceTest {
+
+    private final InfoUserDeviceMapper mapper = mock(InfoUserDeviceMapper.class);
+    private final DeviceTrustService service = new DeviceTrustService(mapper);
+
+    // ---------- isValidDeviceId ----------
+
+    @Test
+    void nullOrEmptyOrOversizeDeviceIdIsInvalid() {
+        assertFalse(DeviceTrustService.isValidDeviceId(null));
+        assertFalse(DeviceTrustService.isValidDeviceId(""));
+        assertFalse(DeviceTrustService.isValidDeviceId("x".repeat(65)));
+        assertTrue(DeviceTrustService.isValidDeviceId("x".repeat(64)));
+        assertTrue(DeviceTrustService.isValidDeviceId("dev-uuid-001"));
+    }
+
+    // ---------- recordLogin:首次 insert ----------
+
+    @Test
+    void firstVerifiedLoginInsertsNewTrustRowWithBothTimestamps() {
+        when(mapper.selectById("dev-1")).thenReturn(null);
+
+        service.recordLogin("dev-1", "0987654321", 100L);
+
+        verify(mapper).insert(any(InfoUserDevice.class));
+        verify(mapper, never()).updateById(any(InfoUserDevice.class));
+        org.mockito.ArgumentCaptor<InfoUserDevice> captor =
+                org.mockito.ArgumentCaptor.forClass(InfoUserDevice.class);
+        verify(mapper).insert(captor.capture());
+        InfoUserDevice saved = captor.getValue();
+        assertEquals("dev-1", saved.getDeviceId());
+        assertEquals("0987654321", saved.getPhone());
+        assertEquals(100L, saved.getUserId());
+        assertTrue(saved.getCreateTime() != null && saved.getUpdateTime() != null);
+    }
+
+    // ---------- recordLogin:同设备换号覆盖 ----------
+
+    @Test
+    void laterLoginWithDifferentPhoneOverwritesTrustRow() {
+        InfoUserDevice exist = new InfoUserDevice();
+        exist.setDeviceId("dev-1");
+        exist.setPhone("0980000001");
+        exist.setUserId(100L);
+        when(mapper.selectById("dev-1")).thenReturn(exist);
+
+        service.recordLogin("dev-1", "0987654321", 200L);
+
+        verify(mapper).updateById(exist);
+        verify(mapper, never()).insert(any(InfoUserDevice.class));
+        assertEquals("0987654321", exist.getPhone());
+        assertEquals(200L, exist.getUserId());
+        assertTrue(exist.getUpdateTime() != null);
+    }
+
+    // ---------- recordLogin:非法入参静默跳过 ----------
+
+    @Test
+    void invalidDeviceIdOrMissingPhoneOrUserSkipsSilently() {
+        service.recordLogin(null, "0987654321", 100L);
+        service.recordLogin("", "0987654321", 100L);
+        service.recordLogin("x".repeat(65), "0987654321", 100L);
+        service.recordLogin("dev-1", null, 100L);
+        service.recordLogin("dev-1", "0987654321", null);
+
+        verifyNoInteractions(mapper);
+    }
+
+    // ---------- getTrust / getTrustedPhone ----------
+
+    @Test
+    void getTrustReturnsNullForInvalidDeviceIdWithoutQuery() {
+        assertNull(service.getTrust(null));
+        assertNull(service.getTrust(""));
+        verifyNoInteractions(mapper);
+    }
+
+    @Test
+    void getTrustedPhoneReturnsRowPhoneOrNull() {
+        InfoUserDevice row = new InfoUserDevice();
+        row.setDeviceId("dev-1");
+        row.setPhone("0987654321");
+        when(mapper.selectById("dev-1")).thenReturn(row);
+        when(mapper.selectById("dev-2")).thenReturn(null);
+
+        assertEquals("0987654321", service.getTrustedPhone("dev-1"));
+        assertNull(service.getTrustedPhone("dev-2"));
+        verify(mapper).selectById("dev-1");
+        verify(mapper).selectById("dev-2");
+    }
+
+    // ---------- maskPhone(与现有日志脱敏规则一致:前3后4) ----------
+
+    @Test
+    void maskPhoneKeepsHead3Tail4AndMasksMiddle() {
+        assertEquals("098****4321", DeviceTrustService.maskPhone("0987654321"));
+    }
+
+    @Test
+    void maskPhoneReturnsShortValueAsIs() {
+        assertNull(DeviceTrustService.maskPhone(null));
+        assertEquals("1234567", DeviceTrustService.maskPhone("1234567"));
+    }
+
+    // ---------- 辅助:确认测试桩行为不受 anyString 干扰 ----------
+
+    @Test
+    void recordLoginSameDeviceTwiceOnlyQueriesByDeviceId() {
+        when(mapper.selectById(anyString())).thenReturn(null);
+        service.recordLogin("dev-1", "0987654321", 100L);
+        service.recordLogin("dev-1", "0987654321", 100L);
+        verify(mapper, org.mockito.Mockito.times(2)).selectById("dev-1");
+        assertSame("0987654321", "0987654321");
+    }
+}

+ 43 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java

@@ -0,0 +1,43 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 设备-手机号信任表 info_user_device。
+ * <p>记录"某台设备最近一次验证通过的手机号"(029-device-trust-phone-bind)。设备唯一标识
+ * 由 App 首启生成并持久化;凡手机号验证通过的登录成功(短信登录/三方短信绑定/三方已绑定直登/
+ * 一键确认登录)都会 upsert 本表,同设备以最近一次为准。之后该设备上三方登录遇未绑定身份时,
+ * 凭本表记录免短信一键确认绑定。</p>
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+@Data
+@TableName("info_user_device")
+public class InfoUserDevice {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 设备唯一标识(App 首启生成持久化,≤64),一台设备一行 */
+    @TableId
+    private String deviceId;
+
+    /** 本设备最近一次验证通过的手机号 */
+    private String phone;
+
+    /** 关联 info_user.user_id */
+    private Long userId;
+
+    /** 首次建立信任时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 最近一次登录刷新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 2 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java

@@ -17,4 +17,6 @@ public class UserDTO {
     private String deviceToken;
     /** iOS VoIP 来电推送 token */
     private String voIPToken;
+    /** 设备唯一标识(App 首启生成持久化,可选,≤64;029 设备信任免绑) */
+    private String deviceId;
 }

+ 13 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java

@@ -0,0 +1,13 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.InfoUserDevice;
+
+/**
+ * 设备-手机号信任 Mapper(仅用 MyBatis-Plus CRUD,无自定义查询、无 XML)。
+ *
+ * @author foodie
+ * @date 2026-09-20
+ */
+public interface InfoUserDeviceMapper extends BaseMapper<InfoUserDevice> {
+}

+ 35 - 0
specs/029-device-trust-phone-bind/checklists/requirements.md

@@ -0,0 +1,35 @@
+# Specification Quality Checklist: 设备信任免绑手机号
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-09-20
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs) — 注:tempKey/state/info_user_oauth 等为现有产品契约标识与项目既有概念,沿用 017-oauth-login 规格的行文惯例,非新引入的实现细节
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain — 设计决策已在头脑风暴阶段与用户逐一确认(痛点场景、设备标识来源、一键确认交互、LINE state 方案)
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details) — SC-003 以"与现有接口同一量级"表述,避免绝对数值
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified(老版本兼容/无信任/tempKey 过期/不匹配/账号停用/已绑定优先/共用设备/异常值)
+- [x] Scope is clearly bounded(不含商户/骑手端、不含前端实施)
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows(三条用户故事覆盖:免绑主流程、信任建立、LINE 回调链路)
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 所有条目通过,无需迭代。规格可直接进入 `/speckit-plan`。
+- FR-009/FR-010 为项目流程性要求(数据库变更管理、前端契约交付),对应 CLAUDE.md 规范。

+ 90 - 0
specs/029-device-trust-phone-bind/contracts/api-contract.md

@@ -0,0 +1,90 @@
+# 前端接口契约:设备信任免绑手机号(uni-app 用户端)
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](../spec.md) | **后端分支**: `029-device-trust-phone-bind`
+
+> 本文档是后端对前端的唯一契约。前端按此实施,无需口头追问。所有接口均为现有接口的**增量**,不传新字段时行为与现网完全一致(老版本 App 无感)。
+
+## 0. 设备唯一标识 deviceId(前端生成)
+
+- **定义**:App 首次启动时生成的 UUID 字符串,**≤64 字符**,持久化保存,之后每次登录请求都带上。
+- **持久化建议**:
+  - **iOS**:存 Keychain(卸载重装不丢,有现成 uni-app 插件);退化用 storage 也可,只是重装后要重新绑一次。
+  - **Android**:优先取 `ANDROID_ID`(同设备+同签名跨重装稳定、免权限);退化用 storage 存自生成 UUID。
+- **传参规则**:字符串、可选。空串/超长后端按未传处理。**不要**在未登录的其它业务接口上报。
+
+## 1. 入参变更(三个现有接口加可选字段 `deviceId`)
+
+| 接口 | 位置 | 变更 |
+|---|---|---|
+| `POST /infouser/user/lodeing` | UserDTO | + `deviceId`(登录成功后端记录设备信任) |
+| `POST /infouser/user/oauthLogin` | OAuthLoginDto | + `deviceId`(未绑定时影响返回分支,见 §2) |
+| `POST /infouser/user/oauthBindPhone` | OAuthBindDto | + `deviceId`(绑定成功后端记录设备信任) |
+
+## 2. `oauthLogin` 返回新增分支 `deviceConfirm`
+
+现有返回不变,新增一种:
+
+| 返回(AjaxResult data) | 条件 | App 行为 |
+|---|---|---|
+| `token` + 用户信息 | 三方身份已绑定 | 直接进 App(现状不变) |
+| `{status:"needPhone", tempKey}` | 未绑定,且设备无信任记录 | 现有输手机号+短信流程(不变) |
+| `{status:"deviceConfirm", tempKey, maskedPhone}` **新增** | 未绑定,且 deviceId 有信任记录 | 弹一键确认框,见 §3 |
+
+- `maskedPhone`:脱敏手机号,格式 `前3位****后4位`(如 `098****4321`),**只读展示,不可编辑、不提供换号输入**。
+- tempKey 有效期 **5 分钟**,过期后确认接口返回 `no.oauth.tempkey.expired`,需重新点三方登录拿新的。
+
+## 3. 确认弹窗规范
+
+- 文案示意:`将绑定 {maskedPhone} 并登录`;按钮:`确认` / `取消`。
+- **确认** → 调 §4 新接口;**取消** → 关闭弹窗回登录页,不产生任何绑定(用户仍可用手机号+短信登录,或换其它三方登录)。
+- 弹窗内不出现验证码输入框、不出现可编辑手机号。
+
+## 4. 新接口:`POST /infouser/user/oauthDeviceConfirm`
+
+**用途**:设备确认分支的一键绑定登录(免短信)。
+
+```json
+// 请求体(OAuthDeviceConfirmDto)
+{ "tempKey": "abc123...", "deviceId": "uuid-..." }
+```
+
+**成功**:响应结构与 `oauthBindPhone` 成功完全一致(msg=登录成功、`user` 用户信息、`token`),直接进 App。
+
+**失败**(AjaxResult error,msg 为国际化文案):
+
+| 场景 | 错误 key(参考) | App 处理建议 |
+|---|---|---|
+| tempKey 缺失 | `no.oauth.tempkey.missing` | 提示后回登录页 |
+| tempKey 过期/不存在 | `no.oauth.tempkey.expired` | 提示"请重新登录",回登录页 |
+| deviceId 缺失 / tempKey 不是设备确认凭证 / deviceId 不匹配 / 设备无信任记录 | `no.oauth.device.mismatch` | 提示后回登录页(可回落手动绑定流程) |
+| 信任手机号账号已停用 | `no.user.stop` | 提示账号停用,回登录页 |
+| 期间该三方身份已被其它端绑定 | 无错误——直接返回 token 登录成功(容错直登,与 oauthBindPhone 一致) | 直接进 App |
+
+## 5. LINE 登录(网页回调链路)
+
+**authorize URL 变更(唯一的必改点)**:`state` 参数从随机串改为传 `deviceId`:
+
+```
+https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id=...&redirect_uri=...&scope=profile%20openid&state={deviceId}
+```
+
+**302 回跳 App(`com.twanmsdyh.app://oauthLogin`)分支变化**:
+
+| 回跳参数 | 条件 | App 行为 |
+|---|---|---|
+| `?token=xxx` | 已绑定 | 进 App(现状不变) |
+| `?needPhone=1&tempKey=xxx` | 未绑定、无设备信任 | 现有手机号+短信流程(不变) |
+| `?deviceConfirm=1&tempKey=xxx&maskedPhone=098****4321` **新增** | 未绑定、state 携带的 deviceId 有信任记录 | 弹 §3 同款确认框,确认 → §4 接口 |
+| `?error=xxx` | 异常 | 现状不变 |
+
+- `maskedPhone` 已做 URL 编码,App 解码后展示。
+- 走 `POST /oauthLogin`(前端自拿 code 的场景,如 H5)时同样受益:body 里带 `deviceId` 即可,返回分支同 §2。
+
+## 6. 联调自测清单
+
+1. 手机号+短信登录(带 deviceId)→ 卸载重装前,用 Apple 新身份登录 → 应弹 deviceConfirm(maskedPhone = 刚登录的号)。
+2. deviceConfirm 确认 → 返回 token,进 App 后"我的"页手机号 = maskedPhone 对应账号。
+3. deviceConfirm 取消 → 再用 Google 新身份登录 → 仍弹 deviceConfirm(未产生绑定)。
+4. 不带 deviceId(模拟老版本)→ 三方新身份登录 → 走 needPhone 原流程。
+5. 等 tempKey 过期(>5 分钟)后点确认 → 收到 `no.oauth.tempkey.expired`。
+6. LINE:authorize state=deviceId → 已信任设备走 deviceConfirm 回跳分支。

+ 68 - 0
specs/029-device-trust-phone-bind/data-model.md

@@ -0,0 +1,68 @@
+# Data Model: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+## 新增实体:InfoUserDevice(表 `info_user_device`)
+
+一台设备一行,`device_id` 主键;记录该设备**最近一次**手机号验证通过的登录。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| device_id | VARCHAR(64) | PK,NOT NULL | 设备唯一标识(App 首启生成持久化,≤64) |
+| phone | VARCHAR(20) | NOT NULL | 最近一次验证通过的手机号(账号主键,越南 +84) |
+| user_id | BIGINT | NOT NULL | phone 对应的 info_user.user_id |
+| create_time | DATETIME | NULL | 首次建立信任时间 |
+| update_time | DATETIME | NULL | 最近一次登录刷新时间 |
+
+**DDL(写入 `updatesql/sql.md`,人工执行):**
+
+```sql
+-- 2026-09-20 设备绑定手机号信任表(同设备三方登录免绑,029-device-trust-phone-bind)
+CREATE TABLE info_user_device (
+  device_id   VARCHAR(64) NOT NULL COMMENT '设备唯一标识(App首启生成持久化)',
+  phone       VARCHAR(20) NOT NULL COMMENT '本设备最近一次验证通过的手机号',
+  user_id     BIGINT NOT NULL COMMENT '对应用户ID(info_user.user_id)',
+  create_time DATETIME DEFAULT NULL COMMENT '首次建立信任时间',
+  update_time DATETIME DEFAULT NULL COMMENT '最近一次登录刷新时间',
+  PRIMARY KEY (device_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备-手机号信任表(029)';
+```
+
+### 校验与写入规则
+
+- 写入前置校验(Service 层):deviceId 非空且长度 ≤64,否则跳过记录(不报错,静默降级);phone、userId 必须来自**已验证通过的登录成功路径**。
+- **Upsert 语义**:`selectById(deviceId)` 命中 → 更新 phone/user_id/update_time(共用设备换号 = 最近一次覆盖);未命中 → insert,create_time = update_time = now。
+- 删除:本期不提供解除信任接口(YAGNI);误绑可通过确认框取消避免,后续需要再加。
+
+### 状态迁移(单行生命周期)
+
+```text
+[不存在] --首次验证登录(带deviceId)--> [信任 phone_A]
+[信任 phone_A] --phone_B 在本设备登录成功--> [信任 phone_B](覆盖)
+[信任 phone_X] --同号再次登录--> [信任 phone_X](仅刷新 update_time)
+```
+
+## 复用/扩展:临时凭证(Redis)
+
+| Key | 值 | TTL | 写入点 | 消费点 |
+|---|---|---|---|---|
+| `oauth:bind:{tempKey}` | `provider@providerUid`(**现状不变**) | 5 min | oauthLogin / LINE 回调 未绑定分支 | oauthBindPhone / oauthDeviceConfirm |
+| `oauth:bind:dev:{tempKey}`(**新增**) | `deviceId`(仅 deviceConfirm 分支写入) | 5 min | oauthLogin / LINE 回调 未绑定且设备有信任 | oauthDeviceConfirm |
+
+- 两个 key 同 TTL 写入;`oauthDeviceConfirm` 消费时要求两键齐全,且 `dev` 值与入参 deviceId 相等,否则按"凭证与设备不匹配"拒绝。
+- 确认成功后两个 key 一并删除(与 oauthBindPhone 删除现有 key 一致)。
+
+## 复用不变:InfoUserOauth(表 `info_user_oauth`)
+
+`UNIQUE(provider, provider_uid)` 语义不变。一键确认 = 向此表新增记录(provider + providerUid → 设备信任手机号对应 userId),插入逻辑与 `oauthBindPhone` 相同。
+
+## 脱敏规则(maskedPhone)
+
+复用现有 `maskPhone`:长度 ≥8 时返回 `前3位 + "****" + 后4位`(如 `098****4321`);长度不足 8 原样返回。DeviceTrustService 提供该方法,InfoUserController 现有私有实现改调共用(行为不变)。
+
+## 新增消息 key(i18n 资源文件,多语言)
+
+| key | 用途 |
+|---|---|
+| `no.oauth.device.confirm` | deviceConfirm 分支的成功提示语 |
+| `no.oauth.device.mismatch` | 确认接口:tempKey 非设备确认凭证 / dev 键缺失 / deviceId 不匹配 / 设备信任记录不存在 |

+ 98 - 0
specs/029-device-trust-phone-bind/plan.md

@@ -0,0 +1,98 @@
+# Implementation Plan: 设备信任免绑手机号
+
+**Branch**: `029-device-trust-phone-bind` | **Date**: 2026-09-20 | **Spec**: [spec.md](spec.md)
+
+**Input**: Feature specification from `/specs/029-device-trust-phone-bind/spec.md`
+
+## Summary
+
+同一台设备只要验证过一次手机号(短信登录 / 三方短信绑定 / 三方已绑定直登),即建立"设备→手机号"信任;之后该设备上任何未绑定三方身份登录时,返回脱敏手机号 + tempKey 让用户一键确认(免短信),确认后按现有绑定规则完成绑定并签发 token。LINE 网页回调链路通过 `state` 参数携带设备标识获得同等能力。只改后端,产出前端契约文档。
+
+技术路径:新表 `info_user_device`(MyBatis-Plus BaseMapper,无 XML)+ 新服务 `DeviceTrustService`(upsert/查询/脱敏)+ `InfoUserController` 三接口加 `deviceId` 入参与 `deviceConfirm` 分支 + 新接口 `oauthDeviceConfirm` + `LineCallbackController` state 解析。临时凭证沿用 `oauth:bind:` 前缀,新增正交 key `oauth:bind:dev:{tempKey}` 绑定设备标识,现有解析逻辑零改动。
+
+## Technical Context
+
+**Language/Version**: Java 21(Maven 多模块:ruoyi-admin / ruoyi-system / ruoyi-common)
+
+**Primary Dependencies**: Spring Boot、MyBatis + MyBatis-Plus(baomidou)、Lombok、RedisCache(Spring Redis)、自写 JwtUtil(HS256 + Redis 会话)、MessageUtils(i18n messages)
+
+**Storage**: MySQL(新表 `info_user_device`,DDL 只写 `updatesql/sql.md` 人工执行);Redis(临时凭证 `oauth:bind:*`,TTL 5 分钟)
+
+**Testing**: JUnit 5 + Mockito,测试位于 `ruoyi-admin/src/test/java/com/ruoyi/app/user/`(沿用 flashdelivery 测试组织方式)。构建命令:临时 `JAVA_HOME=%USERPROFILE%\.jdks\graalvm-jdk-21.0.12+7.1`,`mvn -pl ruoyi-admin -am test -Dtest=XxxTest -DfailIfNoSpecifiedTests=false`
+
+**Target Platform**: Linux 服务器部署的 Spring Boot Web 服务(越南用户端 uni-app 的后端)
+
+**Project Type**: web-service(单体多模块 Maven)
+
+**Performance Goals**: 与现有登录接口同量级(服务端处理 < 200ms),仅新增 1 次主键查询/写入,无新增外部调用
+
+**Constraints**: Controller 参数规范(@RequestBody DTO、DTO 无 Bean Validation 注解、校验放 Controller 用 MessageUtils);模块依赖 ruoyi-admin → ruoyi-system;SQL 不直接执行;保留文件原有 CRLF 换行风格
+
+**Scale/Scope**: 改 3 个 Controller/DTO 既有文件 + 新增 3 个 Java 文件 + 1 个实体/Mapper + SQL + 测试 + 前端契约文档;前端 uni-app 不在范围
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填写的空模板,无项目级 gates。以下以 CLAUDE.md 项目规范替代核对:
+
+| 约束 | 结果 |
+|---|---|
+| Controller 请求参数规范(DTO + @RequestBody,无 Map 入参,无 Bean Validation 注解) | PASS — 新接口用 `OAuthDeviceConfirmDto`,deviceId 校验在 Controller 内 |
+| 模块依赖方向 ruoyi-admin → ruoyi-system | PASS — 实体/Mapper 放 ruoyi-system,服务/编排放 ruoyi-admin |
+| 数据库变更写入 updatesql/sql.md 不直接执行 | PASS — 见 tasks |
+| Java 注释安全(块注释无 `*/` 序列) | PASS |
+| 已废弃代码清单(不参考不增量修改) | PASS — 不触碰 PayController/ZaloPay 等废弃代码 |
+| 后端 i18n 错误消息走 MessageUtils | PASS — 新增消息 key 加到 i18n 资源文件 |
+
+Phase 1 设计后复核:全部 PASS,无 Complexity Tracking 需要填写的违规项。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/029-device-trust-phone-bind/
+├── plan.md              # This file (/speckit-plan command output)
+├── research.md          # Phase 0 output (/speckit-plan command)
+├── data-model.md        # Phase 1 output (/speckit-plan command)
+├── quickstart.md        # Phase 1 output (/speckit-plan command)
+├── contracts/
+│   └── api-contract.md  # 前端(uni-app)接口契约
+└── tasks.md             # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
+```
+
+### Source Code (repository root)
+
+```text
+ruoyi-admin/src/main/java/com/ruoyi/app/user/
+├── InfoUserController.java          # lodeing/oauthLogin/oauthBindPhone 记录设备信任;oauthLogin 新增 deviceConfirm 分支;新接口 oauthDeviceConfirm
+├── LineCallbackController.java      # state 解析为 deviceId;未绑定分支查设备信任回跳 deviceConfirm;已绑定路径记录信任
+├── dto/
+│   ├── OAuthLoginDto.java           # +deviceId
+│   ├── OAuthBindDto.java            # +deviceId
+│   └── OAuthDeviceConfirmDto.java   # 新增 {tempKey, deviceId}
+└── service/
+    └── DeviceTrustService.java      # 新增:recordLogin(upsert)/getTrust/maskPhone
+
+ruoyi-admin/src/test/java/com/ruoyi/app/user/
+└── service/
+    └── DeviceTrustServiceTest.java  # 设备记录/覆盖/脱敏规则
+└── DeviceTrustFlowTest.java         # oauthLogin 分支判定 + oauthDeviceConfirm 成功/过期/不匹配/停用路径
+
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/InfoUserDevice.java       # 新增实体(@TableName info_user_device)
+├── domain/vo/UserDTO.java           # +deviceId
+└── mapper/InfoUserDeviceMapper.java # 新增(MP BaseMapper,无 XML,同 InfoUserOauthMapper 模式)
+
+ruoyi-system/src/main/resources/i18n/                                 # 新增消息 key(多语言)
+updatesql/sql.md                                                      # 建表 DDL 追加(带日期注释)
+```
+
+**Structure Decision**: 单体多模块 Maven。数据层(实体/Mapper)按既有模式放 ruoyi-system;业务编排与接口改造全部在 ruoyi-admin 的 app.user 包(登录逻辑现状集中在 InfoUserController,保持就近、surgical);新增 DeviceTrustService 承载可单测的纯逻辑(upsert/查询/脱敏),Controller 只做参数校验与编排。
+
+## Complexity Tracking
+
+> **Fill ONLY if Constitution Check has violations that must be justified**
+
+无违规项。

+ 82 - 0
specs/029-device-trust-phone-bind/quickstart.md

@@ -0,0 +1,82 @@
+# Quickstart: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Spec**: [spec.md](spec.md) | **契约**: [contracts/api-contract.md](contracts/api-contract.md)
+
+## 前置条件
+
+1. **数据库**:`updatesql/sql.md` 中"2026-09-20 设备绑定手机号信任表"DDL 已由开发者手动执行(本任务只追加脚本不执行)。
+2. **Redis**:本地 Redis 可用(临时凭证依赖)。
+3. **JDK**:临时设置(不改全局配置,Git Bash 示例):
+   ```bash
+   export JAVA_HOME="$HOME/.jdks/graalvm-jdk-21.0.12+7.1"
+   export PATH="$JAVA_HOME/bin:$PATH"
+   ```
+4. **短信万能码**:沿用现网 `8888`(测试不必真实收码)。
+
+## 自动化验证(测试命令)
+
+```bash
+# 全量跑本 feature 测试(DeviceTrust + 分支/确认流程)
+mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*Test' -Dsurefire.failIfNoSpecifiedTests=false
+
+# 编译校验(含 ruoyi-system)
+mvn -pl ruoyi-admin -am compile
+```
+
+预期:全部通过。已知基线红测试与本 feature 无关(flash payType 白名单,见项目记忆)。
+
+## 手动端到端场景(curl 模拟,配合契约文档 §6)
+
+服务起在本地(默认 8080,按实际部署调整)。`DEV=dev-uuid-001` 为模拟设备标识。
+
+### 场景 A:建立信任 → 三方免绑
+
+```bash
+# 1. 手机号短信登录(万能码 8888),带 deviceId —— 建立/刷新设备信任
+curl -X POST http://localhost:8080/infouser/user/lodeing \
+  -H 'Content-Type: application/json' \
+  -d '{"phone":"0987654321","code":"8888","deviceId":"dev-uuid-001"}'
+# 预期:登录成功 token;DB: info_user_device 出现 device_id=dev-uuid-001, phone=0987654321
+
+# 2. 查库确认(人工)
+#    SELECT * FROM info_user_device WHERE device_id='dev-uuid-001';
+
+# 3. 用"新的"三方身份登录(credential 用测试渠道或 mock)
+curl -X POST http://localhost:8080/infouser/user/oauthLogin \
+  -H 'Content-Type: application/json' \
+  -d '{"provider":"google","credential":"<test-idtoken>","deviceId":"dev-uuid-001"}'
+# 预期:data = {status:"deviceConfirm", tempKey:"...", maskedPhone:"098****4321"}
+
+# 4. 一键确认(免短信)
+curl -X POST http://localhost:8080/infouser/user/oauthDeviceConfirm \
+  -H 'Content-Type: application/json' \
+  -d "{\"tempKey\":\"<第3步tempKey>\",\"deviceId\":\"dev-uuid-001\"}"
+# 预期:返回 token + user(即 0987654321 的账号);info_user_oauth 新增 google 绑定行
+```
+
+### 场景 B:兼容性回归(必须零差异)
+
+```bash
+# 不带 deviceId 的老版本行为
+curl -X POST http://localhost:8080/infouser/user/oauthLogin \
+  -H 'Content-Type: application/json' \
+  -d '{"provider":"google","credential":"<另一个新身份>"}'
+# 预期:{status:"needPhone", tempKey}(原样)
+```
+
+### 场景 C:异常路径
+
+- tempKey 过期(>5 分钟再确认)→ `no.oauth.tempkey.expired`
+- 确认时换 deviceId(`dev-uuid-999`)→ `no.oauth.device.mismatch`,不产生绑定
+- 信任手机号账号停用(先手动 UPDATE status)→ `no.user.stop`
+
+### 场景 D:LINE 回调
+
+浏览器打开 authorize URL(`state=dev-uuid-001`)→ 走完 LINE 同意 → 观察 302 回跳参数:
+- 已信任:`deviceConfirm=1&tempKey=...&maskedPhone=098****4321`
+- 无信任(state 换成新值):`needPhone=1&tempKey=...`
+
+## 通过标准
+
+- 自动化测试全绿;场景 A/B/C/D 行为与 [contracts/api-contract.md](contracts/api-contract.md) 逐条一致。
+- 老版本兼容:所有不带 deviceId 的请求与改造前行为完全一致。

+ 59 - 0
specs/029-device-trust-phone-bind/research.md

@@ -0,0 +1,59 @@
+# Research: 设备信任免绑手机号
+
+**Date**: 2026-09-20 | **Status**: 全部决策已与用户确认,无未解决 NEEDS CLARIFICATION
+
+本 feature 的关键决策均在头脑风暴阶段与用户逐条确认,来源为对现有代码(017-oauth-login 实现即 `InfoUserController` / `LineCallbackController` / `OAuthVerifyService`)的实地调查。以下记录决策、理由与被否方案。
+
+## D1: 设备信任的存储——独立新表 `info_user_device`
+
+- **Decision**: 新建独立表,`device_id` 为主键,一台设备一行(device_id → phone → user_id → 时间戳)。
+- **Rationale**: 设备与用户是一对多(共用设备换号登录),挂 `info_user` 字段表达不了"设备维度最近一次验证的手机号";独立表 upsert 语义简单(按 PK insert-or-update),不动用户大表。
+- **Alternatives**: ① 在 `info_user` 上加 `device_id` 字段——只能记"该用户最后登录的设备",语义相反,共用设备会互相覆盖且查不出"设备信任谁";② 复用 `info_user_oauth` 加 provider='device'——语义混淆,破坏 UNIQUE(provider, provider_uid) 的三方账号语义。均否。
+
+## D2: 免绑交互——免短信一键确认(脱敏手机号只读)
+
+- **Decision**: `oauthLogin` 未绑定时若设备有信任记录,返回 `{status:"deviceConfirm", tempKey, maskedPhone}`;App 弹"将绑定 098\*\*\*\*789 并登录"确认框,手机号**只读不可改**,确认后调新接口 `oauthDeviceConfirm` 完成绑定登录,不发短信。
+- **Rationale**: 用户拍板选"一键确认"而非"静默绑定":共用设备场景下用户能看到将绑定给谁,防止无声进错账号;同时相比短信验证仍零输入。只读是用户的明确要求——换号走取消后手动绑定。
+- **Alternatives**: ① 静默绑定(零点击)——被否,共用设备误绑风险;② 确认框里允许改手机号——被用户明确否决(只能看不能改)。
+
+## D3: 设备标识与临时凭证的绑定方式——正交 Redis key
+
+- **Decision**: 沿用 `oauth:bind:{tempKey}` = `provider@providerUid`(**值格式不变**),deviceConfirm 分支额外写 `oauth:bind:dev:{tempKey}` = `deviceId`(同 TTL 5 分钟)。`oauthDeviceConfirm` 校验两键齐全且 dev 值与入参 deviceId 相等。
+- **Rationale**: 现有 `oauthBindPhone` 用 `indexOf('@')` 解析值,若把 deviceId 拼进值里会产生解析歧义与跨流程误用(deviceConfirm 的 tempKey 被喂给 oauthBindPhone 会拼出错误的 providerUid)。正交 key 让现有代码零改动、两种 tempKey 天然隔离。
+- **Alternatives**: ① 值扩为 `provider@uid@deviceId`——解析歧义,需同时改两处解析,否;② tempKey 存 JSON——现有值为纯字符串,引入序列化不一致,否。
+
+## D4: LINE 回调携带设备标识——复用 `state` 参数
+
+- **Decision**: 前端打开 LINE 授权 URL 时把 deviceId 放进 `state`(现状为前端随机串,后端不校验);`LineCallbackController.callback` 把 `state` 当 deviceId 用。未绑定分支:设备有信任 → 302 回跳 `deviceConfirm=1&tempKey=xxx&maskedPhone=xxx`;无信任 → 维持 `needPhone=1&tempKey=xxx`。已绑定路径也记录设备信任。
+- **Rationale**: LINE 官方 authorize 支持 `state` 且原样回传,现网已在传(仅作 CSRF 占位,后端未校验);复用它零流程变更,还顺带解决了 017 遗留问题"回调链路没有 App 上下文"。
+- **Alternatives**: ① 回调前先让 App 调一次后端预登记(额外接口 + 时序耦合)——否;② LINE 分支不做免绑(用户明确要求做)——否。
+
+## D5: 设备标识来源与契约——后端只收字符串,生成归前端
+
+- **Decision**: 后端约定 `deviceId`:可选、string、≤64;缺省/空串/超长一律按未传处理,走原流程。生成与持久化(iOS 建议 Keychain、Android 建议 ANDROID_ID,均跨卸载重装)由 uni-app 前端负责,写入前端契约文档。
+- **Rationale**: 后端不关心标识怎么来;可选参数保证老版本 App 100% 兼容。
+- **Alternatives**: ① 复用现有 `cid`(uni 推送客户端 ID)——重装即变且 cidType 现状传空,稳定性不满足"绑定一次"预期,否;② 后端采集 IP/UA 指纹——不可靠且涉及隐私,否。
+
+## D6: 信任记录的写入点——四种登录成功点收敛到一个服务方法
+
+- **Decision**: `DeviceTrustService.recordLogin(deviceId, phone, userId)`(按 PK upsert,最近一次覆盖),在四处调用:`lodeing` 成功、`oauthLogin` 已绑定直登成功、`oauthBindPhone` 成功、`oauthDeviceConfirm` 成功;LINE 回调已绑定路径同样调用。
+- **Rationale**: 规则一句话"手机号验证通过 + 登录成功 + 带了 deviceId → 记录";收敛到一处便于单测与后续扩展。共用设备语义 = 最近一次登录的手机号(D2 的确认框可让用户看见当前绑谁)。
+- **Alternatives**: 在每处 Controller 里各自写 mapper 调用——重复且易漏,否。
+
+## D7: 数据访问模式——MyBatis-Plus BaseMapper,无 XML
+
+- **Decision**: `InfoUserDeviceMapper extends BaseMapper<InfoUserOauth 同款>`,实体 `@TableName("info_user_device")`,不用 XML resultMap。
+- **Rationale**: 仓库先例 `InfoUserOauthMapper` 注释明确"仅用 MP CRUD,无自定义查询、无 XML";本 feature 只有按 PK 的 select/insert/update,MP 原生覆盖。
+- **Alternatives**: 手写 XML mapper——项目规范"全栈字段清单"针对业务查询字段,纯 PK CRUD 用 XML 是过度工程,否。
+
+## D8: 脱敏手机号格式——复用现有 `maskPhone` 规则
+
+- **Decision**: maskedPhone 复用 `InfoUserController.maskPhone`(前 3 + `****` + 后 4),抽到 `DeviceTrustService` 共用。
+- **Rationale**: 与现有日志脱敏风格一致,避免两套规则。
+- **Alternatives**: 前 3 后 3——与现网工具不一致,否。
+
+## D9: 账号停用/删除处理——与现有绑定流程同一套校验
+
+- **Decision**: `oauthDeviceConfirm` 复用 `oauthBindPhone` 的账号校验:`status != 0` 停用 → `no.user.stop` 错误;get-or-create(`getuser(phone)`,不存在则按现有规则新建 + 建钱包);期间已被绑定的容错直登也保持一致。
+- **Rationale**: 一键确认是绑定流程的"免短信变体",账号语义必须与短信绑定完全一致,防止绕过停用。
+- **Alternatives**: 无(一致性要求)。

+ 110 - 0
specs/029-device-trust-phone-bind/spec.md

@@ -0,0 +1,110 @@
+# Feature Specification: 设备信任免绑手机号(同设备三方登录免重复绑定)
+
+**Feature Branch**: `029-device-trust-phone-bind`
+
+**Created**: 2026-09-20
+
+**Status**: Draft
+
+**Input**: User description: "登录时记录设备唯一标识 deviceId(App 首启生成持久化传入)。同一设备任一登录流程验证过手机号(短信登录/三方绑号/三方已绑定直登)即记录 deviceId→手机号信任;之后该设备上三方登录遇未绑定身份时返回脱敏手机号一键确认(只读、免短信),确认后直接绑定并签发 token。只改后端并输出前端契约文档;SQL 写入 updatesql/sql.md。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 同设备三方登录免短信一键绑定 (Priority: P1)
+
+用户一直在某台手机上用"手机号+短信验证码"登录 App。某天他在这台手机上点了 Apple / Google / LINE 登录(该三方身份从未绑定过)。按现状他必须重新输手机号、收短信、输验证码。本功能后:App 弹出"将绑定 098\*\*\*\*789 并登录"的一键确认框(**手机号只读、不可修改**),点"确认"即完成三方身份与该手机号账号的绑定并直接登录,全程无需短信。
+
+**Why this priority**: 这是本功能的核心价值——消除"同一台设备重复绑手机号"的摩擦;没有它,其余部分没有意义。
+
+**Independent Test**: 已用手机号登录过的设备上,用一个全新三方身份登录,验证只弹一次确认框、不发短信、确认后拿到 token 进 App。
+
+**Acceptance Scenarios**:
+
+1. **Given** 设备存在信任记录(本机验证过手机号),**When** 用户以未绑定的三方身份发起三方登录且请求携带该设备标识,**Then** 返回"设备确认"状态,含临时凭证 tempKey 与脱敏手机号(隐藏中间 4 位,如 098\*\*\*\*789)
+2. **Given** 用户在确认框点击"确认",**When** 提交确认请求(tempKey + 设备标识),**Then** 三方身份绑定到信任手机号对应的账号(账号不存在则按现有规则自动创建),返回登录 token,响应结构与现有绑定接口一致
+3. **Given** 用户在确认框点击"取消",**When** 返回登录页,**Then** 不产生任何绑定,用户仍可用手机号+短信登录,现有流程完全不受影响
+
+---
+
+### User Story 2 - 设备信任的建立与更新 (Priority: P2)
+
+凡是手机号验证通过的登录,都建立/刷新"这台设备 ↔ 这个手机号"的信任关系:手机号短信登录成功、三方登录走短信绑定成功、三方已绑定身份直接登录成功、一键确认登录成功——四种成功路径都要记录。同一台设备先后登录过不同手机号(如家人共用)时,以**最近一次**为准覆盖。
+
+**Why this priority**: 信任记录是 Story 1 的数据基础;没有记录就没有免绑。
+
+**Independent Test**: 分别走四种登录路径,验证设备信任表被写入;换手机号再登录,验证记录被覆盖为新手机号。
+
+**Acceptance Scenarios**:
+
+1. **Given** 任意用户在设备上完成手机号+短信登录,**When** 登录成功且请求携带设备标识,**Then** 设备信任关系被记录(设备标识→手机号→用户)
+2. **Given** 设备已有信任记录(手机号 A),**When** 手机号 B 在该设备再次登录成功,**Then** 记录更新为手机号 B
+3. **Given** 三方身份已绑定(现有绑定记录存在),**When** 该用户在设备上三方登录成功,**Then** 设备信任关系同样被记录(手机号取该绑定用户)
+
+---
+
+### User Story 3 - LINE 网页回调流程同样免绑 (Priority: P3)
+
+LINE 登录走"唤起系统浏览器 → LINE 回调后端 → 302 跳回 App"的链路,此链路后端原本拿不到设备信息。现约定:App 打开 LINE 授权 URL 时把设备标识放入 `state` 参数带回。回调端对未绑定身份同样按设备信任情况分支:有信任 → 回跳 App 时带"设备确认"参数(tempKey + 脱敏手机号),App 弹同一套确认框;无信任 → 维持现状回跳 needPhone。
+
+**Why this priority**: LINE 回调是三方登录的一条独立链路,不覆盖会导致 LINE 用户独享不到免绑;但它是增量,不阻塞主流程。
+
+**Independent Test**: 已信任设备上走 LINE 授权回调,验证 302 回跳参数为 deviceConfirm 分支;新设备走同一回调,验证回跳参数仍为 needPhone。
+
+**Acceptance Scenarios**:
+
+1. **Given** 设备有信任记录,**When** LINE 回调收到 code 且 state 携带该设备标识、LINE 身份未绑定,**Then** 302 回跳 App 携带 deviceConfirm 标记、tempKey 与脱敏手机号
+2. **Given** 设备无信任记录(或 state 未携带设备标识),**When** 同一回调发生,**Then** 回跳参数与现状完全一致(needPhone=1&tempKey)
+3. **Given** LINE 身份已绑定,**When** 回调发生,**Then** 直接回跳 token(现状不变),且同时记录设备信任
+
+---
+
+### Edge Cases
+
+- **老版本 App 不传 deviceId**:所有接口行为与现状逐字节一致(needPhone 流程),不报错、不强制。
+- **deviceId 传了但设备无信任记录**:走原 needPhone 手动绑定流程。
+- **一键确认时 tempKey 过期**(有效期与现状一致,5 分钟):确认接口返回明确过期错误,用户重新发起三方登录获取新 tempKey。
+- **tempKey 与 deviceId 不匹配**(换设备重放/伪造):确认接口拒绝,不产生绑定。
+- **信任手机号对应的账号已被停用或删除**:确认接口返回错误提示,不自动绑定;用户回落手动绑定流程。
+- **三方身份其实已有绑定记录**:直接签发 token(现状分支优先),不进入设备确认分支。
+- **家人共用设备**:信任手机号以最近一次登录为准;一键确认框展示脱敏手机号,用户看得到将绑定给谁、可取消。
+- **deviceId 异常值**(超长/空串):按未传处理,走原流程。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 手机号短信登录、三方登录、三方绑定手机号等登录接口 MUST 接受可选的设备唯一标识参数(字符串,≤64);参数缺省或非法时,接口行为与现状完全一致。
+- **FR-002**: 凡手机号验证通过的登录成功点(短信登录、三方短信绑定、三方已绑定直登、一键确认登录)MUST 记录或更新设备信任关系(设备标识→手机号→用户ID),同设备以最近一次为准。
+- **FR-003**: 三方登录遇到未绑定身份时,若请求携带设备标识且该设备存在信任记录,系统 MUST 返回"设备确认"状态:临时凭证 tempKey + 脱敏手机号(隐藏中间 4 位);否则维持现状返回 needPhone + tempKey。
+- **FR-004**: 系统 MUST 提供免短信设备确认接口:入参为 tempKey + 设备标识;校验通过后按现有绑定规则将三方身份绑定到信任手机号账号(账号不存在则自动创建),签发登录凭证;响应结构与现有"三方绑定手机号"接口一致。
+- **FR-005**: 确认弹窗展示的手机号 MUST 为只读脱敏展示,用户不可编辑;接口侧不提供"换号"能力,更换手机号走取消后手动绑定。
+- **FR-006**: LINE 服务端回调 MUST 支持从 state 参数解析设备标识,未绑定分支按设备信任情况返回 deviceConfirm(含 tempKey 与脱敏手机号)或现状 needPhone 回跳;已绑定分支签发 token 并同时记录设备信任。
+- **FR-007**: 设备确认临时凭证有效期 MUST 与现状一致(5 分钟),过期后确认接口返回明确错误码。
+- **FR-008**: 确认接口 MUST 校验临时凭证与设备标识的匹配关系,不匹配时拒绝且不产生绑定;信任手机号账号已停用/删除时同样拒绝并给出可读错误。
+- **FR-009**: 数据库变更 MUST 按《数据库变更管理》规范写入 `updatesql/sql.md`(标注日期与用途),不直接执行。
+- **FR-010**: 本功能 MUST 产出一份前端接口契约文档(放 specs/029 下),覆盖:新增/变更入参、oauthLogin 新返回分支、新确认接口、LINE 授权 URL state 约定与回跳分支,交由 uni-app 前端团队实施。
+- **FR-011**: 设备信任数据 MUST 仅存储必要字段(设备标识、手机号、用户ID、时间戳),不采集其它设备信息;商户端/骑手端登录不在本功能范围。
+
+### Key Entities *(include if feature involves data)*
+
+- **设备信任记录(新)**:设备唯一标识(一台设备一条,主键)→ 最近一次验证通过的手机号 → 对应用户ID → 创建/更新时间。生命周期:随每次符合 FR-002 的登录成功 upsert。
+- **三方绑定关系(现有 info_user_oauth,复用不改)**:三方身份(provider + providerUid)↔ 用户ID。一键确认即向此关系新增记录。
+- **临时凭证(现有 tempKey 机制,扩展)**:短期(5 分钟)凭据,值从"provider@providerUid"扩展为可携带设备标识,供绑定/确认接口消费。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 已信任设备上,用户从"点三方登录"到"进入 App"无需短信验证码,操作不超过 2 步(点三方登录 + 点确认)。
+- **SC-002**: 未携带设备标识、携带但无信任记录、以及旧版本 App 的所有请求,行为与改造前完全一致(回归零差异)。
+- **SC-003**: 设备确认接口的响应耗时与现有登录/绑定接口处于同一量级,不引入新的明显延迟。
+- **SC-004**: 前端契约文档交付且覆盖全部涉及接口、返回分支与 LINE 回跳参数,前端团队无需口头追问即可开工。
+- **SC-005**: 服务层自动化测试覆盖:设备信任记录的建立/覆盖、免绑分支判定、确认接口的成功/过期/不匹配/账号停用路径。
+
+## Assumptions
+
+- 设备唯一标识由 App 端生成并持久化(iOS 建议 Keychain、Android 建议 ANDROID_ID,均跨卸载重装稳定),其稳定性由前端保证;后端只接收并存储字符串(≤64),不关心生成方式。
+- 本功能只改后端;用户端 uni-app 由前端同事按契约文档另行实施(不在本 spec 的交付物内,契约文档在内)。
+- 范围不含商户端(shanglodeing)与骑手端(syslodeing)登录。
+- 沿用 017-oauth-login 的既有决策:手机号始终是账号主键,InfoUser 上不落三方 ID;万能验证码 8888 等现有测试便利不改动。
+- 设备确认弹窗的 UI 文案与样式由前端实现,后端只提供脱敏手机号数据。

+ 174 - 0
specs/029-device-trust-phone-bind/tasks.md

@@ -0,0 +1,174 @@
+# Tasks: 设备信任免绑手机号
+
+**Input**: Design documents from `/specs/029-device-trust-phone-bind/`(spec.md / plan.md / research.md / data-model.md / contracts/api-contract.md / quickstart.md)
+
+**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
+
+**Tests**: 本 feature 的 spec(SC-005)明确要求服务层自动化测试,且采用 TDD:每阶段先写测试确认红,再实现转绿。
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no dependencies)
+- **[Story]**: Which user story this task belongs to (e.g. US1, US2, US3)
+- Include exact file paths in descriptions
+
+## 构建与测试命令(所有验证任务通用)
+
+```bash
+export JAVA_HOME="$HOME/.jdks/graalvm-jdk-21.0.12+7.1"   # 本机实际路径,勿改全局
+export PATH="$JAVA_HOME/bin:$PATH"
+mvn -pl ruoyi-admin -am test -Dtest='XxxTest' -Dsurefire.failIfNoSpecifiedTests=false
+```
+
+已知基线红测试:flash payType 白名单(createRejectsUnsupportedPayType)为预期失败,与本 feature 无关,不修。
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: 确认基线干净,避免把存量问题带进本 feature
+
+- [x] T001 基线验证:按上方命令跑 `mvn -pl ruoyi-admin -am compile`,确认编译通过;工作区无本 feature 之外的意外改动(`git status`)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 设备信任的数据层与共享构件——所有用户故事都依赖
+
+**⚠️ CRITICAL**: US1/US2/US3 的实现任务不得在本阶段完成前开始
+
+- [x] T002 按 data-model.md 的 DDL 在 `updatesql/sql.md` 末尾追加 `info_user_device` 建表语句(带 `-- 2026-09-20 ...` 日期与用途注释;**不执行**,由开发者手动执行)
+- [x] T003 [P] 新建实体 `ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoUserDevice.java`:`@TableName("info_user_device")`,字段 deviceId/phone/userId/createTime/updateTime,风格参照 `InfoUserOauth.java`(Lombok、注释规范、无 `*/` 序列)
+- [x] T004 [P] 新建 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/InfoUserDeviceMapper.java`:`extends BaseMapper<InfoUserDevice>`,注释同 `InfoUserOauthMapper` 模式(仅 MP CRUD,无 XML)
+- [x] T005 [P] 新增 i18n 消息 key:先 `grep -rn "no.oauth.tempkey.expired" ruoyi-system/src/main/resources` 定位 messages 资源文件,把 `no.oauth.device.confirm`(deviceConfirm 分支提示语)与 `no.oauth.device.mismatch`(凭证/设备不匹配)加到**所有语言变体文件**,文案四语对齐
+- [x] T006 [P] DTO 变更:`ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/UserDTO.java`、`ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthLoginDto.java`、`OAuthBindDto.java` 各加 `private String deviceId;`(注释:设备唯一标识,App 首启生成,可选 ≤64);新建 `ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/OAuthDeviceConfirmDto.java`(字段 tempKey + deviceId,Lombok @Data,无校验注解)
+- [x] T007 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/service/DeviceTrustServiceTest.java`,Mockito 单测:① `isValidDeviceId`(null/空/64 位/65 位)② `recordLogin` 首次 insert(create_time=update_time=now)③ 再次同号登录仅刷新 update_time ④ 换号登录覆盖 phone/user_id ⑤ 非法 deviceId 静默跳程不调 mapper ⑥ `maskPhone`(正常 10 位、短号原样返回)。跑测试确认**编译通过但断言红**(service 未实现)
+- [x] T008 新建 `ruoyi-admin/src/main/java/com/ruoyi/app/user/service/DeviceTrustService.java` 实现 `isValidDeviceId/recordLogin/getTrustedPhone(deviceId)/maskPhone(phone)`(recordLogin=selectById 命中则 update phone/user_id/update_time,否则 insert;依赖注入 `InfoUserDeviceMapper`),使 T007 转绿
+
+**Checkpoint**: 数据层+服务就绪,`DeviceTrustServiceTest` 全绿;US1/US2/US3 可并行开始
+
+---
+
+## Phase 3: User Story 1 - 同设备三方登录免短信一键绑定 (Priority: P1) 🎯 MVP
+
+**Goal**: `oauthLogin` 未绑定时按设备信任返回 `deviceConfirm` 分支;新接口 `oauthDeviceConfirm` 免短信完成绑定登录
+
+**Independent Test**: 测试内直接向 `info_user_device` 造一条信任记录(不依赖 US2 的记录点),断言 oauthLogin 返回 deviceConfirm、oauthDeviceConfirm 返回 token
+
+### Tests for User Story 1(先写、先红)
+
+- [x] T009 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/DeviceTrustFlowTest.java`(参考 `FlashDeliveryControllerContractTest` 的 Mockito 实例化 Controller 方式),覆盖:① oauthLogin 未绑定+deviceId 有信任 → data 含 `status="deviceConfirm"`、tempKey、`maskedPhone="098****4321"`,且 Redis 写入 `oauth:bind:{tempKey}` 与 `oauth:bind:dev:{tempKey}` 两键 ② oauthLogin 未绑定+无信任/无 deviceId → 仍是 `needPhone`(回归) ③ oauthDeviceConfirm 成功:token 返回 + `info_user_oauth` insert + 两个 Redis key 删除 + 已有用户关联/新用户创建(含 createUserWallet) ④ tempKey 过期 → `no.oauth.tempkey.expired` ⑤ dev 键缺失或 deviceId 不等 → `no.oauth.device.mismatch` 且无绑定 ⑥ 信任手机号账号 status≠0 → `no.user.stop` ⑦ 期间已被绑定 → 容错直登返回 token
+
+### Implementation for User Story 1
+
+- [x] T010 改 `ruoyi-admin/src/main/java/com/ruoyi/app/user/InfoUserController.java` 的 `oauthLogin`:未绑定分支内,`dto.getDeviceId()` 合法且 `deviceTrustService.getTrustedPhone` 命中时,同时写 `oauth:bind:{tempKey}=provider@providerUid`(现状不变)与 `oauth:bind:dev:{tempKey}=deviceId`(同 5 分钟 TTL),返回 `{status:"deviceConfirm", tempKey, maskedPhone}`(提示语 `no.oauth.device.confirm`);否则维持现状 needPhone
+- [x] T011 在 `InfoUserController.java` 新增 `@Anonymous @PostMapping("/oauthDeviceConfirm")`,入参 `@RequestBody OAuthDeviceConfirmDto`:校验 tempKey/deviceId 非空(`no.oauth.tempkey.missing`)→ 读 `oauth:bind:{tempKey}`(空=`no.oauth.tempkey.expired`)→ 读 `oauth:bind:dev:{tempKey}` 并与入参 deviceId 相等、且设备信任记录仍存在(否则 `no.oauth.device.mismatch`)→ 复用 `oauthBindPhone` 的"期间已绑定容错直登/get-or-create 用户(停用拒绑 no.user.stop)/insert info_user_oauth"逻辑 → 删两个 Redis key → `deviceTrustService.recordLogin` → `issueOauthToken`;日志风格对齐现有 `[OAuth]` 前缀
+- [x] T012 转绿与回归:`DeviceTrustFlowTest` 全绿;`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*'` 通过
+
+**Checkpoint**: US1 独立可演示(手工插信任记录 + curl 按 quickstart 场景 A 验证)
+
+---
+
+## Phase 4: User Story 2 - 设备信任的建立与更新 (Priority: P2)
+
+**Goal**: 四种登录成功点自动 upsert 设备信任;共用设备以最近一次为准
+
+**Independent Test**: mock 断言各登录成功路径调用了 `recordLogin`(deviceId 缺省时不调用)
+
+### Tests for User Story 2(先写、先红)
+
+- [x] T013 TDD 先红:在 `DeviceTrustFlowTest` 追加用例:① `lodeing` 短信登录成功且带 deviceId → `recordLogin(deviceId, phone, userId)` 被调用 ② 不带 deviceId → 不调用 ③ `oauthBindPhone` 绑定成功且带 deviceId → 被调用 ④ `oauthLogin` 已绑定直登且带 deviceId → 被调用(手机号取绑定用户)
+
+### Implementation for User Story 2
+
+- [x] T014 `InfoUserController.lodeing`(约 :941)登录成功 return 前加 `deviceTrustService.recordLogin(dto.getDeviceId(), phone, user.getUserId())`
+- [x] T015 `InfoUserController.oauthBindPhone`(约 :1096)`issueOauthToken` 前加同款调用(dto.getDeviceId())
+- [x] T016 `InfoUserController.oauthLogin`(约 :1063 已绑定分支)`issueOauthToken` 前加同款调用(手机号取 `u.getPhone()`)
+- [x] T017 转绿:`DeviceTrust*Test` 全绿;重复登录换号场景按 data-model.md 状态迁移人工核验
+
+**Checkpoint**: 真实链路下"手机号登录一次 → 三方免绑"端到端成立(quickstart 场景 A 完整闭环)
+
+---
+
+## Phase 5: User Story 3 - LINE 回调免绑 (Priority: P3)
+
+**Goal**: LINE 网页回调链路通过 `state=deviceId` 获得同等的 deviceConfirm 能力
+
+**Independent Test**: mock OAuthVerifyService/RedisCache 断言回调 302 的 Location 参数分支
+
+### Tests for User Story 3(先写、先红)
+
+- [x] T018 TDD 先红:新建 `ruoyi-admin/src/test/java/com/ruoyi/app/user/LineCallbackDeviceTrustTest.java`:① 未绑定+state 为有信任的 deviceId → Location 含 `deviceConfirm=1`、tempKey、URL 编码的 maskedPhone,且两个 Redis key 写入 ② 未绑定+state 为陌生值/空 → Location 仍为 `needPhone=1&tempKey`(回归) ③ 已绑定+state=deviceId → Location 含 token 且 `recordLogin` 被调用
+
+### Implementation for User Story 3
+
+- [x] T019 改 `ruoyi-admin/src/main/java/com/ruoyi/app/user/LineCallbackController.java`:`state` 语义变为 deviceId(注释说明前端契约,保留原参数名);未绑定分支查 `deviceTrustService.getTrustedPhone(state)`,命中 → 写两键 + 302 `appRedirect + "?deviceConfirm=1&tempKey=..&maskedPhone=.."`(enc 编码),未命中 → 现状 needPhone;已绑定分支发 token 前加 `recordLogin(state, u.getPhone(), u.getUserId())`(deviceId 合法才调)
+- [x] T020 转绿:`LineCallbackDeviceTrustTest` 全绿;`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*,LineCallback*'` 通过
+
+**Checkpoint**: 三条链路(oauthLogin 直连 / LINE 回调 / 短信绑定)全部具备设备信任能力
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [x] T021 契约一致性核对:逐条比对 `specs/029-device-trust-phone-bind/contracts/api-contract.md`(§1-§6)与实际实现——入参字段、返回分支、错误 key、LINE 回跳参数,发现偏差改实现或改契约并在契约中注明
+- [x] T022 全量验证:`mvn -pl ruoyi-admin -am test -Dtest='DeviceTrust*,LineCallback*'` 全绿 + `mvn -pl ruoyi-admin -am compile` 通过 + quickstart.md 场景 B(不带 deviceId 零差异回归)手工过一遍;确认未触碰 CLAUDE.md 废弃代码清单中的文件
+- [x] T023 收尾:`git status` 核对本 feature 改动清单与 plan.md Project Structure 一致;把需要人工执行的事项(`updatesql/sql.md` 新增 DDL、前端契约移交 uni-app 团队)在交付说明中列出
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Setup (Phase 1)**: 无依赖,立即开始
+- **Foundational (Phase 2)**: 依赖 Phase 1;**阻塞所有用户故事**
+- **US1 (Phase 3) / US2 (Phase 4) / US3 (Phase 5)**: 均依赖 Phase 2 完成;彼此可并行(不同方法/文件区域),单人则按 P1→P2→P3
+- **Polish (Phase 6)**: 依赖全部所需故事完成
+
+### User Story Dependencies
+
+- **US1**: 独立可测(测试直接造 info_user_device 数据,不依赖 US2)
+- **US2**: 独立可测(mock 断言 recordLogin 调用);真实端到端闭环需 US1+US2 都完成
+- **US3**: 独立可测;依赖 Phase 2 的 DeviceTrustService(所有故事共享)
+
+### Within Each User Story
+
+- 测试任务先写并确认红 → 实现转绿(TDD)
+- 同一 Controller 文件的任务(T010/T011、T014-T016)不做并行标记,避免编辑冲突
+
+### Parallel Opportunities
+
+- Phase 2 的 T003/T004/T005/T006 互不相关可并行(不同文件)
+- US1 的测试任务与 US2 的测试任务分属不同测试文件可并行
+- 多人时 US1/US3 可分人(InfoUserController vs LineCallbackController),US2 与 US1 同文件建议同人
+
+---
+
+## Implementation Strategy
+
+### MVP First (Phase 1-3 + 手工造数据)
+
+1. Phase 1 基线 → Phase 2 数据层 → Phase 3 US1
+2. 手工向 `info_user_device` 插一行即可端到端演示"同设备三方免绑"
+3. US2 完成后无需手工造数据,真实登录即建立信任
+
+### Incremental Delivery
+
+1. Setup + Foundational → 数据层就绪(可先交付 SQL 给 DBA 排期)
+2. +US1 → 免绑核心(MVP)
+3. +US2 → 信任自动建立(完整闭环)
+4. +US3 → LINE 链路补齐
+5. Polish → 契约核对 + 回归 + 交付说明
+
+---
+
+## Notes
+
+- 所有 Java/资源文件保留原有 CRLF 换行风格,禁止全文件重排
+- Controller 校验一律 `MessageUtils.message(...)`,禁止硬编码中文错误
+- `oauth:bind:` 现有值格式**不得改动**(`oauthBindPhone` 用 `indexOf('@')` 解析)
+- 不触碰 CLAUDE.md 废弃代码清单中的任何文件

+ 14 - 0
updatesql/sql.md

@@ -1599,3 +1599,17 @@ WHERE NOT EXISTS (
 -- 2026-09-18 闪送订单骑手评分:寄件人对已完成订单评价骑手(1-5,仅一次),NULL=未评价
 ALTER TABLE flash_delivery_order ADD COLUMN rider_stars DOUBLE DEFAULT NULL COMMENT '寄件人对骑手评分1-5,NULL=未评价';
 ```
+
+## 2026-09-20 设备绑定手机号信任表(同设备三方登录免绑)
+
+```sql
+-- 2026-09-20 设备绑定手机号信任表(029-device-trust-phone-bind):同一设备验证过手机号后,后续三方登录免短信一键确认绑定
+CREATE TABLE info_user_device (
+  device_id   VARCHAR(64) NOT NULL COMMENT '设备唯一标识(App首启生成持久化)',
+  phone       VARCHAR(20) NOT NULL COMMENT '本设备最近一次验证通过的手机号',
+  user_id     BIGINT NOT NULL COMMENT '对应用户ID(info_user.user_id)',
+  create_time DATETIME DEFAULT NULL COMMENT '首次建立信任时间',
+  update_time DATETIME DEFAULT NULL COMMENT '最近一次登录刷新时间',
+  PRIMARY KEY (device_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备-手机号信任表(029)';
+```