chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -2,6 +2,7 @@ package rpc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -14,6 +15,25 @@ import (
|
|||
const userOnlineTTL = 5 * time.Minute
|
||||
const presenceDialogFanoutCandidateLimit = 512
|
||||
|
||||
// onlineNotifyRefreshInterval 是「每 RPC 上线广播」的去重间隔:同一 session 在此间隔内的在线续期
|
||||
// 不再向对端重复广播在线态。TDesktop 的在线 TTL 为 5min,客户端 account.updateStatus 周期为分钟级;
|
||||
// 普通 RPC 只需刷新内存水位,presence fan-out 以分钟级节流即可保持 UI 新鲜。
|
||||
const onlineNotifyRefreshInterval = 2 * time.Minute
|
||||
|
||||
type presencePersistMode uint8
|
||||
|
||||
const (
|
||||
presencePersistSync presencePersistMode = iota
|
||||
presencePersistAsync
|
||||
)
|
||||
|
||||
// offlineAnnounceGrace 是最后一个连接断开后广播 offline 的去抖宽限。
|
||||
// 移动端重连/换 session 都是「先断旧再建新」,瞬时无连接会对全部好友广播一轮
|
||||
// offline→online 抖动;宽限期内重新上线则跳过广播。被动查询不受去抖影响:
|
||||
// presence 条目断连即清,查询回落到持久化 last_seen,语义一致。
|
||||
// var 而非 const:测试需要缩短等待。
|
||||
var offlineAnnounceGrace = 8 * time.Second
|
||||
|
||||
type presenceSessionKey struct {
|
||||
rawAuthKeyID [8]byte
|
||||
sessionID int64
|
||||
|
|
@ -28,22 +48,66 @@ type presenceTracker struct {
|
|||
mu sync.RWMutex
|
||||
bySession map[presenceSessionKey]presenceSessionState
|
||||
byUser map[int64]map[presenceSessionKey]domain.UserStatus
|
||||
// offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它,
|
||||
// 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。
|
||||
offlineTimers map[int64]*time.Timer
|
||||
}
|
||||
|
||||
func newPresenceTracker() *presenceTracker {
|
||||
return &presenceTracker{
|
||||
bySession: make(map[presenceSessionKey]presenceSessionState),
|
||||
byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus),
|
||||
bySession: make(map[presenceSessionKey]presenceSessionState),
|
||||
byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus),
|
||||
offlineTimers: make(map[int64]*time.Timer),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) {
|
||||
// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己
|
||||
// 从 map 移除再执行 fire(fire 内部会查在线态做最终去抖),全程不持 p.mu 调用 fire。
|
||||
func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) {
|
||||
if p == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if p.offlineTimers == nil {
|
||||
p.offlineTimers = make(map[int64]*time.Timer)
|
||||
}
|
||||
if old := p.offlineTimers[userID]; old != nil {
|
||||
old.Stop()
|
||||
}
|
||||
p.offlineTimers[userID] = time.AfterFunc(d, func() {
|
||||
p.mu.Lock()
|
||||
delete(p.offlineTimers, userID)
|
||||
p.mu.Unlock()
|
||||
fire()
|
||||
})
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// cancelOfflineTimer 取消某 user 挂起的 offline 广播去抖定时器(重新上线时调用)。
|
||||
func (p *presenceTracker) cancelOfflineTimer(userID int64) {
|
||||
if p == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
if t := p.offlineTimers[userID]; t != nil {
|
||||
t.Stop()
|
||||
delete(p.offlineTimers, userID)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// setSessionStatus 记录 session 的在线态,并返回是否需要向对端「广播在线」(notify)。
|
||||
// notify 仅在「本 session 此前已在线、本次仍是在线续期、且距上次广播未超 onlineNotifyRefreshInterval」
|
||||
// 时为 false——即每 RPC 的重复在线续期被去重。首次上线/离线/转在线/超刷新间隔一律 true。
|
||||
// 整个判定在 p.mu 内原子完成:开群洪峰下并发首帧只有第一个返回 true,其余看到刚写入的新鲜态返回 false。
|
||||
func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) bool {
|
||||
if p == nil || userID == 0 {
|
||||
return true
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if old, ok := p.bySession[key]; ok {
|
||||
old, hadOld := p.bySession[key]
|
||||
if hadOld {
|
||||
p.removeSessionLocked(key, old.userID)
|
||||
}
|
||||
p.bySession[key] = presenceSessionState{userID: userID, status: status}
|
||||
|
|
@ -53,6 +117,12 @@ func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64,
|
|||
p.byUser[userID] = sessions
|
||||
}
|
||||
sessions[key] = status
|
||||
if hadOld && old.userID == userID &&
|
||||
old.status.Kind == domain.UserStatusOnline && status.Kind == domain.UserStatusOnline &&
|
||||
old.status.Expires-status.WasOnline > int((userOnlineTTL-onlineNotifyRefreshInterval)/time.Second) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *presenceTracker) clearSession(key presenceSessionKey) {
|
||||
|
|
@ -75,6 +145,32 @@ func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int
|
|||
}
|
||||
}
|
||||
|
||||
// expireOnline 把已过期的 online 条目原地降级为 offline,返回受影响用户的去重列表。
|
||||
func (p *presenceTracker) expireOnline(now int) []int64 {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
var users []int64
|
||||
seen := map[int64]struct{}{}
|
||||
for key, st := range p.bySession {
|
||||
if st.status.Kind != domain.UserStatusOnline || st.status.Expires > now {
|
||||
continue
|
||||
}
|
||||
offline := normalizePresenceStatus(st.status, now)
|
||||
p.bySession[key] = presenceSessionState{userID: st.userID, status: offline}
|
||||
if sessions := p.byUser[st.userID]; sessions != nil {
|
||||
sessions[key] = offline
|
||||
}
|
||||
if _, dup := seen[st.userID]; !dup {
|
||||
seen[st.userID] = struct{}{}
|
||||
users = append(users, st.userID)
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) {
|
||||
if p == nil || userID == 0 {
|
||||
return domain.UserStatus{}, false
|
||||
|
|
@ -128,7 +224,7 @@ func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatu
|
|||
return status
|
||||
}
|
||||
|
||||
func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus {
|
||||
func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool, persistMode presencePersistMode) (domain.UserStatus, bool) {
|
||||
now := int(r.clock.Now().Unix())
|
||||
status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now}
|
||||
if !offline {
|
||||
|
|
@ -137,21 +233,68 @@ func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offli
|
|||
Expires: now + int(userOnlineTTL/time.Second),
|
||||
WasOnline: now,
|
||||
}
|
||||
// 重新上线:取消可能挂起的 offline 去抖广播定时器。
|
||||
r.presence.cancelOfflineTimer(userID)
|
||||
}
|
||||
notify := true
|
||||
if key, ok := presenceSessionKeyFromContext(ctx); ok {
|
||||
r.presence.setSessionStatus(key, userID, status)
|
||||
notify = r.presence.setSessionStatus(key, userID, status)
|
||||
}
|
||||
r.persistLastSeen(ctx, userID, now)
|
||||
return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now})
|
||||
// 在线续期(!offline)去抖;显式 offline 是权威写,强制落库。
|
||||
if persistMode == presencePersistAsync {
|
||||
r.persistLastSeenAsync(ctx, userID, now, !offline)
|
||||
} else {
|
||||
r.persistLastSeen(ctx, userID, now, !offline)
|
||||
}
|
||||
return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}), notify
|
||||
}
|
||||
|
||||
func (r *Router) announceSessionOnline(ctx context.Context, userID int64) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
status := r.setPresenceFromContext(ctx, userID, false)
|
||||
r.pushUserStatus(ctx, userID, status)
|
||||
r.pushOnlinePeerStatusesToCurrentSession(ctx, userID)
|
||||
// bot 不参与 presence:官方 bot 无 status,从不产生 updateUserStatus,也不写
|
||||
// last_seen。bot 登录(importBotAuthorization)经此路径,必须整体短路——否则
|
||||
// 与 bot 有私聊的用户会收到协议中不存在的 bot 在线/离线广播,bot 也不登记
|
||||
// presence 条目,sweeper 因此天然不会遇到 bot。
|
||||
if r.userIsBot(ctx, userID) {
|
||||
return
|
||||
}
|
||||
status, notify := r.setPresenceFromContext(ctx, userID, false, presencePersistAsync)
|
||||
if !notify {
|
||||
// 本 session 已在线且在刷新间隔内:跳过「向对端广播在线 + 拉对端在线态给本 session」。
|
||||
// setPresenceFromContext 已每 RPC 续期在线水位;这里省掉开群洪峰下每 RPC 的扇出 + 推送
|
||||
// (实测每 RPC ~70ms,是去分区后开群尾延迟/CPU 的主要残余)。
|
||||
return
|
||||
}
|
||||
r.pushSessionOnlineAsync(ctx, userID, status)
|
||||
}
|
||||
|
||||
// userIsBot 报告 userID 是否为 bot 账号(presence 广播豁免用)。仅在连接生命周期
|
||||
// 事件(登录/断线/登出)调用,频率低;命中 redis UserCache。
|
||||
func (r *Router) userIsBot(ctx context.Context, userID int64) bool {
|
||||
if userID == 0 || r.deps.Users == nil {
|
||||
return false
|
||||
}
|
||||
// bot 标志不可变 → 永久 in-process 缓存,避免 announceSessionOnline 每 RPC 都发一次重投影
|
||||
// Users.ByID。仅缓存已存在的用户结果。命中后纯内存。
|
||||
if v, ok := r.botStatus.Load(userID); ok {
|
||||
return v.(bool)
|
||||
}
|
||||
// 冷启动洪峰下用 singleflight 合并并发首查:~50 个并发首帧只打 1 次 PG(其余共享),
|
||||
// 避免 Users.ByID 重投影 herd(曾让首帧 user_resolve 飙到 ~1.1s)。
|
||||
v, _, _ := r.authUserSF.Do("bot:"+strconv.FormatInt(userID, 10), func() (any, error) {
|
||||
if cached, ok := r.botStatus.Load(userID); ok {
|
||||
return cached.(bool), nil
|
||||
}
|
||||
u, found, err := r.deps.Users.ByID(ctx, userID, userID)
|
||||
if err != nil || !found {
|
||||
return false, nil
|
||||
}
|
||||
r.botStatus.Store(userID, u.Bot)
|
||||
return u.Bot, nil
|
||||
})
|
||||
return v.(bool)
|
||||
}
|
||||
|
||||
func (r *Router) userPresenceStatus(userID int64) domain.UserStatus {
|
||||
|
|
@ -184,11 +327,30 @@ type userLastSeenUpdater interface {
|
|||
UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error
|
||||
}
|
||||
|
||||
func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) {
|
||||
func (r *Router) reserveLastSeenPersist(userID int64, lastSeenAt int, debounce bool) (userLastSeenUpdater, bool) {
|
||||
if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil {
|
||||
return
|
||||
return nil, false
|
||||
}
|
||||
updater, ok := r.deps.Users.(userLastSeenUpdater)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
now := r.clock.Now().Unix()
|
||||
if debounce {
|
||||
if last, ok := r.lastSeenPersist.Load(userID); ok {
|
||||
if lastUnix, ok := last.(int64); ok && now-lastUnix < int64(lastSeenPersistDebounce/time.Second) {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
r.lastSeenPersist.Store(userID, now)
|
||||
return updater, true
|
||||
}
|
||||
|
||||
// persistLastSeen 落库 last_seen。debounce=true 时(在线高频续期)数秒内只落一次 DB;
|
||||
// debounce=false 是权威写(显式 offline / 断连),从不跳过。
|
||||
func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int, debounce bool) {
|
||||
updater, ok := r.reserveLastSeenPersist(userID, lastSeenAt, debounce)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
|
@ -197,25 +359,144 @@ func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt i
|
|||
}
|
||||
}
|
||||
|
||||
func (r *Router) persistLastSeenAsync(ctx context.Context, userID int64, lastSeenAt int, debounce bool) {
|
||||
updater, ok := r.reserveLastSeenPersist(userID, lastSeenAt, debounce)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bgCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second)
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log.Error("Update user last seen panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
|
||||
}
|
||||
}()
|
||||
if err := updater.UpdateLastSeen(bgCtx, userID, lastSeenAt); err != nil {
|
||||
r.log.Warn("Update user last seen failed", zap.Int64("user_id", userID), zap.Int("last_seen_at", lastSeenAt), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *Router) pushSessionOnlineAsync(ctx context.Context, userID int64, status domain.UserStatus) {
|
||||
pushCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second)
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log.Error("push session online panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
|
||||
}
|
||||
}()
|
||||
r.pushUserStatus(pushCtx, userID, status)
|
||||
r.pushOnlinePeerStatusesToCurrentSession(pushCtx, userID)
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *Router) presenceBackgroundContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
bg, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok {
|
||||
bg = WithRawAuthKeyID(bg, rawAuthKeyID)
|
||||
}
|
||||
if authKeyID, ok := AuthKeyIDFrom(ctx); ok {
|
||||
bg = WithAuthKeyID(bg, authKeyID)
|
||||
}
|
||||
if sessionID, ok := SessionIDFrom(ctx); ok {
|
||||
bg = WithSessionID(bg, sessionID)
|
||||
}
|
||||
if userID, ok := UserIDFrom(ctx); ok {
|
||||
bg = WithUserID(bg, userID)
|
||||
}
|
||||
if layer := LayerFrom(ctx); layer != 0 {
|
||||
bg = WithLayer(bg, layer)
|
||||
}
|
||||
if info, ok := ClientInfoFrom(ctx); ok {
|
||||
bg = WithClientInfo(bg, info)
|
||||
}
|
||||
if invokeWithoutUpdatesFrom(ctx) {
|
||||
bg = withInvokeWithoutUpdates(bg)
|
||||
}
|
||||
return bg, cancel
|
||||
}
|
||||
|
||||
// SessionOffline is called by mtprotoedge when an active connection disappears.
|
||||
// Business-side effects stay here: mtprotoedge only reports lifecycle facts.
|
||||
//
|
||||
// 同步路径只做内存清理(presence/clientInfo 条目);持久化与 offline 广播经
|
||||
// 去抖宽限后在后台带超时执行——连接关闭发生在 serveConn 退出路径上,同步做
|
||||
// DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。
|
||||
func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) {
|
||||
r.forgetClientSessionInfo(rawAuthKeyID, sessionID)
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID)
|
||||
key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}
|
||||
r.presence.clearSession(key)
|
||||
if !lastForUser {
|
||||
r.presence.clearSession(key)
|
||||
return
|
||||
}
|
||||
now := int(r.clock.Now().Unix())
|
||||
status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now}
|
||||
r.presence.setSessionStatus(key, userID, status)
|
||||
r.persistLastSeen(ctx, userID, now)
|
||||
disconnectedAt := int(r.clock.Now().Unix())
|
||||
// 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer),避免断连风暴堆积。
|
||||
r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() {
|
||||
r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// announceUserOfflineIfStillGone 在去抖宽限到期后持久化 last_seen 并广播 offline;
|
||||
// 宽限期内用户重新上线则整体跳过。WasOnline 取断连时刻,数据不受去抖延迟影响。
|
||||
func (r *Router) announceUserOfflineIfStillGone(rawAuthKeyID [8]byte, sessionID, userID int64, disconnectedAt int) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log.Error("announce user offline panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
|
||||
}
|
||||
}()
|
||||
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ctx = WithSessionID(WithRawAuthKeyID(ctx, rawAuthKeyID), sessionID)
|
||||
// bot 不广播 offline、不写 last_seen(与 announceSessionOnline 对称)。
|
||||
if r.userIsBot(ctx, userID) {
|
||||
return
|
||||
}
|
||||
status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: disconnectedAt}
|
||||
// 断连降级是权威 last_seen,强制落库(不去抖)。
|
||||
r.persistLastSeen(ctx, userID, disconnectedAt, false)
|
||||
r.pushUserStatus(ctx, userID, status)
|
||||
}
|
||||
|
||||
// RunPresenceSweeper 周期把过期的 online presence 降级为 offline 并广播给在线
|
||||
// 关注方。客户端正常每隔几分钟 updateStatus 续期;停止续期(后台挂起)的设备
|
||||
// 到期后好友应主动收到 offline,而不是等到下次查询才看到状态变化。
|
||||
func (r *Router) RunPresenceSweeper(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
r.sweepExpiredPresence(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) sweepExpiredPresence(ctx context.Context) {
|
||||
now := int(r.clock.Now().Unix())
|
||||
for _, userID := range r.presence.expireOnline(now) {
|
||||
status, ok := r.presence.statusFor(userID, now)
|
||||
if ok && status.Kind == domain.UserStatusOnline {
|
||||
continue // 该用户另有未过期的 online session
|
||||
}
|
||||
if !ok {
|
||||
status = domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now}
|
||||
}
|
||||
pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
r.pushUserStatus(pushCtx, userID, status)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) {
|
||||
sessionID, ok := SessionIDFrom(ctx)
|
||||
if !ok {
|
||||
|
|
@ -234,6 +515,11 @@ func (r *Router) withUserPresence(u domain.User) domain.User {
|
|||
if u.ID == 0 {
|
||||
return u
|
||||
}
|
||||
// bot 不参与 presence:官方 bot user 不带 status(客户端状态栏恒显 "bot"),
|
||||
// tg 转换层对 bot 也不会输出 Status。
|
||||
if u.Bot {
|
||||
return u
|
||||
}
|
||||
u.Status = r.userPresenceStatusForUser(u)
|
||||
return u
|
||||
}
|
||||
|
|
@ -249,25 +535,18 @@ func (r *Router) withUsersPresence(users []domain.User) []domain.User {
|
|||
return out
|
||||
}
|
||||
|
||||
func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList {
|
||||
if len(list.Contacts) == 0 {
|
||||
return list
|
||||
func (r *Router) withDialogListPresence(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList {
|
||||
userIDs := make(map[int64]struct{})
|
||||
channelIDs := make(map[int64]struct{})
|
||||
for _, msg := range list.Messages {
|
||||
collectMessagePeerRefs(msg, 0, userIDs, channelIDs)
|
||||
}
|
||||
out := list
|
||||
out.Contacts = append([]domain.Contact(nil), list.Contacts...)
|
||||
for i := range out.Contacts {
|
||||
out.Contacts[i].User = r.withUserPresence(out.Contacts[i].User)
|
||||
for _, msg := range list.ChannelMessages {
|
||||
collectChannelMessagePeerRefs(msg, msg.ChannelID, userIDs, channelIDs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) withMessageListPresence(list domain.MessageList) domain.MessageList {
|
||||
list.Users = r.withUsersPresence(list.Users)
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *Router) withDialogListPresence(list domain.DialogList) domain.DialogList {
|
||||
list.Users = r.withUsersPresence(list.Users)
|
||||
cache := newViewerPeerCache(r)
|
||||
list.Users = r.withUsersPresence(mergeDomainUsers(list.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...))
|
||||
list.Channels = mergeDomainChannels(list.Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...)
|
||||
return list
|
||||
}
|
||||
|
||||
|
|
@ -278,15 +557,105 @@ func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.User
|
|||
}
|
||||
|
||||
func (r *Router) tgUser(u domain.User) *tg.User {
|
||||
return tgUser(r.withUserPresence(u))
|
||||
return r.withBotProfileFlags(context.Background(), tgUser(r.withUserPresence(u)))
|
||||
}
|
||||
|
||||
func (r *Router) tgSelfUser(u domain.User) *tg.User {
|
||||
return tgSelfUser(r.withUserPresence(u))
|
||||
return r.withBotProfileFlags(context.Background(), tgSelfUser(r.withUserPresence(u)))
|
||||
}
|
||||
|
||||
func (r *Router) tgUsers(users []domain.User) []tg.UserClass {
|
||||
return tgUsers(r.withUsersPresence(users))
|
||||
out := tgUsers(r.withUsersPresence(users))
|
||||
r.withBotProfileFlagsForUsers(context.Background(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
// tgUsersForViewer 与 tgUsers 同样补 presence,但 viewer 自己的 user 走 self 分支
|
||||
// (DrKLO 靠 user.self 判定 Saved Messages;self=false 会污染账号缓存)。
|
||||
func (r *Router) tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass {
|
||||
out := tgUsersForViewer(viewerUserID, r.withUsersPresence(users))
|
||||
r.withBotProfileFlagsForUsers(context.Background(), out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) withBotProfileFlagsForUsers(ctx context.Context, users []tg.UserClass) {
|
||||
if r.deps.Bots == nil || len(users) == 0 {
|
||||
return
|
||||
}
|
||||
botUsers := make(map[int64][]*tg.User)
|
||||
ids := make([]int64, 0)
|
||||
for _, user := range users {
|
||||
u, ok := user.(*tg.User)
|
||||
if !ok || u == nil || !u.Bot || u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := botUsers[u.ID]; !ok {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
botUsers[u.ID] = append(botUsers[u.ID], u)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok {
|
||||
if profiles, err := batch.BotInfos(ctx, ids); err == nil {
|
||||
for id, items := range botUsers {
|
||||
profile, found := profiles[id]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
for _, u := range items {
|
||||
applyBotProfileFlags(u, profile)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
for id, items := range botUsers {
|
||||
profile, found, err := r.deps.Bots.BotInfo(ctx, id)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
for _, u := range items {
|
||||
applyBotProfileFlags(u, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) withBotProfileFlags(ctx context.Context, out *tg.User) *tg.User {
|
||||
if out == nil || !out.Bot || r.deps.Bots == nil {
|
||||
return out
|
||||
}
|
||||
profile, found, err := r.deps.Bots.BotInfo(ctx, out.ID)
|
||||
if err != nil || !found {
|
||||
return out
|
||||
}
|
||||
applyBotProfileFlags(out, profile)
|
||||
return out
|
||||
}
|
||||
|
||||
func applyBotProfileFlags(out *tg.User, profile domain.BotProfile) {
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if profile.ChatHistory {
|
||||
out.SetBotChatHistory(true)
|
||||
}
|
||||
if profile.Nochats {
|
||||
out.SetBotNochats(true)
|
||||
}
|
||||
if profile.InlineGeo {
|
||||
out.SetBotInlineGeo(true)
|
||||
}
|
||||
if profile.InlinePlaceholder != "" {
|
||||
out.SetBotInlinePlaceholder(profile.InlinePlaceholder)
|
||||
}
|
||||
if profile.HasMainApp {
|
||||
out.SetBotHasMainApp(true)
|
||||
}
|
||||
if profile.HasAttachMenu {
|
||||
out.SetBotAttachMenu(true)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) {
|
||||
|
|
@ -301,98 +670,20 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain
|
|||
Date: int(r.clock.Now().Unix()),
|
||||
Seq: 0,
|
||||
}
|
||||
r.pushUserUpdates(ctx, userID, update)
|
||||
for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) {
|
||||
// presence 是 transient(不写 durable log):未就绪的 session 直接跳过、不进 pending。
|
||||
r.pushUserMessageTransient(ctx, userID, "push user status (own sessions)", update)
|
||||
// 接收者集 = 与本 user 有联系人/私聊关系且当前在线的对端。用「本 user 自己的
|
||||
// contacts + dialog 对端 ∩ 在线」一次性算出(onlineRelevantPeerIDs,2 次查询 + 内存过滤),
|
||||
// 而非遍历全部在线候选逐个 GetPeerDialogs(旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1,
|
||||
// 断连/sweeper 风暴下 O(M×512) 串行 PG)。私聊 dialog 双向建行,两种算法得到同一集合。
|
||||
for _, recipientID := range r.onlineRelevantPeerIDs(ctx, userID) {
|
||||
if recipientID == userID {
|
||||
continue
|
||||
}
|
||||
r.pushUserMessage(ctx, recipientID, "push user status", update)
|
||||
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) onlinePresenceRecipientIDs(ctx context.Context, userID int64) []int64 {
|
||||
if userID == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
out := make([]int64, 0)
|
||||
add := func(ids []int64) {
|
||||
for _, id := range ids {
|
||||
if id == 0 || id == userID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
add(r.onlineContactIDs(ctx, userID))
|
||||
add(r.onlinePrivateDialogPeerIDs(ctx, userID, seen))
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) onlineContactIDs(ctx context.Context, userID int64) []int64 {
|
||||
if r.deps.Contacts == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0)
|
||||
if err != nil || notModified || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
candidates := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
contactID := int64(id)
|
||||
if contactID == 0 || contactID == userID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[contactID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[contactID] = struct{}{}
|
||||
candidates = append(candidates, contactID)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok {
|
||||
return provider.OnlineUserIDsForCandidates(candidates, 0)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func (r *Router) onlinePrivateDialogPeerIDs(ctx context.Context, userID int64, already map[int64]struct{}) []int64 {
|
||||
if r.deps.Dialogs == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
provider, ok := r.deps.Sessions.(OnlineUserProvider)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
candidates := provider.OnlineUserIDs(presenceDialogFanoutCandidateLimit)
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
|
||||
out := make([]int64, 0, len(candidates))
|
||||
for _, candidateID := range candidates {
|
||||
if candidateID == 0 || candidateID == userID {
|
||||
continue
|
||||
}
|
||||
if _, ok := already[candidateID]; ok {
|
||||
continue
|
||||
}
|
||||
list, err := r.deps.Dialogs.GetPeerDialogs(ctx, candidateID, []domain.Peer{peer})
|
||||
if err != nil || !dialogListHasPeer(list, peer) {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidateID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, userID int64) {
|
||||
peerIDs := r.onlineRelevantPeerIDs(ctx, userID)
|
||||
if len(peerIDs) == 0 {
|
||||
|
|
@ -419,6 +710,18 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
|
|||
})
|
||||
}
|
||||
|
||||
// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 ∪ 私聊对端)的缓存有效期;
|
||||
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
|
||||
const (
|
||||
presenceCandidateCacheTTL = 2 * time.Minute
|
||||
lastSeenPersistDebounce = 25 * time.Second
|
||||
)
|
||||
|
||||
type presenceCandidateEntry struct {
|
||||
ids []int64
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 {
|
||||
if userID == 0 {
|
||||
return nil
|
||||
|
|
@ -427,6 +730,25 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6
|
|||
if !ok {
|
||||
return nil
|
||||
}
|
||||
candidates := r.presenceFanoutCandidates(ctx, userID)
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
// online 过滤每次实时进行(不进缓存),所以缓存只影响「候选范围」,不影响在线判定时效。
|
||||
return provider.OnlineUserIDsForCandidates(candidates, 0)
|
||||
}
|
||||
|
||||
// presenceFanoutCandidates 返回 presence fan-out 的候选 peer 集合(联系人 ∪ 私聊对端,online
|
||||
// 过滤前),带短 TTL 缓存。命中时零 DB 查询;未命中走原 hydration 路径算一次并缓存。候选集
|
||||
// 变动很慢(加好友 / 开新私聊),陈旧最多 TTL 秒生效,对 presence(best-effort、transient)可接受。
|
||||
// 仅在两条查询都未报错时才写缓存,避免把瞬时错误导致的残缺集合钉住 TTL。
|
||||
func (r *Router) presenceFanoutCandidates(ctx context.Context, userID int64) []int64 {
|
||||
now := r.clock.Now()
|
||||
if cached, ok := r.presenceCandidateCache.Load(userID); ok {
|
||||
if entry, ok := cached.(*presenceCandidateEntry); ok && entry.expireAt.After(now) {
|
||||
return entry.ids
|
||||
}
|
||||
}
|
||||
seen := map[int64]struct{}{}
|
||||
candidates := make([]int64, 0)
|
||||
add := func(id int64) {
|
||||
|
|
@ -439,9 +761,12 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6
|
|||
seen[id] = struct{}{}
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
failed := false
|
||||
if r.deps.Contacts != nil {
|
||||
ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0)
|
||||
if err == nil && !notModified {
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else if !notModified {
|
||||
for _, id := range ids {
|
||||
add(int64(id))
|
||||
}
|
||||
|
|
@ -449,7 +774,9 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6
|
|||
}
|
||||
if r.deps.Dialogs != nil {
|
||||
list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit})
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
failed = true
|
||||
} else {
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer.Type == domain.PeerTypeUser {
|
||||
add(dialog.Peer.ID)
|
||||
|
|
@ -457,34 +784,8 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6
|
|||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
if !failed {
|
||||
r.presenceCandidateCache.Store(userID, &presenceCandidateEntry{ids: candidates, expireAt: now.Add(presenceCandidateCacheTTL)})
|
||||
}
|
||||
return provider.OnlineUserIDsForCandidates(candidates, 0)
|
||||
}
|
||||
|
||||
func dialogListHasPeer(list domain.DialogList, peer domain.Peer) bool {
|
||||
for _, dialog := range list.Dialogs {
|
||||
if dialog.Peer != peer {
|
||||
continue
|
||||
}
|
||||
if dialog.TopMessage != 0 ||
|
||||
dialog.TopMessageDate != 0 ||
|
||||
dialog.ReadInboxMaxID != 0 ||
|
||||
dialog.ReadOutboxMaxID != 0 ||
|
||||
dialog.UnreadCount != 0 ||
|
||||
dialog.UnreadMentions != 0 ||
|
||||
dialog.UnreadReactions != 0 ||
|
||||
dialog.Pinned ||
|
||||
dialog.UnreadMark ||
|
||||
dialog.Draft != nil {
|
||||
return true
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if msg.Peer == peer {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return candidates
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue