2026-07-17 16:55:22 +08:00

361 lines
14 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"
"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
}