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

333 lines
15 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"
"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 以首次注册的完整解析事实为准推进 activeSave/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_idFlutter 不能以到达次数作为消息条数。
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])
}