rtc禁言
This commit is contained in:
parent
6b61653acc
commit
55a5a23f67
@ -2,6 +2,7 @@ package callbackapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
@ -302,8 +303,25 @@ func (h *Handler) tencentIMCallbackAuthenticated(request *http.Request) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
return subtleStringEqual(request.URL.Query().Get("CallbackAuthToken"), token) ||
|
||||
subtleStringEqual(request.Header.Get("X-Tencent-IM-Callback-Token"), token)
|
||||
if subtleStringEqual(request.URL.Query().Get("CallbackAuthToken"), token) ||
|
||||
subtleStringEqual(request.Header.Get("X-Tencent-IM-Callback-Token"), token) {
|
||||
// 保留既有直传 token 协议,避免已接入的内部探针在控制台切换鉴权方式时失效。
|
||||
return true
|
||||
}
|
||||
|
||||
requestTimeRaw := strings.TrimSpace(request.URL.Query().Get("RequestTime"))
|
||||
requestTime, err := strconv.ParseInt(requestTimeRaw, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if deltaSeconds := time.Now().Unix() - requestTime; deltaSeconds < -60 || deltaSeconds > 60 {
|
||||
// 腾讯云签名只绑定秒级时间戳;限制一分钟窗口可阻止已截获回调被长期重放。
|
||||
return false
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte(token + requestTimeRaw))
|
||||
expectedSign := fmt.Sprintf("%x", digest)
|
||||
return subtleStringEqual(request.URL.Query().Get("Sign"), expectedSign)
|
||||
}
|
||||
|
||||
func (h *Handler) tencentIMSDKAppIDMatched(request *http.Request) bool {
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
"hyapp/services/gateway-service/internal/transport/http/roomapi"
|
||||
"io"
|
||||
@ -12192,6 +12193,32 @@ func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
|
||||
assertTencentCallback(t, unknownRecorder, http.StatusOK, 0)
|
||||
}
|
||||
|
||||
func TestTencentIMCallbackAcceptsSignedAuthAndRejectsExpiredRequest(t *testing.T) {
|
||||
const callbackToken = "callback-token"
|
||||
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
CallbackAuthToken: callbackToken,
|
||||
}).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"CallbackCommand":"Group.CallbackAfterSendMsg","GroupId":"room-1","From_Account":"42"}`)
|
||||
|
||||
requestTime := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&RequestTime="+requestTime+"&Sign="+tencentIMCallbackSignature(callbackToken, requestTime), bytes.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
assertTencentCallback(t, recorder, http.StatusOK, 0)
|
||||
|
||||
expiredTime := strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)
|
||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&RequestTime="+expiredTime+"&Sign="+tencentIMCallbackSignature(callbackToken, expiredTime), bytes.NewReader(body))
|
||||
expiredRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(expiredRecorder, expiredRequest)
|
||||
assertTencentCallback(t, expiredRecorder, http.StatusOK, 1)
|
||||
}
|
||||
|
||||
func tencentIMCallbackSignature(token string, requestTime string) string {
|
||||
digest := sha256.Sum256([]byte(token + requestTime))
|
||||
return fmt.Sprintf("%x", digest)
|
||||
}
|
||||
|
||||
func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, requestID string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
@ -249,6 +250,9 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
OutboxRecords: []outbox.Record{createdEvent},
|
||||
RoomCoverUploadConsumption: coverConsumption,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrRoomCoverUploadNotConsumable) {
|
||||
return nil, xerr.New(xerr.Conflict, "room cover upload is already consumed")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
saveMutationMS = elapsedMS(saveStartedAt)
|
||||
|
||||
@ -377,6 +377,10 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
}
|
||||
if current.MuteUsers[cmd.TargetUserID] {
|
||||
// MicUp 后到 RTC 发流确认之间仍可能发生房间禁言;确认入口必须再次校验,不能复活已撤销的发言权限。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted")
|
||||
}
|
||||
|
||||
seat, exists := current.SeatByUser(cmd.TargetUserID)
|
||||
if !exists {
|
||||
@ -608,6 +612,10 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest)
|
||||
if !exists {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
|
||||
}
|
||||
if !cmd.Muted && current.MuteUsers[cmd.TargetUserID] {
|
||||
// 房间禁言优先级高于用户自己的麦克风开关;解除房间禁言前禁止目标或管理员把 RTC 麦位重新打开。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted")
|
||||
}
|
||||
if seat.MicMuted == cmd.Muted {
|
||||
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
|
||||
}
|
||||
|
||||
@ -70,12 +70,64 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
|
||||
if !unmutedSpeak.GetAllowed() || unmutedSpeak.GetReason() != "" {
|
||||
t.Fatalf("unmuted user must recover public chat permission: %+v", unmutedSpeak)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "unmuted-mic-up"),
|
||||
SeatNo: 2,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unmuted user must recover mic up permission: %v", err)
|
||||
}
|
||||
|
||||
mutedOnSeat, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "mute-current-rtc-seat"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mute current RTC seat failed: %v", err)
|
||||
}
|
||||
var currentSeat *roomv1.SeatState
|
||||
for _, seat := range mutedOnSeat.GetRoom().GetMicSeats() {
|
||||
if seat.GetUserId() == mutedUserID {
|
||||
currentSeat = seat
|
||||
break
|
||||
}
|
||||
}
|
||||
if currentSeat == nil || !currentSeat.GetMicMuted() || currentSeat.GetSeatNo() != 2 {
|
||||
t.Fatalf("room mute must project to the current RTC seat without removing it: %+v", currentSeat)
|
||||
}
|
||||
if _, err := svc.SetMicMute(ctx, &roomv1.SetMicMuteRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "muted-user-open-mic"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: false,
|
||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("room-muted user must not reopen RTC mic: %v", err)
|
||||
}
|
||||
if _, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "muted-publish-confirm"),
|
||||
TargetUserId: mutedUserID,
|
||||
MicSessionId: upResp.GetMicSessionId(),
|
||||
RoomVersion: upResp.GetResult().GetRoomVersion(),
|
||||
EventTimeMs: now.Now().UnixMilli(),
|
||||
Source: "client",
|
||||
}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||||
Meta: rocketMeta(roomID, ownerID, "unmute-current-rtc-seat"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("unmute current RTC seat failed: %v", err)
|
||||
}
|
||||
if _, err := svc.SetMicMute(ctx, &roomv1.SetMicMuteRequest{
|
||||
Meta: rocketMeta(roomID, mutedUserID, "unmuted-user-open-mic"),
|
||||
TargetUserId: mutedUserID,
|
||||
Muted: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("room unmute must restore explicit RTC mic control: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) {
|
||||
|
||||
@ -17,7 +17,7 @@ import (
|
||||
// MuteUser 修改用户禁言状态。
|
||||
func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
// 禁言状态由 room-service 持有,腾讯云 IM 发言前回调必须同步查询 CheckSpeakPermission。
|
||||
// 禁言状态由 room-service 持有:腾讯云 IM 发言前回调查 CheckSpeakPermission,RTC 麦位则同步为服务端静音态。
|
||||
cmd := command.MuteUser{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
@ -51,11 +51,20 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
var mutedSeat *state.MicSeat
|
||||
if cmd.Muted {
|
||||
// map set 表达禁言集合,true 才会被快照导出。
|
||||
// map set 是公屏、上麦和开麦权限的统一事实;麦位静音只是这个事实的 RTC 投影。
|
||||
current.MuteUsers[cmd.TargetUserID] = true
|
||||
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists && !seat.MicMuted {
|
||||
// 房间禁言不能只依赖目标客户端执行 muteLocalAudio;先把当前麦位标成 muted,
|
||||
// 让快照和 room_mic_mute_changed 都保持静音,目标用户解除房间禁言前也不能自行开麦。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].MicMuted = true
|
||||
updated := current.MicSeats[index]
|
||||
mutedSeat = &updated
|
||||
}
|
||||
} else {
|
||||
// 解除禁言直接删除集合成员。
|
||||
// 解除房间禁言只恢复“允许开麦”;不替用户自动打开本地麦克风,当前麦位仍需显式 SetMicMute(false)。
|
||||
delete(current.MuteUsers, cmd.TargetUserID)
|
||||
}
|
||||
|
||||
@ -70,6 +79,28 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records := make([]outbox.Record, 0, 2)
|
||||
if mutedSeat != nil {
|
||||
micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
FromSeat: mutedSeat.SeatNo,
|
||||
ToSeat: mutedSeat.SeatNo,
|
||||
Action: "mic_mute",
|
||||
MicSessionId: mutedSeat.MicSessionID,
|
||||
PublishState: mutedSeat.PublishState,
|
||||
PublishDeadlineMs: mutedSeat.PublishDeadlineMS,
|
||||
MicMuted: true,
|
||||
SeatStatus: mutedSeat.SeatStatus(),
|
||||
TargetGiftValue: roomUserGiftValue(current, cmd.TargetUserID),
|
||||
}, command.UserDisplayProfile{})
|
||||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, micEvent)
|
||||
}
|
||||
records = append(records, muteEvent)
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
@ -81,7 +112,7 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
RoomVersion: current.Version,
|
||||
},
|
||||
}, []outbox.Record{muteEvent}, nil
|
||||
}, records, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -161,6 +161,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
}
|
||||
return replayMutationResult(committed, snapshotWithApp(ctx, current.ToProto()))
|
||||
}
|
||||
if errors.Is(err, ErrRoomCoverUploadNotConsumable) {
|
||||
return nil, xerr.New(xerr.Conflict, "room cover upload is already consumed")
|
||||
}
|
||||
if err != nil {
|
||||
// COMMIT can succeed in MySQL while its acknowledgement is lost to a connection reset. The gift
|
||||
// operation is then already committed and recovery workers correctly stop claiming it, so returning
|
||||
@ -213,6 +216,9 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
RoomCoverUploadConsumption: result.roomCoverUploadConsumption,
|
||||
})
|
||||
commitCancel()
|
||||
if errors.Is(err, ErrRoomCoverUploadNotConsumable) {
|
||||
return nil, xerr.New(xerr.Conflict, "room cover upload is already consumed")
|
||||
}
|
||||
if err != nil && !s.mutationCommitConfirmed(ctx, commandRecord) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -457,9 +457,14 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
||||
current.Version++
|
||||
case *command.MuteUser:
|
||||
if typed.Muted {
|
||||
// 回放禁言集合,供 CheckSpeakPermission 使用。
|
||||
// 回放禁言集合及其 RTC 麦位投影;否则重启后快照会把仍被房间禁言的用户显示成可开麦。
|
||||
current.MuteUsers[typed.TargetUserID] = true
|
||||
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].MicMuted = true
|
||||
}
|
||||
} else {
|
||||
// 与在线命令一致,解除房间禁言不隐式打开用户麦克风。
|
||||
delete(current.MuteUsers, typed.TargetUserID)
|
||||
}
|
||||
current.Version++
|
||||
|
||||
@ -225,6 +225,10 @@ type RoomMediaUploadRegistration struct {
|
||||
// ErrRoomMediaUploadCommandConflict 表示上传 command_id 已绑定另一份文件或用途。
|
||||
var ErrRoomMediaUploadCommandConflict = errors.New("room media upload command payload conflict")
|
||||
|
||||
// ErrRoomCoverUploadNotConsumable 表示凭证不存在、已被消费或与当前 actor 不匹配。
|
||||
// service 必须映射成稳定业务冲突,不能把事务条件未命中作为数据库内部错误暴露。
|
||||
var ErrRoomCoverUploadNotConsumable = errors.New("room cover upload is not consumable")
|
||||
|
||||
// RoomCoverUploadConsumption 是创建/更新命令对预上传封面的单次消费条件。
|
||||
// SQL 还会用 app_code、空 room_id、purpose 和 active 状态做条件更新,防止跨租户、跨用户和重复使用。
|
||||
type RoomCoverUploadConsumption struct {
|
||||
|
||||
@ -180,7 +180,7 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("room cover upload cannot be consumed: room_id=%s cover_upload_id=%s", commit.Command.RoomID, consumption.CoverUploadID)
|
||||
return fmt.Errorf("%w: room_id=%s cover_upload_id=%s", roomservice.ErrRoomCoverUploadNotConsumable, commit.Command.RoomID, consumption.CoverUploadID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user