2026-07-07 18:28:50 +08:00

277 lines
9.2 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 (
"context"
"log/slog"
"slices"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/room/cell"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
"hyapp/services/room-service/internal/router"
)
type loadedRoomRef struct {
// AppCode 是运行态 Cell 所属租户,后台 worker 必须用它恢复 ctx。
AppCode string
// RoomID 是已装载 Room Cell 的业务房间 ID。
RoomID string
}
func (s *Service) installCell(ctx context.Context, roomID string, roomCell *cell.RoomCell, leaseToken string) {
s.mu.Lock()
defer s.mu.Unlock()
// 注册表只保存当前进程内已装载 CellleaseToken 决定它能否继续承载写路径。
s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)] = &loadedCell{roomCell: roomCell, leaseToken: leaseToken}
}
func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell {
s.mu.Lock()
defer s.mu.Unlock()
// 返回指针后由 RoomCell 自己的 mailbox 保证状态串行化。
loaded := s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)]
if loaded == nil {
return nil
}
return loaded.roomCell
}
func (s *Service) loadCellForLease(ctx context.Context, roomID string, lease router.Lease) *cell.RoomCell {
key := runtimeRoomKey(appcode.FromContext(ctx), roomID)
s.mu.Lock()
defer s.mu.Unlock()
loaded := s.cells[key]
if loaded == nil {
return nil
}
if loaded.leaseToken == "" || loaded.leaseToken != lease.LeaseToken {
// lease token 变化代表当前节点经历过执行权断档;旧 Cell 可能落后于新 owner 已提交的 command log必须丢弃后走恢复。
delete(s.cells, key)
return nil
}
return loaded.roomCell
}
func (s *Service) loadCellForCurrentOwner(ctx context.Context, roomID string) (*cell.RoomCell, error) {
if s.directory == nil {
// 测试中的极简 Service 可能没有装配 directory生产 HealthCheck 会拒绝这种配置。
return s.loadCell(ctx, roomID), nil
}
lease, exists, err := s.directory.Lookup(ctx, runtimeRoomKeyFromContext(ctx, roomID))
if err != nil {
return nil, err
}
if !exists || lease.NodeID != s.nodeID || !lease.ValidAt(s.clock.Now()) {
// 只读路径不能主动接管 lease没有当前 owner 身份时必须走持久化恢复,避免旧 Cell 放行过期状态。
return nil, nil
}
return s.loadCellForLease(ctx, roomID, lease), nil
}
func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) {
if s == nil {
return
}
app := appcode.FromContext(ctx)
key := runtimeRoomKey(app, roomID)
s.mu.Lock()
delete(s.cells, key)
s.mu.Unlock()
if s.directory != nil {
if _, err := s.directory.ReleaseOwner(ctx, key, s.nodeID); err != nil {
// 删除事实已在 MySQL 提交,释放 lease 失败只影响其他节点接管等待 TTL不能回滚删除结果。
logx.Warn(ctx, "room_delete_lease_release_failed",
slog.String("node_id", s.nodeID),
slog.String("app_code", app),
slog.String("room_id", roomID),
slog.String("error", err.Error()),
)
}
}
}
func (s *Service) loadedRoomRefs() []loadedRoomRef {
s.mu.Lock()
defer s.mu.Unlock()
// 复制运行态房间引用后释放锁,避免后台清理期间阻塞其他房间装载。
roomRefs := make([]loadedRoomRef, 0, len(s.cells))
for key := range s.cells {
roomRefs = append(roomRefs, runtimeRoomRef(key))
}
return roomRefs
}
func runtimeRoomKey(appCode string, roomID string) string {
appCode = appcode.Normalize(appCode)
// 使用 NUL 分隔 app_code 和 room_id避免普通字符串拼接出现歧义。
return appCode + "\x00" + roomID
}
// RuntimeRoomKey 返回 Redis owner lease 使用的运行时路由 key。
// transport 层 owner 转发必须和领域层 EnsureOwner 使用同一个 key否则会出现同一房间多 owner。
func RuntimeRoomKey(appCode string, roomID string) string {
return runtimeRoomKey(appCode, roomID)
}
func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string {
// 命令入口统一从 ctx 读取 app_code避免 service 方法忘记把租户键传给 Redis lease。
return runtimeRoomKey(appcode.FromContext(ctx), roomID)
}
func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, now time.Time) (router.Lease, bool, error) {
// 后台 worker 通过 EnsureOwner 续租当前 Cell不能在没有 lease 的情况下读取旧内存态。
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, now, s.leaseTTL)
if err != nil {
return router.Lease{}, false, err
}
if lease.NodeID != s.nodeID || !lease.ValidAt(now) {
// 后台 worker 只处理自己仍然拥有或可安全续租的 Cell。
// 如果其他节点已经接管并持有有效 lease当前节点必须跳过避免旧内存态覆盖新 owner。
return router.Lease{}, false, nil
}
if roomCell := s.loadCellForLease(ctx, roomRef.RoomID, lease); roomCell == nil {
// 旧 Cell 的 token 不连续时,后台 worker 不能靠重新抢到的 lease 继续写旧内存分支。
return router.Lease{}, false, nil
}
return lease, true, nil
}
func (s *Service) verifyLoadedRoomLease(ctx context.Context, roomRef loadedRoomRef, lease router.Lease, now time.Time) (bool, error) {
// 使用同一个 lease token 做 fencing 校验,防止 worker 批处理期间 owner 已经切走。
return s.directory.VerifyOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, lease.LeaseToken, now)
}
func runtimeRoomRef(key string) loadedRoomRef {
// runtime key 是内部格式;解析失败时保留 room_id便于旧测试数据仍能被安全跳过或排查。
for index := 0; index < len(key); index++ {
if key[index] == 0 {
return loadedRoomRef{AppCode: appcode.Normalize(key[:index]), RoomID: key[index+1:]}
}
}
return loadedRoomRef{AppCode: "", RoomID: key}
}
func firstNonZeroInt64(values ...int64) int64 {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}
func commandResult(applied bool, roomVersion int64, now time.Time) *roomv1.CommandResult {
// 所有命令响应统一带 applied、room_version 和服务端时间,便于客户端处理幂等结果。
return &roomv1.CommandResult{
Applied: applied,
RoomVersion: roomVersion,
ServerTimeMs: now.UnixMilli(),
}
}
func baseFromMeta(meta *roomv1.RequestMeta) command.Base {
if meta == nil {
// 上层 mutateRoom 会校验关键字段nil meta 在这里先收敛为空 Base。
return command.Base{}
}
// RequestMeta 是跨服务 protobuf 契约,领域层统一转换成 command.Base。
return command.Base{
AppCode: appcode.Normalize(meta.GetAppCode()),
RequestID: meta.GetRequestId(),
CommandID: meta.GetCommandId(),
ActorID: meta.GetActorUserId(),
Room: meta.GetRoomId(),
GatewayNodeID: meta.GetGatewayNodeId(),
SessionID: meta.GetSessionId(),
SentAtMS: meta.GetSentAtMs(),
}
}
func contextFromMeta(ctx context.Context, meta *roomv1.RequestMeta) context.Context {
if meta == nil {
return appcode.WithContext(ctx, "")
}
return appcode.WithContext(ctx, meta.GetAppCode())
}
func findProtoUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
if snapshot == nil {
return nil
}
// protobuf 快照用户列表规模受房间在线人数限制,线性扫描足够简单。
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
return user
}
}
return nil
}
func containsID(values []int64, target int64) bool {
// 快照里的集合以有序数组表达,守卫查询用线性查找即可。
return slices.Contains(values, target)
}
func snapshotUserBanned(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) bool {
if snapshot == nil || userID <= 0 {
return false
}
for _, ban := range snapshot.GetBanStates() {
if ban.GetUserId() != userID {
continue
}
// ban_states 是带时间边界的权威状态;过期后即使 ban_user_ids 还没刷新也不能继续拦截。
return ban.GetExpiresAtMs() == 0 || ban.GetExpiresAtMs() > nowMS
}
// 旧快照没有 ban_states 时只能按永久 ban 解释 ban_user_ids避免恢复路径放宽权限。
return containsID(snapshot.GetBanUserIds(), userID)
}
func cloneProtoRank(items []state.RankItem) []*roomv1.RankItem {
// SendGift 响应需要 protobuf 榜单项,不能直接暴露内部 state.RankItem。
result := make([]*roomv1.RankItem, 0, len(items))
for _, item := range items {
result = append(result, &roomv1.RankItem{
UserId: item.UserID,
Score: item.Score,
GiftValue: item.GiftValue,
UpdatedAtMs: item.UpdatedAtMS,
})
}
return result
}
// NewRequestMeta 是测试和 stub 组装命令元信息时的便捷函数。
func NewRequestMeta(roomID string, actorUserID int64) *roomv1.RequestMeta {
// 该函数只用于测试和 stub真实入口由 gateway 生成 request_id并透传客户端动作级 command_id。
return &roomv1.RequestMeta{
AppCode: appcode.Default,
RequestId: idgen.New("req"),
CommandId: idgen.New("cmd"),
ActorUserId: actorUserID,
RoomId: roomID,
GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"),
SentAtMs: time.Now().UnixMilli(),
}
}