494 lines
17 KiB
Go
494 lines
17 KiB
Go
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-service;room-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 只支持公共发现列表 tab,Mine 关系流走 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 的排序边界包含关系用户 ID,visited/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_id,rooms 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
|
||
}
|