2026-07-07 19:09:20 +08:00

528 lines
17 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 mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
)
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
// 幂等检查只看 command log只有成功提交过的命令才算 seen。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
FROM room_command_log
WHERE app_code = ? AND room_id = ? AND command_id = ?
LIMIT 1`,
appcode.FromContext(ctx),
roomID,
commandID,
)
var record roomservice.CommandRecord
if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有命令日志表示本命令尚未成功提交。
return roomservice.CommandRecord{}, false, nil
}
return roomservice.CommandRecord{}, false, err
}
return record, true, nil
}
// SaveCommand 追加一条成功命令日志。
func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error {
// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
appCode := normalizedRecordAppCode(ctx, record.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE command_id = command_id`,
appCode,
record.RoomID,
record.RoomVersion,
record.CommandID,
record.CommandType,
record.OwnerNodeID,
record.LeaseToken,
record.Payload,
record.Replayable,
record.CreatedAtMS,
)
return err
}
// SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。
func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error {
appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := r.lockRoomMutation(ctx, tx, appCode, commit.Command.RoomID); err != nil {
_ = tx.Rollback()
return err
}
if commit.Command.Replayable && enforceUniqueReplayableRoomVersion(commit.Command.CommandType) {
var existingCommandID string
err := tx.QueryRowContext(ctx,
`SELECT command_id
FROM room_command_log FORCE INDEX (idx_room_command_replay)
WHERE app_code = ? AND room_id = ? AND room_version = ? AND replayable = TRUE AND command_id <> ?
LIMIT 1
FOR UPDATE`,
appCode,
commit.Command.RoomID,
commit.Command.RoomVersion,
commit.Command.CommandID,
).Scan(&existingCommandID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return err
}
if existingCommandID != "" {
_ = tx.Rollback()
// 同房间同版本只能有一个可回放状态变更;这是恢复链路的最后一道持久化防线。
return fmt.Errorf("room command version conflict: room_id=%s room_version=%d existing_command_id=%s new_command_id=%s", commit.Command.RoomID, commit.Command.RoomVersion, existingCommandID, commit.Command.CommandID)
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE command_id = command_id`,
appCode,
commit.Command.RoomID,
commit.Command.RoomVersion,
commit.Command.CommandID,
commit.Command.CommandType,
commit.Command.OwnerNodeID,
commit.Command.LeaseToken,
commit.Command.Payload,
commit.Command.Replayable,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
if err := r.insertRoomOutboxRecords(ctx, tx, commit.OutboxRecords); err != nil {
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
_ = tx.Rollback()
return err
}
if commit.DeleteRoom {
if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
if strings.TrimSpace(commit.RoomStatus) != "" {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
commit.RoomStatus,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
commit.RoomStatus,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomSeatCount != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET seat_count = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomSeatCount,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET seat_count = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomSeatCount,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomPasswordHash != nil {
locked := strings.TrimSpace(*commit.RoomPasswordHash) != ""
if _, err := tx.ExecContext(ctx,
`UPDATE rooms
SET room_password_hash = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
*commit.RoomPasswordHash,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET locked = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
locked,
commit.Command.CreatedAtMS,
appCode,
commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.RoomMode != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms SET mode = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
*commit.RoomMode, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.VisibleRegionID != nil {
if _, err := tx.ExecContext(ctx,
`UPDATE rooms SET visible_region_id = ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`,
*commit.VisibleRegionID, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
if commit.CloseInfo != nil {
info := commit.CloseInfo
reason, adminName, closedAt := info.Reason, info.AdminName, info.ClosedAtMS
adminID := info.AdminID
if info.ClearOnOpen {
reason, adminName, closedAt, adminID = "", "", 0, 0
}
for _, table := range []string{"rooms", "room_list_entries"} {
if _, err := tx.ExecContext(ctx,
`UPDATE `+table+`
SET close_reason = ?, closed_by_admin_id = ?, closed_by_admin_name = ?, closed_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
reason, adminID, adminName, closedAt, commit.Command.CreatedAtMS, appCode, commit.Command.RoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
}
for _, stat := range commit.RoomUserGiftStats {
if stat.UserID <= 0 || stat.GiftValue <= 0 {
continue
}
statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
statRoomID := strings.TrimSpace(stat.RoomID)
if statRoomID == "" {
statRoomID = commit.Command.RoomID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_gift_stats (
app_code, room_id, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
statAppCode,
statRoomID,
stat.UserID,
stat.GiftValue,
stat.LastGiftAtMS,
commit.Command.CreatedAtMS,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
for _, stat := range commit.RoomUserContributionPeriodStats {
if stat.UserID <= 0 || stat.GiftValue <= 0 || strings.TrimSpace(stat.Period) == "" || stat.PeriodStartMS <= 0 {
continue
}
statAppCode := normalizedRecordAppCode(ctx, stat.AppCode)
statRoomID := strings.TrimSpace(stat.RoomID)
if statRoomID == "" {
statRoomID = commit.Command.RoomID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_user_contribution_period_stats (
app_code, room_id, period, period_start_ms, period_end_ms, user_id, gift_value, last_gift_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
gift_value = gift_value + VALUES(gift_value),
period_end_ms = VALUES(period_end_ms),
last_gift_at_ms = VALUES(last_gift_at_ms),
updated_at_ms = VALUES(updated_at_ms)`,
statAppCode,
statRoomID,
strings.TrimSpace(stat.Period),
stat.PeriodStartMS,
stat.PeriodEndMS,
stat.UserID,
stat.GiftValue,
stat.LastGiftAtMS,
commit.Command.CreatedAtMS,
commit.Command.CreatedAtMS,
); err != nil {
_ = tx.Rollback()
return err
}
}
if weekly := commit.RoomWeeklyContribution; weekly != nil && weekly.GiftValue > 0 {
weeklyAppCode := normalizedRecordAppCode(ctx, weekly.AppCode)
weeklyRoomID := strings.TrimSpace(weekly.RoomID)
if weeklyRoomID == "" {
weeklyRoomID = commit.Command.RoomID
}
occurredMS := weekly.OccurredMS
if occurredMS <= 0 {
occurredMS = commit.Command.CreatedAtMS
}
weekStartMS := roomContributionWeekStartMS(occurredMS)
if _, err := tx.ExecContext(ctx,
`UPDATE room_list_entries
SET weekly_contribution = CASE
WHEN contribution_week_start_ms = ? THEN weekly_contribution + ?
ELSE ?
END,
contribution_week_start_ms = ?,
updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
weekStartMS,
weekly.GiftValue,
weekly.GiftValue,
weekStartMS,
commit.Command.CreatedAtMS,
weeklyAppCode,
weeklyRoomID,
); err != nil {
_ = tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
var lockedRoomID string
err := tx.QueryRowContext(ctx,
`SELECT room_id
FROM rooms
WHERE app_code = ? AND room_id = ?
FOR UPDATE`,
appCode,
roomID,
).Scan(&lockedRoomID)
if errors.Is(err, sql.ErrNoRows) {
// CreateRoom 的测试级别 command-log 写入可能没有预置 rooms 行;生产命令在 SaveMutation 前已有 room meta。
return nil
}
if err != nil {
return err
}
return nil
}
func enforceUniqueReplayableRoomVersion(commandType string) bool {
switch strings.TrimSpace(commandType) {
case command.SendGift{}.Type():
// SendGift 是账务成功后的 additive 房间表现:热度、榜单和展示事件都可以按自增 id 顺序补偿。
// 历史机器人房间已经存在同版本礼物命令时,继续拦截会让 Cell 永久卡在旧版本,反而阻止后续快照推进。
return false
default:
// presence、麦位、房间资料和生命周期命令会改变恢复结构同版本双写必须 fail-closed。
return true
}
}
func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
// 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。
// room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。
statements := []string{
`DELETE FROM room_outbox WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_region_pins WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_follows WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_user_contribution_period_stats WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
`DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`,
`DELETE FROM rooms WHERE app_code = ? AND room_id = ?`,
}
for _, statement := range statements {
if _, err := tx.ExecContext(ctx, statement, appCode, roomID); err != nil {
return err
}
}
return nil
}
func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error {
if len(records) == 0 {
return nil
}
for _, record := range records {
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
if record.Envelope != nil {
record.Envelope.AppCode = record.AppCode
}
envelopeBytes, err := proto.Marshal(record.Envelope)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at_ms, retry_count, next_retry_at_ms, last_error, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.AppCode,
record.EventID,
record.EventType,
record.RoomID,
record.Status,
envelopeBytes,
record.CreatedAtMS,
record.RetryCount,
record.NextRetryAtMS,
nullableString(record.LastError),
record.CreatedAtMS,
); err != nil {
return err
}
}
return nil
}
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at_ms
FROM room_command_log
WHERE app_code = ? AND room_id = ? AND room_version > ?
ORDER BY room_version ASC, id ASC`,
appcode.FromContext(ctx),
roomID,
roomVersion,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]roomservice.CommandRecord, 0)
for rows.Next() {
// payload 保持原始字节,领域层根据 command_type 反序列化。
var record roomservice.CommandRecord
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAtMS); err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
// rows.Err 捕获迭代过程中发生的底层连接或扫描错误。
return nil, err
}
return records, nil
}
// SaveSnapshot 保存房间最新快照。
func (r *Repository) SaveSnapshot(ctx context.Context, snapshot roomservice.SnapshotRecord) error {
// 快照按 room_id 保留最新一条;同版本后写可能来自旧 owner 分支,不能覆盖已经存在的恢复来源。
appCode := normalizedRecordAppCode(ctx, snapshot.AppCode)
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_snapshots (app_code, room_id, room_version, payload, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
payload = IF(VALUES(room_version) > room_version, VALUES(payload), payload),
created_at_ms = IF(VALUES(room_version) > room_version, VALUES(created_at_ms), created_at_ms),
updated_at_ms = IF(VALUES(room_version) > room_version, VALUES(updated_at_ms), updated_at_ms),
room_version = IF(VALUES(room_version) > room_version, VALUES(room_version), room_version)`,
appCode,
snapshot.RoomID,
snapshot.RoomVersion,
snapshot.Payload,
snapshot.CreatedAtMS,
snapshot.CreatedAtMS,
)
return err
}
// GetLatestSnapshot 读取房间最新快照。
func (r *Repository) GetLatestSnapshot(ctx context.Context, roomID string) (roomservice.SnapshotRecord, bool, error) {
// room_snapshots 主键是 room_id因此查询结果最多一条。
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_version, payload, created_at_ms
FROM room_snapshots
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
roomID,
)
var snapshot roomservice.SnapshotRecord
if err := row.Scan(&snapshot.AppCode, &snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有快照时上层会从 RoomMeta 和 command log 从头恢复。
return roomservice.SnapshotRecord{}, false, nil
}
return roomservice.SnapshotRecord{}, false, err
}
return snapshot, true, nil
}