package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/url" "path" "strings" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/state" ) const ( roomMediaPurposeBackground = "room_background" roomMediaPurposeImage = "room_image" roomMediaStatusActive = "active" roomBackgroundMaxBytes = int64(12 << 20) roomBackgroundMaxWidth = int32(2160) roomBackgroundMaxHeight = int32(3840) roomBackgroundMaxPixels = int64(8_294_400) roomImageMaxBytes = int64(8 << 20) roomImageMaxDimension = int32(4096) roomImageMaxPixels = int64(16_777_216) roomMediaMaxFrames = int32(120) roomMediaMaxDurationMS = int64(15_000) ) var roomMediaMIMEByFormat = map[string]string{ "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", } // validateRoomMedia 校验专用上传入口产生的完整媒体事实。room-service 不下载 URL 重新嗅探, // 但会约束 COS key 归属、https、格式/MIME、尺寸和哈希,防止客户端绕过 gateway 直调 gRPC。 func validateRoomMedia(ctxAppCode string, roomID string, actorUserID int64, purpose string, media state.RoomMedia) error { media.URL = strings.TrimSpace(media.URL) media.ObjectKey = strings.TrimLeft(strings.TrimSpace(media.ObjectKey), "/") media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType)) media.Format = strings.ToLower(strings.TrimSpace(media.Format)) media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256)) media.Status = strings.ToLower(strings.TrimSpace(media.Status)) if media.URL == "" || media.ObjectKey == "" || media.Status != roomMediaStatusActive { return xerr.New(xerr.InvalidArgument, "room media is incomplete") } if len(media.URL) > 1024 || len(media.ObjectKey) > 512 || len(media.PosterURL) > 1024 || len(media.ThumbnailURL) > 1024 { return xerr.New(xerr.InvalidArgument, "room media url or object_key is too long") } parsed, err := url.Parse(media.URL) if err != nil || parsed.Scheme != "https" || strings.TrimSpace(parsed.Host) == "" { return xerr.New(xerr.InvalidArgument, "room media url must be https") } if expected := roomMediaObjectPrefix(ctxAppCode, actorUserID, roomID, purpose); !strings.HasPrefix(media.ObjectKey, expected) { return xerr.New(xerr.PermissionDenied, "room media object does not belong to actor and room") } if path.Clean(media.ObjectKey) != media.ObjectKey || !(strings.TrimLeft(parsed.Path, "/") == media.ObjectKey || strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/"+media.ObjectKey)) { // gateway 还会按 COS access_url 校验 host/base path;owner service 至少把 URL path 与同一 object_key 绑定, // 防止客户端拿一条合法 key 配合另一张外部图片绕过素材归属。 return xerr.New(xerr.PermissionDenied, "room media url does not match object_key") } return validateRoomMediaProperties(purpose, media) } // validateRoomMediaProperties 供上传授权阶段在对象尚未写入 COS、因而还没有 URL/key 时复核解析结果。 func validateRoomMediaProperties(purpose string, media state.RoomMedia) error { media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType)) media.Format = strings.ToLower(strings.TrimSpace(media.Format)) media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256)) if strings.TrimSpace(media.PosterURL) != "" || strings.TrimSpace(media.ThumbnailURL) != "" { // 当前上传链路没有生成或校验派生图,客户端不能借 poster/thumbnail 字段挂入任意外部 URL。 // 等后续服务端真正生成首帧和缩略图后,再由 owner service 填充这两个只读事实字段。 return xerr.New(xerr.InvalidArgument, "poster_url and thumbnail_url are not supported") } expectedMIME, supported := roomMediaMIMEByFormat[media.Format] if !supported || expectedMIME != media.ContentType { return xerr.New(xerr.InvalidArgument, "room media format does not match content_type") } if media.Width <= 0 || media.Height <= 0 || media.FrameCount <= 0 || media.SizeBytes <= 0 { return xerr.New(xerr.InvalidArgument, "room media dimensions are invalid") } decodedHash, err := hex.DecodeString(media.SHA256) if err != nil || len(decodedHash) != sha256.Size { return xerr.New(xerr.InvalidArgument, "room media sha256 is invalid") } if media.Animated { if media.Format != "gif" && media.Format != "webp" { return xerr.New(xerr.InvalidArgument, "animated room media must be gif or webp") } if media.FrameCount < 2 || media.FrameCount > roomMediaMaxFrames || media.DurationMS <= 0 || media.DurationMS > roomMediaMaxDurationMS { return xerr.New(xerr.InvalidArgument, "animated room media exceeds frame or duration limit") } } else if media.FrameCount != 1 || media.DurationMS != 0 { return xerr.New(xerr.InvalidArgument, "static room media metadata is invalid") } pixels := int64(media.Width) * int64(media.Height) switch purpose { case roomMediaPurposeBackground: if media.SizeBytes > roomBackgroundMaxBytes || media.Width > roomBackgroundMaxWidth || media.Height > roomBackgroundMaxHeight || pixels > roomBackgroundMaxPixels { return xerr.New(xerr.InvalidArgument, "room background exceeds media limits") } case roomMediaPurposeImage: if media.SizeBytes > roomImageMaxBytes || media.Width > roomImageMaxDimension || media.Height > roomImageMaxDimension || pixels > roomImageMaxPixels { return xerr.New(xerr.InvalidArgument, "room image exceeds media limits") } default: return xerr.New(xerr.InvalidArgument, "room media purpose is invalid") } return nil } // roomMediaObjectPrefix 是上传与提交共同使用的租户/用户/房间归属边界。 // path.Join 同时消除可疑的 ../ 片段,gateway 生成对象 key 时必须使用同一规则。 func roomMediaObjectPrefix(rawAppCode string, actorUserID int64, roomID string, purpose string) string { directory := "images" if purpose == roomMediaPurposeBackground { directory = "backgrounds" } return path.Join("room-media", appcode.Normalize(rawAppCode), directory, fmt.Sprintf("%d", actorUserID), strings.TrimSpace(roomID)) + "/" } func roomMediaExpectedObjectKey(rawAppCode string, actorUserID int64, roomID string, purpose string, commandID string, media state.RoomMedia) string { commandHash := sha256.Sum256([]byte(strings.TrimSpace(commandID))) extension := media.Format if extension == "jpeg" { extension = "jpg" } return path.Join( roomMediaObjectPrefix(rawAppCode, actorUserID, roomID, purpose), hex.EncodeToString(commandHash[:8])+"-"+strings.ToLower(strings.TrimSpace(media.SHA256))[:16]+"."+extension, ) } func roomMediaUploadPayloadSHA256(media state.RoomMedia) (string, error) { // URL/object_key/status 是上传生命周期字段,不属于首次解析的文件事实;其余字段使用结构体固定顺序编码。 payload, err := json.Marshal(struct { ContentType string `json:"content_type"` Format string `json:"format"` SizeBytes int64 `json:"size_bytes"` Width int32 `json:"width"` Height int32 `json:"height"` Animated bool `json:"animated"` FrameCount int32 `json:"frame_count"` DurationMS int64 `json:"duration_ms"` PosterURL string `json:"poster_url"` ThumbnailURL string `json:"thumbnail_url"` SHA256 string `json:"sha256"` }{ ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount, DurationMS: media.DurationMS, PosterURL: media.PosterURL, ThumbnailURL: media.ThumbnailURL, SHA256: media.SHA256, }) if err != nil { return "", err } sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:]), nil } func sameRoomMediaProperties(left state.RoomMedia, right state.RoomMedia) bool { left.URL, left.ObjectKey, left.Status = "", "", "" right.URL, right.ObjectKey, right.Status = "", "", "" return left == right } func roomMediaFromProto(media *roomv1.RoomMedia) state.RoomMedia { if media == nil { return state.RoomMedia{} } return state.RoomMedia{ URL: strings.TrimSpace(media.GetUrl()), ObjectKey: strings.TrimLeft(strings.TrimSpace(media.GetObjectKey()), "/"), ContentType: strings.ToLower(strings.TrimSpace(media.GetContentType())), Format: strings.ToLower(strings.TrimSpace(media.GetFormat())), SizeBytes: media.GetSizeBytes(), Width: media.GetWidth(), Height: media.GetHeight(), Animated: media.GetAnimated(), FrameCount: media.GetFrameCount(), DurationMS: media.GetDurationMs(), PosterURL: strings.TrimSpace(media.GetPosterUrl()), ThumbnailURL: strings.TrimSpace(media.GetThumbnailUrl()), SHA256: strings.ToLower(strings.TrimSpace(media.GetSha256())), Status: strings.ToLower(strings.TrimSpace(media.GetStatus())), } } func roomMediaToProto(media state.RoomMedia) *roomv1.RoomMedia { if strings.TrimSpace(media.URL) == "" && strings.TrimSpace(media.ObjectKey) == "" { return nil } return &roomv1.RoomMedia{ Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount, DurationMs: media.DurationMS, PosterUrl: media.PosterURL, ThumbnailUrl: media.ThumbnailURL, Sha256: media.SHA256, Status: media.Status, } } func roomMediaEventSnapshot(media state.RoomMedia) *roomeventsv1.RoomMediaSnapshot { return &roomeventsv1.RoomMediaSnapshot{ Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes, Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount, DurationMs: media.DurationMS, PosterUrl: media.PosterURL, ThumbnailUrl: media.ThumbnailURL, Sha256: media.SHA256, } } func roomBackgroundState(background RoomBackgroundImage) state.RoomBackground { return state.RoomBackground{ BackgroundID: background.BackgroundID, RoomID: background.RoomID, CreatedByUserID: background.CreatedByUserID, CreatedAtMS: background.CreatedAtMS, UpdatedAtMS: background.UpdatedAtMS, CommandID: background.CommandID, Media: background.Media, } } func roomBackgroundToProto(background RoomBackgroundImage) *roomv1.RoomBackgroundImage { return &roomv1.RoomBackgroundImage{ BackgroundId: background.BackgroundID, RoomId: background.RoomID, ImageUrl: background.ImageURL, CreatedByUserId: background.CreatedByUserID, CreatedAtMs: background.CreatedAtMS, UpdatedAtMs: background.UpdatedAtMS, Media: roomMediaToProto(background.Media), CommandId: background.CommandID, } } func roomBackgroundsToProto(backgrounds []RoomBackgroundImage) []*roomv1.RoomBackgroundImage { items := make([]*roomv1.RoomBackgroundImage, 0, len(backgrounds)) for _, background := range backgrounds { items = append(items, roomBackgroundToProto(background)) } return items } func legacyRoomBackgroundCommandID(rawAppCode string, roomID string, actorUserID int64, imageURL string) string { // legacy 请求没有 command_id;用租户、房间、房主和 URL 生成稳定短键,重试不会重复建素材, // 同时不把 URL 明文放进 VARCHAR(128) 或日志索引。 sum := sha256.Sum256([]byte(strings.Join([]string{ appcode.Normalize(rawAppCode), strings.TrimSpace(roomID), fmt.Sprintf("%d", actorUserID), strings.TrimSpace(imageURL), }, "\x00"))) return "legacy_bg_" + hex.EncodeToString(sum[:16]) }