This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 53e8599bdd
commit 0a8ecbe09b
75 changed files with 2299 additions and 276 deletions
+91 -23
View File
@@ -2,6 +2,8 @@ package live
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -22,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")
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
)
@@ -37,23 +40,26 @@ type Category struct {
}
type Room struct {
ID uint
Name string
CategoryID uint
CategoryName string
CoverURL string
Intro string
Sort int
AnchorUserID uint
AnchorName string
AnchorAvatar string
StreamURL string
ReplayURL string
Enabled bool
Status string
ViewerCount int
CreatedAt time.Time
UpdatedAt time.Time
ID uint
Name string
CategoryID uint
CategoryName string
CoverURL string
Intro string
Sort int
AnchorUserID uint
AnchorName string
AnchorAvatar string
StreamURL string
ReplayURL string
Enabled bool
Status string
ViewerCount int
ViewerCountText string
LikeCount int
StartedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CategoryFilter struct {
@@ -62,12 +68,13 @@ type CategoryFilter struct {
}
type RoomFilter struct {
Name string
AnchorName string
CategoryID uint
Status string
Enabled *bool
CreatedAt []time.Time
Name string
AnchorName string
CategoryID uint
Status string
Enabled *bool
CategoryEnabled *bool
CreatedAt []time.Time
}
type ListOptions struct {
@@ -174,6 +181,17 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
matched = append(matched, room)
}
}
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") {
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ViewerCount == matched[j].ViewerCount {
return matched[i].ID < matched[j].ID
}
if strings.EqualFold(options.OrderBy, "viewer_count_asc") {
return matched[i].ViewerCount < matched[j].ViewerCount
}
return matched[i].ViewerCount > matched[j].ViewerCount
})
}
total := int64(len(matched))
if options.Offset >= len(matched) {
return []*Room{}, total, nil
@@ -196,6 +214,32 @@ func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
return uc.attachRuntime(ctx, []*Room{room})[0], nil
}
func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
room, err := uc.Get(ctx, id)
if err != nil {
return nil, err
}
if !room.Enabled || room.Status != RoomStatusLive {
return nil, ErrRoomNotLive
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return nil, err
}
if !category.Enabled {
return nil, ErrRoomNotLive
}
return room, nil
}
func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
room, err := uc.Get(ctx, id)
if err != nil {
return false, err
}
return room.Enabled && room.Status == RoomStatusLive, nil
}
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
if err := validateRoom(room); err != nil {
return nil, err
@@ -258,17 +302,41 @@ func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room
for _, room := range rooms {
if !room.Enabled {
room.Status = RoomStatusDisabled
room.ViewerCountText = formatViewerCount(room.ViewerCount)
continue
}
room.Status = RoomStatusOffline
if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline {
room.Status = RoomStatusLive
room.ViewerCount = stream.SubscriberCount
room.LikeCount = stream.LikeCount
if !stream.StartedAt.IsZero() {
startedAt := stream.StartedAt
room.StartedAt = &startedAt
}
}
room.ViewerCountText = formatViewerCount(room.ViewerCount)
}
return rooms
}
func formatViewerCount(value int) string {
if value < 1000 {
if value < 0 {
value = 0
}
return formatUint(uint(value))
}
if value >= 10000 {
return formatDecimal(float64(value)/10000, "w")
}
return formatDecimal(float64(value)/1000, "k")
}
func formatDecimal(value float64, suffix string) string {
return fmt.Sprintf("%.1f%s", value, suffix)
}
func roomStreamID(id uint) string { return "room-" + formatUint(id) }
func formatUint(value uint) string {
+160
View File
@@ -0,0 +1,160 @@
package live
import (
"context"
"errors"
"testing"
)
type catalogRepoStub struct {
categories []*Category
rooms []*Room
}
func (*catalogRepoStub) ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) {
return nil, 0, nil
}
func (r *catalogRepoStub) GetCategory(_ context.Context, id uint) (*Category, error) {
for _, category := range r.categories {
if category.ID == id {
copy := *category
return &copy, nil
}
}
return nil, ErrCategoryNotFound
}
func (*catalogRepoStub) CreateCategory(context.Context, *Category) (*Category, error) {
return nil, nil
}
func (*catalogRepoStub) UpdateCategory(context.Context, *Category) (*Category, error) {
return nil, nil
}
func (*catalogRepoStub) DeleteCategory(context.Context, uint) error { return nil }
func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
filtered := make([]*Room, 0, len(r.rooms))
for _, room := range r.rooms {
if filter.CategoryID > 0 && room.CategoryID != filter.CategoryID {
continue
}
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
continue
}
if filter.CategoryEnabled != nil {
category, err := r.GetCategory(context.Background(), room.CategoryID)
if err != nil || category.Enabled != *filter.CategoryEnabled {
continue
}
}
copy := *room
filtered = append(filtered, &copy)
}
total := int64(len(filtered))
if options.Offset >= len(filtered) {
return []*Room{}, total, nil
}
end := options.Offset + options.Limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[options.Offset:end], total, nil
}
func (r *catalogRepoStub) GetRoom(_ context.Context, id uint) (*Room, error) {
for _, room := range r.rooms {
if room.ID == id {
copy := *room
return &copy, nil
}
}
return nil, ErrRoomNotFound
}
func (*catalogRepoStub) CreateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
func (*catalogRepoStub) UpdateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
func (*catalogRepoStub) DeleteRoom(context.Context, uint) error { return nil }
func (*catalogRepoStub) UpdateRoomEnabled(context.Context, uint, bool) error {
return nil
}
func (*catalogRepoStub) SaveRoomSort(context.Context, map[uint]int) error { return nil }
func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
enabled := true
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: true},
{ID: 4, CategoryID: 2, Enabled: true},
}}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true, SubscriberCount: 100},
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 5600},
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
}})
rooms := NewRoomUsecase(catalog, streams)
items, total, err := rooms.List(context.Background(), RoomFilter{CategoryID: 1, Status: RoomStatusLive, Enabled: &enabled}, ListOptions{Offset: 1, Limit: 1, OrderBy: "viewer_count_desc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 1 || items[0].ID != 1 {
t.Fatalf("live page total=%d items=%+v", total, items)
}
if items[0].ViewerCount != 100 || items[0].ViewerCountText != "100" || items[0].Status != RoomStatusLive {
t.Fatalf("runtime fields = %+v", items[0])
}
}
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, Enabled: true},
{ID: 2, Enabled: true},
{ID: 3, Enabled: false},
}}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-1": {StreamID: "room-1", PublisherOnline: true},
"room-3": {StreamID: "room-3", PublisherOnline: true},
}})
rooms := NewRoomUsecase(catalog, streams)
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
got, err := rooms.IsLive(context.Background(), roomID)
if err != nil {
t.Fatalf("IsLive(%d) error = %v", roomID, err)
}
if got != want {
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
}
}
}
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(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: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, 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},
}})
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)
}
if _, err = rooms.Get(context.Background(), roomID); err != nil {
t.Errorf("admin Get(%d) error=%v", roomID, err)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package live
import (
"context"
"errors"
"time"
kratoserrors "github.com/go-kratos/kratos/v3/errors"
)
var (
ErrFollowNotFound = kratoserrors.NotFound("LIVE_FOLLOW_NOT_FOUND", "follow relation not found")
ErrFollowInvalid = kratoserrors.BadRequest("LIVE_FOLLOW_INVALID_ARGUMENT", "invalid follow argument")
)
type Follow struct {
ID uint
UserID uint
RoomID uint
AnchorUserID uint
CreatedAt time.Time
}
type FollowRepo interface {
Get(context.Context, uint, uint) (*Follow, error)
Create(context.Context, *Follow) (*Follow, error)
Delete(context.Context, uint, uint) error
}
type FollowUsecase struct{ repo FollowRepo }
func NewFollowUsecase(repo FollowRepo) *FollowUsecase { return &FollowUsecase{repo: repo} }
func (uc *FollowUsecase) Get(ctx context.Context, userID, roomID uint) (*Follow, error) {
if userID == 0 || roomID == 0 {
return nil, ErrFollowInvalid
}
return uc.repo.Get(ctx, userID, roomID)
}
func (uc *FollowUsecase) Follow(ctx context.Context, userID, roomID uint) (*Follow, error) {
if userID == 0 || roomID == 0 {
return nil, ErrFollowInvalid
}
if existing, err := uc.repo.Get(ctx, userID, roomID); err == nil {
return existing, nil
} else if !errors.Is(err, ErrFollowNotFound) {
return nil, err
}
return uc.repo.Create(ctx, &Follow{UserID: userID, RoomID: roomID})
}
func (uc *FollowUsecase) Unfollow(ctx context.Context, userID, roomID uint) error {
if userID == 0 || roomID == 0 {
return ErrFollowInvalid
}
return uc.repo.Delete(ctx, userID, roomID)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase)
+11
View File
@@ -27,6 +27,8 @@ type Stream struct {
PublisherID uint
PublisherOnline bool
SubscriberCount int
LikeCount int
StartedAt time.Time
UpdatedAt time.Time
}
@@ -37,6 +39,7 @@ type StreamRepo interface {
UnregisterPublisher(context.Context, string, uint) error
RegisterSubscriber(context.Context, string, uint) (*Stream, error)
UnregisterSubscriber(context.Context, string, uint) error
Like(context.Context, string) (int, error)
}
type StreamUsecase struct{ repo StreamRepo }
@@ -79,6 +82,14 @@ func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID stri
return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID)
}
func (uc *StreamUsecase) Like(ctx context.Context, streamID string) (int, error) {
streamID = normalizeStreamID(streamID)
if streamID == "" {
return 0, ErrInvalidStreamID
}
return uc.repo.Like(ctx, streamID)
}
func normalizeStreamID(value string) string {
return strings.TrimSpace(value)
}
+10
View File
@@ -5,6 +5,15 @@ import (
"testing"
)
func TestFormatViewerCount(t *testing.T) {
tests := map[int]string{0: "0", 999: "999", 1000: "1.0k", 5600: "5.6k", 10000: "1.0w", 12500: "1.2w"}
for input, want := range tests {
if got := formatViewerCount(input); got != want {
t.Fatalf("formatViewerCount(%d) = %q, want %q", input, got, want)
}
}
}
type testRepo struct {
streams map[string]*Stream
}
@@ -41,6 +50,7 @@ func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, e
return nil, nil
}
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil }
func (r *testRepo) Like(context.Context, string) (int, error) { return 0, nil }
func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
repo := &testRepo{streams: make(map[string]*Stream)}