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:
parent
7e7c1e072c
commit
80481ae0ef
@ -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" }
|
||||
*
|
||||
* <p>带上 token 后,后端可代表用户调用涂鸦 API(远程代发指令/查状态)。</p>
|
||||
*/
|
||||
@POST("v1/tuya/auth/bind-native")
|
||||
Call<Result<Map<String, Object>>> bindNative(
|
||||
@Header("Authorization") String auth,
|
||||
@Body Map<String, String> body);
|
||||
@Body Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 解绑涂鸦账号
|
||||
|
||||
@ -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。
|
||||
*
|
||||
* <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 =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -193,11 +193,20 @@ public class TuyaBindActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, String> body = new HashMap<>();
|
||||
Map<String, Object> 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<Result<Map<String, Object>>>() {
|
||||
|
||||
@ -29,6 +29,29 @@ public class TuyaAutoRelogin {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -195,7 +195,8 @@ public class TuyaController {
|
||||
HttpServletRequest request) {
|
||||
Long memberId = extractMemberId(request);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -23,4 +23,18 @@ public class TuyaNativeBindRequest {
|
||||
|
||||
/** 平台:android / ios / h5 */
|
||||
private String platform = "android";
|
||||
|
||||
/**
|
||||
* 涂鸦用户访问令牌(APP 原生 SDK 登录后拿到)
|
||||
*
|
||||
* <p>有了它后端才能代用户调用涂鸦用户级 API:远程代发指令、查询设备状态,
|
||||
* 从而做到「手机不在线时服务器仍可控制/监控设备」。</p>
|
||||
*/
|
||||
private String accessToken;
|
||||
|
||||
/** 刷新令牌(用于后端自动续期,避免用户反复授权) */
|
||||
private String refreshToken;
|
||||
|
||||
/** 访问令牌过期时间(秒级时间戳) */
|
||||
private Long expireTime;
|
||||
}
|
||||
|
||||
@ -350,42 +350,71 @@ public class TuyaCloudService {
|
||||
|
||||
/**
|
||||
* APP原生SDK绑定(用户通过APP端涂鸦插件登录后同步到后端)
|
||||
* 不需要token交换,直接记录绑定关系
|
||||
*
|
||||
* <p>APP 会把涂鸦原生 SDK 拿到的 access_token / refresh_token 一并上报,
|
||||
* 后端加密保存后即可代表该用户调用涂鸦用户级 API(远程代发指令、查询状态),
|
||||
* 实现「手机不在线也能控/能看」。没有 token 时退化为仅标记绑定。</p>
|
||||
*/
|
||||
public Map<String, Object> bindNative(String tuyaUid, String account,
|
||||
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 {
|
||||
// 检查是否已有绑定
|
||||
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<String, Object> 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, "涂鸦授权已过期,请重新绑定");
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user