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, "涂鸦授权已过期,请重新绑定");
}