fix: 修复 WebRTC 控制稳定性与输入事件重复问题
- 修复安卓端 401 拦截器线程问题,确保 Toast 在主线程执行 - 修复 Web 端按键/触摸重复触发:增加按下状态跟踪,防止悬停触发动作和长按重复 - 修复 Web 端指针取消时未释放触摸状态导致被控端卡死 - 修复 TURN 未启用时返回 401 导致客户端异常登出,改为返回 enabled=false - 修复后台管理端 401 后路由守卫跳转失败 - 增加控制指令调试日志用于排查问题
This commit is contained in:
@@ -214,10 +214,11 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
@Override
|
||||
protected void initData() {
|
||||
// 任意 REST 接口 401 -> 直接回登录页(与 WebSocket 4001/4003 行为一致)。
|
||||
UnauthorizedInterceptor.setHandler(msg -> {
|
||||
// 注意:该回调运行在 OkHttp 拦截器线程,必须切回主线程再操作 Toast / UI。
|
||||
UnauthorizedInterceptor.setHandler(msg -> runOnUiThread(() -> {
|
||||
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_LONG).show();
|
||||
logoutAndReset();
|
||||
});
|
||||
}));
|
||||
|
||||
// 令牌就绪 -> 建立信令 WebSocket
|
||||
viewModel.getAccessToken().observe(this, token -> {
|
||||
@@ -303,7 +304,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
loadDevices();
|
||||
}
|
||||
|
||||
/** 合并设备列表(在线设备优先置于顶部),用于列表展示。 */
|
||||
/**
|
||||
* 合并设备列表(在线设备优先置于顶部),用于列表展示。
|
||||
*/
|
||||
private void mergeDevices(List<BindingItem> items) {
|
||||
for (BindingItem item : items) {
|
||||
int idx = -1;
|
||||
@@ -323,7 +326,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
deviceList.sort((a, b) -> Boolean.compare(b.isOnline(), a.isOnline()));
|
||||
}
|
||||
|
||||
/** 设备列表为空时显示占位提示。 */
|
||||
/**
|
||||
* 设备列表为空时显示占位提示。
|
||||
*/
|
||||
private void updateDeviceEmpty() {
|
||||
boolean empty = deviceList.isEmpty();
|
||||
binding.tvDeviceEmpty.setVisibility(empty ? View.VISIBLE : View.GONE);
|
||||
|
||||
@@ -29,11 +29,19 @@ const selectedResolution = ref(0);
|
||||
const fpsOptions = ref([15, 24, 30, 60]);
|
||||
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
|
||||
|
||||
// 记录当前处于按下状态的按键:pointerleave/pointerup 在指针未按下时也会触发,
|
||||
// 若无此跟踪,鼠标仅扫过按钮就会向被控端发送孤立的 KEY UP。
|
||||
const pressedButtons = new Set();
|
||||
|
||||
function pressDown(code) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
if (pressedButtons.has(code)) return;
|
||||
pressedButtons.add(code);
|
||||
sendKeyDown(code);
|
||||
}
|
||||
function pressUp(code) {
|
||||
if (!pressedButtons.has(code)) return;
|
||||
pressedButtons.delete(code);
|
||||
if (!store.dataChannelOpen) return;
|
||||
sendKeyUp(code);
|
||||
}
|
||||
@@ -71,18 +79,26 @@ function lookupKeyCode(e) {
|
||||
}
|
||||
|
||||
// 固定按键:按下/抬起分离(如电源键需要真实按下-抬起配对)
|
||||
// pressedKeys 跟踪已按下的物理键,避免长按自动重复触发重复 DOWN、
|
||||
// 以及无对应 DOWN 的孤立 UP(如在窗口外按下、回到窗口内才抬起)。
|
||||
const pressedKeys = new Set();
|
||||
|
||||
function onKeyCaptureDown(e) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
const m = lookupKeyCode(e);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
if (pressedKeys.has(m.code)) return; // 浏览器长按自动重复,忽略
|
||||
pressedKeys.add(m.code);
|
||||
sendKeyDown(m.code);
|
||||
}
|
||||
function onKeyCaptureUp(e) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
const m = lookupKeyCode(e);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
if (!pressedKeys.has(m.code)) return;
|
||||
pressedKeys.delete(m.code);
|
||||
if (!store.dataChannelOpen) return;
|
||||
sendKeyUp(m.code);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ const TOUCH_SLOP = 0.02;
|
||||
const SAMPLE_MS = 16;
|
||||
|
||||
let startX = 0, startY = 0, startTime = 0, isLongPressed = false, longTimer = null, lastMoveTs = 0;
|
||||
// 指针是否处于按下状态:未按下时 pointermove/pointerup 不得产生任何指令,
|
||||
// 否则鼠标仅悬停划过画面就会向被控端发送大量 ACTION_MOVE。
|
||||
let isDown = false;
|
||||
|
||||
function clearLong() {
|
||||
if (longTimer) { clearTimeout(longTimer); longTimer = null; }
|
||||
@@ -53,6 +56,7 @@ function clearLong() {
|
||||
function onDown(e) {
|
||||
if (!active.value) return;
|
||||
overlayRef.value.setPointerCapture && overlayRef.value.setPointerCapture(e.pointerId);
|
||||
isDown = true;
|
||||
isLongPressed = false;
|
||||
const p = mapRelative(e.clientX, e.clientY);
|
||||
startX = p.x; startY = p.y; startTime = Date.now(); lastMoveTs = 0;
|
||||
@@ -65,7 +69,7 @@ function onDown(e) {
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (!active.value) return;
|
||||
if (!active.value || !isDown) return;
|
||||
const p = mapRelative(e.clientX, e.clientY);
|
||||
if (Math.abs(p.x - startX) > TOUCH_SLOP || Math.abs(p.y - startY) > TOUCH_SLOP) clearLong();
|
||||
const now = Date.now();
|
||||
@@ -76,7 +80,8 @@ function onMove(e) {
|
||||
}
|
||||
|
||||
function onUp(e) {
|
||||
if (!active.value) return;
|
||||
if (!active.value || !isDown) return;
|
||||
isDown = false;
|
||||
clearLong();
|
||||
const p = mapRelative(e.clientX, e.clientY);
|
||||
sendMotionEvent(1, p.x, p.y); // ACTION_UP
|
||||
@@ -88,6 +93,15 @@ function onUp(e) {
|
||||
else sendSwipe(startX, startY, p.x, p.y, Math.max(dur, 1));
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
clearLong();
|
||||
// 按下过程中指针被取消(如系统手势接管):补发 ACTION_UP 释放,避免被控端卡在按下态。
|
||||
if (isDown && active.value) {
|
||||
isDown = false;
|
||||
sendMotionEvent(1, startX, startY); // ACTION_UP
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,7 +115,7 @@ function onUp(e) {
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="clearLong"
|
||||
@pointercancel="onCancel"
|
||||
></div>
|
||||
|
||||
<div v-if="!hasStream" class="placeholder">
|
||||
|
||||
@@ -222,6 +222,7 @@ export class WebRtcController {
|
||||
if (!this.dataChannel || this.dataChannel.readyState !== 'open') return false;
|
||||
const bytes = encodeControlMessage(fields);
|
||||
this.dataChannel.send(bytes);
|
||||
console.debug('[控制指令]', describeControlMessage(fields), `bytes=${bytes.length}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -327,6 +328,26 @@ export class WebRtcController {
|
||||
}
|
||||
}
|
||||
|
||||
// 将待发送的控制指令格式化为可读日志文本(坐标保留 4 位小数)。
|
||||
function describeControlMessage(f) {
|
||||
const pt = (x, y) => `(${Number(x).toFixed(4)}, ${Number(y).toFixed(4)})`;
|
||||
switch (f.action) {
|
||||
case 1: return `模拟点击 TOUCH ${pt(f.x, f.y)}`;
|
||||
case 2: return `模拟滑动 SWIPE ${pt(f.x1, f.y1)} -> ${pt(f.x2, f.y2)} duration=${f.duration}ms`;
|
||||
case 3: {
|
||||
const ka = f.keyAction === 0 ? 'DOWN' : f.keyAction === 1 ? 'UP' : 'CLICK';
|
||||
return `模拟按键 KEY keyCode=${f.keyCode} keyAction=${ka}`;
|
||||
}
|
||||
case 4: return `模拟长按 LONG_PRESS ${pt(f.x, f.y)}`;
|
||||
case 5: {
|
||||
const ma = f.motionAction === 0 ? 'DOWN' : f.motionAction === 1 ? 'UP' : f.motionAction === 2 ? 'MOVE' : String(f.motionAction);
|
||||
return `触摸轨迹 MOTION ${ma} ${pt(f.x, f.y)}`;
|
||||
}
|
||||
case 6: return `切换分辨率 SET_RESOLUTION ${f.width}x${f.height}@${f.fps}fps`;
|
||||
default: return `未知指令 action=${f.action}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 将字节/秒格式化为可读速率
|
||||
function formatSpeed(bps) {
|
||||
if (!bps || bps <= 0) return '0 B/s';
|
||||
|
||||
@@ -391,10 +391,11 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
|
||||
|
||||
| 方法 | 路径 | 令牌 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` |
|
||||
| GET | `/api/client/turn-credentials` | 用户/设备 | 返回 `enabled`、`iceServers`(含 urls/username/credential)、`expiresAt`、`ttlSeconds` |
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"iceServers": [
|
||||
{ "urls": "turn:turn.ttstd.com:3478?transport=udp",
|
||||
"username": "1730000000:ab12cd34ef56",
|
||||
@@ -406,6 +407,7 @@ curl -X POST https://www.ttstd.com/api/client/pairing/redeem \
|
||||
```
|
||||
|
||||
> 客户端应将 `iceServers` 直接传入 `RTCPeerConnection` 配置;凭证过期后重新 GET 本接口刷新。
|
||||
> 服务端未启用 TURN 时返回 200 + `{"enabled": false, "iceServers": []}`(不会返回 401),客户端据此降级为仅 STUN。
|
||||
|
||||
#### 骚扰举报(P2 风控)
|
||||
被控端遭遇骚扰时可举报某主控端账号,管理员在 `/api/admin/abuse-reports` 查看并处理。
|
||||
|
||||
@@ -210,11 +210,24 @@ public class ClientController {
|
||||
|
||||
/**
|
||||
* 获取 TURN 短期凭证(ICE servers)。主控端与被控端在建立 PeerConnection 前调用。
|
||||
*
|
||||
* <p>TURN 未启用时返回 200 + 空 iceServers(enabled=false),而不是 401:
|
||||
* 「服务未开启」并非认证失败,返回 401 会触发各客户端的令牌刷新/强制登出逻辑
|
||||
* (Web/Flutter 会无意义轮换 refreshToken,Android 端 401 拦截器会清理凭据)。
|
||||
*/
|
||||
@GetMapping("/turn-credentials")
|
||||
public Map<String, Object> turnCredentials(HttpServletRequest request) {
|
||||
AuthPrincipal principal = requirePrincipal(request);
|
||||
return turnCredentialService.issue(principal.principalType() + ":" + principal.principalId());
|
||||
if (!turnCredentialService.isEnabled()) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("enabled", false);
|
||||
body.put("iceServers", List.of());
|
||||
return body;
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>(
|
||||
turnCredentialService.issue(principal.principalType() + ":" + principal.principalId()));
|
||||
body.put("enabled", true);
|
||||
return body;
|
||||
}
|
||||
|
||||
// ==================== 骚扰举报(P2 风控) ====================
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import router from '@/router';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
/**
|
||||
* 请求基地址。
|
||||
@@ -39,10 +40,11 @@ request.interceptors.response.use(
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
// 清空 store 内存与 localStorage 中的令牌,避免路由守卫因内存 token 残留把登录页弹回首页
|
||||
useUserStore().logout();
|
||||
ElMessage.error('登录已失效,请重新登录');
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.replace({ name: 'login' });
|
||||
if (router.currentRoute.value.name !== 'Login') {
|
||||
router.replace({ name: 'Login' });
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||
|
||||
Reference in New Issue
Block a user