465 lines
15 KiB
Go
465 lines
15 KiB
Go
// Package mysql 实现 room-service 的 MySQL 持久化边界。
|
||
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"strings"
|
||
|
||
"google.golang.org/protobuf/proto"
|
||
"hyapp/pkg/appcode"
|
||
"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 := 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) 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 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。
|
||
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
|
||
room_version = IF(VALUES(room_version) >= room_version, VALUES(room_version), room_version),
|
||
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)`,
|
||
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
|
||
}
|