This commit is contained in:
Yvan
2026-09-22 20:20:23 +08:00
parent bf33e87d72
commit ceaea78b0f
42 changed files with 2845 additions and 154 deletions
+28 -10
View File
@@ -24,6 +24,7 @@ var (
ErrCategoryDisabled = kratoserrors.BadRequest("LIVE_CATEGORY_DISABLED", "category is disabled")
ErrRoomCategoryLocked = kratoserrors.BadRequest("LIVE_ROOM_CATEGORY_LOCKED", "live room category cannot be changed while broadcasting")
ErrRoomInvalidArgument = kratoserrors.BadRequest("LIVE_ROOM_INVALID_ARGUMENT", "invalid live room data")
ErrRoomDisabled = kratoserrors.BadRequest("LIVE_ROOM_DISABLED", "live room is disabled")
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
)
@@ -152,8 +153,8 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
filter.Name = strings.TrimSpace(filter.Name)
filter.AnchorName = strings.TrimSpace(filter.AnchorName)
options = normalizeListOptions(options)
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline {
return uc.listByRuntimeStatus(ctx, filter, options)
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline || ordersByViewerCount(options.OrderBy) {
return uc.listByRuntime(ctx, filter, options)
}
rooms, total, err := uc.repo.ListRooms(ctx, filter, options)
if err != nil {
@@ -162,10 +163,15 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
return uc.attachRuntime(ctx, rooms), total, nil
}
func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
func (uc *RoomUsecase) listByRuntime(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
all := make([]*Room, 0)
storageOrder := options.OrderBy
if ordersByViewerCount(storageOrder) {
// Runtime sorting happens after loading, so keep storage batches stable.
storageOrder = "id_asc"
}
for offset := 0; ; offset += 100 {
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy})
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: storageOrder})
if err != nil {
return nil, 0, err
}
@@ -176,12 +182,13 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
}
all = uc.attachRuntime(ctx, all)
matched := make([]*Room, 0, len(all))
filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline
for _, room := range all {
if room.Status == filter.Status {
if !filterRuntimeStatus || room.Status == filter.Status {
matched = append(matched, room)
}
}
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") {
if ordersByViewerCount(options.OrderBy) {
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ViewerCount == matched[j].ViewerCount {
return matched[i].ID < matched[j].ID
@@ -203,6 +210,10 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
return matched[options.Offset:end], total, nil
}
func ordersByViewerCount(order string) bool {
return strings.EqualFold(order, "viewer_count_asc") || strings.EqualFold(order, "viewer_count_desc")
}
func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
if id == 0 {
return nil, ErrRoomNotFound
@@ -219,15 +230,15 @@ func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
if err != nil {
return nil, err
}
if !room.Enabled || room.Status != RoomStatusLive {
return nil, ErrRoomNotLive
if !room.Enabled {
return nil, ErrRoomDisabled
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return nil, err
}
if !category.Enabled {
return nil, ErrRoomNotLive
return nil, ErrCategoryDisabled
}
return room, nil
}
@@ -237,7 +248,14 @@ func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
if err != nil {
return false, err
}
return room.Enabled && room.Status == RoomStatusLive, nil
if !room.Enabled || room.Status != RoomStatusLive {
return false, nil
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return false, err
}
return category.Enabled, nil
}
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
+125 -17
View File
@@ -40,6 +40,12 @@ func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, option
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
continue
}
if filter.Status == RoomStatusDisabled && room.Enabled {
continue
}
if (filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline) && !room.Enabled {
continue
}
if filter.CategoryEnabled != nil {
category, err := r.GetCategory(context.Background(), room.CategoryID)
if err != nil || category.Enabled != *filter.CategoryEnabled {
@@ -105,19 +111,26 @@ func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
}
}
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, Enabled: true},
{ID: 2, Enabled: true},
{ID: 3, Enabled: false},
}}
func TestRoomUsecaseIsLiveRequiresEnabledRoomCategoryAndOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true},
"room-3": {StreamID: "room-3", PublisherOnline: true},
"room-4": {StreamID: "room-4", PublisherOnline: true},
"room-5": {StreamID: "room-5", PublisherOnline: true},
}})
rooms := NewRoomUsecase(catalog, streams)
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false, 4: false} {
got, err := rooms.IsLive(context.Background(), roomID)
if err != nil {
t.Fatalf("IsLive(%d) error = %v", roomID, err)
@@ -126,16 +139,21 @@ func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
}
}
if live, err := rooms.IsLive(context.Background(), 5); live || !errors.Is(err, ErrCategoryNotFound) {
t.Fatalf("missing category IsLive(5) = %v, err=%v", live, err)
}
}
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
func TestRoomUsecaseGetPublicIncludesOfflineRoomsAndRequiresEnabledCategory(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 1, Enabled: true},
{ID: 6, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
@@ -145,16 +163,106 @@ func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
}})
rooms := NewRoomUsecase(catalog, streams)
item, err := rooms.GetPublic(context.Background(), 1)
if err != nil || item.ID != 1 || item.Status != RoomStatusLive {
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err)
}
for _, roomID := range []uint{2, 3, 4} {
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) {
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive)
for _, test := range []struct {
id uint
status string
replay string
}{
{id: 1, status: RoomStatusLive},
{id: 2, status: RoomStatusOffline, replay: "https://example.test/replay.mp4"},
{id: 5, status: RoomStatusOffline},
} {
item, err := rooms.GetPublic(context.Background(), test.id)
if err != nil || item.ID != test.id || item.Status != test.status || item.ReplayURL != test.replay {
t.Fatalf("GetPublic(%d) item=%+v err=%v", test.id, item, err)
}
if _, err = rooms.Get(context.Background(), roomID); err != nil {
}
for roomID, want := range map[uint]error{0: ErrRoomNotFound, 3: ErrRoomDisabled, 4: ErrCategoryDisabled, 6: ErrCategoryNotFound, 99: ErrRoomNotFound} {
if _, err := rooms.GetPublic(context.Background(), roomID); !errors.Is(err, want) {
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, want)
}
}
for _, roomID := range []uint{3, 4, 6} {
if _, err := rooms.Get(context.Background(), roomID); err != nil {
t.Errorf("admin Get(%d) error=%v", roomID, err)
}
}
}
func TestRoomUsecaseListsPublicRoomsByRuntimeBeforePagination(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}, {ID: 3, Enabled: true}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: true},
{ID: 4, CategoryID: 1, Enabled: false},
{ID: 5, CategoryID: 2, Enabled: true},
{ID: 6, CategoryID: 1, Enabled: true},
{ID: 7, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 100},
"room-3": {StreamID: "room-3", PublisherOnline: true, SubscriberCount: 200},
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
"room-5": {StreamID: "room-5", PublisherOnline: true, SubscriberCount: 1000},
"room-7": {StreamID: "room-7", PublisherOnline: true, SubscriberCount: 50},
}})
rooms := NewRoomUsecase(catalog, streams)
for _, test := range []struct {
name string
status string
categoryID uint
order string
offset int
limit int
total int64
want []uint
}{
{name: "all default", limit: 10, total: 5, want: []uint{1, 2, 3, 6, 7}},
{name: "all popular", order: "viewer_count_desc", limit: 10, total: 5, want: []uint{3, 2, 7, 1, 6}},
{name: "all popular page", order: "viewer_count_desc", offset: 1, limit: 2, total: 5, want: []uint{2, 7}},
{name: "all ascending", order: "VIEWER_COUNT_ASC", limit: 10, total: 5, want: []uint{1, 6, 7, 2, 3}},
{name: "live only", status: RoomStatusLive, order: "viewer_count_desc", limit: 10, total: 3, want: []uint{3, 2, 7}},
{name: "offline replay and empty", status: RoomStatusOffline, order: "viewer_count_desc", limit: 10, total: 2, want: []uint{1, 6}},
{name: "category filter", categoryID: 1, order: "viewer_count_desc", limit: 10, total: 4, want: []uint{3, 2, 1, 6}},
{name: "disabled excluded", status: RoomStatusDisabled, order: "viewer_count_desc", limit: 10},
{name: "past final page", order: "viewer_count_desc", offset: 5, limit: 2, total: 5},
} {
t.Run(test.name, func(t *testing.T) {
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Status: test.status, CategoryID: test.categoryID, Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Offset: test.offset, Limit: test.limit, OrderBy: test.order})
if err != nil {
t.Fatal(err)
}
if total != test.total || len(items) != len(test.want) {
t.Fatalf("total=%d items=%+v, want total=%d ids=%v", total, items, test.total, test.want)
}
for index, id := range test.want {
if items[index].ID != id {
t.Fatalf("item[%d]=%d, want %d", index, items[index].ID, id)
}
}
})
}
}
func TestRoomUsecasePopularSortIncludesRoomsBeyondStorageBatch(t *testing.T) {
catalog := &catalogRepoStub{categories: []*Category{{ID: 1, Enabled: true}}}
for id := uint(1); id <= 105; id++ {
catalog.rooms = append(catalog.rooms, &Room{ID: id, CategoryID: 1, Enabled: true})
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-105": {StreamID: "room-105", PublisherOnline: true, SubscriberCount: 500},
}})
rooms := NewRoomUsecase(catalog, streams)
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Limit: 2, OrderBy: "viewer_count_desc"})
if err != nil {
t.Fatal(err)
}
if total != 105 || len(items) != 2 || items[0].ID != 105 || items[1].ID != 1 {
t.Fatalf("popular rooms total=%d items=%+v", total, items)
}
}