Files
kra-new/internal/biz/live/catalog.go
T
2026-09-22 12:04:22 +08:00

399 lines
11 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")
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 {
return uc.listByRuntimeStatus(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) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
all := make([]*Room, 0)
for offset := 0; ; offset += 100 {
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy})
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))
for _, room := range all {
if room.Status == filter.Status {
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
}
end := options.Offset + options.Limit
if end > len(matched) {
end = len(matched)
}
return matched[options.Offset:end], total, nil
}
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 || 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
}
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
}