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

249 lines
11 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 (
"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 pathowner 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])
}