216 lines
8.1 KiB
Go
216 lines
8.1 KiB
Go
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
|
||
}
|