277 lines
9.2 KiB
Go
277 lines
9.2 KiB
Go
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()
|
||
|
||
// 注册表只保存当前进程内已装载 Cell;leaseToken 决定它能否继续承载写路径。
|
||
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(),
|
||
}
|
||
}
|