234 lines
7.6 KiB
Go
234 lines
7.6 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"log"
|
||
"strings"
|
||
|
||
roomv1 "hyapp/api/proto/room/v1"
|
||
"hyapp/pkg/xerr"
|
||
)
|
||
|
||
const (
|
||
roomListTabHot = "hot"
|
||
roomListTabNew = "new"
|
||
defaultRoomListLimit = 20
|
||
maxRoomListLimit = 50
|
||
)
|
||
|
||
// roomListCursor 是服务端生成的不透明翻页游标。
|
||
// 客户端只能原样传回,不能依赖内部字段;tab 写入游标用于拒绝跨 tab 复用。
|
||
type roomListCursor struct {
|
||
Tab string `json:"tab"`
|
||
SortScore int64 `json:"sort_score,omitempty"`
|
||
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
||
RoomID string `json:"room_id"`
|
||
}
|
||
|
||
// ListRooms 查询当前用户区域内的房间列表读模型。
|
||
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||
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())
|
||
cursor, err := decodeRoomListCursor(tab, req.GetCursor())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
|
||
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
|
||
Tab: tab,
|
||
Limit: limit + 1,
|
||
CursorSortScore: cursor.SortScore,
|
||
CursorCreatedAtMS: cursor.CreatedAtMS,
|
||
CursorRoomID: cursor.RoomID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
nextCursor := ""
|
||
if len(entries) > limit {
|
||
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
|
||
last := entries[limit-1]
|
||
nextCursor = encodeRoomListCursor(tab, last)
|
||
entries = entries[:limit]
|
||
}
|
||
|
||
items := make([]*roomv1.RoomListItem, 0, len(entries))
|
||
for _, entry := range entries {
|
||
items = append(items, roomListItemToProto(entry))
|
||
}
|
||
|
||
return &roomv1.ListRoomsResponse{
|
||
Rooms: items,
|
||
NextCursor: nextCursor,
|
||
}, nil
|
||
}
|
||
|
||
// 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 补偿。
|
||
log.Printf("component=room_list_projector room_id=%s status=load_meta_failed error=%q", snapshot.GetRoomId(), err.Error())
|
||
return
|
||
}
|
||
if !exists {
|
||
log.Printf("component=room_list_projector room_id=%s status=meta_missing", snapshot.GetRoomId())
|
||
return
|
||
}
|
||
|
||
nowMS := s.clock.Now().UnixMilli()
|
||
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
|
||
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
|
||
// 列表读模型是最终一致;失败不改变命令成功语义。
|
||
log.Printf("component=room_list_projector room_id=%s region_id=%d status=upsert_failed error=%q", snapshot.GetRoomId(), meta.VisibleRegionID, err.Error())
|
||
}
|
||
}
|
||
|
||
// 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++
|
||
}
|
||
}
|
||
|
||
title := snapshot.GetRoomExt()["title"]
|
||
coverURL := snapshot.GetRoomExt()["cover_url"]
|
||
|
||
return RoomListEntry{
|
||
RoomID: snapshot.GetRoomId(),
|
||
VisibleRegionID: normalizeVisibleRegionID(visibleRegionID),
|
||
OwnerUserID: snapshot.GetOwnerUserId(),
|
||
HostUserID: snapshot.GetHostUserId(),
|
||
Title: title,
|
||
CoverURL: coverURL,
|
||
Mode: snapshot.GetMode(),
|
||
Status: snapshot.GetStatus(),
|
||
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,后续调整公式不需要回放 command log。
|
||
return heat*1000 + int64(onlineCount)*100 + int64(occupiedSeatCount)*10
|
||
}
|
||
|
||
// normalizeRoomListTab 固定首版只支持 hot/new,未知 tab fail-closed。
|
||
func normalizeRoomListTab(tab string) string {
|
||
switch strings.ToLower(strings.TrimSpace(tab)) {
|
||
case "", roomListTabHot:
|
||
return roomListTabHot
|
||
case roomListTabNew:
|
||
return roomListTabNew
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// 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, encoded string) (roomListCursor, error) {
|
||
encoded = strings.TrimSpace(encoded)
|
||
if encoded == "" {
|
||
return roomListCursor{Tab: tab}, 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.RoomID == "" {
|
||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||
}
|
||
|
||
return cursor, nil
|
||
}
|
||
|
||
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
|
||
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
|
||
func encodeRoomListCursor(tab string, entry RoomListEntry) string {
|
||
cursor := roomListCursor{
|
||
Tab: tab,
|
||
SortScore: entry.SortScore,
|
||
CreatedAtMS: entry.CreatedAtMS,
|
||
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{
|
||
RoomId: entry.RoomID,
|
||
OwnerUserId: entry.OwnerUserID,
|
||
HostUserId: entry.HostUserID,
|
||
Title: entry.Title,
|
||
CoverUrl: entry.CoverURL,
|
||
Mode: entry.Mode,
|
||
Status: entry.Status,
|
||
Heat: entry.Heat,
|
||
OnlineCount: entry.OnlineCount,
|
||
SeatCount: entry.SeatCount,
|
||
OccupiedSeatCount: entry.OccupiedSeatCount,
|
||
VisibleRegionId: entry.VisibleRegionID,
|
||
}
|
||
}
|