liyong
2 小时以前 7a23c450f3ac85de7dca1b908de273ff636ce218
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
package cn.iocoder.yudao.module.im.framework.rtc.core;
 
import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.signers.JWTSignerUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import lombok.extern.slf4j.Slf4j;
 
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
 
/**
 * LiveKit 客户端
 * <p>
 * 厂商绑定层封装:Token 签发(join / admin)、Server API(DeleteRoom / ListParticipants)、identity 拼接 / 解析
 * <p>
 * 由 {@link cn.iocoder.yudao.module.im.framework.rtc.config.ImRtcConfiguration} 注册为 Bean,本类不带 {@code @Component}
 *
 * @author 芋道源码
 */
@Slf4j
public class LiveKitClient {
 
    /**
     * Twirp 端点路径
     */
    private static final String TWIRP_DELETE_ROOM = "/twirp/livekit.RoomService/DeleteRoom";
    private static final String TWIRP_LIST_PARTICIPANTS = "/twirp/livekit.RoomService/ListParticipants";
 
    /**
     * 管理 Token 有效期;Server API 一次调用即弃,10 秒足够
     */
    private static final Duration ADMIN_TOKEN_TTL = Duration.ofSeconds(10);
 
    /**
     * Server API HTTP 调用的超时上限;超时后直接报错,避免 LiveKit 异常 / 网络抖动时业务长时间阻塞
     */
    private static final int SERVER_API_TIMEOUT_MS = 10_000;
 
    private final ImProperties imProperties;
 
    /**
     * @param imProperties IM 全局配置;从中读 livekitUrl / apiKey / apiSecret / tokenTtlHours
     */
    public LiveKitClient(ImProperties imProperties) {
        this.imProperties = imProperties;
    }
 
    /**
     * 签发客户端进房 Token;有效期从 {@link ImProperties.Rtc#getTokenTtlHours()} 读取
     *
     * @param identity    用户唯一标识;写入 sub claim;同 identity 重连会踢前一个连接
     * @param displayName 客户端展示名;可空
     * @param room    房间名
     * @return JWT 字符串
     */
    public String signJoinToken(String identity, String displayName, String room) {
        Assert.notBlank(identity, "identity 不可为空");
        Assert.notBlank(room, "room 不可为空");
        ImProperties.Rtc cfg = imProperties.getRtc();
 
        // video claim:限定客户端能在该房间内做什么
        Map<String, Object> video = new HashMap<>();
        video.put("roomJoin", true);              // 允许加入房间
        video.put("room", room);              // 限定只能加入这个房间
        video.put("canPublish", true);            // 允许发布媒体(推流)
        video.put("canSubscribe", true);          // 允许订阅媒体(拉流)
        video.put("canPublishData", true);        // 允许发送 data channel 消息
 
        long nowSec = Instant.now().getEpochSecond();
        long ttlSec = Duration.ofHours(cfg.getTokenTtlHours()).getSeconds();
        JWT jwt = JWT.create()
                .setIssuer(cfg.getApiKey())
                .setSubject(identity)
                .setNotBefore(new Date(nowSec * 1000))
                .setExpiresAt(new Date((nowSec + ttlSec) * 1000))
                .setPayload("video", video)
                .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)));
        if (StrUtil.isNotEmpty(displayName)) {
            jwt.setPayload("name", displayName);
        }
        return jwt.sign();
    }
 
    /**
     * 调用 LiveKit Server API 删除房间:用于通话结束时,强制断开异常残留客户端
     *
     * @param room 房间名
     */
    @SuppressWarnings("EmptyTryBlock")
    public void deleteRoom(String room) {
        try (HttpResponse ignored = postTwirp(TWIRP_DELETE_ROOM, room)) {
            // 状态码不区分;调用方失败即兜底
        }
    }
 
    /**
     * 调用 LiveKit Server API 查询某房间内的参与者数量:用于定时扫描僵尸通话
     * <p>
     * 房间不存在 LiveKit 返回 404,视同 0 人
     *
     * @param room 房间名
     * @return 参与者数量;HTTP 失败返回 -1
     */
    public int listParticipants(String room) {
        try (HttpResponse response = postTwirp(TWIRP_LIST_PARTICIPANTS, room)) {
            if (response.getStatus() == 404) {
                return 0;
            }
            if (!response.isOk()) {
                log.warn("[listParticipants][LiveKit 返回非 2xx status={} room={} body={}]",
                        response.getStatus(), room, response.body());
                return -1;
            }
            JSONArray participants = JSONUtil.parseObj(response.body()).getJSONArray("participants");
            return CollUtil.size(participants);
        }
    }
 
    /**
     * 拼接 LiveKit identity;当前单端 = userId 字符串
     * <p>
     * 多端扩展时改 {@code userId + "#" + terminal} 格式,调用方无需改
     *
     * @param userId 用户编号
     * @return identity 字符串
     */
    public String buildIdentity(Long userId) {
        Assert.notNull(userId, "userId 不可为空");
        return String.valueOf(userId);
    }
 
    /**
     * 从 LiveKit identity 解析业务 userId
     * <p>
     * 当前 identity 直接是 userId 字符串;预留 {@code userId#terminal} 多端格式
     *
     * @param identity LiveKit identity
     * @return 用户编号;解析失败返回 null
     */
    public Long parseUserId(String identity) {
        if (StrUtil.isBlank(identity)) {
            return null;
        }
        int sep = identity.indexOf('#');
        String idPart = sep >= 0 ? identity.substring(0, sep) : identity;
        try {
            return Long.parseLong(idPart);
        } catch (NumberFormatException e) {
            return null;
        }
    }
 
    /**
     * 校验 LiveKit Webhook 签名;流程参见
     * <a href="https://docs.livekit.io/home/server/webhook/#receiving-webhooks">webhook 文档</a>
     * <p>
     * 校验两步:
     * 1.1 JWT HS256 签名验证;密钥使用 LiveKit API Secret
     * 1.2 body 的 sha256 与 JWT 内 claim 一致;防止抓到 token 后篡改 body
     *
     * @param authHeader 请求头 Authorization 原值(含 "Bearer " 前缀)
     * @param rawBody    请求原始 body
     * @return 是否通过;签名异常一律视为不通过
     */
    public boolean verifyWebhookSignature(String authHeader, String rawBody) {
        if (StrUtil.isBlank(authHeader)) {
            return false;
        }
        String token = StrUtil.removePrefix(authHeader, "Bearer ").trim();
        ImProperties.Rtc cfg = imProperties.getRtc();
        try {
            JWT jwt = JWT.of(token);
            // JWT HS256 签名验证
            if (!jwt.setKey(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)).verify()) {
                return false;
            }
            // body sha256 一致性校验
            Object expectedSha = jwt.getPayload("sha256");
            if (expectedSha == null) {
                return false;
            }
            // 计算 body 的 sha256,并与 JWT 内 claim 对比
            String actualSha = Base64.encode(DigestUtil.sha256(rawBody));
            return Objects.equals(expectedSha.toString(), actualSha);
        } catch (Exception e) {
            log.warn("[verifyWebhookSignature][签名解析异常 bodyLength={}]",
                    rawBody == null ? 0 : rawBody.length(), e);
            return false;
        }
    }
 
    /**
     * 签发管理 Token;用于调 Server API(DeleteRoom / ListParticipants / RemoveParticipant 等)
     *
     * @return JWT 字符串
     */
    private String signAdminToken() {
        ImProperties.Rtc cfg = imProperties.getRtc();
        // roomAdmin claim 给管理类 API 必备
        long nowSec = Instant.now().getEpochSecond();
        return JWT.create()
                .setIssuer(cfg.getApiKey())
                .setNotBefore(new Date(nowSec * 1000))
                .setExpiresAt(new Date((nowSec + ADMIN_TOKEN_TTL.getSeconds()) * 1000))
                .setPayload("video", MapUtil.of("roomAdmin", true))
                .setSigner(JWTSignerUtil.hs256(cfg.getApiSecret().getBytes(StandardCharsets.UTF_8)))
                .sign();
    }
 
    /**
     * Twirp 协议 POST 调用;统一处理 ws→http 协议切换、签 admin token、Bearer 头、JSON body、超时
     *
     * @param path     Twirp 端点路径,例如 {@code /twirp/livekit.RoomService/DeleteRoom}
     * @param room 房间名;写入 JSON body 的 room 字段
     * @return HTTP 响应;调用方负责 close 与状态码判断
     */
    private HttpResponse postTwirp(String path, String room) {
        Assert.notBlank(room, "room 不可为空");
        String token = signAdminToken();
        return HttpRequest.post(HttpUtils.wsUrlToHttp(imProperties.getRtc().getLivekitUrl()) + path)
                .header("Authorization", "Bearer " + token)
                .header("Content-Type", "application/json")
                .body(JSONUtil.toJsonStr(MapUtil.of("room", room)))
                .timeout(SERVER_API_TIMEOUT_MS)
                .execute();
    }
 
}