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