2026-05-11 19:26:01 +08:00

233 lines
8.1 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 service
import (
"bytes"
"context"
"log/slog"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
)
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
if cmd.RoomID() == "" || cmd.ID() == "" || cmd.ActorUserID() == 0 {
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
}
if s.isDraining() {
// 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
}
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
if err != nil {
return mutationResult{}, err
}
if seen {
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outbox。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, err
}
return mutationResult{snapshot: snapshot}, nil
}
// 确保当前节点持有房间 lease并在没有内存 Cell 时完成恢复。
roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, err
}
value, err := roomCell.Do(ctx, func(current *state.RoomState, currentRank *rank.LocalRank) (any, error) {
// 在副本上执行命令,只有 command log/outbox 保存成功后才整体替换当前状态。
nextState := current.Clone()
nextRank := currentRank.Clone()
now := s.clock.Now()
result, outboxRecords, err := mutate(now, nextState, nextRank)
if err != nil {
// 校验、钱包或业务失败时不提交状态,不写 command log。
return nil, err
}
if result.snapshot == nil {
// 无特殊返回需求的命令默认返回变更后的快照。
result.snapshot = nextState.ToProto()
}
result.snapshot = snapshotWithApp(ctx, result.snapshot)
if result.snapshot.GetVersion() != current.Version {
// 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: nextState.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: commandPayloadForRecord(payload, result),
Replayable: replayable,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: outboxRecords,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
}); err != nil {
return nil, err
}
// 保存已提交 outbox 记录,出 Cell 锁后再通知 activity-service外部服务慢调用不能占住房间串行执行队列。
result.outboxRecords = outboxRecords
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
current.ReplaceWith(nextState)
currentRank.ReplaceWith(nextRank)
result.snapshot = snapshotWithApp(ctx, current.ToProto())
result.applied = true
} else {
// no-op 命令仍写入 command log保证相同 command_id 重试可命中幂等,
// 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
return nil, err
}
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: current.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: payload,
Replayable: false,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
}); err != nil {
return nil, err
}
}
return result, nil
})
if err != nil {
return mutationResult{}, err
}
result := value.(mutationResult)
if result.applied && shouldSnapshot(result.snapshot, s.snapshotEveryN) {
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
return mutationResult{}, err
}
}
if result.applied {
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
s.projectRoomListBestEffort(ctx, result.snapshot)
// 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。
s.projectRoomPresenceBestEffort(ctx, result.snapshot)
// activity-service 消费的是已经落库的 room outbox 事实失败只记日志outbox 补偿链路仍保留房间 IM 投递能力。
s.publishRoomEventsBestEffort(ctx, result.outboxRecords)
}
if result.applied && result.syncEvent != nil {
// 同步腾讯云 IM 失败不回滚房间状态outbox 后续负责补偿投递。
if err := s.syncPublisher.PublishRoomEvent(ctx, *result.syncEvent); err == nil {
// 同步投递成功后 best-effort 标记 delivered避免补偿 worker 对同一 event_id 重复补投。
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), defaultOutboxWorkerOptions().PublishTimeout)
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
cancel()
if markErr != nil {
logx.Error(ctx, "outbox_mark_delivered_failed", markErr,
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", result.syncEvent.EventID),
slog.String("room_id", result.syncEvent.RoomID),
)
}
}
}
return result, nil
}
func (s *Service) publishRoomEventsBestEffort(ctx context.Context, records []outbox.Record) {
if s == nil || s.eventPublisher == nil || len(records) == 0 {
return
}
for _, record := range records {
if record.Envelope == nil {
// 只有 protobuf envelope 能跨服务消费;没有 envelope 的历史/本地事件仍只参与原 outbox 投递。
continue
}
if err := s.eventPublisher.PublishOutboxEvent(ctx, record.Envelope); err != nil {
logx.Warn(ctx, "room_event_consumer_publish_failed",
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.String("error", err.Error()),
)
}
}
}
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
payload, err := command.Serialize(cmd)
if err != nil {
return nil, false, err
}
record, exists, err := s.repository.GetCommand(ctx, cmd.RoomID(), cmd.ID())
if err != nil {
return nil, false, err
}
if !exists {
return payload, false, nil
}
currentIDPayload, err := command.IdempotencyPayload(payload)
if err != nil {
return nil, true, err
}
storedIDPayload, err := command.IdempotencyPayload(record.Payload)
if err != nil {
return nil, true, err
}
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict")
}
return payload, true, nil
}
func commandPayloadForRecord(defaultPayload []byte, result mutationResult) []byte {
if len(result.commandPayload) > 0 {
return result.commandPayload
}
return defaultPayload
}
func (s *Service) ensureLeaseStillOwned(ctx context.Context, roomID string, lease router.Lease) error {
ok, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, lease.LeaseToken, s.clock.Now())
if err != nil {
return err
}
if !ok {
return xerr.New(xerr.Unavailable, "room lease is no longer owned by this node")
}
return nil
}