package mysql import ( "context" "database/sql" "errors" "strings" "time" "hyapp/pkg/appcode" roomservice "hyapp/services/room-service/internal/room/service" ) // GetRoomMediaUploadRegistration 按上传 command_id 的主键读取首次授权事实。 // 该查询只命中 PRIMARY KEY,不扫描媒体表,可安全放在每次专用上传的权限检查之前。 func (r *Repository) GetRoomMediaUploadRegistration(ctx context.Context, roomID string, commandID string) (roomservice.RoomMediaUploadRegistration, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256, expected_object_key, object_url, status, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, sha256, authorized_room_version, created_at_ms, updated_at_ms FROM room_media_upload_registrations WHERE app_code = ? AND room_id = ? AND command_id = ? LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(roomID), strings.TrimSpace(commandID)) registration, err := scanRoomMediaUploadRegistration(row) if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMediaUploadRegistration{}, false, nil } return registration, err == nil, err } // GetActiveRoomMediaUploadByObjectKey 是 Save/Send 接受媒体事实前的权威反查。 // expected_object_key 有唯一索引,查询不会按 URL 或 JSON 字段扫描。 func (r *Repository) GetActiveRoomMediaUploadByObjectKey(ctx context.Context, objectKey string) (roomservice.RoomMediaUploadRegistration, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256, expected_object_key, object_url, status, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, sha256, authorized_room_version, created_at_ms, updated_at_ms FROM room_media_upload_registrations WHERE app_code = ? AND expected_object_key = ? AND status = 'active' LIMIT 1`, appcode.FromContext(ctx), strings.TrimLeft(strings.TrimSpace(objectKey), "/")) registration, err := scanRoomMediaUploadRegistration(row) if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMediaUploadRegistration{}, false, nil } return registration, err == nil, err } // RegisterRoomMediaUpload 在首次授权通过后原子占用 command_id。 // 并发重复通过唯一主键收敛到首行,再逐字段比较;不同文件、用户或用途不能被 ON DUPLICATE 静默吞掉。 func (r *Repository) RegisterRoomMediaUpload(ctx context.Context, registration roomservice.RoomMediaUploadRegistration) (roomservice.RoomMediaUploadRegistration, error) { registration.AppCode = appcode.FromContext(ctx) registration.RoomID = strings.TrimSpace(registration.RoomID) registration.CommandID = strings.TrimSpace(registration.CommandID) registration.Purpose = strings.TrimSpace(registration.Purpose) registration.MediaPayloadSHA256 = strings.ToLower(strings.TrimSpace(registration.MediaPayloadSHA256)) registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/") registration.ObjectURL = "" registration.Status = "authorized" if registration.CreatedAtMS <= 0 { registration.CreatedAtMS = time.Now().UTC().UnixMilli() } if registration.UpdatedAtMS <= 0 { registration.UpdatedAtMS = registration.CreatedAtMS } _, err := r.db.ExecContext(ctx, ` INSERT INTO room_media_upload_registrations ( app_code, room_id, command_id, actor_user_id, purpose, media_payload_sha256, expected_object_key, object_url, status, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, sha256, authorized_room_version, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, '', 'authorized', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE command_id = room_media_upload_registrations.command_id`, registration.AppCode, registration.RoomID, registration.CommandID, registration.ActorUserID, registration.Purpose, registration.MediaPayloadSHA256, registration.ExpectedObjectKey, registration.Media.ContentType, registration.Media.Format, registration.Media.SizeBytes, registration.Media.Width, registration.Media.Height, registration.Media.Animated, registration.Media.FrameCount, registration.Media.DurationMS, registration.Media.SHA256, registration.AuthorizedVersion, registration.CreatedAtMS, registration.UpdatedAtMS) if err != nil { return roomservice.RoomMediaUploadRegistration{}, err } saved, exists, err := r.GetRoomMediaUploadRegistration(ctx, registration.RoomID, registration.CommandID) if err != nil { return roomservice.RoomMediaUploadRegistration{}, err } if !exists || !sameRoomMediaUploadRegistration(saved, registration) { return roomservice.RoomMediaUploadRegistration{}, roomservice.ErrRoomMediaUploadCommandConflict } return saved, nil } // CompleteRoomMediaUpload 只把首次授权的 expected_object_key 推进为 active。 // URL 和完整媒体事实必须先由 service 与授权记录逐字段匹配;并发 complete 仍以首个 URL 为准。 func (r *Repository) CompleteRoomMediaUpload(ctx context.Context, registration roomservice.RoomMediaUploadRegistration) (roomservice.RoomMediaUploadRegistration, error) { registration.AppCode = appcode.FromContext(ctx) registration.RoomID = strings.TrimSpace(registration.RoomID) registration.CommandID = strings.TrimSpace(registration.CommandID) registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/") registration.ObjectURL = strings.TrimSpace(registration.ObjectURL) registration.Status = "active" if registration.UpdatedAtMS <= 0 { registration.UpdatedAtMS = time.Now().UTC().UnixMilli() } _, err := r.db.ExecContext(ctx, ` UPDATE room_media_upload_registrations SET object_url = ?, status = 'active', updated_at_ms = ? WHERE app_code = ? AND room_id = ? AND command_id = ? AND status = 'authorized'`, registration.ObjectURL, registration.UpdatedAtMS, registration.AppCode, registration.RoomID, registration.CommandID) if err != nil { return roomservice.RoomMediaUploadRegistration{}, err } saved, exists, err := r.GetRoomMediaUploadRegistration(ctx, registration.RoomID, registration.CommandID) if err != nil { return roomservice.RoomMediaUploadRegistration{}, err } if !exists || !sameRoomMediaUploadRegistration(saved, registration) || saved.Status != "active" || strings.TrimSpace(saved.ObjectURL) != registration.ObjectURL { return roomservice.RoomMediaUploadRegistration{}, roomservice.ErrRoomMediaUploadCommandConflict } return saved, nil } func scanRoomMediaUploadRegistration(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomMediaUploadRegistration, error) { var registration roomservice.RoomMediaUploadRegistration err := scanner.Scan( ®istration.AppCode, ®istration.RoomID, ®istration.CommandID, ®istration.ActorUserID, ®istration.Purpose, ®istration.MediaPayloadSHA256, ®istration.ExpectedObjectKey, ®istration.ObjectURL, ®istration.Status, ®istration.Media.ContentType, ®istration.Media.Format, ®istration.Media.SizeBytes, ®istration.Media.Width, ®istration.Media.Height, ®istration.Media.Animated, ®istration.Media.FrameCount, ®istration.Media.DurationMS, ®istration.Media.SHA256, ®istration.AuthorizedVersion, ®istration.CreatedAtMS, ®istration.UpdatedAtMS, ) if err == nil { registration.Media.ObjectKey = registration.ExpectedObjectKey registration.Media.URL = registration.ObjectURL if registration.Status == "active" { registration.Media.Status = "active" } } return registration, err } func sameRoomMediaUploadRegistration(left roomservice.RoomMediaUploadRegistration, right roomservice.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), "/") }