From 80481ae0ef1bd4aa6cde0ec3978be84de0d9eae0 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Wed, 28 Oct 2026 12:50:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(V):=20=E5=90=8E=E7=AB=AF=E6=89=93=E9=80=9A?= =?UTF-8?q?=E6=B6=82=E9=B8=A6=E7=94=A8=E6=88=B7token=E9=80=9A=E8=B7=AF,=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=BF=9C=E7=A8=8B=E4=BB=A3=E5=8F=91=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E4=B8=8E=E4=BB=A3=E6=9F=A5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因: APP原生SDK绑定只写占位符 native_sdk, 后端任何用户级涂鸦API都报 '请先绑定涂鸦账号'; 且 refresh 失败时会删绑定记录, 导致 tuya_user_binding 被清空 - APP: 登录成功/自动续登/启动时读取涂鸦token(反射,兼容SDK差异)并上报 - 后端: bind-native 接收并加密保存 access_token/refresh_token/过期时间; 有真实用户token时不再用简单模式(项目级token)覆盖 - 绑定记录刷新失败改为标记失效(status=0)而非删除, 避免与APP绑定状态不一致 - 效果: 后端可代表用户调用涂鸦API(远程代发指令含Mesh子设备经网关透传、查询设备状态) --- .../java/com/xhai/iot/data/ApiService.java | 7 +- .../com/xhai/iot/service/TuyaSdkService.java | 113 ++++++++++++++++++ .../java/com/xhai/iot/ui/MainActivity.java | 8 ++ .../com/xhai/iot/ui/TuyaBindActivity.java | 11 +- .../com/xhai/iot/util/TuyaAutoRelogin.java | 25 ++++ .../iot/device/controller/TuyaController.java | 3 +- .../dto/tuya/TuyaNativeBindRequest.java | 14 +++ .../iot/device/service/TuyaCloudService.java | 111 ++++++++++++----- 8 files changed, 260 insertions(+), 32 deletions(-) diff --git a/Android/app/src/main/java/com/xhai/iot/data/ApiService.java b/Android/app/src/main/java/com/xhai/iot/data/ApiService.java index 0944768..a0993e3 100644 --- a/Android/app/src/main/java/com/xhai/iot/data/ApiService.java +++ b/Android/app/src/main/java/com/xhai/iot/data/ApiService.java @@ -196,12 +196,15 @@ public interface ApiService { /** * 原生SDK绑定涂鸦账号 * POST /api/v1/tuya/auth/bind-native - * body: { "tuyaUid", "account", "loginMethod", "platform" } + * body: { "tuyaUid", "account", "loginMethod", "platform", + * "accessToken", "refreshToken", "expireTime" } + * + *

带上 token 后,后端可代表用户调用涂鸦 API(远程代发指令/查状态)。

*/ @POST("v1/tuya/auth/bind-native") Call>> bindNative( @Header("Authorization") String auth, - @Body Map body); + @Body Map body); /** * 解绑涂鸦账号 diff --git a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java index 9d071f4..2836611 100644 --- a/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java +++ b/Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java @@ -1,5 +1,6 @@ package com.xhai.iot.service; +import android.content.Context; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -562,6 +563,118 @@ public class TuyaSdkService { } } + // ==================== 涂鸦用户 Token(供后端代发指令/查状态) ==================== + + /** 涂鸦用户 token 信息 */ + public static class UserTokenInfo { + public String accessToken; + public String refreshToken; + /** 过期时间(秒级时间戳) */ + public long expireTime; + + public boolean isValid() { + return accessToken != null && !accessToken.isEmpty(); + } + } + + /** + * 读取当前涂鸦用户的 token。 + * + *

用反射实现,避免不同 SDK 版本的 getToken() 签名差异导致编译失败。

+ */ + public static UserTokenInfo readUserToken() { + UserTokenInfo info = new UserTokenInfo(); + try { + Object user = com.thingclips.smart.home.sdk.ThingHomeSdk.getUserInstance(); + if (user == null) { + return info; + } + Object token = null; + try { + java.lang.reflect.Method m = user.getClass().getMethod("getToken"); + token = m.invoke(user); + } catch (Throwable t) { + Log.w(TAG, "读取涂鸦token失败(getToken): " + t.getMessage()); + } + if (token == null) { + return info; + } + Object access = readField(token, "accessToken"); + Object refresh = readField(token, "refreshToken"); + Object expire = readField(token, "expireTime"); + info.accessToken = access instanceof String ? (String) access : null; + info.refreshToken = refresh instanceof String ? (String) refresh : null; + if (expire instanceof Number) { + info.expireTime = ((Number) expire).longValue(); + } + } catch (Throwable e) { + Log.w(TAG, "读取涂鸦token异常: " + e.getMessage()); + } + return info; + } + + /** 反射读取字段(public 或 private 均尝试) */ + private static Object readField(Object obj, String name) { + try { + java.lang.reflect.Field f = obj.getClass().getField(name); + return f.get(obj); + } catch (Throwable t) { + try { + java.lang.reflect.Field f = obj.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(obj); + } catch (Throwable t2) { + return null; + } + } + } + + /** + * 把当前涂鸦 token 同步到后端。 + * + *

后端拿到后即可代表该用户调用涂鸦 API:远程代发指令(手机不在家也能控)、 + * 查询设备状态(远程在线判定更权威)。失败不影响 APP 自身使用。

+ */ + public static void reportUserTokenToBackend(Context ctx, String tuyaUid, String account) { + try { + if (ctx == null) return; + UserTokenInfo info = readUserToken(); + if (!info.isValid()) { + Log.w(TAG, "未取到涂鸦token,跳过上报"); + return; + } + String auth = SessionManager.getInstance(ctx).getBearerToken(); + if (auth == null) { + Log.w(TAG, "未登录本应用,跳过涂鸦token上报"); + return; + } + Map body = new HashMap<>(); + body.put("tuyaUid", tuyaUid != null ? tuyaUid : ""); + body.put("account", account != null ? account : ""); + body.put("loginMethod", "phone"); + body.put("platform", "android"); + body.put("accessToken", info.accessToken); + body.put("refreshToken", info.refreshToken != null ? info.refreshToken : ""); + body.put("expireTime", info.expireTime); + ApiClient.getApiService().bindNative(auth, body) + .enqueue(new retrofit2.Callback>>() { + @Override + public void onResponse(retrofit2.Call>> call, + retrofit2.Response>> response) { + Log.i(TAG, "涂鸦token上报结果: " + response.code()); + } + + @Override + public void onFailure(retrofit2.Call>> call, + Throwable t) { + Log.w(TAG, "涂鸦token上报失败: " + t.getMessage()); + } + }); + } catch (Throwable e) { + Log.w(TAG, "涂鸦token上报异常: " + e.getMessage()); + } + } + private static final java.util.concurrent.ConcurrentHashMap onlineOverride = new java.util.concurrent.ConcurrentHashMap<>(); diff --git a/Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java index 351d71f..ea20114 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java @@ -71,6 +71,14 @@ public class MainActivity extends AppCompatActivity { boolean tuyaLoggedIn = com.thingclips.smart.home.sdk.ThingHomeSdk .getUserInstance().isLogin(); SessionManager.getInstance(this).setTuyaBound(tuyaLoggedIn); + if (tuyaLoggedIn) { + // 同步一次涂鸦 token 到后端:后端据此代表用户调用涂鸦 API + // (远程代发指令、查询设备状态 —— 手机不在线也能控/能看) + try { + com.xhai.iot.service.TuyaSdkService.reportUserTokenToBackend(this, null, null); + } catch (Throwable ignored) { + } + } if (!tuyaLoggedIn && com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) { com.xhai.iot.util.TuyaAutoRelogin.relogin(this, new com.xhai.iot.util.TuyaAutoRelogin.Callback() { diff --git a/Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java b/Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java index 797ebf0..16b6e4d 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java @@ -193,11 +193,20 @@ public class TuyaBindActivity extends AppCompatActivity { return; } - Map body = new HashMap<>(); + Map body = new HashMap<>(); body.put("tuyaUid", tuyaUid); body.put("account", account); body.put("loginMethod", "phone"); body.put("platform", "android"); + // 一并上报涂鸦 token:后端拿到后可代表用户调用涂鸦 API + // (远程代发指令、查询设备状态 —— 手机不在线也能控/能看) + com.xhai.iot.service.TuyaSdkService.UserTokenInfo tokenInfo = + com.xhai.iot.service.TuyaSdkService.readUserToken(); + if (tokenInfo.isValid()) { + body.put("accessToken", tokenInfo.accessToken); + body.put("refreshToken", tokenInfo.refreshToken != null ? tokenInfo.refreshToken : ""); + body.put("expireTime", tokenInfo.expireTime); + } ApiClient.getApiService().bindNative(auth, body) .enqueue(new Callback>>() { diff --git a/Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java b/Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java index 17b231e..4f3da63 100644 --- a/Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java +++ b/Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java @@ -29,6 +29,29 @@ public class TuyaAutoRelogin { return new TuyaCredentialStore(context).has(); } + /** + * 续登成功后把新的涂鸦 token 同步到后端。 + * + *

后端拿到用户 token 后即可代表用户调用涂鸦 API(远程代发指令、查状态), + * 实现「手机不在线也能控/能看」。失败不影响 APP 自身使用。

+ */ + private static void reportToken(Context context, User user, String account) { + try { + String uid = null; + String name = null; + if (user != null) { + try { uid = user.getUid(); } catch (Throwable ignored) {} + try { name = user.getUsername(); } catch (Throwable ignored) {} + } + if (name == null || name.isEmpty()) { + name = account; + } + com.xhai.iot.service.TuyaSdkService.reportUserTokenToBackend(context, uid, name); + } catch (Throwable e) { + Log.w(TAG, "续登后上报涂鸦token失败: " + e.getMessage()); + } + } + /** 用保存的凭据自动重新登录涂鸦(先手机号,失败再邮箱) */ public static void relogin(Context context, Callback callback) { TuyaCredentialStore store = new TuyaCredentialStore(context); @@ -43,6 +66,7 @@ public class TuyaAutoRelogin { @Override public void onSuccess(User user) { Log.i(TAG, "自动续登成功"); + reportToken(context, user, account); callback.onSuccess(user); } @@ -53,6 +77,7 @@ public class TuyaAutoRelogin { @Override public void onSuccess(User user) { Log.i(TAG, "自动续登成功(邮箱)"); + reportToken(context, user, account); callback.onSuccess(user); } diff --git a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java index 572fe57..2223258 100644 --- a/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java +++ b/backend/iot-device/src/main/java/com/iot/device/controller/TuyaController.java @@ -195,7 +195,8 @@ public class TuyaController { HttpServletRequest request) { Long memberId = extractMemberId(request); Map result = tuyaCloudService.bindNative( - req.getTuyaUid(), req.getAccount(), req.getLoginMethod(), memberId); + req.getTuyaUid(), req.getAccount(), req.getLoginMethod(), memberId, + req.getAccessToken(), req.getRefreshToken(), req.getExpireTime()); return Result.success(result); } diff --git a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaNativeBindRequest.java b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaNativeBindRequest.java index ec8cd41..37fe339 100644 --- a/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaNativeBindRequest.java +++ b/backend/iot-device/src/main/java/com/iot/device/dto/tuya/TuyaNativeBindRequest.java @@ -23,4 +23,18 @@ public class TuyaNativeBindRequest { /** 平台:android / ios / h5 */ private String platform = "android"; + + /** + * 涂鸦用户访问令牌(APP 原生 SDK 登录后拿到) + * + *

有了它后端才能代用户调用涂鸦用户级 API:远程代发指令、查询设备状态, + * 从而做到「手机不在线时服务器仍可控制/监控设备」。

+ */ + private String accessToken; + + /** 刷新令牌(用于后端自动续期,避免用户反复授权) */ + private String refreshToken; + + /** 访问令牌过期时间(秒级时间戳) */ + private Long expireTime; } diff --git a/backend/iot-device/src/main/java/com/iot/device/service/TuyaCloudService.java b/backend/iot-device/src/main/java/com/iot/device/service/TuyaCloudService.java index 933afab..035796a 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/TuyaCloudService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/TuyaCloudService.java @@ -350,42 +350,71 @@ public class TuyaCloudService { /** * APP原生SDK绑定(用户通过APP端涂鸦插件登录后同步到后端) - * 不需要token交换,直接记录绑定关系 + * + *

APP 会把涂鸦原生 SDK 拿到的 access_token / refresh_token 一并上报, + * 后端加密保存后即可代表该用户调用涂鸦用户级 API(远程代发指令、查询状态), + * 实现「手机不在线也能控/能看」。没有 token 时退化为仅标记绑定。

*/ public Map bindNative(String tuyaUid, String account, String loginMethod, Long memberId) { + return bindNative(tuyaUid, account, loginMethod, memberId, null, null, null); + } + + public Map bindNative(String tuyaUid, String account, + String loginMethod, Long memberId, + String accessToken, String refreshToken, + Long expireTime) { try { // 检查是否已有绑定 TuyaUserBinding existing = tuyaUserBindingMapper.selectByMemberId(memberId); LocalDateTime now = LocalDateTime.now(); - + boolean hasRealToken = StringUtils.hasText(accessToken) + && !"native_sdk".equals(accessToken); + if (existing != null) { - // 更新已有绑定 - existing.setTuyaUserId(tuyaUid); - existing.setTuyaOpenId(account); // 用account存储登录账号 + // 更新已有绑定(uid/account 为空时保留原值,避免 token 续期上报把身份信息冲掉) + if (StringUtils.hasText(tuyaUid)) { + existing.setTuyaUserId(tuyaUid); + } + if (StringUtils.hasText(account)) { + existing.setTuyaOpenId(account); // 用account存储登录账号 + } existing.setStatus(1); existing.setUpdateTime(now); + if (hasRealToken) { + existing.setAccessToken(encrypt(accessToken)); + existing.setRefreshToken(encrypt(StringUtils.hasText(refreshToken) ? refreshToken : "")); + existing.setTokenExpireAt(expireAt(expireTime)); + } tuyaUserBindingMapper.updateById(existing); - - log.info("会员 {} 更新了涂鸦绑定: uid={}, account={}", memberId, tuyaUid, account); + + log.info("会员 {} 更新了涂鸦绑定: uid={}, account={}, token={}", + memberId, tuyaUid, account, hasRealToken ? "已更新" : "未提供"); } else { // 创建新绑定记录 TuyaUserBinding binding = new TuyaUserBinding(); binding.setMemberId(memberId); binding.setTuyaUserId(tuyaUid); binding.setTuyaOpenId(account); - // 原生SDK模式下,token由APP端管理,这里仅标记已绑定 - binding.setAccessToken(encrypt("native_sdk")); - binding.setRefreshToken(encrypt("")); - binding.setTokenExpireAt(now.plusYears(10)); // 长期有效 + if (hasRealToken) { + // APP 上报了真实 token:后端可代表用户调用涂鸦 API + binding.setAccessToken(encrypt(accessToken)); + binding.setRefreshToken(encrypt(StringUtils.hasText(refreshToken) ? refreshToken : "")); + binding.setTokenExpireAt(expireAt(expireTime)); + } else { + // 原生SDK模式下未上报 token,仅标记已绑定 + binding.setAccessToken(encrypt("native_sdk")); + binding.setRefreshToken(encrypt("")); + binding.setTokenExpireAt(now.plusYears(10)); // 长期有效 + } binding.setDefaultHomeId(0L); binding.setStatus(1); binding.setCreateTime(now); binding.setUpdateTime(now); tuyaUserBindingMapper.insert(binding); - - log.info("会员 {} 通过APP原生SDK绑定了涂鸦账号: uid={}, account={}", - memberId, tuyaUid, account); + + log.info("会员 {} 通过APP原生SDK绑定了涂鸦账号: uid={}, account={}, token={}", + memberId, tuyaUid, account, hasRealToken ? "已保存" : "未提供"); } Map resp = new LinkedHashMap<>(); @@ -394,19 +423,24 @@ public class TuyaCloudService { resp.put("loginMethod", loginMethod); resp.put("account", maskAccount(account)); - // 原生SDK模式只标记绑定,不产生后端可用的涂鸦 token。但后端的 - // OpenAPI 调用(设备控制/详情查询/探活兜底)都依赖 user token, - // 占位符 "native_sdk" 会导致所有调用报 token invalid。 - // 这里同步用简单模式(grant_type=1)换取平台级 token 覆盖占位符, - // 使后端具备 OpenAPI 调用能力;APP 每次登录/续登都会重新绑定, - // token 也会随之刷新。失败不阻断绑定(保留占位符,仅能力受限)。 - try { - autoBind(memberId); - resp.put("backendTokenRefreshed", true); - log.info("会员 {} 原生绑定后已同步刷新后端涂鸦 token(简单模式)", memberId); - } catch (Exception e) { - log.warn("会员 {} 原生绑定后刷新后端 token 失败(不阻断绑定): {}", - memberId, e.getMessage()); + // APP 上报了真实用户 token 时,后端已具备代表用户调用涂鸦 API 的能力, + // 不再用简单模式(grant_type=1)覆盖 —— 简单模式拿到的是"项目级 token", + // 覆盖后反而丢失了用户身份,无法代用户控制设备。 + if (!hasRealToken) { + // 原生SDK模式未上报 token:退化为用简单模式换取平台级 token, + // 至少让后端具备部分 OpenAPI 调用能力(设备状态查询等)。 + // 失败不阻断绑定(保留占位符,仅能力受限)。 + try { + autoBind(memberId); + resp.put("backendTokenRefreshed", true); + log.info("会员 {} 原生绑定后已同步刷新后端涂鸦 token(简单模式)", memberId); + } catch (Exception e) { + log.warn("会员 {} 原生绑定后刷新后端 token 失败(不阻断绑定): {}", + memberId, e.getMessage()); + } + } else { + resp.put("backendTokenRefreshed", false); + resp.put("userTokenStored", true); } return resp; @@ -644,6 +678,19 @@ public class TuyaCloudService { // ==================== 用户 Token 管理 ==================== + /** + * 计算 token 过期时间 + * + * @param expireTime 秒级时间戳(可空) + */ + private LocalDateTime expireAt(Long expireTime) { + if (expireTime != null && expireTime > 0) { + // 提前 10 分钟过期,避免边界失效 + return LocalDateTime.now().plusSeconds(Math.max(expireTime - 600, 600)); + } + return LocalDateTime.now().plusDays(30); + } + /** * 获取用户的涂鸦访问令牌 */ @@ -682,7 +729,15 @@ public class TuyaCloudService { // 检查refresh_token是否有效(空值或占位符无法刷新) if (!StringUtils.hasText(refreshTk) || "native_sdk".equals(refreshTk)) { log.warn("会员 {} 的refresh_token无效,需要重新绑定", memberId); - tuyaUserBindingMapper.deleteByMemberId(memberId); + // 不删除绑定记录:APP 端可能仍持有有效会话,删掉会导致 + // 后端与 APP 的绑定状态不一致(表现为"未绑定涂鸦账号")。 + // 仅标记为失效,等 APP 下次登录/续登上报新 token 后恢复。 + try { + fresh.setStatus(0); + fresh.setUpdateTime(LocalDateTime.now()); + tuyaUserBindingMapper.updateById(fresh); + } catch (Exception ignore) { + } throw new BusinessException(ResultCode.THIRD_PARTY_AUTH_ERROR, "涂鸦授权已过期,请重新绑定"); }