package service import ( "context" "errors" "strings" "time" "unicode/utf8" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/giftlimits" "hyapp/pkg/roomid" "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" ) // SaveRoomBackground 把房主上传后的背景图 URL 保存到房间维度素材列表。 // 该动作只维护低频素材表,不改变 Room Cell 当前背景;真正生效必须调用 SetRoomBackground。 func (s *Service) SaveRoomBackground(ctx context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) roomID, actorUserID, commandID, media, err := normalizeSaveRoomBackground(req) if err != nil { return nil, err } if commandID != "" && !giftlimits.ValidCommandID(commandID) { return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid") } imageURL := media.URL replayCommandID := commandID if replayCommandID == "" { // legacy_timed 没有客户端 command_id;这里先算出与首次提交一致的稳定键,确保旧成功也能先于可变权限重放。 replayCommandID = legacyRoomBackgroundCommandID(req.GetMeta().GetAppCode(), roomID, actorUserID, imageURL) } if reader, ok := s.repository.(RoomBackgroundCommandReader); ok { existing, exists, readErr := reader.GetRoomBackgroundByCommand(ctx, roomID, replayCommandID) if readErr != nil { return nil, readErr } if exists { if !sameRoomBackgroundSaveRequest(existing, actorUserID, imageURL, media) { return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict") } // 已成功保存是不可变事实。房间随后关闭、owner 元数据变化或 VIP 到期都不能把同一重试改成失败。 return &roomv1.SaveRoomBackgroundResponse{ Background: roomBackgroundToProto(existing), ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } } if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil { return nil, err } access, err := s.loadEffectiveVIPAccess(ctx, req.GetMeta().GetRequestId(), actorUserID) if err != nil { return nil, err } if access.programType == tieredPrivilegeVIPProgram { if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, "save_room_background"); err != nil { // Fami P1 的素材保存会形成持久数据,必须在写库前校验当前精确 benefit。 return nil, err } if !giftlimits.ValidCommandID(commandID) { return nil, xerr.New(xerr.InvalidArgument, "command_id is invalid") } if err := validateRoomMedia(req.GetMeta().GetAppCode(), roomID, actorUserID, roomMediaPurposeBackground, media); err != nil { return nil, err } if err := s.requireActiveRoomMediaUpload(ctx, roomID, actorUserID, roomMediaPurposeBackground, commandID, media); err != nil { // URL/key 前缀只能证明路径归属;这里还要用 active registration 锁定首次嗅探的哈希、尺寸和格式。 return nil, err } } else { // Lalu legacy_timed 保留历史 image_url-only 协议;服务端按 URL 生成稳定幂等键,空 Media 表示未经过新版专用上传。 if media.URL == "" || utf8.RuneCountInString(media.URL) > maxRoomBackgroundRunes { return nil, xerr.New(xerr.InvalidArgument, "image_url is invalid") } commandID = replayCommandID media = state.RoomMedia{} } nowMS := s.clock.Now().UnixMilli() background, err := s.repository.SaveRoomBackground(ctx, RoomBackgroundImage{ AppCode: req.GetMeta().GetAppCode(), RoomID: roomID, ImageURL: imageURL, CommandID: commandID, Media: media, CreatedByUserID: actorUserID, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, }) if err != nil { if errors.Is(err, ErrRoomBackgroundCommandConflict) { return nil, xerr.New(xerr.IdempotencyConflict, "command_id payload conflict") } return nil, err } return &roomv1.SaveRoomBackgroundResponse{ Background: roomBackgroundToProto(background), ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } // ListRoomBackgrounds 返回房主保存过的背景图列表和当前生效背景。 // 列表属于房间编辑面板数据,因此只允许房主读取。 func (s *Service) ListRoomBackgrounds(ctx context.Context, req *roomv1.ListRoomBackgroundsRequest) (*roomv1.ListRoomBackgroundsResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) roomID, viewerUserID, err := normalizeListRoomBackgrounds(req) if err != nil { return nil, err } if err := s.requireRoomOwnerMeta(ctx, roomID, viewerUserID); err != nil { return nil, err } backgrounds, err := s.repository.ListRoomBackgrounds(ctx, roomID) if err != nil { return nil, err } snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return nil, err } return &roomv1.ListRoomBackgroundsResponse{ Backgrounds: roomBackgroundsToProto(backgrounds), SelectedBackgroundUrl: snapshot.GetRoomExt()[roomExtBackgroundKey], SelectedBackground: snapshot.GetActiveBackground(), ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } // SetRoomBackground 把已保存的背景图设置为当前房间背景。 // 当前背景属于房间展示状态,必须经 Room Cell 命令链路、command log 和快照恢复。 func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBackgroundRequest) (*roomv1.SetRoomBackgroundResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) roomID, actorUserID, backgroundID, err := normalizeSetRoomBackground(req) if err != nil { return nil, err } cmd := command.SetRoomBackground{ Base: baseFromMeta(req.GetMeta()), BackgroundID: backgroundID, } _, record, seen, err := s.commandRecordForIdempotency(ctx, cmd) if err != nil { return nil, err } var background RoomBackgroundImage if seen { stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload) if deserializeErr != nil { return nil, deserializeErr } storedCommand, ok := stored.(*command.SetRoomBackground) if !ok { return nil, xerr.New(xerr.Internal, "stored room background command is invalid") } cmd = *storedCommand background = roomBackgroundFromSetCommand(cmd) } else { if err := s.requireRoomOwnerMeta(ctx, roomID, actorUserID); err != nil { return nil, err } var exists bool background, exists, err = s.repository.GetRoomBackground(ctx, roomID, backgroundID) if err != nil { return nil, err } if !exists { return nil, xerr.New(xerr.NotFound, "room background not found") } cmd.BackgroundID = background.BackgroundID cmd.BackgroundURL = background.ImageURL cmd.Background = roomBackgroundState(background) } 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 current.OwnerUserID != cmd.ActorUserID() { return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied") } if err := s.requireCustomRoomBackgroundBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), "set_room_background"); err != nil { // 放在 mutate 回调内可让同一 command_id 的已提交重试先命中幂等,不会因 VIP 后续到期改写旧命令结果。 return mutationResult{}, nil, err } if current.RoomExt == nil { current.RoomExt = make(map[string]string) } if (current.ActiveBackground != nil && current.ActiveBackground.BackgroundID == cmd.BackgroundID) || (current.ActiveBackground == nil && current.RoomExt[roomExtBackgroundKey] == cmd.BackgroundURL) { return mutationResult{snapshot: current.ToProto()}, nil, nil } current.RoomExt[roomExtBackgroundKey] = cmd.BackgroundURL if cmd.Background.Media.URL != "" { backgroundSnapshot := cmd.Background current.ActiveBackground = &backgroundSnapshot } else { current.ActiveBackground = nil } current.Version++ backgroundEvent, err := outbox.Build(current.RoomID, "RoomBackgroundChanged", current.Version, now, &roomeventsv1.RoomBackgroundChanged{ ActorUserId: cmd.ActorUserID(), BackgroundId: cmd.BackgroundID, RoomBackgroundUrl: current.RoomExt[roomExtBackgroundKey], Media: roomMediaEventSnapshot(cmd.Background.Media), }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: backgroundEvent.EventID, RoomID: current.RoomID, EventType: "room_background_changed", ActorUserID: cmd.ActorUserID(), RoomVersion: current.Version, Attributes: map[string]string{ "background_id": int64String(cmd.BackgroundID), "room_background_url": current.RoomExt[roomExtBackgroundKey], }, }, }, []outbox.Record{backgroundEvent}, nil }) if err != nil { return nil, err } return &roomv1.SetRoomBackgroundResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, Background: roomBackgroundToProto(background), }, nil } func sameRoomBackgroundSaveRequest(existing RoomBackgroundImage, actorUserID int64, imageURL string, media state.RoomMedia) bool { if existing.CreatedByUserID != actorUserID || strings.TrimSpace(existing.ImageURL) != strings.TrimSpace(imageURL) { return false } if strings.TrimSpace(media.ObjectKey) != "" { // 新版媒体事实全部由 gateway 解析并由 room-service 校验;任一字段变化都代表 command_id 被复用。 return existing.Media == media } legacyCandidate := media legacyCandidate.URL = "" // legacy_timed 只接受 image_url,其余媒体字段必须同时为空,不能借旧成功绕过新版专用上传校验。 return existing.Media == (state.RoomMedia{}) && legacyCandidate == (state.RoomMedia{}) } func roomBackgroundFromSetCommand(cmd command.SetRoomBackground) RoomBackgroundImage { backgroundID := cmd.Background.BackgroundID if backgroundID <= 0 { backgroundID = cmd.BackgroundID } roomID := strings.TrimSpace(cmd.Background.RoomID) if roomID == "" { roomID = cmd.RoomID() } createdByUserID := cmd.Background.CreatedByUserID if createdByUserID <= 0 { createdByUserID = cmd.ActorUserID() } // command log 固化第一次应用时的素材快照;旧记录没有 Background 时继续用 BackgroundURL 返回历史结果。 return RoomBackgroundImage{ AppCode: cmd.AppCode, BackgroundID: backgroundID, RoomID: roomID, ImageURL: strings.TrimSpace(cmd.BackgroundURL), CommandID: cmd.Background.CommandID, Media: cmd.Background.Media, CreatedByUserID: createdByUserID, CreatedAtMS: cmd.Background.CreatedAtMS, UpdatedAtMS: cmd.Background.UpdatedAtMS, } } func normalizeSaveRoomBackground(req *roomv1.SaveRoomBackgroundRequest) (string, int64, string, state.RoomMedia, error) { if req == nil || req.GetMeta() == nil { return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "request is required") } roomID := strings.TrimSpace(req.GetRoomId()) if roomID == "" { roomID = strings.TrimSpace(req.GetMeta().GetRoomId()) } if !roomid.ValidStringID(roomID) { return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "room_id is invalid") } actorUserID := req.GetMeta().GetActorUserId() if actorUserID <= 0 { return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "actor_user_id is required") } commandID := strings.TrimSpace(req.GetMeta().GetCommandId()) media := roomMediaFromProto(req.GetMedia()) requestImageURL := strings.TrimSpace(req.GetImageUrl()) if media.URL != "" && requestImageURL != "" && media.URL != requestImageURL { return "", 0, "", state.RoomMedia{}, xerr.New(xerr.InvalidArgument, "image_url does not match media.url") } if media.URL == "" { media.URL = requestImageURL } return roomID, actorUserID, commandID, media, nil } func normalizeListRoomBackgrounds(req *roomv1.ListRoomBackgroundsRequest) (string, int64, error) { if req == nil || req.GetMeta() == nil { return "", 0, xerr.New(xerr.InvalidArgument, "request is required") } roomID := strings.TrimSpace(req.GetRoomId()) if !roomid.ValidStringID(roomID) { return "", 0, xerr.New(xerr.InvalidArgument, "room_id is invalid") } viewerUserID := req.GetViewerUserId() if viewerUserID <= 0 { return "", 0, xerr.New(xerr.InvalidArgument, "viewer_user_id is required") } return roomID, viewerUserID, nil } func normalizeSetRoomBackground(req *roomv1.SetRoomBackgroundRequest) (string, int64, int64, error) { if req == nil || req.GetMeta() == nil { return "", 0, 0, xerr.New(xerr.InvalidArgument, "request is required") } roomID := strings.TrimSpace(req.GetMeta().GetRoomId()) if !roomid.ValidStringID(roomID) { return "", 0, 0, xerr.New(xerr.InvalidArgument, "room_id is invalid") } actorUserID := req.GetMeta().GetActorUserId() if actorUserID <= 0 { return "", 0, 0, xerr.New(xerr.InvalidArgument, "actor_user_id is required") } if strings.TrimSpace(req.GetMeta().GetCommandId()) == "" { return "", 0, 0, xerr.New(xerr.InvalidArgument, "command_id is required") } if req.GetBackgroundId() <= 0 { return "", 0, 0, xerr.New(xerr.InvalidArgument, "background_id is required") } return roomID, actorUserID, req.GetBackgroundId(), nil } func (s *Service) requireRoomOwnerMeta(ctx context.Context, roomID string, actorUserID int64) error { meta, exists, err := s.repository.GetRoomMeta(ctx, roomID) if err != nil { return err } if !exists { return xerr.New(xerr.NotFound, "room not found") } if meta.OwnerUserID != actorUserID { return xerr.New(xerr.PermissionDenied, "permission denied") } if meta.Status != state.RoomStatusActive { return xerr.New(xerr.Conflict, "room is not active") } return nil }