249 lines
11 KiB
Go
249 lines
11 KiB
Go
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])
|
||
}
|