// Package mysql 实现 room-service 的 MySQL 持久化边界。 package mysql import ( "context" "database/sql" "errors" "strings" "time" "hyapp/pkg/appcode" roomservice "hyapp/services/room-service/internal/room/service" ) // SaveRoomMeta 插入房间元数据。 func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error { // rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。 appCode := normalizedRecordAppCode(ctx, meta.AppCode) nowMS := time.Now().UTC().UnixMilli() _, err := r.db.ExecContext(ctx, `INSERT INTO rooms (app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appCode, meta.RoomID, meta.RoomShortID, meta.OwnerUserID, meta.SeatCount, meta.Mode, meta.Status, meta.RoomPasswordHash, meta.VisibleRegionID, nowMS, nowMS, ) return mapRoomMetaDuplicateError(err) } // GetRoomMeta 查询房间元数据。 func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) { // meta 是无内存 Cell 恢复的入口;不存在时上层返回 room not found。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE app_code = ? AND room_id = ?`, appcode.FromContext(ctx), roomID, ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { // 未创建房间不是数据库错误,用 exists=false 表达。 return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // GetRoomMetaByGlobalID 从全局 room_id 解析房间所属 App。 // 该查询只允许可信内部回调使用;uk_rooms_room_id 同时保证查询命中索引并阻止跨 App 歧义。 func (r *Repository) GetRoomMetaByGlobalID(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE room_id = ? LIMIT 1`, strings.TrimSpace(roomID), ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // GetRoomMetaByShortID 通过展示短房号解析内部房间 ID。 func (r *Repository) GetRoomMetaByShortID(ctx context.Context, roomShortID string) (roomservice.RoomMeta, bool, error) { // rooms.uk_rooms_short_id 保证同 app 下短房号唯一;这里只做解析,不改变 GetRoomMeta 的精确 room_id 语义。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE app_code = ? AND room_short_id = ? LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(roomShortID), ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // GetRoomMetaByOwner 查询用户已经创建的房间。 func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) { // rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。 row := r.db.QueryRowContext(ctx, `SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id FROM rooms WHERE app_code = ? AND owner_user_id = ? LIMIT 1`, appcode.FromContext(ctx), ownerUserID, ) var meta roomservice.RoomMeta if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomMeta{}, false, nil } return roomservice.RoomMeta{}, false, err } return meta, true, nil } // SaveRoomBackground 保存房间背景图素材。command_id 是素材保存幂等键;URL 唯一键只负责去重, // 不能把相同 command_id 的另一份媒体悄悄折叠成旧记录。 func (r *Repository) SaveRoomBackground(ctx context.Context, background roomservice.RoomBackgroundImage) (roomservice.RoomBackgroundImage, error) { appCode := normalizedRecordAppCode(ctx, background.AppCode) roomID := strings.TrimSpace(background.RoomID) imageURL := strings.TrimSpace(background.ImageURL) nowMS := background.CreatedAtMS if nowMS <= 0 { nowMS = time.Now().UTC().UnixMilli() } if existing, exists, err := r.getRoomBackgroundByCommand(ctx, appCode, roomID, background.CommandID); err != nil { return roomservice.RoomBackgroundImage{}, err } else if exists { if !sameRoomBackgroundMedia(existing, background) { return roomservice.RoomBackgroundImage{}, roomservice.ErrRoomBackgroundCommandConflict } return existing, nil } if strings.TrimSpace(background.Media.ObjectKey) == "" { // legacy_timed 以 URL 作为历史幂等键;迁移前的行没有 command_id/media,重复保存必须继续返回原素材。 if existing, exists, err := r.getRoomBackgroundByURL(ctx, appCode, roomID, imageURL); err != nil { return roomservice.RoomBackgroundImage{}, err } else if exists { return existing, nil } } _, err := r.db.ExecContext(ctx, ` INSERT INTO room_background_images ( app_code, room_id, image_url, command_id, object_key, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, poster_url, thumbnail_url, sha256, media_status, created_by_user_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE background_id = LAST_INSERT_ID(background_id) `, appCode, roomID, imageURL, strings.TrimSpace(background.CommandID), background.Media.ObjectKey, background.Media.ContentType, background.Media.Format, background.Media.SizeBytes, background.Media.Width, background.Media.Height, background.Media.Animated, background.Media.FrameCount, background.Media.DurationMS, background.Media.PosterURL, background.Media.ThumbnailURL, background.Media.SHA256, background.Media.Status, background.CreatedByUserID, nowMS, nowMS) if err != nil { return roomservice.RoomBackgroundImage{}, err } saved, exists, err := r.getRoomBackgroundByCommand(ctx, appCode, roomID, background.CommandID) if err != nil { return roomservice.RoomBackgroundImage{}, err } if !exists || !sameRoomBackgroundMedia(saved, background) { return roomservice.RoomBackgroundImage{}, roomservice.ErrRoomBackgroundCommandConflict } return saved, nil } // GetRoomBackground 精确读取一个房间背景图素材。 func (r *Repository) GetRoomBackground(ctx context.Context, roomID string, backgroundID int64) (roomservice.RoomBackgroundImage, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT `+roomBackgroundSelectColumns+` FROM room_background_images WHERE app_code = ? AND room_id = ? AND background_id = ? LIMIT 1 `, appcode.FromContext(ctx), strings.TrimSpace(roomID), backgroundID) background, err := scanRoomBackground(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomBackgroundImage{}, false, nil } return roomservice.RoomBackgroundImage{}, false, err } return background, true, nil } // GetRoomBackgroundByCommand 供 service 在任何可变权限/房态检查之前重放已保存素材。 // command_id 唯一索引保证最多一行;app_code 继续从已验签 RequestMeta context 读取,避免跨租户重放。 func (r *Repository) GetRoomBackgroundByCommand(ctx context.Context, roomID string, commandID string) (roomservice.RoomBackgroundImage, bool, error) { return r.getRoomBackgroundByCommand(ctx, appcode.FromContext(ctx), strings.TrimSpace(roomID), strings.TrimSpace(commandID)) } // ListRoomBackgrounds 返回某个房间保存过的背景图素材,按最近保存优先。 func (r *Repository) ListRoomBackgrounds(ctx context.Context, roomID string) ([]roomservice.RoomBackgroundImage, error) { rows, err := r.db.QueryContext(ctx, ` SELECT `+roomBackgroundSelectColumns+` FROM room_background_images WHERE app_code = ? AND room_id = ? ORDER BY created_at_ms DESC, background_id DESC `, appcode.FromContext(ctx), strings.TrimSpace(roomID)) if err != nil { return nil, err } defer rows.Close() backgrounds := []roomservice.RoomBackgroundImage{} for rows.Next() { background, err := scanRoomBackground(rows) if err != nil { return nil, err } backgrounds = append(backgrounds, background) } return backgrounds, rows.Err() } func scanRoomBackground(scanner interface{ Scan(dest ...any) error }) (roomservice.RoomBackgroundImage, error) { var background roomservice.RoomBackgroundImage err := scanner.Scan( &background.AppCode, &background.BackgroundID, &background.RoomID, &background.ImageURL, &background.CommandID, &background.Media.ObjectKey, &background.Media.ContentType, &background.Media.Format, &background.Media.SizeBytes, &background.Media.Width, &background.Media.Height, &background.Media.Animated, &background.Media.FrameCount, &background.Media.DurationMS, &background.Media.PosterURL, &background.Media.ThumbnailURL, &background.Media.SHA256, &background.Media.Status, &background.CreatedByUserID, &background.CreatedAtMS, &background.UpdatedAtMS, ) return background, err } const roomBackgroundSelectColumns = `app_code, background_id, room_id, image_url, COALESCE(command_id, ''), object_key, content_type, media_format, size_bytes, width, height, animated, frame_count, duration_ms, poster_url, thumbnail_url, sha256, media_status, created_by_user_id, created_at_ms, updated_at_ms` func (r *Repository) getRoomBackgroundByCommand(ctx context.Context, appCode string, roomID string, commandID string) (roomservice.RoomBackgroundImage, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT `+roomBackgroundSelectColumns+` FROM room_background_images WHERE app_code = ? AND room_id = ? AND command_id = ? LIMIT 1`, appCode, roomID, strings.TrimSpace(commandID)) background, err := scanRoomBackground(row) if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomBackgroundImage{}, false, nil } return background, err == nil, err } func (r *Repository) getRoomBackgroundByURL(ctx context.Context, appCode string, roomID string, imageURL string) (roomservice.RoomBackgroundImage, bool, error) { row := r.db.QueryRowContext(ctx, `SELECT `+roomBackgroundSelectColumns+` FROM room_background_images WHERE app_code = ? AND room_id = ? AND image_url = ? LIMIT 1`, appCode, roomID, strings.TrimSpace(imageURL)) background, err := scanRoomBackground(row) if errors.Is(err, sql.ErrNoRows) { return roomservice.RoomBackgroundImage{}, false, nil } return background, err == nil, err } func sameRoomBackgroundMedia(left roomservice.RoomBackgroundImage, right roomservice.RoomBackgroundImage) bool { return strings.TrimSpace(left.ImageURL) == strings.TrimSpace(right.ImageURL) && left.CreatedByUserID == right.CreatedByUserID && left.Media == right.Media }