2026-04-25 01:18:30 +08:00

412 lines
13 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"
"time"
_ "github.com/go-sql-driver/mysql"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp/api/proto/events/room/v1"
"hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
)
// Repository 把 room-service 的权威状态持久化到 MySQL。
type Repository struct {
// db 是标准库连接池,所有 repository 方法都通过它访问 MySQL。
db *sql.DB
}
// Open 建立 MySQL 连接池,并验证数据库可用。
func Open(ctx context.Context, dsn string, maxOpenConns int, maxIdleConns int) (*Repository, error) {
// sql.Open 只校验 driver 和 DSN 形态,真实连通性由后续 PingContext 验证。
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if maxOpenConns > 0 {
// 只在配置显式给出正数时覆盖标准库默认值。
db.SetMaxOpenConns(maxOpenConns)
}
if maxIdleConns > 0 {
// 空闲连接数需要小于等于最大连接数,由配置层负责合理设置。
db.SetMaxIdleConns(maxIdleConns)
}
// 长连接定期轮换,避免云数据库或网络层回收导致长期坏连接驻留。
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
// 启动探测失败时必须关闭连接池,避免返回半可用 repository。
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
// Close 关闭底层 MySQL 连接池。
func (r *Repository) Close() error {
// App.Close 调用该方法释放所有空闲连接,并阻止后续新查询。
return r.db.Close()
}
// Ping 验证 MySQL 连接池当前仍能在调用方 deadline 内拿到可用连接。
func (r *Repository) Ping(ctx context.Context) error {
// ready 探针通过该方法确认 command log/snapshot/outbox 的持久化来源可用。
return r.db.PingContext(ctx)
}
// Migrate 创建 room-service 首版需要的 MySQL 表。
func (r *Repository) Migrate(ctx context.Context) error {
// 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。
statements := []string{
`CREATE TABLE IF NOT EXISTS rooms (
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
owner_user_id BIGINT NOT NULL,
host_user_id BIGINT NOT NULL,
seat_count INT NOT NULL,
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS room_snapshots (
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
room_version BIGINT NOT NULL,
payload LONGBLOB NOT NULL,
created_at TIMESTAMP(3) NOT NULL,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX idx_room_snapshots_version (room_id, room_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS room_command_log (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
room_id VARCHAR(64) NOT NULL,
room_version BIGINT NOT NULL,
command_id VARCHAR(128) NOT NULL,
command_type VARCHAR(64) NOT NULL,
payload LONGBLOB NOT NULL,
replayable BOOLEAN NOT NULL,
created_at TIMESTAMP(3) NOT NULL,
UNIQUE KEY uk_room_command (room_id, command_id),
KEY idx_room_command_replay (room_id, room_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS room_outbox (
event_id VARCHAR(128) NOT NULL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
room_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
envelope LONGBLOB NOT NULL,
created_at TIMESTAMP(3) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
last_error TEXT NULL,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
KEY idx_room_outbox_pending (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
for _, statement := range statements {
// 每条 DDL 独立执行,失败时返回具体 SQL 错误给启动流程。
if _, err := r.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
// SaveRoomMeta 插入房间元数据。
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
// rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。
_, err := r.db.ExecContext(ctx,
`INSERT INTO rooms (room_id, owner_user_id, host_user_id, seat_count, mode, status)
VALUES (?, ?, ?, ?, ?, ?)`,
meta.RoomID,
meta.OwnerUserID,
meta.HostUserID,
meta.SeatCount,
meta.Mode,
meta.Status,
)
return 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 room_id, owner_user_id, host_user_id, seat_count, mode, status
FROM rooms
WHERE room_id = ?`,
roomID,
)
var meta roomservice.RoomMeta
if err := row.Scan(&meta.RoomID, &meta.OwnerUserID, &meta.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 未创建房间不是数据库错误,用 exists=false 表达。
return roomservice.RoomMeta{}, false, nil
}
return roomservice.RoomMeta{}, false, err
}
return meta, true, nil
}
// HasCommand 判断命令是否已经成功落盘。
func (r *Repository) HasCommand(ctx context.Context, roomID string, commandID string) (bool, error) {
// 幂等检查只看 command log只有成功提交过的命令才算 seen。
row := r.db.QueryRowContext(ctx,
`SELECT 1
FROM room_command_log
WHERE room_id = ? AND command_id = ?
LIMIT 1`,
roomID,
commandID,
)
var marker int
if err := row.Scan(&marker); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 没有命令日志表示本命令尚未成功提交。
return false, nil
}
return false, err
}
return true, nil
}
// SaveCommand 追加一条成功命令日志。
func (r *Repository) SaveCommand(ctx context.Context, record roomservice.CommandRecord) error {
// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_command_log (room_id, room_version, command_id, command_type, payload, replayable, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE command_id = command_id`,
record.RoomID,
record.RoomVersion,
record.CommandID,
record.CommandType,
record.Payload,
record.Replayable,
record.CreatedAt,
)
return err
}
// 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 room_id, room_version, command_id, command_type, payload, replayable, created_at
FROM room_command_log
WHERE room_id = ? AND room_version > ?
ORDER BY room_version ASC, id ASC`,
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.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.Payload, &record.Replayable, &record.CreatedAt); 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 保留最新一条,版本更旧的写入不会覆盖较新的恢复来源。
_, err := r.db.ExecContext(ctx,
`INSERT INTO room_snapshots (room_id, room_version, payload, created_at)
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 = IF(VALUES(room_version) >= room_version, VALUES(created_at), created_at)`,
snapshot.RoomID,
snapshot.RoomVersion,
snapshot.Payload,
snapshot.CreatedAt,
)
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 room_id, room_version, payload, created_at
FROM room_snapshots
WHERE room_id = ?`,
roomID,
)
var snapshot roomservice.SnapshotRecord
if err := row.Scan(&snapshot.RoomID, &snapshot.RoomVersion, &snapshot.Payload, &snapshot.CreatedAt); 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
}
// SaveOutbox 追加待投递 outbox 事件。
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。
return nil
}
// 多条事件用一个事务写入,保证同一命令产生的事件不会部分入库。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, record := range records {
// envelope 是 protobuf 信封,存储层不解析具体事件 body。
envelopeBytes, err := proto.Marshal(record.Envelope)
if err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO room_outbox (event_id, event_type, room_id, status, envelope, created_at, retry_count, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE event_id = event_id`,
record.EventID,
record.EventType,
record.RoomID,
record.Status,
envelopeBytes,
record.CreatedAt,
record.RetryCount,
nullableString(record.LastError),
); err != nil {
// 任一事件失败就回滚整批,避免 GiftSent/HeatChanged/RankChanged 部分缺失。
_ = tx.Rollback()
return err
}
}
// commit 成功后事件才对 outbox worker 可见。
return tx.Commit()
}
// ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
if limit <= 0 {
// 默认批量上限避免调用方传 0 导致全表扫描。
limit = 100
}
// 按创建时间顺序扫描 pending尽量保持事件投递顺序。
rows, err := r.db.QueryContext(ctx,
`SELECT event_id, event_type, room_id, status, envelope, created_at, retry_count, COALESCE(last_error, '')
FROM room_outbox
WHERE status = ?
ORDER BY created_at ASC
LIMIT ?`,
outbox.StatusPending,
limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]outbox.Record, 0, limit)
for rows.Next() {
// 每条 outbox 记录恢复 envelope发布器只消费 protobuf 信封。
var envelopeBytes []byte
var record outbox.Record
if err := rows.Scan(&record.EventID, &record.EventType, &record.RoomID, &record.Status, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
return nil, err
}
var envelope roomeventsv1.EventEnvelope
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
// envelope 损坏说明 outbox 数据不可投递,需要暴露错误而不是跳过。
return nil, err
}
record.Envelope = &envelope
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, err
}
return records, nil
}
// MarkOutboxPublished 标记事件投递成功。
func (r *Repository) MarkOutboxPublished(ctx context.Context, eventID string) error {
// 成功后清空 last_error后续扫描不再返回该事件。
_, err := r.db.ExecContext(ctx,
`UPDATE room_outbox
SET status = ?, last_error = NULL
WHERE event_id = ?`,
outbox.StatusPublished,
eventID,
)
return err
}
// MarkOutboxFailed 标记事件投递失败并累计重试次数。
func (r *Repository) MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error {
// 失败仍保持 pendingworker 下一轮会继续扫描到该事件。
_, err := r.db.ExecContext(ctx,
`UPDATE room_outbox
SET status = ?, retry_count = retry_count + 1, last_error = ?
WHERE event_id = ?`,
outbox.StatusPending,
lastErr,
eventID,
)
return err
}
func nullableString(value string) sql.NullString {
// 空错误写 NULL便于 SQL 查询区分没有错误和空字符串。
return sql.NullString{
String: value,
Valid: value != "",
}
}