对接
This commit is contained in:
@@ -2,10 +2,14 @@ package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,6 +21,24 @@ var (
|
||||
ErrSendNotAllowed = kratoserrors.BadRequest("GIFT_SEND_NOT_ALLOWED", "gift cannot be sent")
|
||||
)
|
||||
|
||||
// InsufficientBalanceError preserves the wallet snapshot needed by clients
|
||||
// to decide whether to open the recharge flow. It unwraps to the stable
|
||||
// ErrInsufficientBalance reason so existing errors.Is checks keep working.
|
||||
type InsufficientBalanceError struct {
|
||||
Required int64
|
||||
Balance int64
|
||||
Missing int64
|
||||
}
|
||||
|
||||
func (e *InsufficientBalanceError) Error() string {
|
||||
if e == nil {
|
||||
return ErrInsufficientBalance.Error()
|
||||
}
|
||||
return fmt.Sprintf("insufficient diamond balance: need %d, have %d, missing %d", e.Required, e.Balance, e.Missing)
|
||||
}
|
||||
|
||||
func (e *InsufficientBalanceError) Unwrap() error { return ErrInsufficientBalance }
|
||||
|
||||
type Gift struct {
|
||||
ID uint
|
||||
Name, Icon, Description string
|
||||
@@ -72,13 +94,21 @@ type Repo interface {
|
||||
type Notifier interface {
|
||||
GiftSent(context.Context, *Send)
|
||||
}
|
||||
type roomAvailability interface {
|
||||
IsLive(context.Context, uint) (bool, error)
|
||||
}
|
||||
type Usecase struct {
|
||||
repo Repo
|
||||
notifier Notifier
|
||||
rooms roomAvailability
|
||||
}
|
||||
|
||||
func NewUsecase(repo Repo, notifier Notifier) *Usecase {
|
||||
return &Usecase{repo: repo, notifier: notifier}
|
||||
func NewUsecase(repo Repo, notifier Notifier, rooms *bizlive.RoomUsecase) *Usecase {
|
||||
return newUsecase(repo, notifier, rooms)
|
||||
}
|
||||
|
||||
func newUsecase(repo Repo, notifier Notifier, rooms roomAvailability) *Usecase {
|
||||
return &Usecase{repo: repo, notifier: notifier, rooms: rooms}
|
||||
}
|
||||
func (uc *Usecase) List(ctx context.Context, f Filter, o ListOptions) ([]*Gift, int64, error) {
|
||||
f.Name = strings.TrimSpace(f.Name)
|
||||
@@ -115,6 +145,13 @@ func (uc *Usecase) Send(ctx context.Context, userID, roomID, giftID uint, quanti
|
||||
if userID == 0 || roomID == 0 || giftID == 0 || quantity < 1 || quantity > 9999 {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
if uc.rooms == nil {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
live, err := uc.rooms.IsLive(ctx, roomID)
|
||||
if err != nil || !live {
|
||||
return nil, ErrSendNotAllowed
|
||||
}
|
||||
result, err := uc.repo.Send(ctx, userID, roomID, giftID, quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package gift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type sendRepoStub struct {
|
||||
Repo
|
||||
called bool
|
||||
}
|
||||
|
||||
func (r *sendRepoStub) Send(_ context.Context, userID, roomID, giftID uint, quantity int) (*Send, error) {
|
||||
r.called = true
|
||||
return &Send{UserID: userID, RoomID: &roomID, GiftID: &giftID, Quantity: quantity}, nil
|
||||
}
|
||||
|
||||
type availabilityStub struct {
|
||||
live bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s availabilityStub) IsLive(context.Context, uint) (bool, error) { return s.live, s.err }
|
||||
|
||||
func TestSendRejectsOfflineRoomBeforeWalletMutation(t *testing.T) {
|
||||
repo := &sendRepoStub{}
|
||||
uc := newUsecase(repo, nil, availabilityStub{live: false})
|
||||
|
||||
if _, err := uc.Send(context.Background(), 1, 7, 3, 2); !errors.Is(err, ErrSendNotAllowed) {
|
||||
t.Fatalf("offline send error = %v", err)
|
||||
}
|
||||
if repo.called {
|
||||
t.Fatal("offline room reached gift repository")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAcceptsCustomQuantityForLiveRoom(t *testing.T) {
|
||||
repo := &sendRepoStub{}
|
||||
uc := newUsecase(repo, nil, availabilityStub{live: true})
|
||||
|
||||
item, err := uc.Send(context.Background(), 1, 7, 3, 37)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repo.called || item.Quantity != 37 {
|
||||
t.Fatalf("custom quantity result = %+v called=%v", item, repo.called)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +24,7 @@ var (
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -37,23 +40,26 @@ type Category struct {
|
||||
}
|
||||
|
||||
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
|
||||
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 {
|
||||
@@ -62,12 +68,13 @@ type CategoryFilter struct {
|
||||
}
|
||||
|
||||
type RoomFilter struct {
|
||||
Name string
|
||||
AnchorName string
|
||||
CategoryID uint
|
||||
Status string
|
||||
Enabled *bool
|
||||
CreatedAt []time.Time
|
||||
Name string
|
||||
AnchorName string
|
||||
CategoryID uint
|
||||
Status string
|
||||
Enabled *bool
|
||||
CategoryEnabled *bool
|
||||
CreatedAt []time.Time
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
@@ -174,6 +181,17 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
|
||||
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
|
||||
@@ -196,6 +214,32 @@ func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
|
||||
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
|
||||
@@ -258,17 +302,41 @@ func (uc *RoomUsecase) attachRuntime(ctx context.Context, rooms []*Room) []*Room
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type catalogRepoStub struct {
|
||||
categories []*Category
|
||||
rooms []*Room
|
||||
}
|
||||
|
||||
func (*catalogRepoStub) ListCategories(context.Context, CategoryFilter, ListOptions) ([]*Category, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (r *catalogRepoStub) GetCategory(_ context.Context, id uint) (*Category, error) {
|
||||
for _, category := range r.categories {
|
||||
if category.ID == id {
|
||||
copy := *category
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
func (*catalogRepoStub) CreateCategory(context.Context, *Category) (*Category, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*catalogRepoStub) UpdateCategory(context.Context, *Category) (*Category, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*catalogRepoStub) DeleteCategory(context.Context, uint) error { return nil }
|
||||
|
||||
func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
|
||||
filtered := make([]*Room, 0, len(r.rooms))
|
||||
for _, room := range r.rooms {
|
||||
if filter.CategoryID > 0 && room.CategoryID != filter.CategoryID {
|
||||
continue
|
||||
}
|
||||
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
|
||||
continue
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
category, err := r.GetCategory(context.Background(), room.CategoryID)
|
||||
if err != nil || category.Enabled != *filter.CategoryEnabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
copy := *room
|
||||
filtered = append(filtered, ©)
|
||||
}
|
||||
total := int64(len(filtered))
|
||||
if options.Offset >= len(filtered) {
|
||||
return []*Room{}, total, nil
|
||||
}
|
||||
end := options.Offset + options.Limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
return filtered[options.Offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *catalogRepoStub) GetRoom(_ context.Context, id uint) (*Room, error) {
|
||||
for _, room := range r.rooms {
|
||||
if room.ID == id {
|
||||
copy := *room
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrRoomNotFound
|
||||
}
|
||||
|
||||
func (*catalogRepoStub) CreateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
|
||||
func (*catalogRepoStub) UpdateRoom(context.Context, *Room) (*Room, error) { return nil, nil }
|
||||
func (*catalogRepoStub) DeleteRoom(context.Context, uint) error { return nil }
|
||||
func (*catalogRepoStub) UpdateRoomEnabled(context.Context, uint, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (*catalogRepoStub) SaveRoomSort(context.Context, map[uint]int) error { return nil }
|
||||
|
||||
func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
|
||||
enabled := true
|
||||
catalog := &catalogRepoStub{rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 3, CategoryID: 1, Enabled: true},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
}}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true, SubscriberCount: 100},
|
||||
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 5600},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
items, total, err := rooms.List(context.Background(), RoomFilter{CategoryID: 1, Status: RoomStatusLive, Enabled: &enabled}, ListOptions{Offset: 1, Limit: 1, OrderBy: "viewer_count_desc"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 1 || items[0].ID != 1 {
|
||||
t.Fatalf("live page total=%d items=%+v", total, items)
|
||||
}
|
||||
if items[0].ViewerCount != 100 || items[0].ViewerCountText != "100" || items[0].Status != RoomStatusLive {
|
||||
t.Fatalf("runtime fields = %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
|
||||
catalog := &catalogRepoStub{rooms: []*Room{
|
||||
{ID: 1, Enabled: true},
|
||||
{ID: 2, Enabled: true},
|
||||
{ID: 3, Enabled: false},
|
||||
}}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
|
||||
got, err := rooms.IsLive(context.Background(), roomID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsLive(%d) error = %v", roomID, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
|
||||
catalog := &catalogRepoStub{
|
||||
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}},
|
||||
rooms: []*Room{
|
||||
{ID: 1, CategoryID: 1, Enabled: true},
|
||||
{ID: 2, CategoryID: 1, Enabled: true},
|
||||
{ID: 3, CategoryID: 1, Enabled: false},
|
||||
{ID: 4, CategoryID: 2, Enabled: true},
|
||||
},
|
||||
}
|
||||
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
|
||||
"room-1": {StreamID: "room-1", PublisherOnline: true},
|
||||
"room-3": {StreamID: "room-3", PublisherOnline: true},
|
||||
"room-4": {StreamID: "room-4", PublisherOnline: true},
|
||||
}})
|
||||
rooms := NewRoomUsecase(catalog, streams)
|
||||
|
||||
item, err := rooms.GetPublic(context.Background(), 1)
|
||||
if err != nil || item.ID != 1 || item.Status != RoomStatusLive {
|
||||
t.Fatalf("GetPublic(1) item=%+v err=%v", item, err)
|
||||
}
|
||||
for _, roomID := range []uint{2, 3, 4} {
|
||||
if _, err = rooms.GetPublic(context.Background(), roomID); !errors.Is(err, ErrRoomNotLive) {
|
||||
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, ErrRoomNotLive)
|
||||
}
|
||||
if _, err = rooms.Get(context.Background(), roomID); err != nil {
|
||||
t.Errorf("admin Get(%d) error=%v", roomID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
kratoserrors "github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFollowNotFound = kratoserrors.NotFound("LIVE_FOLLOW_NOT_FOUND", "follow relation not found")
|
||||
ErrFollowInvalid = kratoserrors.BadRequest("LIVE_FOLLOW_INVALID_ARGUMENT", "invalid follow argument")
|
||||
)
|
||||
|
||||
type Follow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
RoomID uint
|
||||
AnchorUserID uint
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type FollowRepo interface {
|
||||
Get(context.Context, uint, uint) (*Follow, error)
|
||||
Create(context.Context, *Follow) (*Follow, error)
|
||||
Delete(context.Context, uint, uint) error
|
||||
}
|
||||
|
||||
type FollowUsecase struct{ repo FollowRepo }
|
||||
|
||||
func NewFollowUsecase(repo FollowRepo) *FollowUsecase { return &FollowUsecase{repo: repo} }
|
||||
|
||||
func (uc *FollowUsecase) Get(ctx context.Context, userID, roomID uint) (*Follow, error) {
|
||||
if userID == 0 || roomID == 0 {
|
||||
return nil, ErrFollowInvalid
|
||||
}
|
||||
return uc.repo.Get(ctx, userID, roomID)
|
||||
}
|
||||
|
||||
func (uc *FollowUsecase) Follow(ctx context.Context, userID, roomID uint) (*Follow, error) {
|
||||
if userID == 0 || roomID == 0 {
|
||||
return nil, ErrFollowInvalid
|
||||
}
|
||||
if existing, err := uc.repo.Get(ctx, userID, roomID); err == nil {
|
||||
return existing, nil
|
||||
} else if !errors.Is(err, ErrFollowNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.Create(ctx, &Follow{UserID: userID, RoomID: roomID})
|
||||
}
|
||||
|
||||
func (uc *FollowUsecase) Unfollow(ctx context.Context, userID, roomID uint) error {
|
||||
if userID == 0 || roomID == 0 {
|
||||
return ErrFollowInvalid
|
||||
}
|
||||
return uc.repo.Delete(ctx, userID, roomID)
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase)
|
||||
var ProviderSet = wire.NewSet(NewStreamUsecase, NewCategoryUsecase, NewRoomUsecase, NewFollowUsecase)
|
||||
|
||||
@@ -27,6 +27,8 @@ type Stream struct {
|
||||
PublisherID uint
|
||||
PublisherOnline bool
|
||||
SubscriberCount int
|
||||
LikeCount int
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -37,6 +39,7 @@ type StreamRepo interface {
|
||||
UnregisterPublisher(context.Context, string, uint) error
|
||||
RegisterSubscriber(context.Context, string, uint) (*Stream, error)
|
||||
UnregisterSubscriber(context.Context, string, uint) error
|
||||
Like(context.Context, string) (int, error)
|
||||
}
|
||||
|
||||
type StreamUsecase struct{ repo StreamRepo }
|
||||
@@ -79,6 +82,14 @@ func (uc *StreamUsecase) UnregisterSubscriber(ctx context.Context, streamID stri
|
||||
return uc.repo.UnregisterSubscriber(ctx, normalizeStreamID(streamID), userID)
|
||||
}
|
||||
|
||||
func (uc *StreamUsecase) Like(ctx context.Context, streamID string) (int, error) {
|
||||
streamID = normalizeStreamID(streamID)
|
||||
if streamID == "" {
|
||||
return 0, ErrInvalidStreamID
|
||||
}
|
||||
return uc.repo.Like(ctx, streamID)
|
||||
}
|
||||
|
||||
func normalizeStreamID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatViewerCount(t *testing.T) {
|
||||
tests := map[int]string{0: "0", 999: "999", 1000: "1.0k", 5600: "5.6k", 10000: "1.0w", 12500: "1.2w"}
|
||||
for input, want := range tests {
|
||||
if got := formatViewerCount(input); got != want {
|
||||
t.Fatalf("formatViewerCount(%d) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type testRepo struct {
|
||||
streams map[string]*Stream
|
||||
}
|
||||
@@ -41,6 +50,7 @@ func (r *testRepo) RegisterSubscriber(context.Context, string, uint) (*Stream, e
|
||||
return nil, nil
|
||||
}
|
||||
func (r *testRepo) UnregisterSubscriber(context.Context, string, uint) error { return nil }
|
||||
func (r *testRepo) Like(context.Context, string) (int, error) { return 0, nil }
|
||||
|
||||
func TestStreamUsecaseValidatesAndDelegates(t *testing.T) {
|
||||
repo := &testRepo{streams: make(map[string]*Stream)}
|
||||
|
||||
@@ -53,6 +53,7 @@ type Repo interface {
|
||||
ListRecent(context.Context, uint, int) ([]*Message, error)
|
||||
CreateChat(context.Context, uint, uint, string) (*Message, error)
|
||||
CreateGift(context.Context, *GiftSnapshot) (*Message, error)
|
||||
Clear(context.Context, uint) error
|
||||
}
|
||||
|
||||
type Usecase struct{ repo Repo }
|
||||
@@ -83,3 +84,10 @@ func (uc *Usecase) RecordGift(ctx context.Context, gift *GiftSnapshot) (*Message
|
||||
}
|
||||
return uc.repo.CreateGift(ctx, gift)
|
||||
}
|
||||
|
||||
func (uc *Usecase) Clear(ctx context.Context, roomID uint) error {
|
||||
if roomID == 0 {
|
||||
return ErrRoomNotFound
|
||||
}
|
||||
return uc.repo.Clear(ctx, roomID)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type Account struct {
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username, Password, ConfirmPassword string
|
||||
Username, Nickname, Password, ConfirmPassword string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
@@ -84,6 +84,12 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st
|
||||
if n := len([]rune(in.Username)); n < 3 || n > 32 || in.Password == "" || in.Password != in.ConfirmPassword {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
in.Nickname = strings.TrimSpace(in.Nickname)
|
||||
if in.Nickname == "" {
|
||||
in.Nickname = in.Username
|
||||
} else if n := len([]rune(in.Nickname)); n < 2 || n > 16 {
|
||||
return nil, ErrInvalidAccount
|
||||
}
|
||||
config, err := uc.security.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -95,7 +101,7 @@ func (uc *Usecase) Register(ctx context.Context, in *RegisterInput, ip, agent st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Username, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds)
|
||||
account, err := uc.repo.Register(ctx, &Account{Username: in.Username, Nickname: in.Nickname, AuthorityID: ClientAuthorityID, Enabled: true}, string(hash), InitialDiamonds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package liveclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/biz/system"
|
||||
)
|
||||
|
||||
type registerRepoStub struct {
|
||||
Repo
|
||||
account *Account
|
||||
}
|
||||
|
||||
func (r *registerRepoStub) Register(_ context.Context, account *Account, _ string, _ int64) (*Account, error) {
|
||||
copy := *account
|
||||
copy.UserID = 1
|
||||
copy.UUID = "client-uuid"
|
||||
copy.CreatedAt = time.Now()
|
||||
r.account = ©
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
type registerSecurityRepoStub struct{ system.SecurityRepo }
|
||||
|
||||
func (*registerSecurityRepoStub) SecurityConfig(context.Context) (*system.SecurityConfig, error) {
|
||||
return &system.SecurityConfig{PwdMinLength: 8}, nil
|
||||
}
|
||||
|
||||
type registerSettingsStub struct{ system.RuntimeSettings }
|
||||
|
||||
func (*registerSettingsStub) UseMultipoint() bool { return false }
|
||||
|
||||
type registerIssuerStub struct{ system.TokenIssuer }
|
||||
|
||||
func (*registerIssuerStub) IssueToken(*system.User, uint, bool, time.Duration) (*system.IssuedToken, error) {
|
||||
return &system.IssuedToken{Value: "token", ExpiresAt: time.Now().Add(time.Hour), TTL: time.Hour}, nil
|
||||
}
|
||||
|
||||
func (*registerIssuerStub) ReissueToken(claims *system.AuthClaims, _ uint) (*system.IssuedToken, error) {
|
||||
return &system.IssuedToken{Value: "token", ExpiresAt: claims.ExpiresAt, TTL: time.Until(claims.ExpiresAt)}, nil
|
||||
}
|
||||
|
||||
func (*registerIssuerStub) ParseToken(string) (*system.AuthClaims, error) { return nil, nil }
|
||||
|
||||
func newRegisterUsecase(repo *registerRepoStub) *Usecase {
|
||||
security := system.NewSecurityUsecase(®isterSecurityRepoStub{}, nil, ®isterSettingsStub{}, nil)
|
||||
auth := system.NewAuthenticationUsecase(nil, security, ®isterIssuerStub{}, nil)
|
||||
return NewUsecase(repo, security, auth, nil)
|
||||
}
|
||||
|
||||
func TestRegisterUsesTrimmedNickname(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
result, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Nickname: " 直播用户 ",
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Account.Nickname != "直播用户" || repo.account.Nickname != "直播用户" {
|
||||
t.Fatalf("nickname = %q, repo nickname = %q", result.Account.Nickname, repo.account.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDefaultsNicknameToUsername(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
result, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Account.Nickname != "viewer" {
|
||||
t.Fatalf("nickname = %q, want username fallback", result.Account.Nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsNicknameOutsideRange(t *testing.T) {
|
||||
for _, nickname := range []string{"A", "12345678901234567"} {
|
||||
t.Run(nickname, func(t *testing.T) {
|
||||
repo := ®isterRepoStub{}
|
||||
uc := newRegisterUsecase(repo)
|
||||
_, err := uc.Register(context.Background(), &RegisterInput{
|
||||
Username: "viewer",
|
||||
Nickname: nickname,
|
||||
Password: "password",
|
||||
ConfirmPassword: "password",
|
||||
}, "", "")
|
||||
if !errors.Is(err, ErrInvalidAccount) {
|
||||
t.Fatalf("error = %v, want ErrInvalidAccount", err)
|
||||
}
|
||||
if repo.account != nil {
|
||||
t.Fatal("repository called for invalid nickname")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func toBiz(v *row) *biz.User {
|
||||
return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
|
||||
}
|
||||
func userOrder(v string) string {
|
||||
m := map[string]string{"id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
m := map[string]string{"id_asc": "sys_users.id ASC", "id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_asc": "sys_users.created_at ASC", "created_desc": "sys_users.created_at DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
if x := m[v]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -9,5 +9,14 @@ import (
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{})
|
||||
}}, {ID: "202609220003_live_wallet_backfill", Migrate: func(db *gorm.DB) error {
|
||||
// Historical client accounts predate live_wallets. Backfill only missing
|
||||
// rows with the same 100-diamond welcome balance as a new registration;
|
||||
// existing balances and administrative adjustments remain untouched.
|
||||
return db.Exec(`INSERT INTO live_wallets (user_id, diamond_balance, total_recharge_cents, total_spent_diamonds, created_at, updated_at)
|
||||
SELECT u.id, 100, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM sys_users u
|
||||
WHERE u.deleted_at IS NULL AND u.authority_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM live_wallets w WHERE w.user_id = u.id)`, 1000).Error
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -228,23 +228,13 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
|
||||
if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
allowedQuantity := false
|
||||
for _, tier := range decode(gift.Tiers) {
|
||||
if tier == quantity {
|
||||
allowedQuantity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowedQuantity {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: userID}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total := gift.Price * int64(quantity)
|
||||
if wallet.DiamondBalance < total {
|
||||
return bizgift.ErrInsufficientBalance
|
||||
return &bizgift.InsufficientBalanceError{Required: total, Balance: wallet.DiamondBalance, Missing: total - wallet.DiamondBalance}
|
||||
}
|
||||
rate := int64(10)
|
||||
var setting ledger.SettingPO
|
||||
@@ -292,18 +282,18 @@ func mapError(err error) error {
|
||||
return err
|
||||
}
|
||||
func giftOrder(v string) string {
|
||||
m := map[string]string{"sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC"}
|
||||
m := map[string]string{"sort_asc": "sort ASC", "sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC", "created_at_asc": "created_at ASC", "created_at_desc": "created_at DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "sort ASC,created_at DESC"
|
||||
return "sort ASC,created_at DESC,id ASC"
|
||||
}
|
||||
func sendOrder(v string) string {
|
||||
m := map[string]string{"created_at_asc": "created_at ASC", "price_asc": "gift_price ASC", "price_desc": "gift_price DESC", "quantity_asc": "quantity ASC", "quantity_desc": "quantity DESC", "total_asc": "diamond_total ASC", "total_desc": "diamond_total DESC"}
|
||||
m := map[string]string{"created_at_asc": "live_gift_sends.created_at ASC", "created_at_desc": "live_gift_sends.created_at DESC", "price_asc": "live_gift_sends.gift_price ASC", "price_desc": "live_gift_sends.gift_price DESC", "quantity_asc": "live_gift_sends.quantity ASC", "quantity_desc": "live_gift_sends.quantity DESC", "total_asc": "live_gift_sends.diamond_total ASC", "total_desc": "live_gift_sends.diamond_total DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "created_at DESC"
|
||||
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
|
||||
@@ -67,12 +67,12 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) {
|
||||
t.Fatalf("unsupported quantity error = %v", err)
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil {
|
||||
t.Fatalf("custom quantity should be accepted: %v", err)
|
||||
}
|
||||
var before ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 {
|
||||
t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err)
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 {
|
||||
t.Fatalf("wallet did not debit custom quantity: wallet=%+v err=%v", before, err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -81,7 +81,38 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
var sends int64
|
||||
db.Where("user_id = ?", 1).First(&after)
|
||||
db.Model(&ledger.GiftSendPO{}).Count(&sends)
|
||||
if after.DiamondBalance != 50 || after.TotalSpentDiamonds != 50 || sends != 1 {
|
||||
if after.DiamondBalance != 40 || after.TotalSpentDiamonds != 60 || sends != 2 {
|
||||
t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
custom := giftPO{Name: "玫瑰", Icon: "custom", Price: 99, Description: "admin configured", Tiers: "[1]", Sort: 88, Enabled: false}
|
||||
if err := db.Create(&custom).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&giftPO{}).Where("id = ?", custom.ID).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&giftPO{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 24 {
|
||||
t.Fatalf("gift count = %d, want 24", count)
|
||||
}
|
||||
var rose giftPO
|
||||
if err := db.Where("name = ?", "玫瑰").First(&rose).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rose.Icon != "custom" || rose.Price != 99 || rose.Sort != 88 || rose.Enabled {
|
||||
t.Fatalf("existing gift was overwritten: %+v", rose)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,59 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]giftPO{{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true}, {Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true}, {Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true}, {Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true}}).Error
|
||||
}}}
|
||||
}}, {ID: "202609220001_live_gift_catalog_seed", Migrate: seedDefaultCatalog}}
|
||||
}
|
||||
|
||||
// seedDefaultCatalog is intentionally additive. Existing installations may
|
||||
// have edited names, prices, icons, tiers, sort, or enabled state in the
|
||||
// administration UI; migration must never overwrite those choices. We only
|
||||
// insert a missing named gift so the client can rely on the six quick gifts
|
||||
// and the 24-item "more gifts" grid being available after an upgrade.
|
||||
func seedDefaultCatalog(db *gorm.DB) error {
|
||||
defaults := []giftPO{
|
||||
{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true},
|
||||
{Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true},
|
||||
{Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true},
|
||||
{Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true},
|
||||
{Name: "城堡", Icon: "🏰", Price: 1314, Description: "城堡降临,点亮直播间", Tiers: "[1,10,66,1314]", Sort: 5, Enabled: true},
|
||||
{Name: "皇冠", Icon: "👑", Price: 5200, Description: "皇冠加冕,彰显特别支持", Tiers: "[1,10,66,1314]", Sort: 6, Enabled: true},
|
||||
{Name: "爱心", Icon: "❤️", Price: 5, Description: "把喜欢送到主播身边", Tiers: "[1,10,66,1314]", Sort: 7, Enabled: true},
|
||||
{Name: "棒棒糖", Icon: "🍭", Price: 8, Description: "甜甜的支持送给你", Tiers: "[1,10,66,1314]", Sort: 8, Enabled: true},
|
||||
{Name: "咖啡", Icon: "☕", Price: 15, Description: "为主播续上一杯咖啡", Tiers: "[1,10,66,1314]", Sort: 9, Enabled: true},
|
||||
{Name: "蛋糕", Icon: "🍰", Price: 20, Description: "分享一份甜蜜祝福", Tiers: "[1,10,66,1314]", Sort: 10, Enabled: true},
|
||||
{Name: "气球", Icon: "🎈", Price: 30, Description: "让快乐在直播间起飞", Tiers: "[1,10,66,1314]", Sort: 11, Enabled: true},
|
||||
{Name: "钻戒", Icon: "💍", Price: 88, Description: "闪耀的心意送给你", Tiers: "[1,10,66,1314]", Sort: 12, Enabled: true},
|
||||
{Name: "麦克风", Icon: "🎤", Price: 99, Description: "为精彩歌声喝彩", Tiers: "[1,10,66,1314]", Sort: 13, Enabled: true},
|
||||
{Name: "吉他", Icon: "🎸", Price: 120, Description: "伴着旋律一起摇摆", Tiers: "[1,10,66,1314]", Sort: 14, Enabled: true},
|
||||
{Name: "花束", Icon: "💐", Price: 166, Description: "把鲜花和掌声送给主播", Tiers: "[1,10,66,1314]", Sort: 15, Enabled: true},
|
||||
{Name: "甜甜圈", Icon: "🍩", Price: 188, Description: "甜甜圈住每一份快乐", Tiers: "[1,10,66,1314]", Sort: 16, Enabled: true},
|
||||
{Name: "烟花", Icon: "🎆", Price: 233, Description: "烟花为精彩时刻喝彩", Tiers: "[1,10,66,1314]", Sort: 17, Enabled: true},
|
||||
{Name: "豪华游艇", Icon: "🛥️", Price: 520, Description: "乘风破浪驶入直播间", Tiers: "[1,10,66,520]", Sort: 18, Enabled: true},
|
||||
{Name: "直升机", Icon: "🚁", Price: 666, Description: "高调登场,气氛拉满", Tiers: "[1,10,66,666]", Sort: 19, Enabled: true},
|
||||
{Name: "热气球", Icon: "🎈", Price: 888, Description: "带着祝福飞向天空", Tiers: "[1,10,66,888]", Sort: 20, Enabled: true},
|
||||
{Name: "月亮", Icon: "🌙", Price: 999, Description: "月光陪伴每一个夜晚", Tiers: "[1,10,66,999]", Sort: 21, Enabled: true},
|
||||
{Name: "太阳", Icon: "☀️", Price: 1024, Description: "把热烈的能量送给主播", Tiers: "[1,10,66,1024]", Sort: 22, Enabled: true},
|
||||
{Name: "彩虹", Icon: "🌈", Price: 1888, Description: "愿每一次相遇都如彩虹", Tiers: "[1,10,66,1314]", Sort: 23, Enabled: true},
|
||||
{Name: "宝箱", Icon: "🎁", Price: 2888, Description: "打开宝箱收获惊喜", Tiers: "[1,10,66,1314]", Sort: 24, Enabled: true},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
var existing giftPO
|
||||
err := db.Unscoped().Where("name = ?", item.Name).First(&existing).Error
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
// A concurrent migration/admin write may win the unique race. The
|
||||
// named gift is present then, so leave its configuration untouched.
|
||||
var raced giftPO
|
||||
if db.Unscoped().Where("name = ?", item.Name).First(&raced).Error == nil {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,7 +174,8 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
query = query.Where("live_rooms.name LIKE ?", "%"+filter.Name+"%")
|
||||
value := "%" + filter.Name + "%"
|
||||
query = query.Where("live_rooms.name LIKE ? OR CAST(live_rooms.id AS CHAR) LIKE ?", value, value)
|
||||
}
|
||||
if filter.AnchorName != "" {
|
||||
value := "%" + filter.AnchorName + "%"
|
||||
@@ -186,6 +187,9 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
if filter.Enabled != nil {
|
||||
query = query.Where("live_rooms.enabled = ?", *filter.Enabled)
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
query = query.Where("live_categories.enabled = ?", *filter.CategoryEnabled)
|
||||
}
|
||||
if filter.Status == bizlive.RoomStatusDisabled {
|
||||
query = query.Where("live_rooms.enabled = ?", false)
|
||||
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
|
||||
|
||||
@@ -74,3 +74,39 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabledCategory := categoryPO{Name: "Games", Enabled: true}
|
||||
disabledCategory := categoryPO{Name: "Hidden", Enabled: false}
|
||||
if err := db.Create(&enabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&disabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&disabledCategory).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, room := range []*roomPO{
|
||||
{Name: "Visible", CategoryID: enabledCategory.ID, CoverURL: "visible.png", AnchorUserID: 2, Enabled: true},
|
||||
{Name: "Hidden", CategoryID: disabledCategory.ID, CoverURL: "hidden.png", AnchorUserID: 2, Enabled: true},
|
||||
} {
|
||||
if err := db.Create(room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
enabled := true
|
||||
rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(rooms) != 1 || rooms[0].Name != "Visible" {
|
||||
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
type followPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
|
||||
RoomID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
|
||||
AnchorUserID uint `gorm:"not null;index"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (followPO) TableName() string { return "live_room_follows" }
|
||||
|
||||
type followRepo struct{ data Provider }
|
||||
|
||||
func NewFollowRepo(data Provider) bizlive.FollowRepo { return &followRepo{data: data} }
|
||||
|
||||
func (r *followRepo) Get(ctx context.Context, userID, roomID uint) (*bizlive.Follow, error) {
|
||||
var row followPO
|
||||
err := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, bizlive.ErrFollowNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return followToBiz(&row), nil
|
||||
}
|
||||
|
||||
func (r *followRepo) Create(ctx context.Context, item *bizlive.Follow) (*bizlive.Follow, error) {
|
||||
var room roomPO
|
||||
if err := r.db(ctx).First(&room, item.RoomID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, bizlive.ErrRoomNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
item.AnchorUserID = room.AnchorUserID
|
||||
po := &followPO{UserID: item.UserID, RoomID: item.RoomID, AnchorUserID: item.AnchorUserID}
|
||||
if err := r.db(ctx).Create(po).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return r.Get(ctx, item.UserID, item.RoomID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return followToBiz(po), nil
|
||||
}
|
||||
|
||||
func (r *followRepo) Delete(ctx context.Context, userID, roomID uint) error {
|
||||
result := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).Delete(&followPO{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return bizlive.ErrFollowNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *followRepo) db(ctx context.Context) *gorm.DB {
|
||||
if r == nil || r.data == nil || r.data.DB() == nil {
|
||||
return nil
|
||||
}
|
||||
return r.data.DB().WithContext(ctx)
|
||||
}
|
||||
|
||||
func followToBiz(row *followPO) *bizlive.Follow {
|
||||
return &bizlive.Follow{ID: row.ID, UserID: row.UserID, RoomID: row.RoomID, AnchorUserID: row.AnchorUserID}
|
||||
}
|
||||
@@ -18,5 +18,7 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error
|
||||
}}, {ID: "202609220002_live_follow_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &followPO{})
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewStreamRepo,
|
||||
NewCatalogRepo,
|
||||
NewFollowRepo,
|
||||
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
|
||||
)
|
||||
|
||||
@@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI
|
||||
}
|
||||
state.stream.PublisherID = userID
|
||||
state.stream.PublisherOnline = true
|
||||
// A new broadcast starts with a fresh in-room like counter. Keeping the
|
||||
// stream state object is useful when viewers connected before the publisher,
|
||||
// but likes must never leak into the next broadcast session.
|
||||
state.stream.LikeCount = 0
|
||||
state.stream.StartedAt = time.Now()
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
@@ -75,6 +80,7 @@ func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, use
|
||||
}
|
||||
state.stream.PublisherID = 0
|
||||
state.stream.PublisherOnline = false
|
||||
state.stream.StartedAt = time.Time{}
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
if len(state.users) == 0 {
|
||||
delete(r.streams, streamID)
|
||||
@@ -121,6 +127,19 @@ func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, us
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) Like(_ context.Context, streamID string) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
|
||||
r.streams[streamID] = state
|
||||
}
|
||||
state.stream.LikeCount++
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return state.stream.LikeCount, nil
|
||||
}
|
||||
|
||||
func clone(stream *bizlive.Stream) *bizlive.Stream {
|
||||
if stream == nil {
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,7 @@ package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,9 +12,14 @@ import (
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
type repo struct {
|
||||
data Provider
|
||||
mu sync.RWMutex
|
||||
nextID uint
|
||||
rooms map[uint][]*biz.Message
|
||||
}
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data, rooms: make(map[uint][]*biz.Message)} }
|
||||
|
||||
type messagePO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
@@ -46,14 +51,16 @@ func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.M
|
||||
if err := r.ensureRoom(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []messagePO
|
||||
if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
r.mu.RLock()
|
||||
items := r.rooms[roomID]
|
||||
if len(items) > limit {
|
||||
items = items[len(items)-limit:]
|
||||
}
|
||||
out := make([]*biz.Message, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
out := make([]*biz.Message, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, cloneMessage(item))
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -65,11 +72,7 @@ func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}), nil
|
||||
}
|
||||
|
||||
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) {
|
||||
@@ -87,28 +90,28 @@ func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Mes
|
||||
if nickname == "" {
|
||||
nickname = user.Nickname
|
||||
}
|
||||
row := &messagePO{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}), nil
|
||||
}
|
||||
|
||||
func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error {
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var boundary messagePO
|
||||
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
|
||||
})
|
||||
func (r *repo) append(item *biz.Message) *biz.Message {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.nextID++
|
||||
item.ID = r.nextID
|
||||
item.CreatedAt = time.Now()
|
||||
items := append(r.rooms[item.RoomID], cloneMessage(item))
|
||||
if len(items) > 100 {
|
||||
items = append([]*biz.Message(nil), items[len(items)-100:]...)
|
||||
}
|
||||
r.rooms[item.RoomID] = items
|
||||
return cloneMessage(item)
|
||||
}
|
||||
|
||||
func (r *repo) Clear(_ context.Context, roomID uint) error {
|
||||
r.mu.Lock()
|
||||
delete(r.rooms, roomID)
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error {
|
||||
@@ -136,3 +139,11 @@ func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) {
|
||||
func toBiz(row *messagePO) *biz.Message {
|
||||
return &biz.Message{ID: row.ID, RoomID: row.RoomID, Type: row.Type, UserID: row.UserID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, Content: row.Content, GiftID: row.GiftID, GiftName: row.GiftName, GiftIcon: row.GiftIcon, Quantity: row.Quantity, DiamondTotal: row.DiamondTotal, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func cloneMessage(item *biz.Message) *biz.Message {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *item
|
||||
return ©
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&messagePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`,
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, deleted_at datetime)`,
|
||||
@@ -37,6 +34,22 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestChatHistoryIsProcessLocalNotPersisted(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
first := NewRepo(testProvider{db: db})
|
||||
if _, err := first.CreateChat(context.Background(), 7, 3, "current session"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewRepo(testProvider{db: db})
|
||||
items, err := second.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("chat leaked across runtime sessions: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatKeepsLatestHundredMessages(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
@@ -65,3 +78,21 @@ func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
|
||||
t.Fatalf("gift message = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearDropsRoomMessagesWhenBroadcastEnds(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err := repo.CreateChat(context.Background(), 7, 3, "before close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Clear(context.Background(), 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("messages remained after room close: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,18 @@ func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash
|
||||
|
||||
func clientRoutes() []struct{ method, path string } {
|
||||
return []struct{ method, path string }{
|
||||
{method: "POST", path: "/live/client/auth/logout"},
|
||||
{method: "GET", path: "/live/client/me"},
|
||||
{method: "GET", path: "/live/client/balance"},
|
||||
{method: "PUT", path: "/live/client/me"},
|
||||
{method: "PUT", path: "/live/client/me/password"},
|
||||
{method: "POST", path: "/live/rooms/:id/gifts"},
|
||||
{method: "GET", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/rooms/:id/follow"},
|
||||
{method: "DELETE", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/recharge/orders"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 22 {
|
||||
t.Fatalf("migration versions = %d, want 22", versions)
|
||||
if versions != 26 {
|
||||
t.Fatalf("migration versions = %d, want 26", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -179,7 +179,7 @@ func toBiz(v *row) *biz.Order {
|
||||
}
|
||||
|
||||
func orderBy(v string) string {
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"}
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC", "created_at_desc": "live_recharge_orders.created_at DESC", "id_asc": "live_recharge_orders.id ASC", "id_desc": "live_recharge_orders.id DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -206,6 +206,36 @@ func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetM
|
||||
})
|
||||
}
|
||||
|
||||
// GrantAuthorityAPIs idempotently grants an explicit API set to one authority.
|
||||
// Keeping this helper in data/system preserves ownership of the Casbin storage
|
||||
// shape while allowing module migrations to add narrowly scoped client routes.
|
||||
func GrantAuthorityAPIs(db *gorm.DB, authorityID uint, apis []platformmodule.API) error {
|
||||
if db == nil || authorityID == 0 || len(apis) == 0 || !db.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, api := range apis {
|
||||
method := strings.ToUpper(strings.TrimSpace(api.Method))
|
||||
path := strings.TrimSpace(api.Path)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
exists, err := policyExists(tx, authorityID, path, method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
rule := newPolicyRule(authorityID, path, method)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GrantMenuAPIs makes a visible administration menu usable by granting only
|
||||
// its explicitly supplied API set to authorities that already own the menu.
|
||||
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
|
||||
|
||||
@@ -34,6 +34,7 @@ type Manager struct {
|
||||
peers map[*melody.Session]*peer
|
||||
observers map[*melody.Session]struct{}
|
||||
nextSub atomic.Uint64
|
||||
nextGuest atomic.Uint64
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -53,16 +54,19 @@ type publishedTrack struct {
|
||||
}
|
||||
|
||||
type peer struct {
|
||||
session *melody.Session
|
||||
manager *Manager
|
||||
room *room
|
||||
streamID string
|
||||
userID uint
|
||||
role string
|
||||
subscriberID string
|
||||
pc *webrtc.PeerConnection
|
||||
lastBeat atomic.Int64
|
||||
cleanup sync.Once
|
||||
session *melody.Session
|
||||
manager *Manager
|
||||
room *room
|
||||
streamID string
|
||||
userID uint
|
||||
role string
|
||||
authenticated bool
|
||||
subscriberID string
|
||||
pcMu sync.RWMutex
|
||||
pc *webrtc.PeerConnection
|
||||
closing bool
|
||||
lastBeat atomic.Int64
|
||||
cleanup sync.Once
|
||||
}
|
||||
|
||||
type message struct {
|
||||
@@ -210,20 +214,28 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "INVALID_JOIN", "message": "role and streamId are required"})
|
||||
return
|
||||
}
|
||||
userID, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
userID, authenticated := sessionUserID(session)
|
||||
if role == "publisher" && !authenticated {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required"})
|
||||
return
|
||||
}
|
||||
if role == "subscriber" && !authenticated {
|
||||
userID = m.anonymousViewerID()
|
||||
}
|
||||
if existing := m.peer(session); existing != nil {
|
||||
m.closePeer(existing, "rejoin")
|
||||
}
|
||||
r := m.getOrCreateRoom(streamID)
|
||||
if role == "publisher" {
|
||||
if err := m.authorizeRoomPublisher(streamID, userID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "PUBLISH_FORBIDDEN", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if err := m.authorizeRoomSubscriber(streamID); err != nil {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": "ROOM_UNAVAILABLE", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
r := m.getOrCreateRoom(streamID)
|
||||
if role == "publisher" {
|
||||
r.mu.RLock()
|
||||
occupied := r.publisher != nil
|
||||
r.mu.RUnlock()
|
||||
@@ -235,7 +247,10 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role}
|
||||
if roomID, ok := roomIDFromStream(streamID); ok {
|
||||
_ = m.chat.Clear(context.Background(), roomID)
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: true}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.publisher = p
|
||||
@@ -244,7 +259,6 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
m.sendPublisherStatus(streamID, false, "publisher_waiting_for_offer")
|
||||
m.broadcastStreamList()
|
||||
return
|
||||
@@ -269,7 +283,7 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.send(session, "JoinRejected", map[string]any{"code": errorCode(err), "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)}
|
||||
p := &peer{session: session, manager: m, room: r, streamID: streamID, userID: userID, role: role, authenticated: authenticated, subscriberID: "sub_" + strconv.FormatUint(m.nextSub.Add(1), 10)}
|
||||
p.lastBeat.Store(time.Now().UnixMilli())
|
||||
r.mu.Lock()
|
||||
r.viewers[p] = struct{}{}
|
||||
@@ -278,14 +292,17 @@ func (m *Manager) join(session *melody.Session, payload joinPayload) {
|
||||
m.mu.Lock()
|
||||
m.peers[session] = p
|
||||
m.mu.Unlock()
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": userID, "mode": "pion-sfu"})
|
||||
m.sendChatHistory(session, streamID)
|
||||
publicUserID := userID
|
||||
if !authenticated {
|
||||
publicUserID = 0
|
||||
}
|
||||
m.send(session, "Joined", map[string]any{"role": role, "streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID, "mode": "pion-sfu"})
|
||||
m.sendPublisherStatusTo(session, streamID, ready, "publisher_offline")
|
||||
if ready {
|
||||
m.send(session, "PublisherStatus", map[string]any{"streamId": streamID, "ready": true})
|
||||
}
|
||||
if publisher := m.publisher(r); publisher != nil {
|
||||
m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": userID})
|
||||
m.send(publisher.session, "NewSubscriber", map[string]any{"streamId": streamID, "subscriberId": p.subscriberID, "userId": publicUserID})
|
||||
}
|
||||
m.broadcastStreamList()
|
||||
}
|
||||
@@ -296,6 +313,10 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
m.send(session, "Error", map[string]any{"code": "NOT_JOINED", "message": "join a room before chatting"})
|
||||
return
|
||||
}
|
||||
if !p.authenticated {
|
||||
m.send(session, "Error", map[string]any{"code": "AUTH_REQUIRED", "message": "authenticated user is required for chat"})
|
||||
return
|
||||
}
|
||||
roomID, ok := roomIDFromStream(p.streamID)
|
||||
if !ok {
|
||||
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
|
||||
@@ -309,23 +330,6 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
|
||||
m.broadcastRoom(p.streamID, "ChatMessage", chatMessagePayload(item))
|
||||
}
|
||||
|
||||
func (m *Manager) sendChatHistory(session *melody.Session, streamID string) {
|
||||
roomID, ok := roomIDFromStream(streamID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := m.chat.ListRecent(context.Background(), roomID, 100)
|
||||
if err != nil {
|
||||
m.log("load live chat history failed", "room_id", roomID, "error", err)
|
||||
return
|
||||
}
|
||||
messages := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
messages = append(messages, chatMessagePayload(item))
|
||||
}
|
||||
m.send(session, "ChatHistory", map[string]any{"roomId": roomID, "messages": messages})
|
||||
}
|
||||
|
||||
// GiftSent implements gift.Notifier. Gift persistence and wallet debit have
|
||||
// already committed; chat persistence is therefore best-effort and must never
|
||||
// turn a successful debit into a failed gift response.
|
||||
@@ -388,6 +392,23 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) authorizeRoomSubscriber(streamID string) error {
|
||||
if strings.HasPrefix(streamID, "room-") {
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(streamID, "room-"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return errors.New("invalid live room stream id")
|
||||
}
|
||||
if _, err = m.roomCatalog.GetPublic(context.Background(), uint(id)); err != nil {
|
||||
return errors.New("live room is unavailable")
|
||||
}
|
||||
}
|
||||
stream, err := m.streams.Get(context.Background(), streamID)
|
||||
if err != nil || stream == nil || !stream.PublisherOnline {
|
||||
return errors.New("publisher is offline")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptionPayload) {
|
||||
p := m.peer(session)
|
||||
if p == nil || (p.role != "publisher" && p.role != "subscriber") {
|
||||
@@ -398,20 +419,22 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio
|
||||
m.send(session, "Error", map[string]any{"code": "INVALID_SDP", "message": "sdp is required"})
|
||||
return
|
||||
}
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
}
|
||||
pc, err := m.api.NewPeerConnection(webrtc.Configuration{ICEServers: liveICEServers()})
|
||||
if err != nil {
|
||||
m.send(session, "Error", map[string]any{"code": "PEER_CONNECTION", "message": err.Error()})
|
||||
return
|
||||
}
|
||||
p.pc = pc
|
||||
oldPC, installed := p.replacePeerConnection(pc)
|
||||
if !installed {
|
||||
_ = pc.Close()
|
||||
return
|
||||
}
|
||||
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
|
||||
if state == webrtc.ICEConnectionStateFailed || state == webrtc.ICEConnectionStateClosed {
|
||||
m.closePeer(p, "ice_"+state.String())
|
||||
}
|
||||
m.handleICEConnectionStateChange(p, pc, state)
|
||||
})
|
||||
if oldPC != nil {
|
||||
_ = oldPC.Close()
|
||||
}
|
||||
if p.role == "publisher" {
|
||||
pc.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
m.onPublisherTrack(p, track)
|
||||
@@ -448,6 +471,13 @@ func (m *Manager) handleOffer(session *melody.Session, payload sessionDescriptio
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) handleICEConnectionStateChange(p *peer, pc *webrtc.PeerConnection, state webrtc.ICEConnectionState) {
|
||||
if state != webrtc.ICEConnectionStateFailed && state != webrtc.ICEConnectionStateClosed {
|
||||
return
|
||||
}
|
||||
m.closePeerConnection(p, pc, "ice_"+state.String())
|
||||
}
|
||||
|
||||
func (m *Manager) onPublisherTrack(p *peer, remote *webrtc.TrackRemote) {
|
||||
codec := remote.Codec().RTPCodecCapability
|
||||
local, err := webrtc.NewTrackLocalStaticRTP(codec, remote.ID(), p.streamID)
|
||||
@@ -514,15 +544,26 @@ func (m *Manager) heartbeatLoop() {
|
||||
}
|
||||
|
||||
func (m *Manager) closePeer(p *peer, reason string) {
|
||||
m.closePeerConnection(p, nil, reason)
|
||||
}
|
||||
|
||||
func (m *Manager) closePeerConnection(p *peer, candidate *webrtc.PeerConnection, reason string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
pc, ok := p.beginClose(candidate)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.cleanup.Do(func() {
|
||||
if p.pc != nil {
|
||||
_ = p.pc.Close()
|
||||
if pc != nil {
|
||||
_ = pc.Close()
|
||||
}
|
||||
if p.role == "publisher" {
|
||||
_ = m.streams.UnregisterPublisher(context.Background(), p.streamID, p.userID)
|
||||
if roomID, ok := roomIDFromStream(p.streamID); ok {
|
||||
_ = m.chat.Clear(context.Background(), roomID)
|
||||
}
|
||||
p.room.mu.Lock()
|
||||
tracks := make([]*publishedTrack, 0, len(p.room.tracks))
|
||||
for _, track := range p.room.tracks {
|
||||
@@ -566,6 +607,36 @@ func (m *Manager) closePeer(p *peer, reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (p *peer) replacePeerConnection(next *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) {
|
||||
p.pcMu.Lock()
|
||||
defer p.pcMu.Unlock()
|
||||
if p.closing {
|
||||
return nil, false
|
||||
}
|
||||
previous := p.pc
|
||||
p.pc = next
|
||||
return previous, true
|
||||
}
|
||||
|
||||
func (p *peer) hasPeerConnection(candidate *webrtc.PeerConnection) bool {
|
||||
p.pcMu.RLock()
|
||||
current := p.pc
|
||||
p.pcMu.RUnlock()
|
||||
return candidate != nil && current == candidate
|
||||
}
|
||||
|
||||
func (p *peer) beginClose(candidate *webrtc.PeerConnection) (*webrtc.PeerConnection, bool) {
|
||||
p.pcMu.Lock()
|
||||
defer p.pcMu.Unlock()
|
||||
if p.closing || candidate != nil && p.pc != candidate {
|
||||
return nil, false
|
||||
}
|
||||
p.closing = true
|
||||
current := p.pc
|
||||
p.pc = nil
|
||||
return current, true
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreateRoom(streamID string) *room {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -685,6 +756,12 @@ func sessionUserID(session *melody.Session) (uint, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) anonymousViewerID() uint {
|
||||
// Allocate downward from the maximum uint so anonymous connections never
|
||||
// share the zero ID and cannot collide with ordinary auto-increment users.
|
||||
return ^uint(0) - uint(m.nextGuest.Add(1))
|
||||
}
|
||||
|
||||
func peerStreamID(m *Manager, session *melody.Session) string {
|
||||
if p := m.peer(session); p != nil {
|
||||
return p.streamID
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/olahol/melody"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
type liveHubStub struct {
|
||||
mu sync.Mutex
|
||||
messages map[*melody.Session][][]byte
|
||||
}
|
||||
|
||||
func (h *liveHubStub) Enabled() bool { return true }
|
||||
func (h *liveHubStub) Broadcast([]byte) error { return nil }
|
||||
func (h *liveHubStub) BroadcastBinary([]byte) error { return nil }
|
||||
func (h *liveHubStub) Sessions() ([]*platformws.Session, error) { return nil, nil }
|
||||
func (h *liveHubStub) Len() int { return 0 }
|
||||
func (h *liveHubStub) SendBinary(*platformws.Session, []byte) error { return nil }
|
||||
func (h *liveHubStub) OnMessage(func(*platformws.Session, []byte)) {}
|
||||
func (h *liveHubStub) OnBinaryMessage(func(*platformws.Session, []byte)) {}
|
||||
func (h *liveHubStub) OnConnect(func(*platformws.Session)) {}
|
||||
func (h *liveHubStub) OnDisconnect(func(*platformws.Session)) {}
|
||||
func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.messages == nil {
|
||||
h.messages = make(map[*melody.Session][][]byte)
|
||||
}
|
||||
h.messages[session] = append(h.messages[session], append([]byte(nil), message...))
|
||||
return nil
|
||||
}
|
||||
|
||||
type liveCatalogStub struct {
|
||||
bizlive.CatalogRepo
|
||||
room *bizlive.Room
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
copy := *s.room
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
|
||||
return &bizlive.Category{ID: s.room.CategoryID, Enabled: true}, nil
|
||||
}
|
||||
|
||||
type liveStreamStub struct {
|
||||
bizlive.StreamRepo
|
||||
mu sync.Mutex
|
||||
streams map[string]*bizlive.Stream
|
||||
users map[uint]struct{}
|
||||
registerCalls int
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
stream := s.streams[streamID]
|
||||
if stream == nil {
|
||||
return &bizlive.Stream{StreamID: streamID}, nil
|
||||
}
|
||||
copy := *stream
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
items := make([]*bizlive.Stream, 0, len(s.streams))
|
||||
for _, stream := range s.streams {
|
||||
if stream.PublisherOnline {
|
||||
copy := *stream
|
||||
items = append(items, ©)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registerCalls++
|
||||
if s.users == nil {
|
||||
s.users = make(map[uint]struct{})
|
||||
}
|
||||
if _, exists := s.users[userID]; exists {
|
||||
return nil, bizlive.ErrSubscriberExists
|
||||
}
|
||||
s.users[userID] = struct{}{}
|
||||
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
|
||||
}
|
||||
|
||||
func newAnonymousTestManager(catalog bizlive.CatalogRepo, streams bizlive.StreamRepo) *Manager {
|
||||
streamUC := bizlive.NewStreamUsecase(streams)
|
||||
return &Manager{
|
||||
hub: &liveHubStub{},
|
||||
streams: streamUC,
|
||||
roomCatalog: bizlive.NewRoomUsecase(catalog, streamUC),
|
||||
rooms: make(map[string]*room),
|
||||
peers: make(map[*melody.Session]*peer),
|
||||
observers: make(map[*melody.Session]struct{}),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
|
||||
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
|
||||
first := &melody.Session{Keys: map[string]any{}}
|
||||
second := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(first, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
manager.join(second, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
|
||||
p1, p2 := manager.peer(first), manager.peer(second)
|
||||
if p1 == nil || p2 == nil {
|
||||
t.Fatalf("anonymous subscribers did not join: first=%+v second=%+v", p1, p2)
|
||||
}
|
||||
if p1.authenticated || p2.authenticated || p1.userID == 0 || p2.userID == 0 || p1.userID == p2.userID {
|
||||
t.Fatalf("anonymous identities are not unique: first=%d second=%d", p1.userID, p2.userID)
|
||||
}
|
||||
if len(streams.users) != 2 {
|
||||
t.Fatalf("subscriber count = %d, want 2", len(streams.users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{err: bizlive.ErrRoomNotFound}, &liveStreamStub{})
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-999999"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 {
|
||||
t.Fatalf("unknown room created runtime state: peers=%d rooms=%d", len(manager.peers), len(manager.rooms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
|
||||
streams := &liveStreamStub{}
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil || len(manager.rooms) != 0 || len(streams.users) != 0 || streams.registerCalls != 0 {
|
||||
t.Fatalf("offline room created runtime state: peers=%d rooms=%d users=%d registrations=%d", len(manager.peers), len(manager.rooms), len(streams.users), streams.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousPublisherIsRejected(t *testing.T) {
|
||||
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, Enabled: true}}, &liveStreamStub{})
|
||||
session := &melody.Session{Keys: map[string]any{}}
|
||||
|
||||
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
|
||||
|
||||
if manager.peer(session) != nil {
|
||||
t.Fatal("anonymous publisher joined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
|
||||
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer oldPC.Close()
|
||||
newPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer newPC.Close()
|
||||
|
||||
p := &peer{pc: oldPC}
|
||||
replaced, installed := p.replacePeerConnection(newPC)
|
||||
if !installed || replaced != oldPC {
|
||||
t.Fatalf("replaced peer connection = %p, want %p", replaced, oldPC)
|
||||
}
|
||||
manager := &Manager{}
|
||||
manager.handleICEConnectionStateChange(p, oldPC, webrtc.ICEConnectionStateClosed)
|
||||
|
||||
cleanupAvailable := false
|
||||
p.cleanup.Do(func() { cleanupAvailable = true })
|
||||
if !cleanupAvailable {
|
||||
t.Fatal("superseded peer connection consumed peer cleanup")
|
||||
}
|
||||
if !p.hasPeerConnection(newPC) {
|
||||
t.Fatal("replacement peer connection is no longer active")
|
||||
}
|
||||
}
|
||||
|
||||
var _ platformws.Hub = (*liveHubStub)(nil)
|
||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 22 {
|
||||
t.Fatalf("module migrations = %d, want 22", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 26 {
|
||||
t.Fatalf("module migrations = %d, want 26", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 11 || apis != 50 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
|
||||
if menus != 11 || apis != 69 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/69", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -39,10 +39,24 @@ func Definition() module.Definition {
|
||||
steps = append(steps, migration.Step{ID: "202609210001_remove_live_dashboard_menu", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.RemoveAdminMenus(db, "liveDashboard")
|
||||
}})
|
||||
steps = append(steps, migration.Step{ID: "202609220004_live_client_policies", Migrate: func(db *gorm.DB) error {
|
||||
// Existing live-client accounts do not pass through the registration
|
||||
// path after an upgrade, so add newly introduced client endpoints to
|
||||
// their role without replacing any administrator-managed policies.
|
||||
return datasystem.GrantAuthorityAPIs(db, 1000, []module.API{
|
||||
{Method: "POST", Path: "/live/client/auth/logout"},
|
||||
{Method: "GET", Path: "/live/client/balance"},
|
||||
{Method: "GET", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "POST", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "DELETE", Path: "/live/rooms/:id/follow"},
|
||||
{Method: "GET", Path: "/live/recharge/orders/:orderNo"},
|
||||
{Method: "GET", Path: "/live/recharge/orders/:orderNo/status"},
|
||||
})
|
||||
}})
|
||||
return module.Definition{Name: "live", Migrations: steps, Surface: module.Surface{Menus: menus, APIs: apis}}
|
||||
}
|
||||
func APIs() []module.API {
|
||||
defs := []struct{ m, p, d string }{{"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}}
|
||||
defs := []struct{ m, p, d string }{{"GET", "/live/streams", "获取在线直播列表"}, {"GET", "/live/streams/:streamId", "获取直播间状态"}, {"GET", "/live/categories", "获取直播分类"}, {"GET", "/live/rooms", "分页获取热门直播间"}, {"GET", "/live/rooms/:id", "获取直播间详情"}, {"POST", "/live/rooms/:id/like", "直播间点赞"}, {"GET", "/live/rooms/:id/follow", "查询关注状态"}, {"POST", "/live/rooms/:id/follow", "关注直播间"}, {"DELETE", "/live/rooms/:id/follow", "取消关注"}, {"GET", "/live/rooms/:id/messages", "获取直播间聊天记录"}, {"GET", "/live/gifts", "获取可用礼物"}, {"GET", "/live/recharge/providers", "获取可用充值支付渠道"}, {"GET", "/live/recharge/plans", "获取充值档位"}, {"POST", "/live/client/auth/login", "直播用户登录"}, {"POST", "/live/client/auth/register", "直播用户注册"}, {"POST", "/live/client/auth/logout", "直播用户退出"}, {"GET", "/live/client/me", "获取直播用户资料"}, {"GET", "/live/client/balance", "获取钻石余额"}, {"PUT", "/live/client/me", "更新直播用户资料"}, {"PUT", "/live/client/me/password", "修改直播用户密码"}, {"GET", "/live/recharge/orders/:orderNo", "查询我的充值订单"}, {"GET", "/live/recharge/orders/:orderNo/status", "查询我的充值支付状态"}, {"POST", "/live/rooms/:id/gifts", "直播间送礼"}, {"POST", "/live/recharge/orders", "创建充值订单"}, {"GET", "/live/admin/dashboard", "直播数据概览"}, {"GET", "/live/admin/categories", "查询直播分类"}, {"POST", "/live/admin/categories", "新建直播分类"}, {"PUT", "/live/admin/categories/:id", "更新直播分类"}, {"DELETE", "/live/admin/categories/:id", "删除直播分类"}, {"GET", "/live/admin/rooms", "查询直播间"}, {"GET", "/live/admin/rooms/:id", "查询直播间详情"}, {"POST", "/live/admin/rooms", "新建直播间"}, {"PUT", "/live/admin/rooms/:id", "更新直播间"}, {"DELETE", "/live/admin/rooms/:id", "删除直播间"}, {"PATCH", "/live/admin/rooms/:id/status", "启停直播间"}, {"PUT", "/live/admin/rooms/sort", "保存直播间排序"}, {"GET", "/live/admin/gifts", "查询礼物"}, {"GET", "/live/admin/gifts/:id", "查询礼物详情"}, {"POST", "/live/admin/gifts", "新建礼物"}, {"PUT", "/live/admin/gifts/:id", "更新礼物"}, {"DELETE", "/live/admin/gifts/:id", "删除礼物"}, {"PATCH", "/live/admin/gifts/:id/status", "上下架礼物"}, {"PUT", "/live/admin/gifts/sort", "保存礼物排序"}, {"GET", "/live/admin/sends", "查询打赏流水"}, {"GET", "/live/admin/orders", "查询充值订单"}, {"GET", "/live/admin/orders/:orderNo", "查询充值订单详情"}, {"PUT", "/live/admin/recharge/plans", "保存充值档位"}, {"GET", "/live/admin/users", "查询用户资产"}, {"GET", "/live/admin/users/:id", "查询用户详情"}, {"PATCH", "/live/admin/users/:id/status", "启停用户"}, {"POST", "/live/admin/users/:id/balance", "调整钻石余额"}, {"PUT", "/live/admin/settings", "保存全站设置"}}
|
||||
out := make([]module.API, 0, len(defs))
|
||||
for _, v := range defs {
|
||||
out = append(out, module.API{Path: v.p, Method: v.m, Group: "宇宙直播", Description: v.d})
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestLiveClientPolicyMigrationAddsNewPrivateRoutes(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE casbin_rule (
|
||||
id integer primary key autoincrement,
|
||||
ptype text,
|
||||
v0 text,
|
||||
v1 text,
|
||||
v2 text,
|
||||
v3 text,
|
||||
v4 text,
|
||||
v5 text
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var migrate func(*gorm.DB) error
|
||||
for _, step := range Definition().Migrations {
|
||||
if step.ID == "202609220004_live_client_policies" {
|
||||
migrate = step.Migrate
|
||||
break
|
||||
}
|
||||
}
|
||||
if migrate == nil {
|
||||
t.Fatal("live client policy migration is missing")
|
||||
}
|
||||
if err = migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrate(db); err != nil {
|
||||
t.Fatalf("policy migration is not idempotent: %v", err)
|
||||
}
|
||||
|
||||
want := []struct{ method, path string }{
|
||||
{method: "POST", path: "/live/client/auth/logout"},
|
||||
{method: "GET", path: "/live/client/balance"},
|
||||
{method: "GET", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/rooms/:id/follow"},
|
||||
{method: "DELETE", path: "/live/rooms/:id/follow"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
|
||||
}
|
||||
for _, route := range want {
|
||||
var count int64
|
||||
if err = db.Table("casbin_rule").Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "1000", route.path, route.method).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("policy %s %s count = %d, want 1", route.method, route.path, count)
|
||||
}
|
||||
}
|
||||
|
||||
var publicProviderPolicies int64
|
||||
if err = db.Table("casbin_rule").Where("v0 = ? AND v1 = ?", "1000", "/live/recharge/providers").Count(&publicProviderPolicies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if publicProviderPolicies != 0 {
|
||||
t.Fatalf("public provider discovery unexpectedly received a client policy: %d", publicProviderPolicies)
|
||||
}
|
||||
}
|
||||
@@ -84,17 +84,24 @@ var routes = map[string]routeValue{
|
||||
"GET /logViewer/content": {group: "文件日志", description: "分块读取日志文件内容"},
|
||||
"GET /logViewer/dates": {group: "文件日志", description: "获取存在日志的日期"},
|
||||
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
|
||||
"GET /api/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams": {group: "直播", description: "获取在线直播列表", public: true},
|
||||
"GET /live/streams/:streamId": {group: "直播", description: "获取直播间状态", public: true},
|
||||
"GET /live/categories": {group: "宇宙直播", description: "获取直播分类", public: true},
|
||||
"GET /live/rooms": {group: "宇宙直播", description: "获取直播间", public: true},
|
||||
"GET /live/rooms/:id": {group: "宇宙直播", description: "获取直播间详情", public: true},
|
||||
"POST /live/rooms/:id/like": {group: "宇宙直播", description: "直播间点赞", public: true},
|
||||
"GET /live/rooms/:id/follow": {group: "宇宙直播", description: "查询关注状态"},
|
||||
"POST /live/rooms/:id/follow": {group: "宇宙直播", description: "关注主播"},
|
||||
"DELETE /live/rooms/:id/follow": {group: "宇宙直播", description: "取消关注"},
|
||||
"GET /live/rooms/:id/messages": {group: "宇宙直播", description: "获取直播间聊天记录", public: true},
|
||||
"GET /live/gifts": {group: "宇宙直播", description: "获取礼物", public: true},
|
||||
"GET /live/recharge/providers": {group: "宇宙直播", description: "获取可用充值支付渠道", public: true},
|
||||
"GET /live/recharge/plans": {group: "宇宙直播", description: "获取充值档位", public: true},
|
||||
"GET /live/recharge/orders/:orderNo": {group: "宇宙直播", description: "查询我的充值订单"},
|
||||
"GET /live/recharge/orders/:orderNo/status": {group: "宇宙直播", description: "查询我的充值支付状态"},
|
||||
"GET /live/settings": {group: "宇宙直播", description: "获取直播设置", public: true},
|
||||
"GET /live/client/me": {group: "宇宙直播", description: "获取直播用户资料"},
|
||||
"GET /live/client/balance": {group: "宇宙直播", description: "获取直播用户钻石余额"},
|
||||
"GET /live/admin/dashboard": {group: "宇宙直播", description: "直播数据概览"},
|
||||
"GET /live/admin/categories": {group: "宇宙直播", description: "查询直播分类"},
|
||||
"GET /live/admin/rooms": {group: "宇宙直播", description: "查询直播间"},
|
||||
@@ -114,6 +121,7 @@ var routes = map[string]routeValue{
|
||||
"POST /live/recharge/orders": {group: "宇宙直播", description: "创建充值订单", audit: true},
|
||||
"POST /live/client/auth/login": {group: "宇宙直播", description: "直播用户登录", public: true},
|
||||
"POST /live/client/auth/register": {group: "宇宙直播", description: "直播用户注册", public: true},
|
||||
"POST /live/client/auth/logout": {group: "宇宙直播", description: "直播用户退出", audit: true},
|
||||
"PUT /live/admin/categories/:id": {group: "宇宙直播", description: "更新直播分类", audit: true},
|
||||
"PUT /live/admin/rooms/:id": {group: "宇宙直播", description: "更新直播间", audit: true},
|
||||
"PUT /live/admin/rooms/sort": {group: "宇宙直播", description: "保存直播间排序", audit: true},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package routecatalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLiveClientRouteVisibilityContract(t *testing.T) {
|
||||
public := []struct{ method, path string }{
|
||||
{"GET", "/live/streams"},
|
||||
{"GET", "/live/streams/:streamId"},
|
||||
{"GET", "/live/categories"},
|
||||
{"GET", "/live/rooms"},
|
||||
{"GET", "/live/rooms/:id"},
|
||||
{"POST", "/live/rooms/:id/like"},
|
||||
{"GET", "/live/rooms/:id/messages"},
|
||||
{"GET", "/live/gifts"},
|
||||
{"GET", "/live/recharge/providers"},
|
||||
{"GET", "/live/recharge/plans"},
|
||||
{"POST", "/live/client/auth/login"},
|
||||
{"POST", "/live/client/auth/register"},
|
||||
}
|
||||
for _, route := range public {
|
||||
descriptor, ok := Lookup(route.method, route.path)
|
||||
if !ok || !descriptor.Public {
|
||||
t.Errorf("%s %s must be public: found=%v descriptor=%+v", route.method, route.path, ok, descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
private := []struct{ method, path string }{
|
||||
{"GET", "/live/client/me"},
|
||||
{"GET", "/live/client/balance"},
|
||||
{"POST", "/live/client/auth/logout"},
|
||||
{"GET", "/live/rooms/:id/follow"},
|
||||
{"POST", "/live/rooms/:id/follow"},
|
||||
{"DELETE", "/live/rooms/:id/follow"},
|
||||
{"POST", "/live/rooms/:id/gifts"},
|
||||
{"POST", "/live/recharge/orders"},
|
||||
{"GET", "/live/recharge/orders/:orderNo"},
|
||||
{"GET", "/live/recharge/orders/:orderNo/status"},
|
||||
}
|
||||
for _, route := range private {
|
||||
descriptor, ok := Lookup(route.method, route.path)
|
||||
if !ok || descriptor.Public {
|
||||
t.Errorf("%s %s must require login: found=%v descriptor=%+v", route.method, route.path, ok, descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveMoneyChangingRoutesAreAudited(t *testing.T) {
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{"POST", "/live/rooms/:id/gifts"},
|
||||
{"POST", "/live/recharge/orders"},
|
||||
{"POST", "/live/client/auth/logout"},
|
||||
} {
|
||||
if !ShouldAudit(route.method, route.path) {
|
||||
t.Errorf("%s %s must be audited", route.method, route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-12
@@ -79,20 +79,21 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !middleware.AuthenticateWebSocket(c, auth) {
|
||||
if wsLogger != nil {
|
||||
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed",
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(),
|
||||
"has_token", hasToken, "origin", c.GetHeader("Origin"))
|
||||
}
|
||||
return
|
||||
}
|
||||
claims := middleware.Claims(c)
|
||||
keys := map[string]any{
|
||||
"request_id": contextString(c, "request_id"),
|
||||
"request_id": contextString(c, "request_id"),
|
||||
"authenticated": false,
|
||||
}
|
||||
if claims != nil {
|
||||
keys["user_id"] = claims.ID
|
||||
// Watching is public. A valid token enriches the connection; a stale
|
||||
// token is cleared and continues as anonymous so it cannot block video.
|
||||
if hasToken {
|
||||
if authenticated := middleware.AuthenticateWebSocketOptional(c, auth); !authenticated && wsLogger != nil {
|
||||
wsLogger.InfoContext(c.Request.Context(), "websocket token unavailable; continuing anonymously",
|
||||
"mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"))
|
||||
}
|
||||
if claims := middleware.Claims(c); claims != nil {
|
||||
keys["user_id"] = claims.ID
|
||||
keys["authenticated"] = true
|
||||
}
|
||||
}
|
||||
if wsLogger != nil {
|
||||
wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
bizgift "kra/internal/biz/gift"
|
||||
"kra/internal/service/dto"
|
||||
service "kra/internal/service/gift"
|
||||
)
|
||||
@@ -136,6 +138,16 @@ func (h *Gift) Send(c *gin.Context) {
|
||||
}
|
||||
v, e := h.s.Send(c.Request.Context(), claims.ID, id, in)
|
||||
if e != nil {
|
||||
var insufficient *bizgift.InsufficientBalanceError
|
||||
if errors.As(e, &insufficient) {
|
||||
Write(c, CodeError, gin.H{
|
||||
"reason": "insufficient_balance",
|
||||
"required": insufficient.Required,
|
||||
"balance": insufficient.Balance,
|
||||
"missing": insufficient.Missing,
|
||||
}, e.Error())
|
||||
return
|
||||
}
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
@@ -14,10 +15,89 @@ import (
|
||||
type Live struct {
|
||||
service *liveservice.StreamService
|
||||
catalog *liveservice.CatalogService
|
||||
follows *liveservice.FollowService
|
||||
}
|
||||
|
||||
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService) *Live {
|
||||
return &Live{service: service, catalog: catalog}
|
||||
func NewLive(service *liveservice.StreamService, catalog *liveservice.CatalogService, follows *liveservice.FollowService) *Live {
|
||||
return &Live{service: service, catalog: catalog, follows: follows}
|
||||
}
|
||||
|
||||
func (h *Live) FollowStatus(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
followed, err := h.follows.Status(c.Request.Context(), claims.ID, id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": followed})
|
||||
}
|
||||
|
||||
func (h *Live) Follow(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
followed, err := h.follows.Follow(c.Request.Context(), claims.ID, id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": followed})
|
||||
}
|
||||
|
||||
func (h *Live) Unfollow(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
if err := h.follows.Unfollow(c.Request.Context(), claims.ID, id); err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"followed": false})
|
||||
}
|
||||
|
||||
func (h *Live) Like(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Do not create runtime stream state for arbitrary room IDs. The stream
|
||||
// repository is process-local, so validating the catalog first also keeps
|
||||
// bogus likes from accumulating forever.
|
||||
room, err := h.catalog.GetRoom(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
if !room.Enabled || room.Status != bizlive.RoomStatusLive {
|
||||
businessFail(c, bizlive.ErrRoomNotLive)
|
||||
return
|
||||
}
|
||||
count, err := h.service.Like(c.Request.Context(), fmt.Sprintf("room-%d", id))
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"likeCount": count})
|
||||
}
|
||||
|
||||
func (h *Live) List(c *gin.Context) {
|
||||
@@ -114,17 +194,27 @@ func (h *Live) PublicRooms(c *gin.Context) {
|
||||
in.PageSize = 20
|
||||
}
|
||||
if in.OrderBy == "" {
|
||||
in.OrderBy = "sort_asc"
|
||||
in.OrderBy = "viewer_count_desc"
|
||||
}
|
||||
enabled := true
|
||||
in.Enabled = &enabled
|
||||
items, total, e := h.catalog.ListRooms(c.Request.Context(), in)
|
||||
items, total, e := h.catalog.ListPublicRooms(c.Request.Context(), in)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items, "total": total, "page": in.Page, "pageSize": in.PageSize})
|
||||
}
|
||||
func (h *Live) GetPublicRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, e := h.catalog.GetPublicRoom(c.Request.Context(), id)
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Live) GetRoom(c *gin.Context) {
|
||||
id, ok := businessID(c)
|
||||
if !ok {
|
||||
@@ -203,15 +293,6 @@ func (h *Live) SaveRoomSort(c *gin.Context) {
|
||||
OK(c)
|
||||
}
|
||||
|
||||
func (h *Live) LegacyList(c *gin.Context) {
|
||||
items, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": "直播列表获取失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "streams": items})
|
||||
}
|
||||
|
||||
func (h *Live) Find(c *gin.Context) {
|
||||
item, err := h.service.Get(c.Request.Context(), c.Param("streamId"))
|
||||
if err != nil {
|
||||
|
||||
@@ -15,14 +15,14 @@ func (h *LiveChat) History(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in, ok := bindQuery[dto.LiveChatHistoryRequest](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.History(c.Request.Context(), roomID, in.Limit)
|
||||
// Chat is intentionally scoped to the current live connection. Do not
|
||||
// replay rows persisted by an earlier viewer/session through HTTP. Still
|
||||
// validate the room so callers receive a useful not-found response.
|
||||
items, err := h.service.History(c.Request.Context(), roomID, 1)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": items})
|
||||
_ = items
|
||||
OKWithData(c, gin.H{"list": []*dto.LiveChatMessageResponse{}})
|
||||
}
|
||||
|
||||
@@ -58,6 +58,20 @@ func (h *LiveClient) Me(c *gin.Context) {
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) Balance(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Balance(c.Request.Context(), claims.ID)
|
||||
if err != nil {
|
||||
businessFail(c, err)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *LiveClient) UpdateProfile(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
|
||||
@@ -9,6 +9,14 @@ import (
|
||||
type Recharge struct{ s *service.Service }
|
||||
|
||||
func NewRecharge(s *service.Service) *Recharge { return &Recharge{s: s} }
|
||||
func (h *Recharge) Providers(c *gin.Context) {
|
||||
v, e := h.s.Providers(c.Request.Context())
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, gin.H{"list": v})
|
||||
}
|
||||
func (h *Recharge) Plans(c *gin.Context) {
|
||||
v, e := h.s.Plans(c.Request.Context())
|
||||
if e != nil {
|
||||
@@ -48,6 +56,34 @@ func (h *Recharge) Get(c *gin.Context) {
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
|
||||
func (h *Recharge) GetMine(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
v, e := h.s.GetForUser(c.Request.Context(), claims.ID, c.Param("orderNo"))
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
|
||||
func (h *Recharge) PaymentStatus(c *gin.Context) {
|
||||
claims := Claims(c)
|
||||
if claims == nil {
|
||||
NoAuth(c, "请登录")
|
||||
return
|
||||
}
|
||||
v, e := h.s.PaymentStatus(c.Request.Context(), claims.ID, c.Param("orderNo"))
|
||||
if e != nil {
|
||||
businessFail(c, e)
|
||||
return
|
||||
}
|
||||
OKWithData(c, v)
|
||||
}
|
||||
func (h *Recharge) Create(c *gin.Context) {
|
||||
in, ok := bindJSON[dto.RechargeOrderRequest](c)
|
||||
if !ok {
|
||||
|
||||
@@ -43,6 +43,33 @@ func AuthenticateWebSocket(c *gin.Context, auth TokenAuthenticator) bool {
|
||||
return authenticate(c, auth, true)
|
||||
}
|
||||
|
||||
// AuthenticateWebSocketOptional enriches a public WebSocket connection when
|
||||
// a valid token is available. Missing, expired, malformed, or revoked tokens
|
||||
// fall back to an anonymous session; invalid cookie state is cleared so a
|
||||
// stale login cannot prevent public live viewing.
|
||||
func AuthenticateWebSocketOptional(c *gin.Context, auth TokenAuthenticator) bool {
|
||||
token := RequestToken(c, true)
|
||||
if token == "" || auth == nil {
|
||||
return false
|
||||
}
|
||||
value, err, _ := refreshTokens.Do(token, func() (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), tokenAuthTimeout)
|
||||
defer cancel()
|
||||
return auth.AuthenticateToken(ctx, token)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.SetCookie(c, "x-token", "", -1)
|
||||
return false
|
||||
}
|
||||
authentication, ok := value.(*system.TokenAuthentication)
|
||||
if !ok || authentication == nil || authentication.Claims == nil {
|
||||
httpx.SetCookie(c, "x-token", "", -1)
|
||||
return false
|
||||
}
|
||||
applyTokenAuthentication(c, authentication)
|
||||
return true
|
||||
}
|
||||
|
||||
func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool) bool {
|
||||
token := RequestToken(c, allowQueryToken)
|
||||
if token == "" {
|
||||
@@ -74,13 +101,17 @@ func authenticate(c *gin.Context, auth TokenAuthenticator, allowQueryToken bool)
|
||||
httpx.NoAuth(c, "无法处理此token")
|
||||
return false
|
||||
}
|
||||
applyTokenAuthentication(c, authentication)
|
||||
return true
|
||||
}
|
||||
|
||||
func applyTokenAuthentication(c *gin.Context, authentication *system.TokenAuthentication) {
|
||||
if authentication.Refreshed != nil {
|
||||
c.Header("new-token", authentication.Refreshed.Value)
|
||||
c.Header("new-expires-at", strconv.FormatInt(authentication.Refreshed.ExpiresAt.Unix(), 10))
|
||||
httpx.SetCookie(c, "x-token", authentication.Refreshed.Value, int(authentication.Refreshed.TTL.Seconds()))
|
||||
}
|
||||
c.Set(claimsKey, authentication.Claims)
|
||||
return true
|
||||
}
|
||||
|
||||
// RequestToken extracts the authentication token accepted by HTTP handlers.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"kra/internal/biz/system"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -79,3 +80,36 @@ func TestAuthenticateWebSocketSupportsBearerHeaderAndReportsInvalidToken(t *test
|
||||
t.Fatalf("invalid token status=%d, want %d", invalidRecorder.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateWebSocketOptionalFallsBackToAnonymous(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &tokenAuthenticatorStub{err: system.ErrTokenExpired}
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/ws", nil)
|
||||
context.Request.AddCookie(&http.Cookie{Name: "x-token", Value: "expired-token"})
|
||||
|
||||
if AuthenticateWebSocketOptional(context, stub) {
|
||||
t.Fatal("expired token was treated as authenticated")
|
||||
}
|
||||
if Claims(context) != nil {
|
||||
t.Fatalf("anonymous fallback retained claims: %#v", Claims(context))
|
||||
}
|
||||
if recorder.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("optional authentication interrupted public handshake: body=%s", recorder.Body.String())
|
||||
}
|
||||
if cookie := recorder.Header().Get("Set-Cookie"); !strings.Contains(cookie, "x-token=") || !strings.Contains(cookie, "Max-Age=0") {
|
||||
t.Fatalf("expired token cookie was not cleared: %q", cookie)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateWebSocketOptionalStoresValidClaims(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
stub := &tokenAuthenticatorStub{claims: &system.AuthClaims{ID: 9}}
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = httptest.NewRequest(http.MethodGet, "/ws?token=valid-token", nil)
|
||||
|
||||
if !AuthenticateWebSocketOptional(context, stub) || Claims(context) == nil || Claims(context).ID != 9 {
|
||||
t.Fatalf("valid optional token was not applied: claims=%#v", Claims(context))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
func RegisterLive(public, private *gin.RouterGroup, h *handler.Live) {
|
||||
public.GET("/live/streams", h.List)
|
||||
public.GET("/live/streams/:streamId", h.Find)
|
||||
// Keep the old list endpoint as a compatibility alias for existing clients.
|
||||
public.GET("/api/streams", h.LegacyList)
|
||||
public.GET("/live/categories", h.PublicCategories)
|
||||
public.GET("/live/rooms", h.PublicRooms)
|
||||
public.GET("/live/rooms/:id", h.GetRoom)
|
||||
public.GET("/live/rooms/:id", h.GetPublicRoom)
|
||||
public.POST("/live/rooms/:id/like", h.Like)
|
||||
private.GET("/live/rooms/:id/follow", h.FollowStatus)
|
||||
private.POST("/live/rooms/:id/follow", h.Follow)
|
||||
private.DELETE("/live/rooms/:id/follow", h.Unfollow)
|
||||
private.GET("/live/admin/categories", h.ListCategories)
|
||||
private.POST("/live/admin/categories", h.CreateCategory)
|
||||
private.PUT("/live/admin/categories/:id", h.UpdateCategory)
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"kra/internal/server/handler"
|
||||
)
|
||||
|
||||
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient) {
|
||||
func RegisterLiveClient(public, private *gin.RouterGroup, h *handler.LiveClient, session *handler.Session) {
|
||||
public.POST("/live/client/auth/login", h.Login)
|
||||
public.POST("/live/client/auth/register", h.Register)
|
||||
private.POST("/live/client/auth/logout", session.Logout)
|
||||
private.GET("/live/client/me", h.Me)
|
||||
private.GET("/live/client/balance", h.Balance)
|
||||
private.PUT("/live/client/me", h.UpdateProfile)
|
||||
private.PUT("/live/client/me/password", h.ChangePassword)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
)
|
||||
|
||||
func RegisterRecharge(public, private *gin.RouterGroup, h *handler.Recharge) {
|
||||
public.GET("/live/recharge/providers", h.Providers)
|
||||
public.GET("/live/recharge/plans", h.Plans)
|
||||
private.GET("/live/recharge/orders/:orderNo", h.GetMine)
|
||||
private.GET("/live/recharge/orders/:orderNo/status", h.PaymentStatus)
|
||||
private.PUT("/live/admin/recharge/plans", h.SavePlans)
|
||||
private.GET("/live/admin/orders", h.List)
|
||||
private.GET("/live/admin/orders/:orderNo", h.Get)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
func() { RegisterPayment(private, public, r.handlers.Payment) },
|
||||
func() { RegisterLive(public, private, r.handlers.Live) },
|
||||
func() { RegisterLiveChat(public, r.handlers.LiveChat) },
|
||||
func() { RegisterLiveClient(public, private, r.handlers.LiveClient) },
|
||||
func() { RegisterLiveClient(public, private, r.handlers.LiveClient, r.handlers.Session) },
|
||||
func() { RegisterGift(public, private, r.handlers.Gift) },
|
||||
func() { RegisterRecharge(public, private, r.handlers.Recharge) },
|
||||
func() { RegisterAsset(private, r.handlers.Asset) },
|
||||
|
||||
@@ -30,7 +30,7 @@ func (s *websocketAuthStub) AuthenticateToken(_ context.Context, token string) (
|
||||
|
||||
var _ middleware.TokenAuthenticator = (*websocketAuthStub)(nil)
|
||||
|
||||
func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
func TestWebSocketRouteAcceptsAnonymousAndAuthenticatedViewers(t *testing.T) {
|
||||
store := runtimeconfig.NewStore()
|
||||
raw, err := json.Marshal(map[string]any{"path": "/ws"})
|
||||
if err != nil {
|
||||
@@ -46,26 +46,35 @@ func TestWebSocketRouteRequiresAndAcceptsLoginToken(t *testing.T) {
|
||||
auth := &websocketAuthStub{claims: &system.AuthClaims{ID: 7}}
|
||||
engine := NewGinEngineWithRuntime(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, auth, nil, nil, nil, "test", nil, ws)
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
engine.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/ws", nil))
|
||||
if unauthorized.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthorized websocket status=%d body=%s", unauthorized.Code, unauthorized.Body.String())
|
||||
}
|
||||
|
||||
httpServer := httptest.NewServer(engine)
|
||||
defer httpServer.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws?token=login-token"
|
||||
connection, response, err := gorillawebsocket.DefaultDialer.Dial(wsURL, nil)
|
||||
baseURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws"
|
||||
anonymous, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL, nil)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("authorized websocket handshake failed: %v", err)
|
||||
t.Fatalf("anonymous websocket handshake failed: %v", err)
|
||||
}
|
||||
if connection == nil {
|
||||
t.Fatal("authorized websocket handshake returned nil connection")
|
||||
if anonymous == nil {
|
||||
t.Fatal("anonymous websocket handshake returned nil connection")
|
||||
}
|
||||
_ = connection.Close()
|
||||
_ = anonymous.Close()
|
||||
if auth.got != "" {
|
||||
t.Fatalf("anonymous websocket unexpectedly authenticated token=%q", auth.got)
|
||||
}
|
||||
|
||||
authenticated, response, err := gorillawebsocket.DefaultDialer.Dial(baseURL+"?token=login-token", nil)
|
||||
if response != nil && response.Body != nil {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("authenticated websocket handshake failed: %v", err)
|
||||
}
|
||||
if authenticated == nil {
|
||||
t.Fatal("authenticated websocket handshake returned nil connection")
|
||||
}
|
||||
_ = authenticated.Close()
|
||||
if auth.got != "login-token" {
|
||||
t.Fatalf("websocket auth token=%q, want login-token", auth.got)
|
||||
}
|
||||
|
||||
@@ -55,24 +55,27 @@ type LiveRoomRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type LiveRoomResponse struct {
|
||||
ID uint `json:"id"`
|
||||
StreamID string `json:"streamId"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AnchorUserID uint `json:"anchorUserId"`
|
||||
AnchorName string `json:"anchorName"`
|
||||
AnchorAvatar string `json:"anchorAvatar"`
|
||||
StreamURL string `json:"streamUrl"`
|
||||
ReplayURL string `json:"replayUrl"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
StreamID string `json:"streamId"`
|
||||
Name string `json:"name"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Intro string `json:"intro"`
|
||||
Sort int `json:"sort"`
|
||||
AnchorUserID uint `json:"anchorUserId"`
|
||||
AnchorName string `json:"anchorName"`
|
||||
AnchorAvatar string `json:"anchorAvatar"`
|
||||
StreamURL string `json:"streamUrl"`
|
||||
ReplayURL string `json:"replayUrl"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
ViewerCount int `json:"viewerCount"`
|
||||
ViewerCountText string `json:"viewerCountText"`
|
||||
LikeCount int `json:"likeCount"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
type LiveStatusRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
@@ -9,6 +9,7 @@ type LiveClientLoginRequest struct {
|
||||
|
||||
type LiveClientRegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Nickname string `json:"nickname"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
ConfirmPassword string `json:"confirmPassword" binding:"required"`
|
||||
}
|
||||
@@ -34,6 +35,10 @@ type LiveClientAccountResponse struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type LiveClientBalanceResponse struct {
|
||||
DiamondBalance int64 `json:"diamondBalance"`
|
||||
}
|
||||
|
||||
type LiveClientAuthResponse struct {
|
||||
User *LiveClientAccountResponse `json:"user"`
|
||||
Token string `json:"token"`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLiveClientRegisterRequestDecodesNickname(t *testing.T) {
|
||||
var request LiveClientRegisterRequest
|
||||
if err := json.Unmarshal([]byte(`{"username":"viewer","nickname":"直播用户","password":"password","confirmPassword":"password"}`), &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Nickname != "直播用户" {
|
||||
t.Fatalf("nickname = %q", request.Nickname)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ type RechargePlan struct {
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
type RechargeProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type RechargePlansRequest struct {
|
||||
Plans []*RechargePlan `json:"plans" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
func (s *CatalogService) ListPublicRooms(ctx context.Context, input *dto.LiveRoomListRequest) ([]*dto.LiveRoomResponse, int64, error) {
|
||||
enabled := true
|
||||
items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: bizlive.RoomStatusLive, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
result := make([]*dto.LiveRoomResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, roomResponse(item))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) {
|
||||
item, err := s.rooms.Get(ctx, id)
|
||||
if err != nil {
|
||||
@@ -83,6 +95,13 @@ func (s *CatalogService) GetRoom(ctx context.Context, id uint) (*dto.LiveRoomRes
|
||||
}
|
||||
return roomResponse(item), nil
|
||||
}
|
||||
func (s *CatalogService) GetPublicRoom(ctx context.Context, id uint) (*dto.LiveRoomResponse, error) {
|
||||
item, err := s.rooms.GetPublic(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return roomResponse(item), nil
|
||||
}
|
||||
func liveRoomDO(id uint, input *dto.LiveRoomRequest) *bizlive.Room {
|
||||
return &bizlive.Room{ID: id, Name: input.Name, CategoryID: input.CategoryID, CoverURL: input.CoverURL, Intro: input.Intro, Sort: input.Sort, AnchorUserID: input.AnchorUserID, StreamURL: input.StreamURL, ReplayURL: input.ReplayURL, Enabled: input.Enabled}
|
||||
}
|
||||
@@ -113,5 +132,5 @@ func categoryResponse(item *bizlive.Category) *dto.LiveCategoryResponse {
|
||||
return &dto.LiveCategoryResponse{ID: item.ID, Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled, RoomCount: item.RoomCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
}
|
||||
func roomResponse(item *bizlive.Room) *dto.LiveRoomResponse {
|
||||
return &dto.LiveRoomResponse{ID: item.ID, StreamID: fmt.Sprintf("room-%d", item.ID), Name: item.Name, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, AnchorName: item.AnchorName, AnchorAvatar: item.AnchorAvatar, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled, Status: item.Status, ViewerCount: item.ViewerCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
return &dto.LiveRoomResponse{ID: item.ID, StreamID: fmt.Sprintf("room-%d", item.ID), Name: item.Name, CategoryID: item.CategoryID, CategoryName: item.CategoryName, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, AnchorName: item.AnchorName, AnchorAvatar: item.AnchorAvatar, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled, Status: item.Status, ViewerCount: item.ViewerCount, ViewerCountText: item.ViewerCountText, LikeCount: item.LikeCount, StartedAt: item.StartedAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
type FollowService struct{ uc *bizlive.FollowUsecase }
|
||||
|
||||
func NewFollowService(uc *bizlive.FollowUsecase) *FollowService { return &FollowService{uc: uc} }
|
||||
|
||||
func (s *FollowService) Status(ctx context.Context, userID, roomID uint) (bool, error) {
|
||||
_, err := s.uc.Get(ctx, userID, roomID)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, bizlive.ErrFollowNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (s *FollowService) Follow(ctx context.Context, userID, roomID uint) (bool, error) {
|
||||
_, err := s.uc.Follow(ctx, userID, roomID)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (s *FollowService) Unfollow(ctx context.Context, userID, roomID uint) error {
|
||||
return s.uc.Unfollow(ctx, userID, roomID)
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package live
|
||||
|
||||
import "github.com/google/wire"
|
||||
|
||||
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService)
|
||||
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService)
|
||||
|
||||
@@ -30,3 +30,7 @@ func (s *StreamService) Get(ctx context.Context, streamID string) (*dto.LiveStre
|
||||
}
|
||||
return &dto.LiveStreamResponse{StreamID: stream.StreamID, PublisherOnline: stream.PublisherOnline, SubscriberCount: stream.SubscriberCount}, nil
|
||||
}
|
||||
|
||||
func (s *StreamService) Like(ctx context.Context, streamID string) (int, error) {
|
||||
return s.uc.Like(ctx, streamID)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Service struct{ uc *biz.Usecase }
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
|
||||
func (s *Service) Register(ctx context.Context, in *dto.LiveClientRegisterRequest, ip, agent string) (*dto.LiveClientAuthResponse, error) {
|
||||
result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent)
|
||||
result, err := s.uc.Register(ctx, &biz.RegisterInput{Username: in.Username, Nickname: in.Nickname, Password: in.Password, ConfirmPassword: in.ConfirmPassword}, ip, agent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -35,6 +35,14 @@ func (s *Service) Me(ctx context.Context, userID uint) (*dto.LiveClientAccountRe
|
||||
return accountResponse(account), nil
|
||||
}
|
||||
|
||||
func (s *Service) Balance(ctx context.Context, userID uint) (*dto.LiveClientBalanceResponse, error) {
|
||||
account, err := s.uc.Get(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.LiveClientBalanceResponse{DiamondBalance: account.DiamondBalance}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uint, in *dto.LiveClientProfileRequest) (*dto.LiveClientAccountResponse, error) {
|
||||
account, err := s.uc.UpdateProfile(ctx, &biz.ProfileInput{UserID: userID, Nickname: in.Nickname, Avatar: in.Avatar})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
)
|
||||
|
||||
type providerConfigRepo struct {
|
||||
configs []*integrationbiz.IntegrationConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *providerConfigRepo) ListIntegrationConfigs(context.Context, string) ([]*integrationbiz.IntegrationConfig, error) {
|
||||
return r.configs, r.err
|
||||
}
|
||||
|
||||
func (*providerConfigRepo) FindIntegrationConfig(context.Context, string, string) (*integrationbiz.IntegrationConfig, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (*providerConfigRepo) SaveIntegrationConfig(context.Context, *integrationbiz.IntegrationConfig) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (*providerConfigRepo) DeleteIntegrationConfig(context.Context, string, string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func TestProvidersOnlyReturnsEnabledSupportedPaymentConfigs(t *testing.T) {
|
||||
repo := &providerConfigRepo{configs: []*integrationbiz.IntegrationConfig{
|
||||
{Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-v3", Enabled: true, Values: []byte(`{"api_key":"must-not-leak"}`)},
|
||||
{Kind: integrationbiz.IntegrationKindPayment, Provider: "alipay", Enabled: false},
|
||||
{Kind: integrationbiz.IntegrationKindPayment, Provider: "unknown-provider", Enabled: true},
|
||||
{Kind: integrationbiz.IntegrationKindPayment, Provider: "wechat-v3", Enabled: true},
|
||||
nil,
|
||||
}}
|
||||
integrations := integrationbiz.NewIntegrationConfigUsecase(repo, nil)
|
||||
service := NewService(nil, integrations)
|
||||
|
||||
providers, err := service.Providers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(providers) != 1 || providers[0].ID != "wechat-v3" || providers[0].Name == "" {
|
||||
t.Fatalf("providers = %+v", providers)
|
||||
}
|
||||
encoded, err := json.Marshal(providers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "must-not-leak") || strings.Contains(string(encoded), "api_key") {
|
||||
t.Fatalf("provider response leaked integration config: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvidersPropagatesConfigReadFailure(t *testing.T) {
|
||||
want := errors.New("load failed")
|
||||
integrations := integrationbiz.NewIntegrationConfigUsecase(&providerConfigRepo{err: want}, nil)
|
||||
service := NewService(nil, integrations)
|
||||
|
||||
if _, err := service.Providers(context.Background()); !errors.Is(err, want) {
|
||||
t.Fatalf("Providers() error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,48 @@ package recharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
integrationbiz "kra/internal/biz/integration"
|
||||
bizpayment "kra/internal/biz/payment"
|
||||
biz "kra/internal/biz/recharge"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Service struct{ uc *biz.Usecase }
|
||||
type Service struct {
|
||||
uc *biz.Usecase
|
||||
integrations *integrationbiz.IntegrationConfigUsecase
|
||||
}
|
||||
|
||||
func NewService(uc *biz.Usecase) *Service { return &Service{uc: uc} }
|
||||
func NewService(uc *biz.Usecase, integrations *integrationbiz.IntegrationConfigUsecase) *Service {
|
||||
return &Service{uc: uc, integrations: integrations}
|
||||
}
|
||||
|
||||
func (s *Service) Providers(ctx context.Context) ([]*dto.RechargeProvider, error) {
|
||||
configs, err := s.integrations.List(ctx, integrationbiz.IntegrationKindPayment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers := make([]*dto.RechargeProvider, 0, len(configs))
|
||||
seen := make(map[string]struct{}, len(configs))
|
||||
for _, config := range configs {
|
||||
if config == nil || !config.Enabled || !bizpayment.IsSupportedPaymentProvider(config.Provider) {
|
||||
continue
|
||||
}
|
||||
definition, ok := integrationbiz.IntegrationDefinition(integrationbiz.IntegrationKindPayment, config.Provider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[definition.Provider]; exists {
|
||||
continue
|
||||
}
|
||||
seen[definition.Provider] = struct{}{}
|
||||
providers = append(providers, &dto.RechargeProvider{ID: definition.Provider, Name: definition.Name})
|
||||
}
|
||||
sort.Slice(providers, func(i, j int) bool { return providers[i].ID < providers[j].ID })
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func dates(a, b string) []time.Time {
|
||||
if a == "" || b == "" {
|
||||
@@ -76,6 +108,30 @@ func (s *Service) Get(ctx context.Context, no string) (*dto.RechargeOrderRespons
|
||||
return order(v), nil
|
||||
}
|
||||
|
||||
// GetForUser is the client-facing order lookup. It deliberately maps an
|
||||
// order owned by another account to not-found so order numbers cannot be used
|
||||
// to enumerate another user's payment or diamond balance details.
|
||||
func (s *Service) GetForUser(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) {
|
||||
if userID == 0 {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
item, err := s.uc.Get(ctx, no)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.UserID != userID {
|
||||
return nil, biz.ErrNotFound
|
||||
}
|
||||
return order(item), nil
|
||||
}
|
||||
|
||||
// PaymentStatus is an explicit alias for client polling while a provider
|
||||
// payment is pending. The response includes both provider and fulfillment
|
||||
// state through the normal recharge order DTO.
|
||||
func (s *Service) PaymentStatus(ctx context.Context, userID uint, no string) (*dto.RechargeOrderResponse, error) {
|
||||
return s.GetForUser(ctx, userID, no)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID uint, in *dto.RechargeOrderRequest) (*dto.RechargeCheckoutResponse, error) {
|
||||
v, result, err := s.uc.Create(ctx, userID, in.PlanID, biz.Checkout{Provider: in.Provider, NotifyURL: in.NotifyURL, ReturnURL: in.ReturnURL, ClientIP: in.ClientIP, UserAgent: in.UserAgent, DeviceID: in.DeviceID, Extra: in.Extra})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user