2026-05-28 17:57:29 +08:00

216 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}