feat: live module and jenkins pipeline to harbor deploy
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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")
|
||||
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
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) 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
|
||||
continue
|
||||
}
|
||||
room.Status = RoomStatusOffline
|
||||
if stream := byID[roomStreamID(room.ID)]; stream != nil && stream.PublisherOnline {
|
||||
room.Status = RoomStatusLive
|
||||
room.ViewerCount = stream.SubscriberCount
|
||||
}
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
|
||||
@@ -0,0 +1,84 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxStreams = 20
|
||||
StatusWaiting = "waiting"
|
||||
StatusLive = "live"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStreamID = errors.New("invalid stream id")
|
||||
ErrMaxStreams = errors.New("maximum live streams reached")
|
||||
ErrPublisherExists = errors.New("publisher already exists")
|
||||
ErrPublisherNotFound = errors.New("publisher not found")
|
||||
ErrSubscriberExists = errors.New("subscriber already exists")
|
||||
ErrSubscriberNotFound = errors.New("subscriber not found")
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
StreamID string
|
||||
PublisherID uint
|
||||
PublisherOnline bool
|
||||
SubscriberCount int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type StreamRepo interface {
|
||||
Get(context.Context, string) (*Stream, error)
|
||||
List(context.Context) ([]*Stream, error)
|
||||
RegisterPublisher(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterPublisher(context.Context, string, uint) error
|
||||
RegisterSubscriber(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterSubscriber(context.Context, string, uint) error
|
||||
}
|
||||
|
||||
type StreamUsecase struct{ repo StreamRepo }
|
||||
|
||||
func NewStreamUsecase(repo StreamRepo) *StreamUsecase { return &StreamUsecase{repo: repo} }
|
||||
|
||||
func (uc *StreamUsecase) List(ctx context.Context) ([]*Stream, error) {
|
||||
return uc.repo.List(ctx)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) Get(ctx context.Context, streamID string) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.Get(ctx, streamID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) RegisterPublisher(ctx context.Context, streamID string, userID uint) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" || userID == 0 {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.RegisterPublisher(ctx, streamID, userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) UnregisterPublisher(ctx context.Context, streamID string, userID uint) error {
|
||||
return uc.repo.UnregisterPublisher(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) RegisterSubscriber(ctx context.Context, streamID string, userID uint) (*Stream, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" || userID == 0 {
|
||||
return nil, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.RegisterSubscriber(ctx, streamID, userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID string, userID uint) error {
|
||||
return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func normalizeStreamID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testRepo struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
|
||||
func (r *testRepo) Get(_ context.Context, id string) (*Stream, error) { return r.streams[id], nil }
|
||||
func (r *testRepo) List(context.Context) ([]*Stream, error) {
|
||||
result := make([]*Stream, 0, len(r.streams))
|
||||
for _, item := range r.streams {
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (r *testRepo) RegisterPublisher(_ context.Context, id string, user uint) (*Stream, error) {
|
||||
item := r.streams[id]
|
||||
if item == nil {
|
||||
item = &Stream{StreamID: id}
|
||||
r.streams[id] = item
|
||||
}
|
||||
if item.PublisherOnline {
|
||||
return nil, ErrPublisherExists
|
||||
}
|
||||
item.PublisherOnline, item.PublisherID = true, user
|
||||
return item, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterPublisher(_ context.Context, id string, user uint) error {
|
||||
item := r.streams[id]
|
||||
if item == nil || !item.PublisherOnline || item.PublisherID != user {
|
||||
return ErrPublisherNotFound
|
||||
}
|
||||
item.PublisherOnline, item.PublisherID = false, 0
|
||||
return nil
|
||||
}
|
||||
func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil }
|
||||
|
||||
func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
|
||||
repo := &testRepo{streams: make(map[string]*Stream)}
|
||||
uc := NewStreamUsecase(repo)
|
||||
if _, err := uc.RegisterPublisher(context.Background(), " ", 1); err != ErrInvalidStreamID {
|
||||
t.Fatalf("invalid stream error = %v", err)
|
||||
}
|
||||
if _, err := uc.RegisterPublisher(context.Background(), "room-1", 1); err != nil {
|
||||
t.Fatalf("register publisher error = %v", err)
|
||||
}
|
||||
if _, err := uc.RegisterPublisher(context.Background(), "room-1", 2); err != ErrPublisherExists {
|
||||
t.Fatalf("duplicate publisher error = %v", err)
|
||||
}
|
||||
if err := uc.UnregisterPublisher(context.Background(), "room-1", 1); err != nil {
|
||||
t.Fatalf("unregister publisher error = %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user