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 }