dsh-remote-bridge 2.7.1 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/api/asyncapi.yaml +591 -1
- package/docs/api/openapi.yaml +484 -2
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/MobileGatewayClient.kt +437 -0
- package/lib/index.d.ts +316 -3
- package/lib/index.js +6113 -1248
- package/package.json +2 -2
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
package ai.deepseek.dsh.workspace
|
|
2
|
+
|
|
3
|
+
import java.io.Closeable
|
|
4
|
+
import java.util.Base64
|
|
5
|
+
import java.util.concurrent.CompletableFuture
|
|
6
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
7
|
+
import java.util.concurrent.Executors
|
|
8
|
+
import java.util.concurrent.ScheduledFuture
|
|
9
|
+
import java.util.concurrent.TimeUnit
|
|
10
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
11
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
12
|
+
import kotlinx.serialization.Serializable
|
|
13
|
+
import kotlinx.serialization.json.Json
|
|
14
|
+
import kotlinx.serialization.json.JsonElement
|
|
15
|
+
import kotlinx.serialization.json.JsonObject
|
|
16
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
17
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
18
|
+
import kotlinx.serialization.json.jsonObject
|
|
19
|
+
import kotlinx.serialization.json.jsonPrimitive
|
|
20
|
+
import kotlinx.serialization.json.put
|
|
21
|
+
import okhttp3.HttpUrl
|
|
22
|
+
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
23
|
+
import okhttp3.OkHttpClient
|
|
24
|
+
import okhttp3.Request
|
|
25
|
+
import okhttp3.Response
|
|
26
|
+
import okhttp3.WebSocket
|
|
27
|
+
import okhttp3.WebSocketListener
|
|
28
|
+
|
|
29
|
+
/** `/ws/mobile` 的子协议名。握手时**必须**排在子协议列表第一位,服务端据此判断协议版本。 */
|
|
30
|
+
const val MOBILE_PROTOCOL: String = "dsh-mobile-v1"
|
|
31
|
+
|
|
32
|
+
/** hello 里宣告的协议修订号。与会话快照/问答审批的 `question-approval-v2` 是两码事。 */
|
|
33
|
+
const val MOBILE_HELLO_PROTOCOL: Int = 3
|
|
34
|
+
|
|
35
|
+
/** 服务端主动断开时用的 close code。4001 = 鉴权/配对失败或设备已被吊销,**重连无意义**。 */
|
|
36
|
+
const val MOBILE_CLOSE_AUTH_FAILED: Int = 4001
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `/ws/mobile` 的凭证载体。三种二选一,与服务端 `onConnection` 的判定顺序一致:
|
|
40
|
+
* 1. [Pairing] → 子协议 `dsh-pair.<code>`,**必须**带 `X-DSH-Device-ID`;
|
|
41
|
+
* 2. [Token] → 子协议 `dsh-auth.<token>`(推荐,浏览器/受控环境下不必自定义头);
|
|
42
|
+
* 3. [Bearer] → 子协议只留 `dsh-mobile-v1`,token 走 `Authorization: Bearer`。
|
|
43
|
+
*
|
|
44
|
+
* 刻意**没有** `?token=` 变体:服务端见到 URL query 里的 token/code 会直接 `close(4001)`,
|
|
45
|
+
* 因为它会漏进代理日志。别在这里补一个方便的后门。
|
|
46
|
+
*/
|
|
47
|
+
sealed class MobileCredentials {
|
|
48
|
+
/** 首配:一次性配对码 + 设备标识。配对成功后服务端会先推 `paired` 再推 `hello`。 */
|
|
49
|
+
data class Pairing(
|
|
50
|
+
val code: String,
|
|
51
|
+
val deviceId: String,
|
|
52
|
+
val deviceName: String? = null,
|
|
53
|
+
) : MobileCredentials()
|
|
54
|
+
|
|
55
|
+
/** 已配对设备:把配对拿到的 token 放进子协议。 */
|
|
56
|
+
data class Token(val token: String) : MobileCredentials()
|
|
57
|
+
|
|
58
|
+
/** 已配对设备:token 放 Authorization 头,子协议只保留 `dsh-mobile-v1`。 */
|
|
59
|
+
data class Bearer(val token: String) : MobileCredentials()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** hello 帧里的设备视图。回环管理员连接没有这一节(服务端会整段省略,而不是给 null)。 */
|
|
63
|
+
@Serializable
|
|
64
|
+
data class MobileDevice(
|
|
65
|
+
val id: String,
|
|
66
|
+
val name: String,
|
|
67
|
+
val scopes: List<String> = emptyList(),
|
|
68
|
+
val rootIds: List<String> = emptyList(),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
/** 连接建立后服务端推的第一帧。`protocol` 必须等于 [MOBILE_HELLO_PROTOCOL]。 */
|
|
72
|
+
@Serializable
|
|
73
|
+
data class MobileHello(
|
|
74
|
+
val protocol: Int,
|
|
75
|
+
val capabilities: List<String> = emptyList(),
|
|
76
|
+
val authenticated: Boolean = false,
|
|
77
|
+
val device: MobileDevice? = null,
|
|
78
|
+
val port: Int? = null,
|
|
79
|
+
val clients: Int? = null,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
/** 配对成功的凭证下发帧,只会在 `MobileCredentials.Pairing` 流程里出现,且**早于** hello。 */
|
|
83
|
+
@Serializable
|
|
84
|
+
data class MobilePaired(val token: String, val device: MobileDevice)
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 服务端 `error` 帧。`code` 是稳定契约(`UNKNOWN_TYPE` / `NOT_IMPLEMENTED` / `MISSING_PARAMS` /
|
|
88
|
+
* `FORBIDDEN_SCOPE` / `bad-request` …),`message` 是给人看的,可能变。
|
|
89
|
+
*/
|
|
90
|
+
class MobileGatewayException(
|
|
91
|
+
val code: String,
|
|
92
|
+
override val message: String,
|
|
93
|
+
val requestType: String? = null,
|
|
94
|
+
val requestId: String? = null,
|
|
95
|
+
val statusCode: Int? = null,
|
|
96
|
+
) : RuntimeException(message)
|
|
97
|
+
|
|
98
|
+
/** 请求发出去了但等不到回帧。多半是服务端不认识这个 type,或帧被背压队列丢了。 */
|
|
99
|
+
class MobileRequestTimeoutException(val requestId: String, val type: String, val timeoutMs: Long) :
|
|
100
|
+
RuntimeException("$type (requestId=$requestId) 在 ${timeoutMs}ms 内没有回帧")
|
|
101
|
+
|
|
102
|
+
/** 连接没等到 hello 就断了,或 hello 的 protocol 不匹配。 */
|
|
103
|
+
class MobileHandshakeException(message: String) : RuntimeException(message)
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `/ws/mobile` 的一帧响应。`kind` 是 `MobileServerFrame.kind` 的成员之一;
|
|
107
|
+
* `requestId` 是从 `data` 里提出来的一等字段,方便直接关联。
|
|
108
|
+
*/
|
|
109
|
+
data class MobileFrame(val kind: String, val data: JsonElement) {
|
|
110
|
+
val requestId: String? = (data as? JsonObject)?.get("requestId")?.let {
|
|
111
|
+
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `/ws/mobile` 的帧回调。全部方法都有默认空实现,调用方只覆盖关心的那几个。
|
|
117
|
+
*
|
|
118
|
+
* 回调都在 OkHttp 的读线程上跑,**不要**在里面做阻塞的 [MobileGatewayClient.request]:
|
|
119
|
+
* 那条路径要等读线程派发回帧,会自己把自己锁死。需要请求-响应就用 [MobileGatewayClient.request],
|
|
120
|
+
* 它内部走的是独立的等待者,但仍建议在调用方线程里用。
|
|
121
|
+
*/
|
|
122
|
+
interface MobileGatewayListener {
|
|
123
|
+
/** 每条非 `event` 帧。hello/paired 已单独派发,这里不再重复。 */
|
|
124
|
+
fun onFrame(frame: MobileFrame) {}
|
|
125
|
+
|
|
126
|
+
/** `{kind:'event', data:{...}}`。事件本身带 `type`(如 `chat.message.delta`)。 */
|
|
127
|
+
fun onEvent(event: JsonElement) {}
|
|
128
|
+
|
|
129
|
+
/** 首帧 hello。`protocol != 3` 时先调这个,再走 [onPermanentFailure]。 */
|
|
130
|
+
fun onHello(hello: MobileHello) {}
|
|
131
|
+
|
|
132
|
+
/** 配对成功,拿到长期 token。只有配对流程会触发。 */
|
|
133
|
+
fun onPaired(paired: MobilePaired) {}
|
|
134
|
+
|
|
135
|
+
/** `ping` 的回帧。`at` 与 `time` 是同一毫秒(服务端刻意如此)。 */
|
|
136
|
+
fun onPong(at: Long, time: Long) {}
|
|
137
|
+
|
|
138
|
+
fun onConnectionChanged(connected: Boolean) {}
|
|
139
|
+
|
|
140
|
+
/** 重连没意义的失败(4001、hello 协议不匹配、配对码失效)。 */
|
|
141
|
+
fun onPermanentFailure(error: Throwable) {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `/ws/mobile` 移动端网关客户端。
|
|
146
|
+
*
|
|
147
|
+
* 与 [DshWorkspaceClient] 的分工:REST 客户端覆盖「一次请求一次响应」的能力面,
|
|
148
|
+
* 本类覆盖长连接面 —— hello/能力协商、`event` 推送、HITL 交互、分块传输、
|
|
149
|
+
* 以及靠 `requestId` 关联的异步请求-响应。服务端是 WS 优先、REST 兜底,
|
|
150
|
+
* 所以**同一个能力两处都有**时优先走本类;本类没有的能力再回落到 REST。
|
|
151
|
+
*
|
|
152
|
+
* 用法:
|
|
153
|
+
* ```
|
|
154
|
+
* val client = MobileGatewayClient("ws://127.0.0.1:3090", MobileCredentials.Token(token)) { frame -> ... }
|
|
155
|
+
* val hello = client.awaitHello()
|
|
156
|
+
* client.subscribe(sessionId)
|
|
157
|
+
* client.request("session-stats", mapOf("sessionId" to sessionId))
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* 线程模型:所有写都在 OkHttp 的 WebSocket 队列里串行;[request] 的等待者在调用方线程阻塞,
|
|
161
|
+
* 回帧由读线程按 `requestId` 唤醒。回调见 [MobileGatewayListener] 的线程说明。
|
|
162
|
+
*/
|
|
163
|
+
class MobileGatewayClient(
|
|
164
|
+
wsBaseUrl: String,
|
|
165
|
+
private val credentials: MobileCredentials,
|
|
166
|
+
private val http: OkHttpClient = OkHttpClient(),
|
|
167
|
+
private val listener: MobileGatewayListener = object : MobileGatewayListener {},
|
|
168
|
+
) : Closeable {
|
|
169
|
+
private val json = Json { ignoreUnknownKeys = true }
|
|
170
|
+
private val url: HttpUrl = buildUrl(wsBaseUrl)
|
|
171
|
+
private val closed = AtomicBoolean(false)
|
|
172
|
+
private val scheduler = Executors.newSingleThreadScheduledExecutor()
|
|
173
|
+
private val pending = ConcurrentHashMap<String, CompletableFuture<MobileFrame>>()
|
|
174
|
+
private val requestSeq = AtomicLong(0)
|
|
175
|
+
private val helloFuture = CompletableFuture<MobileHello>()
|
|
176
|
+
|
|
177
|
+
@Volatile private var socket: WebSocket? = null
|
|
178
|
+
@Volatile private var retry: ScheduledFuture<*>? = null
|
|
179
|
+
@Volatile private var attempts = 0
|
|
180
|
+
|
|
181
|
+
/** 已收到的 hello;握手完成前为 null。 */
|
|
182
|
+
@Volatile
|
|
183
|
+
var hello: MobileHello? = null
|
|
184
|
+
private set
|
|
185
|
+
|
|
186
|
+
/** 配对流程拿到的 token;非配对流程恒为 null。 */
|
|
187
|
+
@Volatile
|
|
188
|
+
var pairedToken: String? = null
|
|
189
|
+
private set
|
|
190
|
+
|
|
191
|
+
init { connect() }
|
|
192
|
+
|
|
193
|
+
// ── 握手 ─────────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 把 `ws://host:port` 或 `http(s)://host:port` 归一成 `ws(s)://host:port/ws/mobile`。
|
|
197
|
+
* 传完整路径也接受(幂等),省得调用方两处拼路径拼出 `/ws/mobile/ws/mobile`。
|
|
198
|
+
*/
|
|
199
|
+
private fun buildUrl(base: String): HttpUrl {
|
|
200
|
+
val normalized = when {
|
|
201
|
+
base.startsWith("ws://") -> "http://" + base.removePrefix("ws://")
|
|
202
|
+
base.startsWith("wss://") -> "https://" + base.removePrefix("wss://")
|
|
203
|
+
else -> base
|
|
204
|
+
}
|
|
205
|
+
val parsed = normalized.trimEnd('/').toHttpUrl()
|
|
206
|
+
if (parsed.encodedPath.endsWith("/ws/mobile")) return parsed
|
|
207
|
+
return parsed.newBuilder().addPathSegment("ws").addPathSegment("mobile").build()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 子协议列表:`dsh-mobile-v1` 永远第一位,凭证前缀紧随其后。 */
|
|
211
|
+
private fun subprotocols(): String = when (credentials) {
|
|
212
|
+
is MobileCredentials.Pairing -> "$MOBILE_PROTOCOL, dsh-pair.${credentials.code}"
|
|
213
|
+
is MobileCredentials.Token -> "$MOBILE_PROTOCOL, dsh-auth.${credentials.token}"
|
|
214
|
+
is MobileCredentials.Bearer -> MOBILE_PROTOCOL
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun requestBuilder(): Request.Builder {
|
|
218
|
+
val builder = Request.Builder().url(url).header("Sec-WebSocket-Protocol", subprotocols())
|
|
219
|
+
when (credentials) {
|
|
220
|
+
is MobileCredentials.Pairing -> {
|
|
221
|
+
builder.header("X-DSH-Device-ID", credentials.deviceId)
|
|
222
|
+
// 设备名走 Base64(UTF-8):HTTP 头只保证 ISO-8859-1,中文名直传会被截断。
|
|
223
|
+
credentials.deviceName?.takeIf { it.isNotBlank() }?.let {
|
|
224
|
+
builder.header("X-DSH-Device-Name", Base64.getEncoder().encodeToString(it.toByteArray(Charsets.UTF_8)))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
is MobileCredentials.Bearer -> builder.header("Authorization", "Bearer ${credentials.token}")
|
|
228
|
+
is MobileCredentials.Token -> Unit
|
|
229
|
+
}
|
|
230
|
+
return builder
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private fun connect() {
|
|
234
|
+
if (closed.get()) return
|
|
235
|
+
socket = http.newWebSocket(requestBuilder().build(), object : WebSocketListener() {
|
|
236
|
+
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
237
|
+
attempts = 0
|
|
238
|
+
listener.onConnectionChanged(true)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
242
|
+
// 单帧解析失败不该掀桌子:服务端可能先于客户端升级而多发了一种帧。
|
|
243
|
+
runCatching { dispatch(text) }.onFailure(listener::onPermanentFailure)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
247
|
+
failPending(MobileHandshakeException("连接关闭:$code $reason"))
|
|
248
|
+
listener.onConnectionChanged(false)
|
|
249
|
+
when (code) {
|
|
250
|
+
// 4001 覆盖「配对码无效/过期、设备 ID 缺失、token 被吊销」。
|
|
251
|
+
// 重连只会刷出一串同样的 4001,所以直接判永久失败。
|
|
252
|
+
MOBILE_CLOSE_AUTH_FAILED -> {
|
|
253
|
+
val error = IllegalStateException("移动网关拒绝了凭证($reason),需要重新配对或换 token。")
|
|
254
|
+
helloFuture.completeExceptionally(error)
|
|
255
|
+
listener.onPermanentFailure(error)
|
|
256
|
+
}
|
|
257
|
+
else -> reconnect()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) {
|
|
262
|
+
failPending(error)
|
|
263
|
+
listener.onConnectionChanged(false)
|
|
264
|
+
// 429 = 连接数打满(上限 32,鉴权后才计数)。等一会儿再试是合理的,别当永久失败。
|
|
265
|
+
if (response?.code == 401 || response?.code == 403) {
|
|
266
|
+
helloFuture.completeExceptionally(error)
|
|
267
|
+
listener.onPermanentFailure(error)
|
|
268
|
+
} else {
|
|
269
|
+
reconnect()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 指数退避重连:1s,2s,4s,8s,16s,封顶 30s。与 [EventSubscription] 同一套参数,
|
|
277
|
+
* 免得同一个 App 里两条长连接节奏不一样、把服务端 32 个槽位一起打满。
|
|
278
|
+
*/
|
|
279
|
+
private fun reconnect() {
|
|
280
|
+
if (closed.get()) return
|
|
281
|
+
val delaySeconds = minOf(30L, 1L shl minOf(attempts++, 5))
|
|
282
|
+
retry?.cancel(false)
|
|
283
|
+
retry = scheduler.schedule(::connect, delaySeconds, TimeUnit.SECONDS)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private fun dispatch(text: String) {
|
|
287
|
+
val envelope = json.parseToJsonElement(text).jsonObject
|
|
288
|
+
val kind = envelope["kind"]?.jsonPrimitive?.contentOrNullSafe()
|
|
289
|
+
?: throw MobileHandshakeException("服务端帧缺少 kind:${text.take(200)}")
|
|
290
|
+
val data = envelope["data"] ?: JsonObject(emptyMap())
|
|
291
|
+
val frame = MobileFrame(kind, data)
|
|
292
|
+
|
|
293
|
+
when (kind) {
|
|
294
|
+
"hello" -> {
|
|
295
|
+
val parsed = json.decodeFromJsonElement(MobileHello.serializer(), data)
|
|
296
|
+
hello = parsed
|
|
297
|
+
listener.onHello(parsed)
|
|
298
|
+
if (parsed.protocol != MOBILE_HELLO_PROTOCOL) {
|
|
299
|
+
// 协议不匹配时**不要**继续用这条连接发帧:字段语义已经不可信了。
|
|
300
|
+
val error = MobileHandshakeException(
|
|
301
|
+
"移动网关协议是 ${parsed.protocol},本 SDK 只支持 $MOBILE_HELLO_PROTOCOL。",
|
|
302
|
+
)
|
|
303
|
+
helloFuture.completeExceptionally(error)
|
|
304
|
+
listener.onPermanentFailure(error)
|
|
305
|
+
close()
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
helloFuture.complete(parsed)
|
|
309
|
+
}
|
|
310
|
+
"paired" -> {
|
|
311
|
+
val parsed = json.decodeFromJsonElement(MobilePaired.serializer(), data)
|
|
312
|
+
pairedToken = parsed.token
|
|
313
|
+
listener.onPaired(parsed)
|
|
314
|
+
}
|
|
315
|
+
"pong" -> {
|
|
316
|
+
val at = (data as? JsonObject)?.get("at")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: 0L
|
|
317
|
+
val time = (data as? JsonObject)?.get("time")?.jsonPrimitive?.contentOrNullSafe()?.toLongOrNull() ?: at
|
|
318
|
+
listener.onPong(at, time)
|
|
319
|
+
}
|
|
320
|
+
"event" -> listener.onEvent(data)
|
|
321
|
+
else -> listener.onFrame(frame)
|
|
322
|
+
}
|
|
323
|
+
// requestId 关联放在最后:即使上面某个分支已经派发过回调,等待者也必须被唤醒。
|
|
324
|
+
frame.requestId?.let { id -> pending.remove(id)?.complete(frame) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 非字符串 primitive(数字/布尔)的 `content` 本身就是字面量文本,取它即可。 */
|
|
328
|
+
private fun JsonPrimitive.contentOrNullSafe(): String = content
|
|
329
|
+
|
|
330
|
+
// ── 请求-响应 ────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 发一帧并等它的回帧,靠 `requestId` 关联。
|
|
334
|
+
*
|
|
335
|
+
* 关联约定(与服务端一致,见 asyncapi 的 `/ws/mobile` 描述):
|
|
336
|
+
* - `requestId` 是不透明字符串,服务端**原样回显**,客户端只管比对;
|
|
337
|
+
* - 服务端在没有 requestId 时**整段省略**这个键,不会回 `null` 或空串;
|
|
338
|
+
* - `file-resolve` / `file-download-open` / `file-upload-open` 三帧的 requestId 是**必填**,
|
|
339
|
+
* 缺了服务端回 `bad-request`;
|
|
340
|
+
* - 关联是**每连接**作用域,不同连接可以用同一个 requestId。
|
|
341
|
+
*
|
|
342
|
+
* @throws MobileGatewayException 服务端回 `error` 帧(`UNKNOWN_TYPE` / `MISSING_PARAMS` 等)。
|
|
343
|
+
* @throws MobileRequestTimeoutException 超时。
|
|
344
|
+
*/
|
|
345
|
+
fun request(
|
|
346
|
+
type: String,
|
|
347
|
+
payload: Map<String, JsonElement> = emptyMap(),
|
|
348
|
+
requestId: String = "req-${requestSeq.incrementAndGet()}",
|
|
349
|
+
timeoutMs: Long = 30_000,
|
|
350
|
+
): MobileFrame {
|
|
351
|
+
val future = CompletableFuture<MobileFrame>()
|
|
352
|
+
pending[requestId] = future
|
|
353
|
+
val frame = buildJsonObject {
|
|
354
|
+
put("type", type)
|
|
355
|
+
put("requestId", requestId)
|
|
356
|
+
payload.forEach { (key, value) -> put(key, value) }
|
|
357
|
+
}
|
|
358
|
+
return try {
|
|
359
|
+
sendRaw(frame)
|
|
360
|
+
val reply = future.get(timeoutMs, TimeUnit.MILLISECONDS)
|
|
361
|
+
if (reply.kind == "error") throw toException(reply, type)
|
|
362
|
+
reply
|
|
363
|
+
} catch (timeout: java.util.concurrent.TimeoutException) {
|
|
364
|
+
throw MobileRequestTimeoutException(requestId, type, timeoutMs)
|
|
365
|
+
} catch (failure: java.util.concurrent.ExecutionException) {
|
|
366
|
+
// failPending 会把连接错误包成 ExecutionException;还原成真实原因才好定位。
|
|
367
|
+
throw (failure.cause as? RuntimeException) ?: IllegalStateException("请求失败:$type", failure.cause)
|
|
368
|
+
} finally {
|
|
369
|
+
pending.remove(requestId)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 发一帧不等回帧(`message`、`subscribe`、`approval-response` 这类)。 */
|
|
374
|
+
fun send(frame: JsonObject) {
|
|
375
|
+
sendRaw(frame)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 便捷封装:`{type:'ping'}`。回帧走 [MobileGatewayListener.onPong]。 */
|
|
379
|
+
fun ping() {
|
|
380
|
+
sendRaw(buildJsonObject { put("type", "ping") })
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 订阅单个会话的事件流。服务端会顺带回放该会话未决的审批/提问(带 `replayed:true`)。 */
|
|
384
|
+
fun subscribe(sessionId: String) {
|
|
385
|
+
sendRaw(buildJsonObject { put("type", "subscribe"); put("sessionId", sessionId) })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 取消订阅。之后事件流不再按会话过滤,回到全量。 */
|
|
389
|
+
fun unsubscribe() {
|
|
390
|
+
sendRaw(buildJsonObject { put("type", "unsubscribe") })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private fun sendRaw(frame: JsonObject) {
|
|
394
|
+
val target = socket ?: throw IllegalStateException("移动网关连接尚未建立。")
|
|
395
|
+
if (!target.send(json.encodeToString(JsonObject.serializer(), frame))) {
|
|
396
|
+
// send 返回 false 说明写队列已满/连接已关,帧根本没出去。静默丢帧比抛错更难查。
|
|
397
|
+
throw IllegalStateException("帧没有进入发送队列(连接已关闭或写缓冲已满):$frame")
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private fun toException(reply: MobileFrame, type: String): MobileGatewayException {
|
|
402
|
+
val data = reply.data as? JsonObject
|
|
403
|
+
val code = data?.get("code")?.jsonPrimitive?.contentOrNullSafe() ?: "UNKNOWN"
|
|
404
|
+
val message = data?.get("message")?.jsonPrimitive?.contentOrNullSafe() ?: code
|
|
405
|
+
return MobileGatewayException(
|
|
406
|
+
code = code,
|
|
407
|
+
message = message,
|
|
408
|
+
requestType = data?.get("requestType")?.jsonPrimitive?.contentOrNullSafe() ?: type,
|
|
409
|
+
requestId = reply.requestId,
|
|
410
|
+
statusCode = data?.get("statusCode")?.jsonPrimitive?.contentOrNullSafe()?.toIntOrNull(),
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fun failPending(error: Throwable) {
|
|
415
|
+
pending.values.forEach { it.completeExceptionally(error) }
|
|
416
|
+
pending.clear()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── 生命周期 ─────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/** 等 hello。握手失败(4001、协议不匹配、连接建立不起来)会抛异常而不是干等。 */
|
|
422
|
+
fun awaitHello(timeoutMs: Long = 15_000): MobileHello = try {
|
|
423
|
+
helloFuture.get(timeoutMs, TimeUnit.MILLISECONDS)
|
|
424
|
+
} catch (timeout: java.util.concurrent.TimeoutException) {
|
|
425
|
+
throw MobileHandshakeException("${timeoutMs}ms 内没有收到 hello")
|
|
426
|
+
} catch (failure: java.util.concurrent.ExecutionException) {
|
|
427
|
+
throw (failure.cause as? RuntimeException) ?: MobileHandshakeException("握手失败:${failure.cause?.message}")
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
override fun close() {
|
|
431
|
+
if (!closed.compareAndSet(false, true)) return
|
|
432
|
+
retry?.cancel(false)
|
|
433
|
+
failPending(IllegalStateException("客户端已关闭"))
|
|
434
|
+
socket?.close(1000, "Client closed")
|
|
435
|
+
scheduler.shutdownNow()
|
|
436
|
+
}
|
|
437
|
+
}
|