494 lines
17 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"
"encoding/base64"
"encoding/json"
"log/slog"
"math"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
)
const (
roomListTabHot = "hot"
roomListTabNew = "new"
roomListTabVisited = "visited"
roomListTabFriend = "friend"
roomListTabFollowing = "following"
roomListTabFollowed = "followed"
defaultRoomListLimit = 20
maxRoomListLimit = 50
maxRoomListQueryRunes = 64
roomHotHeatWeight = 650000
roomHotOnlineWeight = 300000
roomHotMicWeight = 50000
roomHotOnlineCap = 50
roomHotMicCap = 10
)
// roomListCursor 是服务端生成的不透明翻页游标。
// 客户端只能原样传回不能依赖内部字段tab 写入游标用于拒绝跨 tab 复用。
type roomListCursor struct {
Tab string `json:"tab"`
Query string `json:"query,omitempty"`
SortScore int64 `json:"sort_score,omitempty"`
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
UpdatedAtMS int64 `json:"updated_at_ms,omitempty"`
SubjectUserID int64 `json:"subject_user_id,omitempty"`
PinListRank int64 `json:"pin_list_rank,omitempty"`
Pinned bool `json:"pinned,omitempty"`
PinWeight int64 `json:"pin_weight,omitempty"`
PinnedUntilMS int64 `json:"pinned_until_ms,omitempty"`
RoomID string `json:"room_id"`
}
// ListRooms 查询公共房间发现列表读模型,并用服务端解析出的用户区域决定置顶和普通房排序桶。
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
viewerUserID := req.GetViewerUserId()
if viewerUserID <= 0 {
// viewer_user_id 必须由 gateway 鉴权后填充room-service 不接受匿名列表查询。
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
tab := normalizeRoomListTab(req.GetTab())
if tab == "" {
return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
}
limit := normalizeRoomListLimit(req.GetLimit())
query, err := normalizeRoomListQuery(req.GetQuery())
if err != nil {
return nil, err
}
cursor, err := decodeRoomListCursor(tab, query, req.GetCursor())
if err != nil {
return nil, err
}
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
AppCode: appcode.FromContext(ctx),
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Tab: tab,
Query: query,
Limit: limit + 1,
CursorSortScore: cursor.SortScore,
CursorCreatedAtMS: cursor.CreatedAtMS,
CursorRoomID: cursor.RoomID,
CursorPinListRank: cursor.PinListRank,
CursorPinned: cursor.Pinned,
CursorPinWeight: cursor.PinWeight,
CursorPinnedUntilMS: cursor.PinnedUntilMS,
NowMS: s.clock.Now().UnixMilli(),
})
if err != nil {
return nil, err
}
return roomListResponseFromEntries(tab, query, entries, limit), nil
}
// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
viewerUserID := req.GetViewerUserId()
if viewerUserID <= 0 {
// feed 一定绑定当前登录用户,不能允许匿名或客户端自选用户。
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
tab := normalizeRoomFeedTab(req.GetTab())
if tab == "" {
return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
}
limit := normalizeRoomListLimit(req.GetLimit())
query, err := normalizeRoomListQuery(req.GetQuery())
if err != nil {
return nil, err
}
cursor, err := decodeRoomListCursor(tab, query, req.GetCursor())
if err != nil {
return nil, err
}
if roomFeedTabRequiresRelationCursor(tab) && strings.TrimSpace(req.GetCursor()) != "" && cursor.SubjectUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
var entries []RoomListEntry
switch tab {
case roomListTabVisited:
entries, err = s.repository.ListRoomUserFeedEntries(ctx, RoomUserFeedQuery{
AppCode: appcode.FromContext(ctx),
UserID: viewerUserID,
FeedType: tab,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Query: query,
Limit: limit + 1,
CursorUpdatedAtMS: cursor.UpdatedAtMS,
CursorRoomID: cursor.RoomID,
})
case roomListTabFollowed:
entries, err = s.repository.ListRoomFollowEntries(ctx, RoomFollowQuery{
AppCode: appcode.FromContext(ctx),
UserID: viewerUserID,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Query: query,
Limit: limit + 1,
CursorFollowedAtMS: cursor.UpdatedAtMS,
CursorRoomID: cursor.RoomID,
})
default:
// friend/following 的关系事实属于 user-serviceroom-service 只根据 gateway 传入的一页关系用户查询 active 房间卡片。
entries, err = s.repository.ListRoomRelatedFeedEntries(ctx, RoomRelatedFeedQuery{
AppCode: appcode.FromContext(ctx),
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
RelatedUsers: roomFeedRelatedUsersFromProto(req.GetRelatedUsers()),
Query: query,
Limit: limit + 1,
CursorUpdatedAtMS: cursor.UpdatedAtMS,
CursorSubjectUserID: cursor.SubjectUserID,
CursorRoomID: cursor.RoomID,
})
}
if err != nil {
return nil, err
}
return roomListResponseFromEntries(tab, query, entries, limit), nil
}
// GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。
func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
ownerUserID := req.GetOwnerUserId()
if ownerUserID <= 0 {
// owner_user_id 必须由 gateway 鉴权后填充Mine 卡片不能接受客户端任意查人。
return nil, xerr.New(xerr.InvalidArgument, "owner_user_id is required")
}
serverTimeMS := s.clock.Now().UnixMilli()
meta, exists, err := s.repository.GetRoomMetaByOwner(ctx, ownerUserID)
if err != nil {
return nil, err
}
if !exists {
return &roomv1.GetMyRoomResponse{ServerTimeMs: serverTimeMS}, nil
}
snapshot, err := s.currentSnapshot(ctx, meta.RoomID)
if err != nil {
return nil, err
}
return &roomv1.GetMyRoomResponse{
HasRoom: true,
Room: roomListItemToProto(roomListEntryFromMetaAndSnapshot(meta, snapshot, serverTimeMS)),
ServerTimeMs: serverTimeMS,
}, nil
}
func roomListResponseFromEntries(tab string, query string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse {
nextCursor := ""
if len(entries) > limit {
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
last := entries[limit-1]
nextCursor = encodeRoomListCursor(tab, query, last)
entries = entries[:limit]
}
items := make([]*roomv1.RoomListItem, 0, len(entries))
for _, entry := range entries {
// response 只暴露列表卡片字段,不把 repository 的 cursor 辅助字段泄露给客户端。
items = append(items, roomListItemToProto(entry))
}
return &roomv1.ListRoomsResponse{
Rooms: items,
NextCursor: nextCursor,
}
}
func roomFeedRelatedUsersFromProto(items []*roomv1.RoomFeedRelatedUser) []RoomFeedRelatedUser {
// gateway 已做关系授权room-service 这里只过滤无效项并保持输入顺序供 repository 排序使用。
relatedUsers := make([]RoomFeedRelatedUser, 0, len(items))
for _, item := range items {
if item == nil || item.GetUserId() <= 0 || item.GetRelationUpdatedAtMs() <= 0 {
continue
}
relatedUsers = append(relatedUsers, RoomFeedRelatedUser{
UserID: item.GetUserId(),
RelationUpdatedAtMS: item.GetRelationUpdatedAtMs(),
})
}
return relatedUsers
}
// projectRoomListBestEffort 根据最新快照刷新房间列表投影。
func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
if snapshot == nil || snapshot.GetRoomId() == "" {
return
}
meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
if err != nil {
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。
logx.Error(ctx, "room_list_load_meta_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
return
}
if !exists {
logx.Warn(ctx, "room_list_meta_missing", slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
return
}
nowMS := s.clock.Now().UnixMilli()
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
// 列表读模型是最终一致;失败不改变命令成功语义。
logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
}
}
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
onlineCount := int32(len(snapshot.GetOnlineUsers()))
seatCount := int32(len(snapshot.GetMicSeats()))
occupiedSeatCount := int32(0)
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() != 0 {
// 列表只需要占麦数量,不暴露具体麦位用户。
occupiedSeatCount++
}
}
// 展示资料仍放在 RoomExt列表投影只在刷新时复制需要的字段。
title := snapshot.GetRoomExt()["title"]
coverURL := snapshot.GetRoomExt()["cover_url"]
roomShortID := snapshot.GetRoomShortId()
if roomShortID == "" {
roomShortID = snapshot.GetRoomExt()["room_short_id"]
}
return RoomListEntry{
AppCode: snapshot.GetAppCode(),
RoomID: snapshot.GetRoomId(),
RoomShortID: roomShortID,
VisibleRegionID: normalizeVisibleRegionID(visibleRegionID),
OwnerUserID: snapshot.GetOwnerUserId(),
Title: title,
CoverURL: coverURL,
Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(),
Locked: snapshot.GetLocked(),
Heat: snapshot.GetHeat(),
OnlineCount: onlineCount,
SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount,
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
}
func roomListSortScore(heat int64, onlineCount int32, occupiedSeatCount int32) int64 {
// 热门分只属于列表读模型,不进入 RoomState贡献值先做 log 压缩,避免历史大房间永久碾压当前有人房间。
if heat < 0 {
heat = 0
}
online := int64(onlineCount)
if online < 0 {
online = 0
}
if online > roomHotOnlineCap {
online = roomHotOnlineCap
}
occupied := int64(occupiedSeatCount)
if occupied < 0 {
occupied = 0
}
if occupied > roomHotMicCap {
occupied = roomHotMicCap
}
heatScore := math.Log2(float64(heat) + 1)
onlineScore := math.Log2(float64(online) + 1)
micScore := float64(occupied)
return int64(heatScore*roomHotHeatWeight + onlineScore*roomHotOnlineWeight + micScore*roomHotMicWeight)
}
// normalizeRoomListTab 只支持公共发现列表 tabMine 关系流走 ListRoomFeeds。
func normalizeRoomListTab(tab string) string {
switch strings.ToLower(strings.TrimSpace(tab)) {
case "", roomListTabHot:
return roomListTabHot
case roomListTabNew:
return roomListTabNew
default:
return ""
}
}
func normalizeRoomFeedTab(tab string) string {
// Mine 页房间流只允许明确 tab避免空 tab 被误解成公共 hot 列表。
switch strings.ToLower(strings.TrimSpace(tab)) {
case roomListTabVisited:
return roomListTabVisited
case roomListTabFriend:
return roomListTabFriend
case roomListTabFollowing:
return roomListTabFollowing
case roomListTabFollowed:
return roomListTabFollowed
default:
return ""
}
}
func roomFeedTabRequiresRelationCursor(tab string) bool {
// friend/following 的排序边界包含关系用户 IDvisited/followed 只需要房间 ID 和时间。
return tab == roomListTabFriend || tab == roomListTabFollowing
}
func normalizeRoomListQuery(query string) (string, error) {
query = strings.TrimSpace(query)
if query == "" {
return "", nil
}
if len([]rune(query)) > maxRoomListQueryRunes {
// 搜索词进入 LIKE 查询,长度必须在入口收敛,避免大字符串放大数据库扫描成本。
return "", xerr.New(xerr.InvalidArgument, "query is too long")
}
return query, nil
}
// normalizeRoomListLimit 统一列表分页上限,防止客户端用大 limit 打穿 MySQL 读模型。
func normalizeRoomListLimit(limit int32) int {
if limit <= 0 {
return defaultRoomListLimit
}
if limit > maxRoomListLimit {
return maxRoomListLimit
}
return int(limit)
}
// normalizeVisibleRegionID 把负数区域收敛到 GLOBAL 桶,避免非法区域影响 SQL 查询边界。
func normalizeVisibleRegionID(regionID int64) int64 {
if regionID < 0 {
return 0
}
return regionID
}
// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。
func decodeRoomListCursor(tab string, query string, encoded string) (roomListCursor, error) {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return roomListCursor{Tab: tab, Query: query}, nil
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
var cursor roomListCursor
if err := json.Unmarshal(payload, &cursor); err != nil {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
if cursor.Tab != tab || cursor.Query != query || cursor.RoomID == "" {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
return cursor, nil
}
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
func encodeRoomListCursor(tab string, query string, entry RoomListEntry) string {
cursor := roomListCursor{
Tab: tab,
Query: query,
SortScore: entry.SortScore,
CreatedAtMS: entry.CreatedAtMS,
UpdatedAtMS: entry.UpdatedAtMS,
SubjectUserID: entry.FeedSubjectUserID,
PinListRank: entry.PinListRank,
Pinned: entry.IsPinned,
PinWeight: entry.PinWeight,
PinnedUntilMS: entry.PinnedUntilMS,
RoomID: entry.RoomID,
}
payload, err := json.Marshal(cursor)
if err != nil {
// 固定结构理论上不会 marshal 失败;失败时返回空游标让客户端停止翻页。
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
return &roomv1.RoomListItem{
AppCode: entry.AppCode,
RoomId: entry.RoomID,
RoomShortId: entry.RoomShortID,
OwnerUserId: entry.OwnerUserID,
Title: entry.Title,
CoverUrl: entry.CoverURL,
Mode: entry.Mode,
Status: entry.Status,
Locked: entry.Locked,
Heat: entry.Heat,
OnlineCount: entry.OnlineCount,
SeatCount: entry.SeatCount,
OccupiedSeatCount: entry.OccupiedSeatCount,
VisibleRegionId: entry.VisibleRegionID,
}
}
func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
if snapshot == nil || snapshot.GetRoomId() == "" {
// GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。
return RoomListEntry{
AppCode: meta.AppCode,
RoomID: meta.RoomID,
RoomShortID: meta.RoomShortID,
VisibleRegionID: normalizeVisibleRegionID(meta.VisibleRegionID),
OwnerUserID: meta.OwnerUserID,
Mode: meta.Mode,
Status: meta.Status,
Locked: meta.RoomPasswordHash != "",
SeatCount: meta.SeatCount,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
}
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if entry.RoomShortID == "" {
// 老快照可能没有 room_short_idrooms meta 是短号的持久兜底来源。
entry.RoomShortID = meta.RoomShortID
}
if entry.Mode == "" {
// mode/status/owner 都从 meta 兜底,避免快照扩展字段缺失导致 Mine 卡片不可用。
entry.Mode = meta.Mode
}
if entry.Status == "" {
entry.Status = meta.Status
}
if entry.OwnerUserID <= 0 {
entry.OwnerUserID = meta.OwnerUserID
}
return entry
}