package service import ( "context" "crypto/sha256" "encoding/hex" "errors" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/giftlimits" "hyapp/pkg/roomid" "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" ) // AuthorizeRoomMediaUpload 在 COS PutObject 之前执行房间 owner/presence、治理态和 VIP 权益门禁。 // 返回的 prefix 是 gateway 生成对象 key 的唯一可信目录,客户端不能自行指定 key。 func (s *Service) AuthorizeRoomMediaUpload(ctx context.Context, req *roomv1.AuthorizeRoomMediaUploadRequest) (*roomv1.AuthorizeRoomMediaUploadResponse, error) { if req == nil || req.GetMeta() == nil { return nil, xerr.New(xerr.InvalidArgument, "request is required") } ctx = contextFromMeta(ctx, req.GetMeta()) roomID := strings.TrimSpace(req.GetMeta().GetRoomId()) actorUserID := req.GetMeta().GetActorUserId() purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose())) if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(req.GetMeta().GetCommandId()) || (purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) { return nil, xerr.New(xerr.InvalidArgument, "room media upload request is invalid") } media := roomMediaFromProto(req.GetMedia()) if err := validateRoomMediaProperties(purpose, media); err != nil { return nil, err } if media.URL != "" || media.ObjectKey != "" || media.Status != roomMediaStatusActive { return nil, xerr.New(xerr.InvalidArgument, "room media authorization requires pre-upload metadata") } guard, ok := s.repository.(RoomMediaUploadGuardStore) if !ok { return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable") } payloadSHA256, err := roomMediaUploadPayloadSHA256(media) if err != nil { return nil, err } registration := RoomMediaUploadRegistration{ AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(req.GetMeta().GetCommandId()), ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, Media: media, } registration.ExpectedObjectKey = roomMediaExpectedObjectKey( registration.AppCode, actorUserID, roomID, purpose, registration.CommandID, media, ) var authorizedReplay *RoomMediaUploadRegistration if existing, exists, readErr := guard.GetRoomMediaUploadRegistration(ctx, roomID, registration.CommandID); readErr != nil { return nil, readErr } else if exists { if !sameRoomMediaUploadIdentity(existing, registration) { return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict") } switch existing.Status { case roomMediaStatusActive: // PutObject 已完成的同载荷重试只重放首次结果;后续权限变化不能把已成功请求改成失败。 return roomMediaUploadAuthorizationResponse(existing, s.clock.Now().UnixMilli()), nil case "authorized": // authorized 只代表 owner 已占用 command_id,不能代表对象已经上传成功。重试 PutObject // 前必须重新检查当前房主/麦位/VIP,避免权限撤销后继续使用旧授权写 COS。 authorizedReplay = &existing default: return nil, xerr.New(xerr.Internal, "room media upload registration status is invalid") } } snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return nil, err } if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive { return nil, xerr.New(xerr.RoomClosed, "room closed") } switch purpose { case roomMediaPurposeBackground: if snapshot.GetOwnerUserId() != actorUserID { return nil, xerr.New(xerr.PermissionDenied, "permission denied") } if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitCustomRoomBackground, "upload_room_background"); err != nil { return nil, err } case roomMediaPurposeImage: if err := requireSnapshotSpeakPermission(snapshot, actorUserID, s.clock.Now().UnixMilli()); err != nil { return nil, err } if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), actorUserID, vipBenefitRoomImageMessage, "upload_room_image"); err != nil { return nil, err } } nowMS := s.clock.Now().UnixMilli() if authorizedReplay != nil { return roomMediaUploadAuthorizationResponse(*authorizedReplay, nowMS), nil } registration.AuthorizedVersion = snapshot.GetVersion() registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS registered, err := guard.RegisterRoomMediaUpload(ctx, registration) if errors.Is(err, ErrRoomMediaUploadCommandConflict) { return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict") } if err != nil { return nil, err } return roomMediaUploadAuthorizationResponse(registered, nowMS), nil } // CompleteRoomMediaUpload 只接受 gateway 已成功 PutObject 后回传的 URL/key。 // owner 以首次注册的完整解析事实为准推进 active,Save/Send 随后只认 active registration。 func (s *Service) CompleteRoomMediaUpload(ctx context.Context, req *roomv1.CompleteRoomMediaUploadRequest) (*roomv1.CompleteRoomMediaUploadResponse, error) { if req == nil || req.GetMeta() == nil { return nil, xerr.New(xerr.InvalidArgument, "request is required") } ctx = contextFromMeta(ctx, req.GetMeta()) roomID := strings.TrimSpace(req.GetMeta().GetRoomId()) commandID := strings.TrimSpace(req.GetMeta().GetCommandId()) actorUserID := req.GetMeta().GetActorUserId() purpose := strings.ToLower(strings.TrimSpace(req.GetPurpose())) if !roomid.ValidStringID(roomID) || actorUserID <= 0 || !giftlimits.ValidCommandID(commandID) || (purpose != roomMediaPurposeBackground && purpose != roomMediaPurposeImage) { return nil, xerr.New(xerr.InvalidArgument, "room media completion request is invalid") } media := roomMediaFromProto(req.GetMedia()) if err := validateRoomMedia(appcode.FromContext(ctx), roomID, actorUserID, purpose, media); err != nil { return nil, err } guard, ok := s.repository.(RoomMediaUploadGuardStore) if !ok { return nil, xerr.New(xerr.Unavailable, "room media upload guard is unavailable") } existing, exists, err := guard.GetRoomMediaUploadRegistration(ctx, roomID, commandID) if err != nil { return nil, err } if !exists { return nil, xerr.New(xerr.NotFound, "room media upload authorization not found") } payloadSHA256, err := roomMediaUploadPayloadSHA256(media) if err != nil { return nil, err } candidate := RoomMediaUploadRegistration{ AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: commandID, ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media, } if !sameRoomMediaUploadIdentity(existing, candidate) || !sameRoomMediaProperties(existing.Media, media) { return nil, xerr.New(xerr.IdempotencyConflict, "room media upload command payload conflict") } if existing.Status == roomMediaStatusActive { if strings.TrimSpace(existing.ObjectURL) != strings.TrimSpace(media.URL) { return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict") } return &roomv1.CompleteRoomMediaUploadResponse{ Active: true, Media: roomMediaToProto(existing.Media), ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } existing.ObjectURL, existing.Status, existing.Media = media.URL, roomMediaStatusActive, media existing.UpdatedAtMS = s.clock.Now().UnixMilli() completed, err := guard.CompleteRoomMediaUpload(ctx, existing) if errors.Is(err, ErrRoomMediaUploadCommandConflict) { return nil, xerr.New(xerr.IdempotencyConflict, "room media upload completion conflict") } if err != nil { return nil, err } return &roomv1.CompleteRoomMediaUploadResponse{ Active: true, Media: roomMediaToProto(completed.Media), ServerTimeMs: s.clock.Now().UnixMilli(), }, nil } func roomMediaUploadAuthorizationResponse(registration RoomMediaUploadRegistration, serverTimeMS int64) *roomv1.AuthorizeRoomMediaUploadResponse { return &roomv1.AuthorizeRoomMediaUploadResponse{ Allowed: true, ObjectKeyPrefix: roomMediaObjectPrefix(registration.AppCode, registration.ActorUserID, registration.RoomID, registration.Purpose), ObjectKey: registration.ExpectedObjectKey, RoomVersion: registration.AuthorizedVersion, ServerTimeMs: serverTimeMS, } } func sameRoomMediaUploadIdentity(left RoomMediaUploadRegistration, right RoomMediaUploadRegistration) bool { return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) && strings.TrimSpace(left.RoomID) == strings.TrimSpace(right.RoomID) && strings.TrimSpace(left.CommandID) == strings.TrimSpace(right.CommandID) && left.ActorUserID == right.ActorUserID && strings.TrimSpace(left.Purpose) == strings.TrimSpace(right.Purpose) && strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) && strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/") } func (s *Service) requireActiveRoomMediaUpload(ctx context.Context, roomID string, actorUserID int64, purpose string, commandID string, media state.RoomMedia) error { guard, ok := s.repository.(RoomMediaUploadGuardStore) if !ok { return xerr.New(xerr.Unavailable, "room media upload guard is unavailable") } registration, exists, err := guard.GetActiveRoomMediaUploadByObjectKey(ctx, media.ObjectKey) if err != nil { return err } if !exists { return xerr.New(xerr.NotFound, "active room media upload not found") } payloadSHA256, err := roomMediaUploadPayloadSHA256(media) if err != nil { return err } candidate := RoomMediaUploadRegistration{ AppCode: appcode.FromContext(ctx), RoomID: roomID, CommandID: strings.TrimSpace(commandID), ActorUserID: actorUserID, Purpose: purpose, MediaPayloadSHA256: payloadSHA256, ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media, } if !sameRoomMediaUploadIdentity(registration, candidate) || strings.TrimSpace(registration.ObjectURL) != strings.TrimSpace(media.URL) || !sameRoomMediaProperties(registration.Media, media) { return xerr.New(xerr.IdempotencyConflict, "room media does not match completed upload") } return nil } // SendRoomImage 把服务端认可的图片消息写入 Room Cell command log + durable outbox。 // outbox 的 at-least-once 重试使用稳定 message_id,Flutter 不能以到达次数作为消息条数。 func (s *Service) SendRoomImage(ctx context.Context, req *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) { if req == nil || req.GetMeta() == nil { return nil, xerr.New(xerr.InvalidArgument, "request is required") } ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.SendRoomImage{ Base: baseFromMeta(req.GetMeta()), MessageID: roomImageMessageID(req.GetMeta()), Image: roomMediaFromProto(req.GetImage()), } if !roomid.ValidStringID(cmd.RoomID()) || cmd.ActorUserID() <= 0 || strings.TrimSpace(cmd.ID()) == "" { return nil, xerr.New(xerr.InvalidArgument, "room image message meta is invalid") } // 与装扮相同,已提交 command_id 必须在当前 VIP 到期/禁言之前直接重放。 if _, record, seen, err := s.commandRecordForIdempotency(ctx, cmd); err != nil { return nil, err } else if seen { stored, deserializeErr := command.Deserialize(record.CommandType, record.Payload) if deserializeErr != nil { return nil, deserializeErr } storedCommand, ok := stored.(*command.SendRoomImage) if !ok { return nil, xerr.New(xerr.Internal, "stored room image command is invalid") } // 返回第一次提交固化的 message_id 和媒体事实,不能用当前重试请求拼出看似成功的另一条消息。 cmd = *storedCommand } else { if err := validateRoomMedia(appcode.FromContext(ctx), cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.Image); err != nil { return nil, err } if err := s.requireActiveRoomMediaUpload(ctx, cmd.RoomID(), cmd.ActorUserID(), roomMediaPurposeImage, cmd.ID(), cmd.Image); err != nil { return nil, err } if err := s.requireVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.ActorUserID(), vipBenefitRoomImageMessage, "send_room_image"); err != nil { return nil, err } } 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 := requireStateSpeakPermission(current, cmd.ActorUserID(), now.UnixMilli()); err != nil { return mutationResult{}, nil, err } // 图片消息不成为房间核心状态字段,但版本推进把 command log、恢复谱系和 outbox 放进同一原子提交。 current.Version++ event, err := outbox.Build(current.RoomID, "RoomImageMessageSent", current.Version, now, &roomeventsv1.RoomImageMessageSent{ MessageId: cmd.MessageID, CommandId: cmd.ID(), SenderUserId: cmd.ActorUserID(), Image: roomMediaEventSnapshot(cmd.Image), }) if err != nil { return mutationResult{}, nil, err } return mutationResult{snapshot: current.ToProtoAt(now.UnixMilli())}, []outbox.Record{event}, nil }) if err != nil { return nil, err } now := s.clock.Now() return &roomv1.SendRoomImageResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), now), Accepted: true, MessageId: cmd.MessageID, RoomId: cmd.RoomID(), SenderUserId: cmd.ActorUserID(), ServerTimeMs: now.UnixMilli(), Image: roomMediaToProto(cmd.Image), }, nil } func requireSnapshotSpeakPermission(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) error { if snapshot == nil || snapshot.GetStatus() != state.RoomStatusActive { return xerr.New(xerr.RoomClosed, "room closed") } switch { case snapshotUserBanned(snapshot, userID, nowMS): return xerr.New(xerr.PermissionDenied, "user is banned") case containsID(snapshot.GetMuteUserIds(), userID): return xerr.New(xerr.PermissionDenied, "user is muted") case !snapshot.GetChatEnabled(): return xerr.New(xerr.Conflict, "room chat is disabled") case findProtoUser(snapshot, userID) == nil: return xerr.New(xerr.PermissionDenied, "user is not in room") default: return nil } } func requireStateSpeakPermission(current *state.RoomState, userID int64, nowMS int64) error { if moderation, banned := current.BanUsers[userID]; banned && moderation.ActiveAt(nowMS) { return xerr.New(xerr.PermissionDenied, "user is banned") } if current.MuteUsers[userID] { return xerr.New(xerr.PermissionDenied, "user is muted") } if !current.ChatEnabled { return xerr.New(xerr.Conflict, "room chat is disabled") } if current.OnlineUsers[userID] == nil { return xerr.New(xerr.PermissionDenied, "user is not in room") } return nil } func roomImageMessageID(meta *roomv1.RequestMeta) string { if meta == nil { return "" } sum := sha256.Sum256([]byte(strings.Join([]string{ appcode.Normalize(meta.GetAppCode()), strings.TrimSpace(meta.GetRoomId()), strings.TrimSpace(meta.GetCommandId()), }, "\x00"))) return "room_msg_" + hex.EncodeToString(sum[:16]) }