package service import ( "context" "strings" "time" "unicode/utf8" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) // UpdateRoomProfile 修改房间展示资料和麦位数量,所有状态变化仍由 Room Cell 串行提交。 func (s *Service) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) patch, err := s.normalizeUpdateRoomProfile(ctx, req) if err != nil { return nil, err } cmd := command.UpdateRoomProfile{ Base: baseFromMeta(req.GetMeta()), RoomName: patch.Name, RoomAvatar: patch.Avatar, RoomDescription: patch.Description, SeatCount: patch.SeatCount, } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } profileChanged := applyProfilePatch(current, cmd) seatChanged, err := applySeatCountPatch(current, cmd.SeatCount) if err != nil { return mutationResult{}, nil, err } if !profileChanged && !seatChanged { // 未提交任何字段变化时仍通过 command log 保护 command_id 幂等,但不广播系统消息。 return mutationResult{snapshot: current.ToProto()}, nil, nil } current.Version++ seatCount := int32(len(current.MicSeats)) profileEvent, err := outbox.Build(current.RoomID, "RoomProfileUpdated", current.Version, now, &roomeventsv1.RoomProfileUpdated{ ActorUserId: cmd.ActorUserID(), RoomName: current.RoomExt[roomExtTitleKey], RoomAvatar: current.RoomExt[roomExtCoverURLKey], RoomDescription: current.RoomExt[roomExtDescriptionKey], SeatCount: seatCount, RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey], }) if err != nil { return mutationResult{}, nil, err } result := mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: profileEvent.EventID, RoomID: current.RoomID, EventType: "room_profile_updated", ActorUserID: cmd.ActorUserID(), RoomVersion: current.Version, Attributes: map[string]string{ "room_name": current.RoomExt[roomExtTitleKey], "room_avatar": current.RoomExt[roomExtCoverURLKey], "room_description": current.RoomExt[roomExtDescriptionKey], "seat_count": int32String(seatCount), "room_background_url": current.RoomExt[roomExtBackgroundKey], }, }, } if seatChanged { result.roomSeatCount = &seatCount } return result, []outbox.Record{profileEvent}, nil }) if err != nil { return nil, err } return &roomv1.UpdateRoomProfileResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil } type updateRoomProfilePatch struct { // Name 为 nil 表示本次请求不修改房间名;非 nil 已完成裁剪和长度校验。 Name *string // Avatar 为 nil 表示不修改头像;空头像在 normalize 阶段已被替换为系统默认头像。 Avatar *string // Description 为 nil 表示不修改简介;非 nil 可以是空字符串,用于清空简介。 Description *string // SeatCount 为 nil 表示不调整麦位数量;非 nil 已通过后台座位数配置校验。 SeatCount *int32 } // normalizeUpdateRoomProfile 把 protobuf patch 收敛为明确的领域补丁。 // 这里区分 nil 和空字符串:nil 是不改字段,空字符串是否允许由具体字段语义决定。 func (s *Service) normalizeUpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomProfileRequest) (updateRoomProfilePatch, error) { var patch updateRoomProfilePatch if req == nil { return patch, xerr.New(xerr.InvalidArgument, "request is required") } if req.RoomName != nil { // 房间名不允许清空,列表卡片和房间详情都依赖它提供最小展示信息。 value := strings.TrimSpace(req.GetRoomName()) if value == "" { return patch, xerr.New(xerr.InvalidArgument, "room_name is required") } if utf8.RuneCountInString(value) > maxRoomNameRunes { return patch, xerr.New(xerr.InvalidArgument, "room_name is too long") } patch.Name = &value } if req.RoomAvatar != nil { value := strings.TrimSpace(req.GetRoomAvatar()) if value == "" { // 房主清空头像时恢复系统默认资源,避免列表卡片落空。 value = defaultRoomAvatar } if utf8.RuneCountInString(value) > maxRoomAvatarRunes { return patch, xerr.New(xerr.InvalidArgument, "room_avatar is too long") } patch.Avatar = &value } if req.RoomDescription != nil { // 简介允许清空,但不能允许无限长内容进入 snapshot、列表投影和 command log。 value := strings.TrimSpace(req.GetRoomDescription()) if utf8.RuneCountInString(value) > maxRoomDescriptionRunes { return patch, xerr.New(xerr.InvalidArgument, "room_description is too long") } patch.Description = &value } if req.SeatCount != nil { // 座位数只能使用后台启用值,避免客户端直接制造超大麦位快照。 value, err := s.validateRoomSeatCount(ctx, req.GetSeatCount()) if err != nil { return patch, err } patch.SeatCount = &value } return patch, nil } // applyProfilePatch 只修改显式传入且确实变化的展示资料字段。 // 返回值用于决定是否递增房间版本和生成 RoomProfileUpdated 事件。 func applyProfilePatch(current *state.RoomState, cmd command.UpdateRoomProfile) bool { if current.RoomExt == nil { // 恢复旧快照或测试构造状态可能没有 RoomExt,写展示字段前必须补齐 map。 current.RoomExt = make(map[string]string) } changed := false if cmd.RoomName != nil && current.RoomExt[roomExtTitleKey] != *cmd.RoomName { // RoomExt 是房间资料的低频扩展容器,避免把展示字段塞进高频核心结构。 current.RoomExt[roomExtTitleKey] = *cmd.RoomName changed = true } if cmd.RoomAvatar != nil && current.RoomExt[roomExtCoverURLKey] != *cmd.RoomAvatar { current.RoomExt[roomExtCoverURLKey] = *cmd.RoomAvatar changed = true } if cmd.RoomDescription != nil && current.RoomExt[roomExtDescriptionKey] != *cmd.RoomDescription { current.RoomExt[roomExtDescriptionKey] = *cmd.RoomDescription changed = true } return changed } // applySeatCountPatch 调整麦位数组大小,不迁移用户、不隐式下麦、不自动解锁。 // 缩容只能删除完全空闲的高编号麦位,避免一个资料修改请求制造复合业务事件。 func applySeatCountPatch(current *state.RoomState, seatCount *int32) (bool, error) { if seatCount == nil { return false, nil } target := *seatCount currentCount := int32(len(current.MicSeats)) if target == currentCount { // 相同座位数是 no-op,仍由外层 command log 负责幂等记录。 return false, nil } if target > currentCount { // 扩容只追加高编号空麦位,现有麦位编号和用户占用状态保持稳定。 for seatNo := currentCount + 1; seatNo <= target; seatNo++ { current.MicSeats = append(current.MicSeats, state.MicSeat{SeatNo: seatNo}) } return true, nil } nextSeats := make([]state.MicSeat, 0, target) for _, seat := range current.MicSeats { if seat.SeatNo <= target { // 保留目标范围内的原麦位,锁定、占用和发流状态都原样保留。 nextSeats = append(nextSeats, seat) continue } if seat.UserID != 0 || seat.Locked || seat.PublishState != "" || seat.MicSessionID != "" { // 缩容不能隐式踢人、清理发流会话或解锁麦位;房主需要先把高编号麦位处理干净。 return false, xerr.New(xerr.Conflict, "removed seats must be empty and unlocked") } } current.MicSeats = nextSeats return true, nil }