feat(V): 后端打通涂鸦用户token通路, 支持远程代发指令与代查状态

- 根因: 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子设备经网关透传、查询设备状态)
This commit is contained in:
2995840376 2026-10-28 12:50:08 +08:00
parent 7e7c1e072c
commit 80481ae0ef
8 changed files with 260 additions and 32 deletions

View File

@ -196,12 +196,15 @@ public interface ApiService {
/** /**
* 原生SDK绑定涂鸦账号 * 原生SDK绑定涂鸦账号
* POST /api/v1/tuya/auth/bind-native * POST /api/v1/tuya/auth/bind-native
* body: { "tuyaUid", "account", "loginMethod", "platform" } * body: { "tuyaUid", "account", "loginMethod", "platform",
* "accessToken", "refreshToken", "expireTime" }
*
* <p>带上 token 后,后端可代表用户调用涂鸦 API(远程代发指令/查状态)。</p>
*/ */
@POST("v1/tuya/auth/bind-native") @POST("v1/tuya/auth/bind-native")
Call<Result<Map<String, Object>>> bindNative( Call<Result<Map<String, Object>>> bindNative(
@Header("Authorization") String auth, @Header("Authorization") String auth,
@Body Map<String, String> body); @Body Map<String, Object> body);
/** /**
* 解绑涂鸦账号 * 解绑涂鸦账号

View File

@ -1,5 +1,6 @@
package com.xhai.iot.service; package com.xhai.iot.service;
import android.content.Context;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; 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。
*
* <p>用反射实现,避免不同 SDK 版本的 getToken() 签名差异导致编译失败。</p>
*/
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 同步到后端。
*
* <p>后端拿到后即可代表该用户调用涂鸦 API:远程代发指令(手机不在家也能控)、
* 查询设备状态(远程在线判定更权威)。失败不影响 APP 自身使用。</p>
*/
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<String, Object> 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<Result<Map<String, Object>>>() {
@Override
public void onResponse(retrofit2.Call<Result<Map<String, Object>>> call,
retrofit2.Response<Result<Map<String, Object>>> response) {
Log.i(TAG, "涂鸦token上报结果: " + response.code());
}
@Override
public void onFailure(retrofit2.Call<Result<Map<String, Object>>> 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<String, Long> onlineOverride = private static final java.util.concurrent.ConcurrentHashMap<String, Long> onlineOverride =
new java.util.concurrent.ConcurrentHashMap<>(); new java.util.concurrent.ConcurrentHashMap<>();

View File

@ -71,6 +71,14 @@ public class MainActivity extends AppCompatActivity {
boolean tuyaLoggedIn = com.thingclips.smart.home.sdk.ThingHomeSdk boolean tuyaLoggedIn = com.thingclips.smart.home.sdk.ThingHomeSdk
.getUserInstance().isLogin(); .getUserInstance().isLogin();
SessionManager.getInstance(this).setTuyaBound(tuyaLoggedIn); 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)) { if (!tuyaLoggedIn && com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) {
com.xhai.iot.util.TuyaAutoRelogin.relogin(this, com.xhai.iot.util.TuyaAutoRelogin.relogin(this,
new com.xhai.iot.util.TuyaAutoRelogin.Callback() { new com.xhai.iot.util.TuyaAutoRelogin.Callback() {

View File

@ -193,11 +193,20 @@ public class TuyaBindActivity extends AppCompatActivity {
return; return;
} }
Map<String, String> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("tuyaUid", tuyaUid); body.put("tuyaUid", tuyaUid);
body.put("account", account); body.put("account", account);
body.put("loginMethod", "phone"); body.put("loginMethod", "phone");
body.put("platform", "android"); 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) ApiClient.getApiService().bindNative(auth, body)
.enqueue(new Callback<Result<Map<String, Object>>>() { .enqueue(new Callback<Result<Map<String, Object>>>() {

View File

@ -29,6 +29,29 @@ public class TuyaAutoRelogin {
return new TuyaCredentialStore(context).has(); return new TuyaCredentialStore(context).has();
} }
/**
* 续登成功后把新的涂鸦 token 同步到后端。
*
* <p>后端拿到用户 token 后即可代表用户调用涂鸦 API(远程代发指令、查状态),
* 实现「手机不在线也能控/能看」。失败不影响 APP 自身使用。</p>
*/
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) { public static void relogin(Context context, Callback callback) {
TuyaCredentialStore store = new TuyaCredentialStore(context); TuyaCredentialStore store = new TuyaCredentialStore(context);
@ -43,6 +66,7 @@ public class TuyaAutoRelogin {
@Override @Override
public void onSuccess(User user) { public void onSuccess(User user) {
Log.i(TAG, "自动续登成功"); Log.i(TAG, "自动续登成功");
reportToken(context, user, account);
callback.onSuccess(user); callback.onSuccess(user);
} }
@ -53,6 +77,7 @@ public class TuyaAutoRelogin {
@Override @Override
public void onSuccess(User user) { public void onSuccess(User user) {
Log.i(TAG, "自动续登成功(邮箱)"); Log.i(TAG, "自动续登成功(邮箱)");
reportToken(context, user, account);
callback.onSuccess(user); callback.onSuccess(user);
} }

View File

@ -195,7 +195,8 @@ public class TuyaController {
HttpServletRequest request) { HttpServletRequest request) {
Long memberId = extractMemberId(request); Long memberId = extractMemberId(request);
Map<String, Object> result = tuyaCloudService.bindNative( Map<String, Object> 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); return Result.success(result);
} }

View File

@ -23,4 +23,18 @@ public class TuyaNativeBindRequest {
/** 平台:android / ios / h5 */ /** 平台:android / ios / h5 */
private String platform = "android"; private String platform = "android";
/**
* 涂鸦用户访问令牌(APP 原生 SDK 登录后拿到)
*
* <p>有了它后端才能代用户调用涂鸦用户级 API:远程代发指令、查询设备状态,
* 从而做到「手机不在线时服务器仍可控制/监控设备」。</p>
*/
private String accessToken;
/** 刷新令牌(用于后端自动续期,避免用户反复授权) */
private String refreshToken;
/** 访问令牌过期时间(秒级时间戳) */
private Long expireTime;
} }

View File

@ -350,42 +350,71 @@ public class TuyaCloudService {
/** /**
* APP原生SDK绑定(用户通过APP端涂鸦插件登录后同步到后端) * APP原生SDK绑定(用户通过APP端涂鸦插件登录后同步到后端)
* 不需要token交换,直接记录绑定关系 *
* <p>APP 会把涂鸦原生 SDK 拿到的 access_token / refresh_token 一并上报,
* 后端加密保存后即可代表该用户调用涂鸦用户级 API(远程代发指令、查询状态),
* 实现「手机不在线也能控/能看」。没有 token 时退化为仅标记绑定。</p>
*/ */
public Map<String, Object> bindNative(String tuyaUid, String account, public Map<String, Object> bindNative(String tuyaUid, String account,
String loginMethod, Long memberId) { String loginMethod, Long memberId) {
return bindNative(tuyaUid, account, loginMethod, memberId, null, null, null);
}
public Map<String, Object> bindNative(String tuyaUid, String account,
String loginMethod, Long memberId,
String accessToken, String refreshToken,
Long expireTime) {
try { try {
// 检查是否已有绑定 // 检查是否已有绑定
TuyaUserBinding existing = tuyaUserBindingMapper.selectByMemberId(memberId); TuyaUserBinding existing = tuyaUserBindingMapper.selectByMemberId(memberId);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
boolean hasRealToken = StringUtils.hasText(accessToken)
&& !"native_sdk".equals(accessToken);
if (existing != null) { if (existing != null) {
// 更新已有绑定 // 更新已有绑定(uid/account 为空时保留原值,避免 token 续期上报把身份信息冲掉)
if (StringUtils.hasText(tuyaUid)) {
existing.setTuyaUserId(tuyaUid); existing.setTuyaUserId(tuyaUid);
}
if (StringUtils.hasText(account)) {
existing.setTuyaOpenId(account); // 用account存储登录账号 existing.setTuyaOpenId(account); // 用account存储登录账号
}
existing.setStatus(1); existing.setStatus(1);
existing.setUpdateTime(now); existing.setUpdateTime(now);
if (hasRealToken) {
existing.setAccessToken(encrypt(accessToken));
existing.setRefreshToken(encrypt(StringUtils.hasText(refreshToken) ? refreshToken : ""));
existing.setTokenExpireAt(expireAt(expireTime));
}
tuyaUserBindingMapper.updateById(existing); tuyaUserBindingMapper.updateById(existing);
log.info("会员 {} 更新了涂鸦绑定: uid={}, account={}", memberId, tuyaUid, account); log.info("会员 {} 更新了涂鸦绑定: uid={}, account={}, token={}",
memberId, tuyaUid, account, hasRealToken ? "已更新" : "未提供");
} else { } else {
// 创建新绑定记录 // 创建新绑定记录
TuyaUserBinding binding = new TuyaUserBinding(); TuyaUserBinding binding = new TuyaUserBinding();
binding.setMemberId(memberId); binding.setMemberId(memberId);
binding.setTuyaUserId(tuyaUid); binding.setTuyaUserId(tuyaUid);
binding.setTuyaOpenId(account); binding.setTuyaOpenId(account);
// 原生SDK模式下,token由APP端管理,这里仅标记已绑定 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.setAccessToken(encrypt("native_sdk"));
binding.setRefreshToken(encrypt("")); binding.setRefreshToken(encrypt(""));
binding.setTokenExpireAt(now.plusYears(10)); // 长期有效 binding.setTokenExpireAt(now.plusYears(10)); // 长期有效
}
binding.setDefaultHomeId(0L); binding.setDefaultHomeId(0L);
binding.setStatus(1); binding.setStatus(1);
binding.setCreateTime(now); binding.setCreateTime(now);
binding.setUpdateTime(now); binding.setUpdateTime(now);
tuyaUserBindingMapper.insert(binding); tuyaUserBindingMapper.insert(binding);
log.info("会员 {} 通过APP原生SDK绑定了涂鸦账号: uid={}, account={}", log.info("会员 {} 通过APP原生SDK绑定了涂鸦账号: uid={}, account={}, token={}",
memberId, tuyaUid, account); memberId, tuyaUid, account, hasRealToken ? "已保存" : "未提供");
} }
Map<String, Object> resp = new LinkedHashMap<>(); Map<String, Object> resp = new LinkedHashMap<>();
@ -394,12 +423,13 @@ public class TuyaCloudService {
resp.put("loginMethod", loginMethod); resp.put("loginMethod", loginMethod);
resp.put("account", maskAccount(account)); resp.put("account", maskAccount(account));
// 原生SDK模式只标记绑定,不产生后端可用的涂鸦 token。但后端的 // APP 上报了真实用户 token 时,后端已具备代表用户调用涂鸦 API 的能力,
// OpenAPI 调用(设备控制/详情查询/探活兜底)都依赖 user token, // 不再用简单模式(grant_type=1)覆盖 —— 简单模式拿到的是"项目级 token",
// 占位符 "native_sdk" 会导致所有调用报 token invalid。 // 覆盖后反而丢失了用户身份,无法代用户控制设备。
// 这里同步用简单模式(grant_type=1)换取平台级 token 覆盖占位符, if (!hasRealToken) {
// 使后端具备 OpenAPI 调用能力;APP 每次登录/续登都会重新绑定, // 原生SDK模式未上报 token:退化为用简单模式换取平台级 token,
// token 也会随之刷新。失败不阻断绑定(保留占位符,仅能力受限)。 // 至少让后端具备部分 OpenAPI 调用能力(设备状态查询等)。
// 失败不阻断绑定(保留占位符,仅能力受限)。
try { try {
autoBind(memberId); autoBind(memberId);
resp.put("backendTokenRefreshed", true); resp.put("backendTokenRefreshed", true);
@ -408,6 +438,10 @@ public class TuyaCloudService {
log.warn("会员 {} 原生绑定后刷新后端 token 失败(不阻断绑定): {}", log.warn("会员 {} 原生绑定后刷新后端 token 失败(不阻断绑定): {}",
memberId, e.getMessage()); memberId, e.getMessage());
} }
} else {
resp.put("backendTokenRefreshed", false);
resp.put("userTokenStored", true);
}
return resp; return resp;
} catch (BusinessException e) { } catch (BusinessException e) {
@ -644,6 +678,19 @@ public class TuyaCloudService {
// ==================== 用户 Token 管理 ==================== // ==================== 用户 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是否有效(空值或占位符无法刷新) // 检查refresh_token是否有效(空值或占位符无法刷新)
if (!StringUtils.hasText(refreshTk) || "native_sdk".equals(refreshTk)) { if (!StringUtils.hasText(refreshTk) || "native_sdk".equals(refreshTk)) {
log.warn("会员 {} 的refresh_token无效,需要重新绑定", memberId); 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, "涂鸦授权已过期,请重新绑定"); throw new BusinessException(ResultCode.THIRD_PARTY_AUTH_ERROR, "涂鸦授权已过期,请重新绑定");
} }