feat: live module and jenkins pipeline to harbor deploy

This commit is contained in:
Yvan
2026-09-21 16:45:44 +08:00
parent 451c8e2abc
commit 829d75c657
145 changed files with 8984 additions and 269 deletions
+330
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
+84
View File
@@ -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)
}
+60
View File
@@ -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)
}
}