417 lines
12 KiB
Go
417 lines
12 KiB
Go
package live
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
|
)
|
|
|
|
const (
|
|
RoomStatusOffline = "offline"
|
|
RoomStatusLive = "live"
|
|
RoomStatusDisabled = "disabled"
|
|
)
|
|
|
|
var (
|
|
ErrRoomNotFound = kratoserrors.NotFound("LIVE_ROOM_NOT_FOUND", "live room not found")
|
|
ErrCategoryNotFound = kratoserrors.NotFound("LIVE_CATEGORY_NOT_FOUND", "live category not found")
|
|
ErrCategoryNameExists = kratoserrors.BadRequest("LIVE_CATEGORY_NAME_EXISTS", "category name already exists")
|
|
ErrCategoryInUse = kratoserrors.BadRequest("LIVE_CATEGORY_IN_USE", "category is still used by live rooms")
|
|
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")
|
|
)
|
|
|
|
type Category struct {
|
|
ID uint
|
|
Name string
|
|
Icon string
|
|
Sort int
|
|
Enabled bool
|
|
RoomCount int64
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
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
|
|
ViewerCountText string
|
|
LikeCount int
|
|
StartedAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type CategoryFilter struct {
|
|
Name string
|
|
Enabled *bool
|
|
}
|
|
|
|
type RoomFilter struct {
|
|
Name string
|
|
AnchorName string
|
|
CategoryID uint
|
|
Status string
|
|
Enabled *bool
|
|
CategoryEnabled *bool
|
|
CreatedAt []time.Time
|
|
}
|
|
|
|
type ListOptions struct {
|
|
Offset int
|
|
Limit int
|
|
OrderBy string
|
|
}
|
|
|
|
type CatalogRepo interface {
|
|
ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error)
|
|
GetCategory(context.Context, uint) (*Category, error)
|
|
CreateCategory(context.Context, *Category) (*Category, error)
|
|
UpdateCategory(context.Context, *Category) (*Category, error)
|
|
DeleteCategory(context.Context, uint) error
|
|
|
|
ListRooms(context.Context, RoomFilter, ListOptions) ([]*Room, int64, error)
|
|
GetRoom(context.Context, uint) (*Room, error)
|
|
CreateRoom(context.Context, *Room) (*Room, error)
|
|
UpdateRoom(context.Context, *Room) (*Room, error)
|
|
DeleteRoom(context.Context, uint) error
|
|
UpdateRoomEnabled(context.Context, uint, bool) error
|
|
SaveRoomSort(context.Context, map[uint]int) error
|
|
}
|
|
|
|
type CategoryUsecase struct{ repo CatalogRepo }
|
|
|
|
func NewCategoryUsecase(repo CatalogRepo) *CategoryUsecase { return &CategoryUsecase{repo: repo} }
|
|
|
|
func (uc *CategoryUsecase) List(ctx context.Context, filter CategoryFilter, options ListOptions) ([]*Category, int64, error) {
|
|
filter.Name = strings.TrimSpace(filter.Name)
|
|
return uc.repo.ListCategories(ctx, filter, normalizeListOptions(options))
|
|
}
|
|
|
|
func (uc *CategoryUsecase) Get(ctx context.Context, id uint) (*Category, error) {
|
|
if id == 0 {
|
|
return nil, ErrCategoryNotFound
|
|
}
|
|
return uc.repo.GetCategory(ctx, id)
|
|
}
|
|
|
|
func (uc *CategoryUsecase) Create(ctx context.Context, item *Category) (*Category, error) {
|
|
if err := validateCategory(item); err != nil {
|
|
return nil, err
|
|
}
|
|
return uc.repo.CreateCategory(ctx, item)
|
|
}
|
|
|
|
func (uc *CategoryUsecase) Update(ctx context.Context, item *Category) (*Category, error) {
|
|
if item == nil || item.ID == 0 {
|
|
return nil, ErrCategoryNotFound
|
|
}
|
|
if err := validateCategory(item); err != nil {
|
|
return nil, err
|
|
}
|
|
return uc.repo.UpdateCategory(ctx, item)
|
|
}
|
|
|
|
func (uc *CategoryUsecase) Delete(ctx context.Context, id uint) error {
|
|
if id == 0 {
|
|
return ErrCategoryNotFound
|
|
}
|
|
return uc.repo.DeleteCategory(ctx, id)
|
|
}
|
|
|
|
type RoomUsecase struct {
|
|
repo CatalogRepo
|
|
streams *StreamUsecase
|
|
}
|
|
|
|
func NewRoomUsecase(repo CatalogRepo, streams *StreamUsecase) *RoomUsecase {
|
|
return &RoomUsecase{repo: repo, streams: streams}
|
|
}
|
|
|
|
func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
|
filter.Name = strings.TrimSpace(filter.Name)
|
|
filter.AnchorName = strings.TrimSpace(filter.AnchorName)
|
|
options = normalizeListOptions(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 {
|
|
return nil, 0, err
|
|
}
|
|
return uc.attachRuntime(ctx, rooms), total, nil
|
|
}
|
|
|
|
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: storageOrder})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
all = append(all, batch...)
|
|
if len(all) >= int(total) || len(batch) == 0 {
|
|
break
|
|
}
|
|
}
|
|
all = uc.attachRuntime(ctx, all)
|
|
matched := make([]*Room, 0, len(all))
|
|
filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline
|
|
for _, room := range all {
|
|
if !filterRuntimeStatus || room.Status == filter.Status {
|
|
matched = append(matched, room)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
end := options.Offset + options.Limit
|
|
if end > len(matched) {
|
|
end = len(matched)
|
|
}
|
|
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
|
|
}
|
|
room, err := uc.repo.GetRoom(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
return nil, ErrRoomDisabled
|
|
}
|
|
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !category.Enabled {
|
|
return nil, ErrCategoryDisabled
|
|
}
|
|
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
|
|
}
|
|
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) {
|
|
if err := validateRoom(room); err != nil {
|
|
return nil, err
|
|
}
|
|
return uc.repo.CreateRoom(ctx, room)
|
|
}
|
|
|
|
func (uc *RoomUsecase) Update(ctx context.Context, room *Room) (*Room, error) {
|
|
if room == nil || room.ID == 0 {
|
|
return nil, ErrRoomNotFound
|
|
}
|
|
if err := validateRoom(room); err != nil {
|
|
return nil, err
|
|
}
|
|
current, err := uc.Get(ctx, room.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if current.Status == RoomStatusLive && current.CategoryID != room.CategoryID {
|
|
return nil, ErrRoomCategoryLocked
|
|
}
|
|
return uc.repo.UpdateRoom(ctx, room)
|
|
}
|
|
|
|
func (uc *RoomUsecase) Delete(ctx context.Context, id uint) error {
|
|
if id == 0 {
|
|
return ErrRoomNotFound
|
|
}
|
|
return uc.repo.DeleteRoom(ctx, id)
|
|
}
|
|
|
|
func (uc *RoomUsecase) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
|
if id == 0 {
|
|
return ErrRoomNotFound
|
|
}
|
|
return uc.repo.UpdateRoomEnabled(ctx, id, enabled)
|
|
}
|
|
|
|
func (uc *RoomUsecase) SaveSort(ctx context.Context, values map[uint]int) error {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
for id, sortValue := range values {
|
|
if id == 0 || sortValue < 0 || sortValue > 9999 {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
}
|
|
return uc.repo.SaveRoomSort(ctx, values)
|
|
}
|
|
|
|
func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room {
|
|
streams, err := uc.streams.List(ctx)
|
|
if err != nil {
|
|
return rooms
|
|
}
|
|
byID := make(map[string]*Stream, len(streams))
|
|
for _, stream := range streams {
|
|
byID[stream.StreamID] = stream
|
|
}
|
|
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 {
|
|
const digits = "0123456789"
|
|
if value == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
index := len(buf)
|
|
for value > 0 {
|
|
index--
|
|
buf[index] = digits[value%10]
|
|
value /= 10
|
|
}
|
|
return string(buf[index:])
|
|
}
|
|
|
|
func normalizeListOptions(options ListOptions) ListOptions {
|
|
if options.Offset < 0 {
|
|
options.Offset = 0
|
|
}
|
|
if options.Limit <= 0 || options.Limit > 100 {
|
|
options.Limit = 10
|
|
}
|
|
return options
|
|
}
|
|
|
|
func validateCategory(item *Category) error {
|
|
if item == nil {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
item.Name = strings.TrimSpace(item.Name)
|
|
item.Icon = strings.TrimSpace(item.Icon)
|
|
if length := len([]rune(item.Name)); length < 1 || length > 10 || item.Sort < 0 || item.Sort > 9999 {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRoom(room *Room) error {
|
|
if room == nil {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
room.Name = strings.TrimSpace(room.Name)
|
|
room.Intro = strings.TrimSpace(room.Intro)
|
|
room.CoverURL = strings.TrimSpace(room.CoverURL)
|
|
room.StreamURL = strings.TrimSpace(room.StreamURL)
|
|
room.ReplayURL = strings.TrimSpace(room.ReplayURL)
|
|
if nameLength := len([]rune(room.Name)); nameLength < 1 || nameLength > 30 {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
if len([]rune(room.Intro)) > 60 || room.CategoryID == 0 || room.AnchorUserID == 0 || room.CoverURL == "" || room.Sort < 0 || room.Sort > 9999 {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
if room.StreamURL != "" && !strings.HasPrefix(room.StreamURL, "http://") && !strings.HasPrefix(room.StreamURL, "https://") {
|
|
return ErrRoomInvalidArgument
|
|
}
|
|
return nil
|
|
}
|