相关im改动

This commit is contained in:
zhx 2026-07-21 18:57:09 +08:00
parent 5d74fb1ff7
commit 00db43b01b
16 changed files with 641 additions and 120 deletions

View File

@ -37,8 +37,12 @@ const (
destroyGroupCommand = "v4/group_open_http_svc/destroy_group"
// deleteGroupMemberCommand 用于把已离房用户从腾讯群里移除,防止 IM 群成员残留扩大消息面。
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
// forbidSendMsgCommand 只修改指定群和指定成员的腾讯 IM 禁言状态,不进入任何消息发送前回调链路。
forbidSendMsgCommand = "v4/group_open_http_svc/forbid_send_msg"
// kickUserCommand 让指定账号现有 IM 登录态失效;被封禁用户需要立即从腾讯 IM SDK 下线。
kickUserCommand = "v4/im_open_login_svc/kick"
// PermanentGroupMuteSeconds 是腾讯 IM 约定的永久禁言秒数;解除禁言使用 0。
PermanentGroupMuteSeconds uint32 = 1<<32 - 1
)
// RESTConfig 保存服务端调用腾讯云 IM REST API 所需配置。
@ -464,6 +468,30 @@ func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, u
return c.DeleteGroupMember(ctx, roomID, userID)
}
// SetGroupMemberMuted 通过腾讯 IM 原生群成员禁言能力投影 Room Cell 状态。
// 该接口只在禁言事实变化时调用,不参与普通群消息发送,因此不会给共享 SDKAppID 的其它项目增加发送时延。
func (c *RESTClient) SetGroupMemberMuted(ctx context.Context, groupID string, userID int64, muted bool) error {
groupID = strings.TrimSpace(groupID)
if groupID == "" || userID <= 0 {
return fmt.Errorf("group member mute is incomplete")
}
muteTime := uint32(0)
if muted {
muteTime = PermanentGroupMuteSeconds
}
request := forbidSendMsgRequest{
GroupID: groupID,
MemberAccounts: []string{FormatUserID(userID)},
MuteTime: muteTime,
}
var response restResponse
if err := c.post(ctx, forbidSendMsgCommand, request, &response); err != nil {
return err
}
return response.err()
}
// KickUser 失效指定用户的腾讯云 IM 登录态。
// 该接口不会禁止未来用新 UserSig 登录,因此调用方必须先在业务状态和 token denylist 上完成封禁。
func (c *RESTClient) KickUser(ctx context.Context, userID int64) error {
@ -667,6 +695,12 @@ type deleteGroupMemberRequest struct {
Reason string `json:"Reason,omitempty"`
}
type forbidSendMsgRequest struct {
GroupID string `json:"GroupId"`
MemberAccounts []string `json:"Members_Account"`
MuteTime uint32 `json:"MuteTime"`
}
type kickUserRequest struct {
UserID string `json:"UserID"`
}

View File

@ -328,6 +328,44 @@ func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) {
}
}
// TestRESTClientSetGroupMemberMutedUsesNativeForbidSendMsg 锁定禁言和解除禁言只调用目标群成员接口。
func TestRESTClientSetGroupMemberMutedUsesNativeForbidSendMsg(t *testing.T) {
paths := make([]string, 0, 2)
bodies := make([]map[string]any, 0, 2)
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
paths = append(paths, request.URL.Path)
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
bodies = append(bodies, body)
return okRESTResponse(), nil
})
if err := client.SetGroupMemberMuted(context.Background(), "room-1001", 10002, true); err != nil {
t.Fatalf("SetGroupMemberMuted(true) failed: %v", err)
}
if err := client.SetGroupMemberMuted(context.Background(), "room-1001", 10002, false); err != nil {
t.Fatalf("SetGroupMemberMuted(false) failed: %v", err)
}
for index, path := range paths {
if path != "/"+forbidSendMsgCommand {
t.Fatalf("request %d path mismatch: got %q", index, path)
}
members, ok := bodies[index]["Members_Account"].([]any)
if !ok || len(members) != 1 || members[0] != "10002" || bodies[index]["GroupId"] != "room-1001" {
t.Fatalf("request %d target mismatch: %+v", index, bodies[index])
}
}
if bodies[0]["MuteTime"] != float64(PermanentGroupMuteSeconds) {
t.Fatalf("mute must use permanent Tencent duration: %+v", bodies[0])
}
if bodies[1]["MuteTime"] != float64(0) {
t.Fatalf("unmute must use MuteTime=0: %+v", bodies[1])
}
}
// TestRESTClientKickUserBuildsTencentRequest 锁定封禁链路使用腾讯云 IM 失效账号登录态接口。
func TestRESTClientKickUserBuildsTencentRequest(t *testing.T) {
var capturedPath string

View File

@ -36,7 +36,7 @@ type RoomClient interface {
}
// RoomGuardClient 抽象 gateway 对 room-service 实时守卫 RPC 的依赖。
// 腾讯云 IM 回调只做字段解析和鉴权,房间 presence、禁言和踢人判断必须回查 room-service
// RoomGuardClient 暴露房间权威状态查询;腾讯 IM 发消息不再同步调用该接口,避免把共享 SDKAppID 的发送链路串到 gateway
type RoomGuardClient interface {
CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error)
VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error)

View File

@ -17,6 +17,7 @@ import (
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"hyapp/pkg/xerr"
)
const (
@ -95,7 +96,9 @@ func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *h
case tencentCallbackJoinGroup:
h.handleTencentIMJoinCallback(writer, request, body)
case tencentCallbackSendMsg:
h.handleTencentIMSendCallback(writer, request, body)
// 公屏禁言已经改为 room_outbox 异步调用腾讯 IM forbid_send_msg。
// 即使控制台误开启旧回调gateway 也必须纯透传,不能再引入 room-service/数据库同步依赖。
writeTencentCallback(writer, http.StatusOK, 0, "")
case tencentCallbackC2CSent:
h.handleTencentIMC2CSentCallback(writer, request, body)
default:
@ -152,43 +155,68 @@ func (h *Handler) handleTencentIMC2CSentCallback(writer http.ResponseWriter, req
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
switch parsed.Kind {
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
userID, ok := parseTencentIMUserID(body.Requestor)
if !ok {
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
return
}
switch parsed.Kind {
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
// 播报群不属于 Room Cell准入只看 user-service 的用户状态、资料完成度、app_code 和区域归属。
h.handleTencentIMBroadcastJoinCallback(writer, request, parsed, userID)
return
case imgroup.KindRoom:
// 房间群仍走 room-service guard保证只有当前房间 presence 用户能加入/留在房间 IM 群。
h.handleTencentIMRoomJoinCallback(writer, request, parsed.RoomID, userID)
callbackAppCode, managed, err := h.resolveTencentIMRoomOwnership(request, parsed.RoomID)
if err != nil {
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, body.GroupID, "rejected", "room_owner_unavailable", "")
writeTencentCallback(writer, http.StatusOK, 1, "room guard unavailable")
return
}
if !managed {
// 腾讯 IM 回调按 SDKAppID 全局生效;共享 SDKAppID 下,非本服务房间必须透传给实际项目。
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, body.GroupID, "passthrough", "room_not_managed", "")
writeTencentCallback(writer, http.StatusOK, 0, "")
return
}
userID, ok := parseTencentIMUserID(body.Requestor)
if !ok {
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
return
}
// 原始 room GroupID 没有租户前缀,必须先由 room-service 持久事实解析真实 app_code 再校验 presence。
h.handleTencentIMRoomJoinCallback(writer, request, parsed.RoomID, userID, callbackAppCode)
return
default:
if h.tencentIMGroupIDReserved(body.GroupID) {
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, body.GroupID, "rejected", "reserved_group_invalid", "")
writeTencentCallback(writer, http.StatusOK, 1, "group_id is invalid")
return
}
// 不属于当前环境 HyApp 保留命名空间的群由共享 SDKAppID 中的其它项目负责,当前回调不能阻断。
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, body.GroupID, "passthrough", "group_not_managed", "")
writeTencentCallback(writer, http.StatusOK, 0, "")
return
}
}
func (h *Handler) handleTencentIMRoomJoinCallback(writer http.ResponseWriter, request *http.Request, roomID string, userID int64) {
func (h *Handler) handleTencentIMRoomJoinCallback(writer http.ResponseWriter, request *http.Request, roomID string, userID int64, callbackAppCode string) {
if roomID == "" || h.roomGuardClient == nil {
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
return
}
resp, err := h.roomGuardClient.VerifyRoomPresence(request.Context(), &roomv1.VerifyRoomPresenceRequest{
resp, err := h.roomGuardClient.VerifyRoomPresence(appcode.WithContext(request.Context(), callbackAppCode), &roomv1.VerifyRoomPresenceRequest{
RoomId: roomID,
UserId: userID,
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
AppCode: callbackAppCode,
})
if err != nil || !resp.GetPresent() {
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, roomID, "rejected", tencentGuardErrorInfo(err, resp.GetReason()), callbackAppCode)
writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason()))
return
}
h.logTencentIMRoomCallbackDecision(request, tencentCallbackJoinGroup, roomID, "allowed", "presence_verified", callbackAppCode)
writeTencentCallback(writer, http.StatusOK, 0, "")
}
@ -207,48 +235,60 @@ func (h *Handler) handleTencentIMBroadcastJoinCallback(writer http.ResponseWrite
writeTencentCallback(writer, http.StatusOK, 0, "")
}
func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
switch parsed.Kind {
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
if h.tencentIMCallbackSenderIsServer(body) {
// 播报群是服务端只写通道;管理员账号发送的 REST 消息由腾讯回调放行。
writeTencentCallback(writer, http.StatusOK, 0, "")
return
// resolveTencentIMRoomOwnership 只把 room-service 持久表中存在的 room_id 认作本项目房间。
// NOT_FOUND 是共享 SDKAppID 的正常外部流量;其余错误可能隐藏真实 HyApp 房间,必须保持 fail-closed。
func (h *Handler) resolveTencentIMRoomOwnership(request *http.Request, roomID string) (string, bool, error) {
if h.roomGuardClient == nil {
return "", false, fmt.Errorf("room guard unavailable")
}
writeTencentCallback(writer, http.StatusOK, 1, "broadcast group is server-only")
return
case imgroup.KindRoom:
userID, ok := parseTencentIMUserID(body.FromAccount)
if !ok {
writeTencentCallback(writer, http.StatusOK, 1, "send guard input invalid")
return
}
h.handleTencentIMRoomSendCallback(writer, request, parsed.RoomID, userID)
return
default:
writeTencentCallback(writer, http.StatusOK, 1, "group_id is invalid")
return
}
}
func (h *Handler) handleTencentIMRoomSendCallback(writer http.ResponseWriter, request *http.Request, roomID string, userID int64) {
if roomID == "" || h.roomGuardClient == nil {
writeTencentCallback(writer, http.StatusOK, 1, "send guard input invalid")
return
}
resp, err := h.roomGuardClient.CheckSpeakPermission(request.Context(), &roomv1.CheckSpeakPermissionRequest{
resp, err := h.roomGuardClient.ResolveRoomAppCode(request.Context(), &roomv1.ResolveRoomAppCodeRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
RoomId: roomID,
UserId: userID,
AppCode: appcode.FromContext(request.Context()),
})
if err != nil || !resp.GetAllowed() {
writeTencentCallback(writer, http.StatusOK, 1, tencentGuardErrorInfo(err, resp.GetReason()))
return
if err != nil {
if xerr.ReasonFromGRPC(err) == xerr.NotFound {
return "", false, nil
}
return "", false, err
}
callbackAppCode := appcode.Normalize(resp.GetAppCode())
if strings.TrimSpace(resp.GetAppCode()) == "" {
return "", false, fmt.Errorf("room app_code is empty")
}
return callbackAppCode, true, nil
}
writeTencentCallback(writer, http.StatusOK, 0, "")
// tencentIMGroupIDReserved 只保留当前环境自己的播报群命名空间。
// 配置了环境前缀时,未携带该前缀的同名群属于其它环境或项目,不能被当前回调拒绝。
func (h *Handler) tencentIMGroupIDReserved(groupID string) bool {
groupID = strings.TrimSpace(groupID)
prefix := strings.TrimSpace(h.tencentIM.GroupIDPrefix)
if prefix != "" {
var ok bool
groupID, ok = strings.CutPrefix(groupID, prefix)
if !ok {
return false
}
}
return strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_")
}
// logTencentIMRoomCallbackDecision 记录可审计但不含签名、消息正文和账号的路由结果。
// HTTP access log 只能看到 200该业务日志用于区分腾讯响应体中的允许、拒绝和共享项目透传。
func (h *Handler) logTencentIMRoomCallbackDecision(request *http.Request, command string, groupID string, decision string, reason string, callbackAppCode string) {
loggedAppCode := strings.TrimSpace(callbackAppCode)
if loggedAppCode != "" {
loggedAppCode = appcode.Normalize(loggedAppCode)
}
slog.LogAttrs(request.Context(), slog.LevelInfo, "tencent_im_room_callback_decision",
slog.String("request_id", httpkit.RequestIDFromContext(request.Context())),
slog.String("callback_command", command),
slog.String("group_id", strings.TrimSpace(groupID)),
slog.String("app_code", loggedAppCode),
slog.String("decision", decision),
slog.String("reason", strings.TrimSpace(reason)),
slog.String("opt_platform", strings.TrimSpace(request.URL.Query().Get("OptPlatform"))),
)
}
func (h *Handler) tencentIMCallbackUser(request *http.Request, callbackAppCode string, userID int64) (*userv1.User, error) {

View File

@ -2875,6 +2875,7 @@ type fakeRoomGuardClient struct {
lastPresence *roomv1.VerifyRoomPresenceRequest
lastRoomApp *roomv1.ResolveRoomAppCodeRequest
err error
roomAppErr error
}
func (f *fakeRoomGuardClient) CheckSpeakPermission(_ context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) {
@ -2903,6 +2904,9 @@ func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.
func (f *fakeRoomGuardClient) ResolveRoomAppCode(_ context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
f.lastRoomApp = req
if f.roomAppErr != nil {
return nil, f.roomAppErr
}
if f.err != nil {
return nil, f.err
}
@ -12116,9 +12120,15 @@ func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
router.ServeHTTP(recorder, request)
assertTencentCallback(t, recorder, http.StatusOK, 0)
if guard.lastRoomApp == nil || guard.lastRoomApp.GetRoomId() != "room-1" {
t.Fatalf("room ownership request mismatch: %+v", guard.lastRoomApp)
}
if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-im-join") {
t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence)
}
if guard.lastPresence.GetAppCode() != "lalu" {
t.Fatalf("presence guard must use resolved app_code, got %+v", guard.lastPresence)
}
guard.presenceResp = &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: "not_in_room"}
deniedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body))
@ -12128,7 +12138,7 @@ func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
assertTencentCallback(t, deniedRecorder, http.StatusOK, 1)
}
func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) {
func TestTencentIMSendCallbackNeverEntersRoomGuard(t *testing.T) {
guard := &fakeRoomGuardClient{
speakResp: &roomv1.CheckSpeakPermissionResponse{Allowed: false, Reason: "user_muted"},
}
@ -12141,13 +12151,77 @@ func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) {
router.ServeHTTP(recorder, request)
assertTencentCallback(t, recorder, http.StatusOK, 1)
if guard.lastSpeak == nil || guard.lastSpeak.GetRoomId() != "room-1" || guard.lastSpeak.GetUserId() != 42 {
t.Fatalf("speak guard request mismatch: %+v", guard.lastSpeak)
assertTencentCallback(t, recorder, http.StatusOK, 0)
if guard.lastRoomApp != nil {
t.Fatalf("before-send callback must not resolve room ownership: %+v", guard.lastRoomApp)
}
if guard.lastSpeak != nil {
t.Fatalf("before-send callback must not enter room speak guard: %+v", guard.lastSpeak)
}
}
func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T) {
func TestTencentIMRoomCallbacksPassthroughGroupsOutsideHyAppOwnership(t *testing.T) {
guard := &fakeRoomGuardClient{roomAppErr: status.Error(codes.NotFound, "room not found")}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
}).Routes(auth.NewVerifier("secret"))
sendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"other-project-room","From_Account":"external-user"}`)
sendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&OptPlatform=RESTAPI", bytes.NewReader(sendBody))
sendRecorder := httptest.NewRecorder()
router.ServeHTTP(sendRecorder, sendRequest)
assertTencentCallback(t, sendRecorder, http.StatusOK, 0)
if guard.lastSpeak != nil {
t.Fatalf("external room send must not reach HyApp speak guard: %+v", guard.lastSpeak)
}
joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"@TGS#external","Requestor_Account":"external-user"}`)
joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&OptPlatform=Android", bytes.NewReader(joinBody))
joinRecorder := httptest.NewRecorder()
router.ServeHTTP(joinRecorder, joinRequest)
assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
if guard.lastPresence != nil {
t.Fatalf("external group join must not reach HyApp presence guard: %+v", guard.lastPresence)
}
}
func TestTencentIMRoomCallbacksFailClosedWhenOwnershipLookupFails(t *testing.T) {
guard := &fakeRoomGuardClient{roomAppErr: status.Error(codes.Unavailable, "room service unavailable")}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"room-1","Requestor_Account":"42"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertTencentCallback(t, recorder, http.StatusOK, 1)
if guard.lastPresence != nil {
t.Fatalf("ownership lookup failure must stop before the presence guard: %+v", guard.lastPresence)
}
}
func TestTencentIMRoomSendCallbackPassthroughDoesNotResolveOwnership(t *testing.T) {
guard := &fakeRoomGuardClient{roomAppResp: &roomv1.ResolveRoomAppCodeResponse{AppCode: "fami"}}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
SDKAppID: 1400000000,
AdminIdentifier: "administrator",
}).Routes(auth.NewVerifier("secret"))
body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"administrator"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&OptPlatform=RESTAPI", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertTencentCallback(t, recorder, http.StatusOK, 0)
if guard.lastRoomApp != nil {
t.Fatalf("before-send passthrough must not resolve room ownership: %+v", guard.lastRoomApp)
}
if guard.lastSpeak != nil {
t.Fatalf("before-send passthrough must not evaluate room speech: %+v", guard.lastSpeak)
}
}
func TestTencentIMBroadcastCallbacksUseRegionGuardAndSendPassthrough(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 1001}
guard := &fakeRoomGuardClient{}
router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
@ -12175,7 +12249,7 @@ func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T
userSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(userSendBody))
userSendRecorder := httptest.NewRecorder()
router.ServeHTTP(userSendRecorder, userSendRequest)
assertTencentCallback(t, userSendRecorder, http.StatusOK, 1)
assertTencentCallback(t, userSendRecorder, http.StatusOK, 0)
adminSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"administrator"}`)
adminSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(adminSendBody))
@ -12202,7 +12276,8 @@ func TestTencentIMBroadcastCallbacksUseConfiguredGroupPrefix(t *testing.T) {
oldGroupRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(oldGroupBody))
oldGroupRecorder := httptest.NewRecorder()
router.ServeHTTP(oldGroupRecorder, oldGroupRequest)
assertTencentCallback(t, oldGroupRecorder, http.StatusOK, 1)
// 配置 test_ 代表当前回调只拥有 test_ 命名空间;无前缀群可能属于共享 SDKAppID 的其它环境,必须透传。
assertTencentCallback(t, oldGroupRecorder, http.StatusOK, 0)
}
func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {

View File

@ -166,6 +166,7 @@ func New(cfg config.Config) (*App, error) {
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
var rtcUserRemover integration.RTCUserRemover
var rtcUserAudioBlocker integration.RTCUserAudioBlocker
var imUserMuter integration.IMUserMuter
var tencentPublisher integration.OutboxPublisher
if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
@ -179,6 +180,7 @@ func New(cfg config.Config) (*App, error) {
}
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
tencentPublisher = concreteTencentPublisher
imUserMuter = concreteTencentPublisher
roomPublisher = concreteTencentPublisher
// 图片消息和 VIP 房间装扮都是用户可见的持久事实,必须由 durable room_outbox 补偿腾讯 IM
// 只过滤这两类事件,避免把现有 direct-IM 展示事件整体接入后制造双消息。
@ -251,6 +253,7 @@ func New(cfg config.Config) (*App, error) {
MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover,
RTCUserAudioBlocker: rtcUserAudioBlocker,
IMUserMuter: imUserMuter,
RoomRocketLaunchScheduler: rocketLaunchScheduler,
RobotDisplayPublisher: tencentPublisher,
RoomDisplayPublisher: tencentPublisher,

View File

@ -67,6 +67,12 @@ type RTCUserAudioBlocker interface {
SetUserAudioBlockedByStrRoomID(ctx context.Context, roomID string, userID int64, blocked bool) error
}
// IMUserMuter 抽象腾讯 IM 群成员禁言投影。
// Room Cell 仍是唯一权威状态;实现只修改指定房间群和指定用户,绝不能注册消息发送前回调。
type IMUserMuter interface {
SetRoomGroupMemberMuted(ctx context.Context, roomID string, userID int64, muted bool) error
}
// OutboxPublisher 抽象 outbox worker 对外部消费者的异步投递。
type OutboxPublisher interface {
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。

View File

@ -64,6 +64,28 @@ func (p *TencentIMPublisher) PublishRoomEvent(ctx context.Context, event tencent
return nil
}
// SetRoomGroupMemberMuted 把 Room Cell 禁言状态投影到腾讯 IM 原生群成员禁言。
// 群缺失时沿用房间消息补偿策略先补建再重试;其它错误返回 outbox worker 继续退避重试。
func (p *TencentIMPublisher) SetRoomGroupMemberMuted(ctx context.Context, roomID string, userID int64, muted bool) error {
if p == nil || p.client == nil {
return nil
}
groupID, err := imgroup.RoomGroupID(roomID)
if err != nil {
return err
}
if err := p.client.SetGroupMemberMuted(ctx, groupID, userID, muted); err != nil {
if !tencentim.IsRESTErrorCode(err, 10010, 10015) {
return err
}
if ensureErr := p.client.EnsureRoomGroup(ctx, groupID, 0); ensureErr != nil {
return fmt.Errorf("ensure room im group after mute failure: %w", ensureErr)
}
return p.client.SetGroupMemberMuted(ctx, groupID, userID, muted)
}
return nil
}
// PublishOutboxEvent 补偿投递 room_outbox 中可转成房间系统消息的事件。
func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if p == nil || p.client == nil || envelope == nil {
@ -295,7 +317,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
appendRoomMediaAttributes(base.Attributes, body.GetImage(), "image_")
return base, true, nil
case "RoomChatEnabledChanged":
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截
// 公屏开关事件只提示客户端更新输入态;单用户服务端禁言由 forbid_send_msg 的独立 outbox 投影负责
var body roomeventsv1.RoomChatEnabledChanged
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err

View File

@ -621,6 +621,71 @@ func TestTencentIMPublisherEnsuresRoomGroupAndRetriesWhenGroupMissing(t *testing
}
}
func TestTencentIMPublisherEnsuresRoomGroupAndRetriesMemberMuteWhenGroupMissing(t *testing.T) {
paths := make([]string, 0, 3)
bodies := make([]map[string]any, 0, 3)
client, err := tencentim.NewRESTClient(tencentim.RESTConfig{
SDKAppID: 1400000000,
SecretKey: "secret",
AdminIdentifier: "administrator",
AdminUserSigTTL: time.Hour,
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
paths = append(paths, request.URL.Path)
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
bodies = append(bodies, body)
if len(paths) == 1 {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"FAIL","ErrorCode":10010,"ErrorInfo":"group not exist"}`))),
Header: make(http.Header),
}, nil
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}`))),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
publisher := NewTencentIMPublisher(client)
if err := publisher.SetRoomGroupMemberMuted(context.Background(), "room-mute", 42, true); err != nil {
t.Fatalf("SetRoomGroupMemberMuted should retry after ensuring group: %v", err)
}
wantPaths := []string{
"/v4/group_open_http_svc/forbid_send_msg",
"/v4/group_open_http_svc/create_group",
"/v4/group_open_http_svc/forbid_send_msg",
}
if len(paths) != len(wantPaths) {
t.Fatalf("path count mismatch: paths=%+v bodies=%+v", paths, bodies)
}
for index, want := range wantPaths {
if paths[index] != want {
t.Fatalf("path[%d] mismatch: got %s want %s", index, paths[index], want)
}
if bodies[index]["GroupId"] != "room-mute" {
t.Fatalf("request %d must preserve room group id: %+v", index, bodies[index])
}
}
for _, index := range []int{0, 2} {
if bodies[index]["MuteTime"] != float64(tencentim.PermanentGroupMuteSeconds) {
t.Fatalf("mute request %d must use Tencent permanent mute value: %+v", index, bodies[index])
}
accounts, ok := bodies[index]["Members_Account"].([]any)
if !ok || len(accounts) != 1 || accounts[0] != "42" {
t.Fatalf("mute request %d must target only user 42: %+v", index, bodies[index])
}
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {

View File

@ -7,7 +7,7 @@ import (
"hyapp/pkg/appcode"
)
// CheckSpeakPermission 给腾讯云 IM 回调或 gateway 提供同步发言守卫。
// CheckSpeakPermission 给 gateway 等受控业务入口提供同步发言守卫;腾讯 IM 普通消息不再进入本服务
func (s *Service) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
closed, err := s.isRoomClosed(ctx, req.GetRoomId())

View File

@ -0,0 +1,162 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
)
const (
// forbid_send_msg 官方单接口上限为 200 QPS双节点各 100 QPS避免共享 SDKAppID 被单服务打满。
imMuteProjectionInterval = 10 * time.Millisecond
// 云 API 执行期间若 Room Cell 状态继续变化,最多在同一门闩内追赶三次,之后交给 outbox 退避重试。
imMuteProjectionMaxAttempts = 3
// REST client 单次上限为 5s总预算额外覆盖进程内门闩、限速和最新快照复核。
imMuteProjectionTimeout = 6 * time.Second
)
type imMuteProjectionKey struct {
appCode string
roomID string
userID int64
}
// imMuteProjectionGate 让同一 app/room/user 的旧 mute 和新 unmute 在单进程内串行,
// 同时允许等待者遵守 context deadlineRoom Cell 最新状态仍是每次云端写入的唯一输入。
type imMuteProjectionGate struct {
token chan struct{}
refs int
}
// syncIMUserMuteFromOutbox 只消费已经随 Room Cell 命令原子提交的 RoomUserMuted 事实。
// 普通群消息不会调用该路径,因此腾讯 IM 回调关闭后,共享 SDKAppID 的其它项目不再依赖本服务延迟或可用性。
func (s *Service) syncIMUserMuteFromOutbox(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if s == nil || s.imUserMuter == nil || envelope == nil || envelope.GetEventType() != "RoomUserMuted" {
return nil
}
var event roomeventsv1.RoomUserMuted
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
return fmt.Errorf("decode im mute outbox event: %w", err)
}
if event.GetTargetUserId() <= 0 || strings.TrimSpace(envelope.GetRoomId()) == "" {
return fmt.Errorf("im mute outbox event is incomplete")
}
return s.reconcileIMUserMute(ctx, envelope.GetRoomId(), event.GetTargetUserId())
}
// reconcileIMUserMute 在每次 forbid_send_msg 前后重读 Room Cell。
// outbox 可能因网络失败重放旧 mute 事件,不能直接采用历史 payload.muted否则会覆盖较新的 unmute。
func (s *Service) reconcileIMUserMute(parent context.Context, roomID string, userID int64) error {
if s == nil || s.imUserMuter == nil {
return nil
}
roomID = strings.TrimSpace(roomID)
if roomID == "" || userID <= 0 {
return fmt.Errorf("im mute reconcile target is incomplete")
}
ctx, cancel := context.WithTimeout(parent, imMuteProjectionTimeout)
defer cancel()
release, err := s.acquireIMMuteProjection(ctx, imMuteProjectionKey{
appCode: appcode.FromContext(ctx),
roomID: roomID,
userID: userID,
})
if err != nil {
return err
}
defer release()
for attempt := 1; attempt <= imMuteProjectionMaxAttempts; attempt++ {
before, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return err
}
if before == nil {
// 房间已被物理清理时群也不再是活跃投递通道,历史禁言投影可以直接结束。
return nil
}
desiredMuted := containsID(before.GetMuteUserIds(), userID)
if err := s.waitIMMuteProjectionRate(ctx); err != nil {
return err
}
if err := s.imUserMuter.SetRoomGroupMemberMuted(ctx, roomID, userID, desiredMuted); err != nil {
return err
}
after, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return err
}
if after == nil || containsID(after.GetMuteUserIds(), userID) == desiredMuted {
return nil
}
// 状态在腾讯请求飞行期间变化;保持同一 keyed gate 并立即投影最新值,避免本轮留下旧云端状态。
}
return fmt.Errorf("im mute state kept changing during %d reconcile attempts", imMuteProjectionMaxAttempts)
}
func (s *Service) acquireIMMuteProjection(ctx context.Context, key imMuteProjectionKey) (func(), error) {
s.imMuteGateMu.Lock()
if s.imMuteGates == nil {
s.imMuteGates = make(map[imMuteProjectionKey]*imMuteProjectionGate)
}
gate := s.imMuteGates[key]
if gate == nil {
gate = &imMuteProjectionGate{token: make(chan struct{}, 1)}
gate.token <- struct{}{}
s.imMuteGates[key] = gate
}
gate.refs++
s.imMuteGateMu.Unlock()
select {
case <-ctx.Done():
s.releaseIMMuteProjectionRef(key, gate)
return nil, ctx.Err()
case <-gate.token:
return func() {
gate.token <- struct{}{}
s.releaseIMMuteProjectionRef(key, gate)
}, nil
}
}
func (s *Service) releaseIMMuteProjectionRef(key imMuteProjectionKey, gate *imMuteProjectionGate) {
s.imMuteGateMu.Lock()
defer s.imMuteGateMu.Unlock()
gate.refs--
if gate.refs == 0 && s.imMuteGates[key] == gate {
delete(s.imMuteGates, key)
}
}
func (s *Service) waitIMMuteProjectionRate(ctx context.Context) error {
s.imMuteRateMu.Lock()
now := time.Now()
slot := now
if s.imMuteNextCall.After(slot) {
slot = s.imMuteNextCall
}
s.imMuteNextCall = slot.Add(imMuteProjectionInterval)
s.imMuteRateMu.Unlock()
wait := time.Until(slot)
if wait <= 0 {
return nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
@ -24,6 +25,25 @@ type recordingRTCUserAudioBlocker struct {
err error
}
type recordingIMUserMuter struct {
mu sync.Mutex
calls []bool
err error
}
func (m *recordingIMUserMuter) SetRoomGroupMemberMuted(_ context.Context, _ string, _ int64, muted bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, muted)
return m.err
}
func (m *recordingIMUserMuter) snapshot() []bool {
m.mu.Lock()
defer m.mu.Unlock()
return append([]bool(nil), m.calls...)
}
func (b *recordingRTCUserAudioBlocker) SetUserAudioBlockedByStrRoomID(_ context.Context, _ string, _ int64, blocked bool) error {
b.mu.Lock()
defer b.mu.Unlock()
@ -42,6 +62,7 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)}
rtcBlocker := &recordingRTCUserAudioBlocker{}
imMuter := &recordingIMUserMuter{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-test",
LeaseTTL: 10 * time.Second,
@ -49,6 +70,7 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
IMUserMuter: imMuter,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-muted-user-guard"
@ -109,10 +131,9 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
}); err != nil {
t.Fatalf("old mute command replay failed: %v", err)
}
if got := rtcBlocker.snapshot(); len(got) != 3 || got[2] {
// pipeline 会向重放返回第一次 mute 的历史 result.snapshotRTC 投影
// 必须忽略它并重读 newer unmute否则重试会把云端永久回退为禁音。
t.Fatalf("old mute replay must reconcile latest unmuted state: %+v", got)
if got := rtcBlocker.snapshot(); len(got) != 0 {
// MuteUser 只提交 Room Cell 与 outbox旧命令重放和新命令都不能把腾讯 RTC 慢调用带入同步请求。
t.Fatalf("room mute command must not call Tencent RTC before durable outbox processing: %+v", got)
}
upResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, mutedUserID, "unmuted-mic-up"),
@ -188,11 +209,13 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
}
directCalls := rtcBlocker.snapshot()
if len(directCalls) != 6 || !directCalls[0] || directCalls[1] || directCalls[2] || !directCalls[3] || !directCalls[4] || directCalls[5] {
// 四次新 MuteUser 分别投影 true/false/true/false旧 mute 重放必须仍投影 false
// 中间被篡改客户端触发的
// audio_started 必须额外 re-block=true因此按调用顺序断言服务端防线完整。
t.Fatalf("unexpected direct RTC audio block sequence: %+v", directCalls)
if len(directCalls) != 1 || !directCalls[0] {
// MuteUser 的 RTC 投影全部异步;只有被篡改客户端触发 audio_started 时,服务端回调会立即 re-block。
t.Fatalf("unexpected pre-outbox RTC audio block sequence: %+v", directCalls)
}
if calls := imMuter.snapshot(); len(calls) != 0 {
// IM 禁言不能进入 MuteUser 同步路径;否则腾讯慢请求会拉长房间命令,且实现重新依赖外部服务可用性。
t.Fatalf("room mute command must not call Tencent IM before durable outbox processing: %+v", calls)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
@ -209,6 +232,17 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
t.Fatalf("stale RTC mute outbox call %d rolled current state backward", index)
}
}
imCalls := imMuter.snapshot()
if len(imCalls) != 4 {
t.Fatalf("each durable RoomUserMuted event must reach Tencent IM projection exactly once: %+v", imCalls)
}
for index, muted := range imCalls {
// IM 投影只由 outbox 触发;处理四条历史事件时当前 Room Cell 已解除禁言,
// 因此每次都必须写 MuteTime=0不能按旧事件载荷恢复永久禁言。
if muted {
t.Fatalf("stale IM mute outbox call %d rolled current state backward", index)
}
}
}
type blockingRTCUserAudioBlocker struct {
@ -267,30 +301,30 @@ func TestRTCUserAudioReconcileConvergesSlowMuteAndNewerUnmute(t *testing.T) {
createRocketRoom(t, ctx, svc, roomID, ownerID, 9101)
joinRocketRoom(t, ctx, svc, roomID, targetUserID)
muteDone := make(chan error, 1)
go func() {
_, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "slow-mute"),
TargetUserId: targetUserID,
Muted: true,
})
muteDone <- err
}); err != nil {
t.Fatalf("mute command failed: %v", err)
}
projectionDone := make(chan error, 1)
go func() {
projectionDone <- svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: 3 * time.Second, BatchSize: 100})
}()
select {
case <-rtcBlocker.firstStarted:
case <-time.After(2 * time.Second):
t.Fatal("slow mute never reached RTC blocker")
t.Fatal("slow mute outbox never reached RTC blocker")
}
unmuteDone := make(chan error, 1)
go func() {
_, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "fast-unmute"),
TargetUserId: targetUserID,
Muted: false,
})
unmuteDone <- err
}()
}); err != nil {
t.Fatalf("newer unmute command failed: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for {
permission, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: targetUserID, AppCode: appcode.Default})
@ -303,11 +337,8 @@ func TestRTCUserAudioReconcileConvergesSlowMuteAndNewerUnmute(t *testing.T) {
time.Sleep(5 * time.Millisecond)
}
close(rtcBlocker.releaseFirst)
if err := <-muteDone; err != nil {
t.Fatalf("slow mute request failed: %v", err)
}
if err := <-unmuteDone; err != nil {
t.Fatalf("newer unmute request failed: %v", err)
if err := <-projectionDone; err != nil {
t.Fatalf("slow mute outbox processing failed: %v", err)
}
calls, blocked := rtcBlocker.snapshot()
if blocked || len(calls) < 2 || !calls[0] || calls[len(calls)-1] {
@ -315,11 +346,12 @@ func TestRTCUserAudioReconcileConvergesSlowMuteAndNewerUnmute(t *testing.T) {
}
}
func TestRTCUserAudioFailureDoesNotBlockRoomMuteOutboxPublisher(t *testing.T) {
func TestMuteProjectionFailuresDoNotBlockFactPublisherAndRemainRetryable(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 10, 0, 0, 0, time.UTC)}
rtcBlocker := &recordingRTCUserAudioBlocker{err: errors.New("rtc unavailable")}
imMuter := &recordingIMUserMuter{err: errors.New("im unavailable")}
outboxPublisher := newRecordingRoomDirectIMPublisher()
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-outbox-test",
@ -327,6 +359,7 @@ func TestRTCUserAudioFailureDoesNotBlockRoomMuteOutboxPublisher(t *testing.T) {
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
IMUserMuter: imMuter,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), outboxPublisher)
roomID := "room-muted-rtc-outbox"
@ -339,7 +372,27 @@ func TestRTCUserAudioFailureDoesNotBlockRoomMuteOutboxPublisher(t *testing.T) {
TargetUserId: targetUserID,
Muted: true,
}); err != nil {
t.Fatalf("durable room mute must succeed despite direct RTC failure: %v", err)
t.Fatalf("durable room mute must not depend on RTC availability: %v", err)
}
if calls := rtcBlocker.snapshot(); len(calls) != 0 {
t.Fatalf("room mute request must return before RTC projection: %+v", calls)
}
if calls := imMuter.snapshot(); len(calls) != 0 {
t.Fatalf("room mute request must return before IM projection: %+v", calls)
}
pending, err := repository.ListPendingOutbox(ctx, 100)
if err != nil {
t.Fatalf("list pending room outbox: %v", err)
}
var muteEventID string
for _, record := range pending {
if record.EventType == "RoomUserMuted" {
muteEventID = record.EventID
break
}
}
if muteEventID == "" {
t.Fatal("RoomUserMuted outbox event not found")
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{
PublishTimeout: time.Second,
@ -351,7 +404,20 @@ func TestRTCUserAudioFailureDoesNotBlockRoomMuteOutboxPublisher(t *testing.T) {
t.Fatalf("process room outbox with RTC failure: %v", err)
}
if outboxPublisher.counts()["RoomUserMuted"] != 1 {
t.Fatalf("RTC failure must not prevent generic RoomUserMuted publication: counts=%+v", outboxPublisher.counts())
t.Fatalf("IM/RTC failures must not prevent generic RoomUserMuted publication: counts=%+v", outboxPublisher.counts())
}
if calls := rtcBlocker.snapshot(); len(calls) != 1 || !calls[0] {
t.Fatalf("RTC projection must attempt the latest muted state once: %+v", calls)
}
if calls := imMuter.snapshot(); len(calls) != 1 || !calls[0] {
t.Fatalf("IM projection must attempt the latest muted state once: %+v", calls)
}
record, exists := repository.OutboxRecord(muteEventID)
if !exists || record.Status != "retryable" || record.RetryCount != 1 {
t.Fatalf("failed cloud projections must keep durable event retryable: %+v exists=%t", record, exists)
}
if !strings.Contains(record.LastError, "im unavailable") || !strings.Contains(record.LastError, "rtc unavailable") {
t.Fatalf("retryable outbox must preserve both projection failures: %+v", record)
}
}

View File

@ -2,12 +2,10 @@ package service
import (
"context"
"log/slog"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
@ -19,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 发言前回调查 CheckSpeakPermissionRTC 麦位则同步为服务端静音态
// 禁言状态由 Room Cell 持有;提交后的 durable outbox 分别投影腾讯 IM 原生群成员禁言和 RTC 音频上行闸门
cmd := command.MuteUser{
Base: baseFromMeta(req.GetMeta()),
TargetUserID: req.GetTargetUserId(),
@ -120,19 +118,6 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
return nil, err
}
if s.rtcUserAudioBlocker != nil {
// 低延迟路径在 Room Cell 提交后立即收敛 RTC必须重读当前状态
// 不能使用 result.snapshot因为旧 command_id 重放会返回历史结果,
// 在 newer unmute 后照旧投影就会把云端状态永久回退。
if err := s.reconcileRTCUserAudio(ctx, cmd.RoomID(), cmd.TargetUserID, false); err != nil {
logx.Warn(ctx, "rtc_user_audio_block_sync_failed",
slog.String("room_id", cmd.RoomID()),
slog.Int64("target_user_id", cmd.TargetUserID),
slog.String("error", err.Error()),
)
}
}
return &roomv1.MuteUserResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,

View File

@ -419,23 +419,36 @@ func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record)
}
var publishErr error
if s.outboxPublisher != nil {
// 通用事实通道必须先尝试发布;RTC 外部 API 慢或失败不能让
// RoomUserMuted 在进入 MQ/活动消费者之前就被截断。RTC 失败时本记录仍会
// 通用事实通道必须先尝试发布;IM/RTC 外部 API 慢或失败不能让
// RoomUserMuted 在进入 MQ/活动消费者之前就被截断。投影失败时本记录仍会
// retryMQ 可能重放由既有 event_id 幂等收敛。
publishErr = s.outboxPublisher.PublishOutboxEvent(ctx, record.Envelope)
}
var imErr error
var rtcErr error
if record.Envelope != nil && record.Envelope.GetEventType() == "RoomUserMuted" {
// RTC 使用独立有界预算,不消耗通用 publisher 的 deadline两侧都尝试后
// 任一失败都让 outbox 保持 retryable避免丢失媒体补偿或业务事实。
rtcCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), rtcMuteProjectionTimeout)
rtcErr = s.syncRTCUserMuteFromOutbox(rtcCtx, record.Envelope)
cancel()
// IM 与 RTC 使用彼此独立的有界预算并行投影,避免串行云请求把单条 outbox lease 拉长到 12 秒。
// 两侧都完成后再汇总错误,任一失败都让事实保持 retryable不会丢失文字禁言或媒体禁音。
projectionContext := appcode.WithContext(context.Background(), appcode.FromContext(ctx))
imResult := make(chan error, 1)
rtcResult := make(chan error, 1)
go func() {
imCtx, imCancel := context.WithTimeout(projectionContext, imMuteProjectionTimeout)
defer imCancel()
imResult <- s.syncIMUserMuteFromOutbox(imCtx, record.Envelope)
}()
go func() {
rtcCtx, rtcCancel := context.WithTimeout(projectionContext, rtcMuteProjectionTimeout)
defer rtcCancel()
rtcResult <- s.syncRTCUserMuteFromOutbox(rtcCtx, record.Envelope)
}()
imErr = <-imResult
rtcErr = <-rtcResult
}
// Join 保留两个独立通道的排障语义;只要任一不为 nil调用方就会
// 将记录改为 retryable下次同时重放 MQ event_id 和 latest-state RTC 投影。
return errors.Join(publishErr, rtcErr)
// Join 保留个独立通道的排障语义;只要任一不为 nil调用方就会把记录改为 retryable
// 下次重放 MQ event_id并分别按 Room Cell 最新状态收敛 IM 与 RTC不信任历史 muted 字段
return errors.Join(publishErr, imErr, rtcErr)
}
// outboxBackoff 根据失败次数计算指数退避,且永远不超过 MaxBackoff。

View File

@ -35,6 +35,8 @@ type Config struct {
RTCUserRemover integration.RTCUserRemover
// RTCUserAudioBlocker 把 Room Cell 禁言事实投影到腾讯 RTC 服务端音频上行闸门。
RTCUserAudioBlocker integration.RTCUserAudioBlocker
// IMUserMuter 把 Room Cell 禁言事实异步投影到腾讯 IM 原生群成员禁言。
IMUserMuter integration.IMUserMuter
// RoomRocketLaunchScheduler 把倒计时发射唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
@ -121,6 +123,14 @@ type Service struct {
rtcUserRemover integration.RTCUserRemover
// rtcUserAudioBlocker 在房间禁言提交后关闭目标用户的 RTC 上行音频;状态仍只保存在 Room Cell。
rtcUserAudioBlocker integration.RTCUserAudioBlocker
// imUserMuter 只由 durable outbox worker 调用,普通 IM 发消息链路永远不会经过 room-service。
imUserMuter integration.IMUserMuter
// imMuteGateMu 保护按 app/room/user 划分的 IM 投影门闩,避免同一用户的旧 mute 与新 unmute 并发写腾讯。
imMuteGateMu sync.Mutex
imMuteGates map[imMuteProjectionKey]*imMuteProjectionGate
// IM forbid_send_msg 官方上限为 200 QPS单节点节流给双节点部署保留共享 SDKAppID 余量。
imMuteRateMu sync.Mutex
imMuteNextCall time.Time
// rtcMuteGateMu 保护按 app/room/user 划分的 RTC 投影门闩;门闩只串行化本进程请求,
// 跨实例竞态由每次云 API 返回后的 latest-state recheck 继续收敛。
rtcMuteGateMu sync.Mutex
@ -312,6 +322,8 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
luckyGiftSendLockTTL: luckyGiftSendLockTTL,
rtcUserRemover: cfg.RTCUserRemover,
rtcUserAudioBlocker: cfg.RTCUserAudioBlocker,
imUserMuter: cfg.IMUserMuter,
imMuteGates: make(map[imMuteProjectionKey]*imMuteProjectionGate),
rtcMuteGates: make(map[rtcMuteProjectionKey]*rtcMuteProjectionGate),
cells: make(map[string]*loadedCell),
robotRuntimes: make(map[string]robotRoomRuntime),

View File

@ -377,7 +377,7 @@ func (s *Server) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminReque
// MuteUser 代理到领域服务。
func (s *Server) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
// 禁言状态是腾讯云 IM 发言回调的权威来源
// 禁言状态由 Room Cell 持有;腾讯 IM/RTC 仅消费 outbox 异步投影,查询接口保留给其它受控入口
ctx = contextWithMetaApp(ctx, req.GetMeta())
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.MuteUserResponse, error) {
return client.MuteUser(callCtx, req)