2026-07-06 12:43:40 +08:00

465 lines
15 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"
"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
}