对接
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 ©, 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, ©)
|
||||
}
|
||||
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 ©, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user