This commit is contained in:
Yvan
2026-09-22 20:20:23 +08:00
parent bf33e87d72
commit ceaea78b0f
42 changed files with 2845 additions and 154 deletions
+28 -10
View File
@@ -24,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")
ErrRoomDisabled = kratoserrors.BadRequest("LIVE_ROOM_DISABLED", "live room is disabled")
ErrRoomNotLive = kratoserrors.BadRequest("LIVE_ROOM_NOT_LIVE", "live room is not broadcasting")
ErrAnchorNotFound = kratoserrors.BadRequest("LIVE_ANCHOR_NOT_FOUND", "anchor user not found")
)
@@ -152,8 +153,8 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
filter.Name = strings.TrimSpace(filter.Name)
filter.AnchorName = strings.TrimSpace(filter.AnchorName)
options = normalizeListOptions(options)
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline {
return uc.listByRuntimeStatus(ctx, filter, options)
if filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline || ordersByViewerCount(options.OrderBy) {
return uc.listByRuntime(ctx, filter, options)
}
rooms, total, err := uc.repo.ListRooms(ctx, filter, options)
if err != nil {
@@ -162,10 +163,15 @@ func (uc *RoomUsecase) List(ctx context.Context, filter RoomFilter, options List
return uc.attachRuntime(ctx, rooms), total, nil
}
func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
func (uc *RoomUsecase) listByRuntime(ctx context.Context, filter RoomFilter, options ListOptions) ([]*Room, int64, error) {
all := make([]*Room, 0)
storageOrder := options.OrderBy
if ordersByViewerCount(storageOrder) {
// Runtime sorting happens after loading, so keep storage batches stable.
storageOrder = "id_asc"
}
for offset := 0; ; offset += 100 {
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: options.OrderBy})
batch, total, err := uc.repo.ListRooms(ctx, filter, ListOptions{Offset: offset, Limit: 100, OrderBy: storageOrder})
if err != nil {
return nil, 0, err
}
@@ -176,12 +182,13 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
}
all = uc.attachRuntime(ctx, all)
matched := make([]*Room, 0, len(all))
filterRuntimeStatus := filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline
for _, room := range all {
if room.Status == filter.Status {
if !filterRuntimeStatus || room.Status == filter.Status {
matched = append(matched, room)
}
}
if strings.EqualFold(options.OrderBy, "viewer_count_asc") || strings.EqualFold(options.OrderBy, "viewer_count_desc") {
if ordersByViewerCount(options.OrderBy) {
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].ViewerCount == matched[j].ViewerCount {
return matched[i].ID < matched[j].ID
@@ -203,6 +210,10 @@ func (uc *RoomUsecase) listByRuntimeStatus(ctx context.Context, filter RoomFilte
return matched[options.Offset:end], total, nil
}
func ordersByViewerCount(order string) bool {
return strings.EqualFold(order, "viewer_count_asc") || strings.EqualFold(order, "viewer_count_desc")
}
func (uc *RoomUsecase) Get(ctx context.Context, id uint) (*Room, error) {
if id == 0 {
return nil, ErrRoomNotFound
@@ -219,15 +230,15 @@ func (uc *RoomUsecase) GetPublic(ctx context.Context, id uint) (*Room, error) {
if err != nil {
return nil, err
}
if !room.Enabled || room.Status != RoomStatusLive {
return nil, ErrRoomNotLive
if !room.Enabled {
return nil, ErrRoomDisabled
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return nil, err
}
if !category.Enabled {
return nil, ErrRoomNotLive
return nil, ErrCategoryDisabled
}
return room, nil
}
@@ -237,7 +248,14 @@ func (uc *RoomUsecase) IsLive(ctx context.Context, id uint) (bool, error) {
if err != nil {
return false, err
}
return room.Enabled && room.Status == RoomStatusLive, nil
if !room.Enabled || room.Status != RoomStatusLive {
return false, nil
}
category, err := uc.repo.GetCategory(ctx, room.CategoryID)
if err != nil {
return false, err
}
return category.Enabled, nil
}
func (uc *RoomUsecase) Create(ctx context.Context, room *Room) (*Room, error) {
+125 -17
View File
@@ -40,6 +40,12 @@ func (r *catalogRepoStub) ListRooms(_ context.Context, filter RoomFilter, option
if filter.Enabled != nil && room.Enabled != *filter.Enabled {
continue
}
if filter.Status == RoomStatusDisabled && room.Enabled {
continue
}
if (filter.Status == RoomStatusLive || filter.Status == RoomStatusOffline) && !room.Enabled {
continue
}
if filter.CategoryEnabled != nil {
category, err := r.GetCategory(context.Background(), room.CategoryID)
if err != nil || category.Enabled != *filter.CategoryEnabled {
@@ -105,19 +111,26 @@ func TestRoomUsecaseFiltersSortsAndPaginatesLiveRooms(t *testing.T) {
}
}
func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
catalog := &catalogRepoStub{rooms: []*Room{
{ID: 1, Enabled: true},
{ID: 2, Enabled: true},
{ID: 3, Enabled: false},
}}
func TestRoomUsecaseIsLiveRequiresEnabledRoomCategoryAndOnlinePublisher(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, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 3, 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},
"room-5": {StreamID: "room-5", PublisherOnline: true},
}})
rooms := NewRoomUsecase(catalog, streams)
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false} {
for roomID, want := range map[uint]bool{1: true, 2: false, 3: false, 4: false} {
got, err := rooms.IsLive(context.Background(), roomID)
if err != nil {
t.Fatalf("IsLive(%d) error = %v", roomID, err)
@@ -126,16 +139,21 @@ func TestRoomUsecaseIsLiveRequiresEnabledOnlinePublisher(t *testing.T) {
t.Errorf("IsLive(%d) = %v, want %v", roomID, got, want)
}
}
if live, err := rooms.IsLive(context.Background(), 5); live || !errors.Is(err, ErrCategoryNotFound) {
t.Fatalf("missing category IsLive(5) = %v, err=%v", live, err)
}
}
func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
func TestRoomUsecaseGetPublicIncludesOfflineRoomsAndRequiresEnabledCategory(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: 2, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 3, CategoryID: 1, Enabled: false},
{ID: 4, CategoryID: 2, Enabled: true},
{ID: 5, CategoryID: 1, Enabled: true},
{ID: 6, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
@@ -145,16 +163,106 @@ func TestRoomUsecaseGetPublicRequiresLiveRoomAndEnabledCategory(t *testing.T) {
}})
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)
for _, test := range []struct {
id uint
status string
replay string
}{
{id: 1, status: RoomStatusLive},
{id: 2, status: RoomStatusOffline, replay: "https://example.test/replay.mp4"},
{id: 5, status: RoomStatusOffline},
} {
item, err := rooms.GetPublic(context.Background(), test.id)
if err != nil || item.ID != test.id || item.Status != test.status || item.ReplayURL != test.replay {
t.Fatalf("GetPublic(%d) item=%+v err=%v", test.id, item, err)
}
if _, err = rooms.Get(context.Background(), roomID); err != nil {
}
for roomID, want := range map[uint]error{0: ErrRoomNotFound, 3: ErrRoomDisabled, 4: ErrCategoryDisabled, 6: ErrCategoryNotFound, 99: ErrRoomNotFound} {
if _, err := rooms.GetPublic(context.Background(), roomID); !errors.Is(err, want) {
t.Errorf("GetPublic(%d) error=%v, want %v", roomID, err, want)
}
}
for _, roomID := range []uint{3, 4, 6} {
if _, err := rooms.Get(context.Background(), roomID); err != nil {
t.Errorf("admin Get(%d) error=%v", roomID, err)
}
}
}
func TestRoomUsecaseListsPublicRoomsByRuntimeBeforePagination(t *testing.T) {
catalog := &catalogRepoStub{
categories: []*Category{{ID: 1, Enabled: true}, {ID: 2, Enabled: false}, {ID: 3, Enabled: true}},
rooms: []*Room{
{ID: 1, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{ID: 2, CategoryID: 1, Enabled: true},
{ID: 3, CategoryID: 1, Enabled: true},
{ID: 4, CategoryID: 1, Enabled: false},
{ID: 5, CategoryID: 2, Enabled: true},
{ID: 6, CategoryID: 1, Enabled: true},
{ID: 7, CategoryID: 3, Enabled: true},
},
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-2": {StreamID: "room-2", PublisherOnline: true, SubscriberCount: 100},
"room-3": {StreamID: "room-3", PublisherOnline: true, SubscriberCount: 200},
"room-4": {StreamID: "room-4", PublisherOnline: true, SubscriberCount: 10000},
"room-5": {StreamID: "room-5", PublisherOnline: true, SubscriberCount: 1000},
"room-7": {StreamID: "room-7", PublisherOnline: true, SubscriberCount: 50},
}})
rooms := NewRoomUsecase(catalog, streams)
for _, test := range []struct {
name string
status string
categoryID uint
order string
offset int
limit int
total int64
want []uint
}{
{name: "all default", limit: 10, total: 5, want: []uint{1, 2, 3, 6, 7}},
{name: "all popular", order: "viewer_count_desc", limit: 10, total: 5, want: []uint{3, 2, 7, 1, 6}},
{name: "all popular page", order: "viewer_count_desc", offset: 1, limit: 2, total: 5, want: []uint{2, 7}},
{name: "all ascending", order: "VIEWER_COUNT_ASC", limit: 10, total: 5, want: []uint{1, 6, 7, 2, 3}},
{name: "live only", status: RoomStatusLive, order: "viewer_count_desc", limit: 10, total: 3, want: []uint{3, 2, 7}},
{name: "offline replay and empty", status: RoomStatusOffline, order: "viewer_count_desc", limit: 10, total: 2, want: []uint{1, 6}},
{name: "category filter", categoryID: 1, order: "viewer_count_desc", limit: 10, total: 4, want: []uint{3, 2, 1, 6}},
{name: "disabled excluded", status: RoomStatusDisabled, order: "viewer_count_desc", limit: 10},
{name: "past final page", order: "viewer_count_desc", offset: 5, limit: 2, total: 5},
} {
t.Run(test.name, func(t *testing.T) {
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Status: test.status, CategoryID: test.categoryID, Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Offset: test.offset, Limit: test.limit, OrderBy: test.order})
if err != nil {
t.Fatal(err)
}
if total != test.total || len(items) != len(test.want) {
t.Fatalf("total=%d items=%+v, want total=%d ids=%v", total, items, test.total, test.want)
}
for index, id := range test.want {
if items[index].ID != id {
t.Fatalf("item[%d]=%d, want %d", index, items[index].ID, id)
}
}
})
}
}
func TestRoomUsecasePopularSortIncludesRoomsBeyondStorageBatch(t *testing.T) {
catalog := &catalogRepoStub{categories: []*Category{{ID: 1, Enabled: true}}}
for id := uint(1); id <= 105; id++ {
catalog.rooms = append(catalog.rooms, &Room{ID: id, CategoryID: 1, Enabled: true})
}
streams := NewStreamUsecase(&testRepo{streams: map[string]*Stream{
"room-105": {StreamID: "room-105", PublisherOnline: true, SubscriberCount: 500},
}})
rooms := NewRoomUsecase(catalog, streams)
enabled := true
items, total, err := rooms.List(context.Background(), RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, ListOptions{Limit: 2, OrderBy: "viewer_count_desc"})
if err != nil {
t.Fatal(err)
}
if total != 105 || len(items) != 2 || items[0].ID != 105 || items[1].ID != 1 {
t.Fatalf("popular rooms total=%d items=%+v", total, items)
}
}
+7 -6
View File
@@ -4,11 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
bizgift "kra/internal/biz/gift"
"kra/internal/data/ledger"
"kra/internal/utils/serial"
"strconv"
"strings"
"time"
@@ -26,7 +26,7 @@ type giftPO struct {
Description string `gorm:"size:255"`
Tiers string `gorm:"type:text"`
Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"`
Enabled bool `gorm:"index"`
}
func (giftPO) TableName() string { return "live_gifts" }
@@ -244,7 +244,11 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
}
}
roomIDCopy, giftIDCopy := room.ID, gift.ID
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
serialNo, err := serial.New("DS")
if err != nil {
return err
}
row := ledger.GiftSendPO{SerialNo: serialNo, UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
if err := tx.Create(&row).Error; err != nil {
return err
}
@@ -295,6 +299,3 @@ func sendOrder(v string) string {
}
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)
}
+182
View File
@@ -3,6 +3,7 @@ package gift
import (
"context"
"errors"
"regexp"
"testing"
bizgift "kra/internal/biz/gift"
@@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
t.Fatalf("existing gift was overwritten: %+v", rose)
}
}
func TestGiftRepoInsufficientBalanceDoesNotWriteLedger(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 11}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
_, err = repo.Send(context.Background(), 1, 7, item.ID, 3)
var insufficient *bizgift.InsufficientBalanceError
if !errors.Is(err, bizgift.ErrInsufficientBalance) || !errors.As(err, &insufficient) {
t.Fatalf("insufficient balance error = %v", err)
}
if insufficient.Required != 21 || insufficient.Balance != 11 || insufficient.Missing != 10 {
t.Fatalf("incorrect recharge shortfall: %+v", insufficient)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 11 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("failed gift changed ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoWalletFailureRollsBackGiftReceipt(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Send(context.Background(), 1, 7, item.ID, 3); err == nil {
t.Fatal("forced wallet update failure was ignored")
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("wallet failure was not rolled back: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoCreateDisabledGiftCannotExposeOrDebitIt(t *testing.T) {
db := openGiftTestDB(t)
if err := db.Table("live_gifts").AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
ctx := context.Background()
active, err := repo.Save(ctx, &bizgift.Gift{Name: "Active", Icon: "active.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
disabled, err := repo.Save(ctx, &bizgift.Gift{Name: "Disabled", Icon: "disabled.png", Price: 5, Tiers: []int{1}, Enabled: false})
if err != nil {
t.Fatal(err)
}
if disabled.Enabled {
t.Fatal("a newly created disabled gift was enabled")
}
reloaded, err := repo.Get(ctx, disabled.ID)
if err != nil || reloaded.Enabled {
t.Fatalf("reloaded disabled gift=%+v error=%v", reloaded, err)
}
enabled := true
items, total, err := repo.List(ctx, bizgift.Filter{Enabled: &enabled}, bizgift.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != active.ID {
t.Fatalf("public gifts include a disabled gift: total=%d items=%+v", total, items)
}
if err = repo.SetEnabled(ctx, active.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
t.Fatalf("disabled gift counted as an enabled alternative: error=%v", err)
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
if _, err = repo.Send(ctx, 1, 7, disabled.ID, 1); !errors.Is(err, bizgift.ErrSendNotAllowed) {
t.Fatalf("disabled gift could be sent: error=%v", err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err = db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("disabled gift changed the ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoConsecutiveSendsHaveUniqueShortSerialNumbers(t *testing.T) {
db := openGiftTestDB(t)
ctx := context.Background()
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
const count = 100
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: count}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
t.Fatal(err)
}
gift, err := repo.Save(ctx, &bizgift.Gift{Name: "Gift", Icon: "gift.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
pattern := regexp.MustCompile(`^DS[A-Z2-7]{26}$`)
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
sent, err := repo.Send(ctx, 1, 7, gift.ID, 1)
if err != nil {
t.Fatalf("gift %d failed: %v", index, err)
}
if !pattern.MatchString(sent.SerialNo) || len(sent.SerialNo) > 32 {
t.Fatalf("invalid gift serial: %q", sent.SerialNo)
}
if _, exists := seen[sent.SerialNo]; exists {
t.Fatalf("duplicate gift serial: %q", sent.SerialNo)
}
seen[sent.SerialNo] = struct{}{}
}
var receipts int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&receipts).Error; err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if receipts != count || wallet.DiamondBalance != 0 || wallet.TotalSpentDiamonds != count {
t.Fatalf("consecutive gifts did not reconcile: receipts=%d wallet=%+v", receipts, wallet)
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ type RechargePlanPO struct {
Sort int `gorm:"index"`
Diamonds int64
AmountCents int64
Enabled bool `gorm:"default:true"`
Enabled bool
}
func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
+4 -4
View File
@@ -23,7 +23,7 @@ type categoryPO struct {
Name string `gorm:"size:40;uniqueIndex"`
Icon string `gorm:"size:500"`
Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"`
Enabled bool `gorm:"index"`
}
func (categoryPO) TableName() string { return "live_categories" }
@@ -41,7 +41,7 @@ type roomPO struct {
AnchorUserID uint `gorm:"index"`
StreamURL string `gorm:"size:1000"`
ReplayURL string `gorm:"size:1000"`
Enabled bool `gorm:"default:true;index"`
Enabled bool `gorm:"index"`
}
func (roomPO) TableName() string { return "live_rooms" }
@@ -171,7 +171,7 @@ func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
db := r.db(ctx)
query := db.Model(&roomPO{}).
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
if filter.Name != "" {
value := "%" + filter.Name + "%"
@@ -219,7 +219,7 @@ func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, erro
var row roomRow
err := r.db(ctx).Model(&roomPO{}).
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id AND live_categories.deleted_at IS NULL").
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
Where("live_rooms.id = ?", id).Scan(&row).Error
if err != nil {
+158 -2
View File
@@ -2,6 +2,7 @@ package live
import (
"context"
"errors"
"testing"
bizlive "kra/internal/biz/live"
@@ -44,7 +45,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
if err := db.Create(&category).Error; err != nil {
t.Fatal(err)
}
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true}
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, ReplayURL: "https://example.test/replay.mp4", Enabled: true}
if err := db.Create(&room).Error; err != nil {
t.Fatal(err)
}
@@ -70,7 +71,7 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled {
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || got.ReplayURL != room.ReplayURL || !got.Enabled {
t.Fatalf("room get scan result=%+v", got)
}
}
@@ -110,3 +111,158 @@ func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
}
}
func TestCatalogRepoPublicVisibilityFiltersBeforePagination(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)
}
visible := categoryPO{Name: "Visible", Enabled: true}
disabled := categoryPO{Name: "Disabled", Enabled: true}
deleted := categoryPO{Name: "Deleted", Enabled: true}
for _, category := range []*categoryPO{&visible, &disabled, &deleted} {
if err := db.Create(category).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(&disabled).Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&deleted).Error; err != nil {
t.Fatal(err)
}
rooms := []*roomPO{
{Name: "Offline Room", CategoryID: visible.ID, Enabled: true},
{Name: "Replay Room", CategoryID: visible.ID, Enabled: true, ReplayURL: "https://example.test/replay.mp4"},
{Name: "Disabled Room", CategoryID: visible.ID, Enabled: true},
{Name: "Disabled Category", CategoryID: disabled.ID, Enabled: true},
{Name: "Deleted Category", CategoryID: deleted.ID, Enabled: true},
{Name: "Missing Category", CategoryID: 999, Enabled: true},
{Name: "Deleted Room", CategoryID: visible.ID, Enabled: true},
}
for _, room := range rooms {
room.AnchorUserID = 2
room.CoverURL = "cover.png"
if err := db.Create(room).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(rooms[2]).Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(rooms[6]).Error; err != nil {
t.Fatal(err)
}
repo := NewCatalogRepo(catalogTestProvider{db: db})
enabled := true
filter := bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}
items, total, err := repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10, OrderBy: "id_asc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 2 || items[0].ID != rooms[0].ID || items[1].ID != rooms[1].ID {
t.Fatalf("public candidates total=%d items=%+v", total, items)
}
if items[1].ReplayURL != rooms[1].ReplayURL {
t.Fatalf("replay URL=%q, want %q", items[1].ReplayURL, rooms[1].ReplayURL)
}
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Offset: 1, Limit: 1, OrderBy: "id_asc"})
if err != nil {
t.Fatal(err)
}
if total != 2 || len(items) != 1 || items[0].ID != rooms[1].ID {
t.Fatalf("public page total=%d items=%+v", total, items)
}
filter.CategoryID = disabled.ID
items, total, err = repo.ListRooms(context.Background(), filter, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 0 || len(items) != 0 {
t.Fatalf("disabled category returned rooms: total=%d items=%+v", total, items)
}
}
func TestCatalogRepoCreatePreservesExplicitEnabledState(t *testing.T) {
db := openCatalogTestDB(t)
// Existing installations still have the old database-level default.
for _, table := range []string{"live_categories", "live_rooms"} {
if err := db.Table(table).AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
}
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)
}
repo := NewCatalogRepo(catalogTestProvider{db: db})
ctx := context.Background()
for _, enabled := range []bool{false, true} {
name := "Disabled"
if enabled {
name = "Enabled"
}
t.Run(name+" category", func(t *testing.T) {
created, err := repo.CreateCategory(ctx, &bizlive.Category{Name: name, Enabled: enabled})
if err != nil {
t.Fatal(err)
}
if created.Enabled != enabled {
t.Fatalf("created category enabled=%v, want %v", created.Enabled, enabled)
}
reloaded, err := repo.GetCategory(ctx, created.ID)
if err != nil || reloaded.Enabled != enabled {
t.Fatalf("reloaded category=%+v error=%v, want enabled=%v", reloaded, err, enabled)
}
})
}
enabled := true
categories, total, err := repo.ListCategories(ctx, bizlive.CategoryFilter{Enabled: &enabled}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(categories) != 1 || categories[0].Name != "Enabled" {
t.Fatalf("public categories include a disabled new category: total=%d categories=%+v", total, categories)
}
categoryID := categories[0].ID
streams := bizlive.NewStreamUsecase(NewStreamRepo())
rooms := bizlive.NewRoomUsecase(repo, streams)
for _, enabled := range []bool{false, true} {
name := "Disabled"
if enabled {
name = "Enabled"
}
t.Run(name+" room", func(t *testing.T) {
created, err := repo.CreateRoom(ctx, &bizlive.Room{
Name: name, CategoryID: categoryID, AnchorUserID: 2,
CoverURL: "cover.png", ReplayURL: "https://example.test/replay.mp4", Enabled: enabled,
})
if err != nil {
t.Fatal(err)
}
if created.Enabled != enabled {
t.Fatalf("created room enabled=%v, want %v", created.Enabled, enabled)
}
reloaded, err := repo.GetRoom(ctx, created.ID)
if err != nil || reloaded.Enabled != enabled {
t.Fatalf("reloaded room=%+v error=%v, want enabled=%v", reloaded, err, enabled)
}
public, err := rooms.GetPublic(ctx, created.ID)
if enabled {
if err != nil || public.Status != bizlive.RoomStatusOffline {
t.Fatalf("enabled offline public room=%+v error=%v", public, err)
}
} else if public != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("disabled new room passed public/publisher authorization: room=%+v error=%v", public, err)
}
})
}
public, total, err := repo.ListRooms(ctx, bizlive.RoomFilter{Enabled: &enabled, CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(public) != 1 || public[0].Name != "Enabled" {
t.Fatalf("public list includes a disabled new room: total=%d rooms=%+v", total, public)
}
}
+7 -7
View File
@@ -3,7 +3,6 @@ package recharge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -13,6 +12,7 @@ import (
biz "kra/internal/biz/recharge"
"kra/internal/data/ledger"
"kra/internal/utils/serial"
)
type Provider interface{ DB() *gorm.DB }
@@ -39,7 +39,7 @@ func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
return err
}
for _, p := range plans {
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: p.Enabled}).Error; err != nil {
return err
}
}
@@ -127,7 +127,11 @@ func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, err
rate = v
}
}
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
orderNo, err := serial.New("CZ")
if err != nil {
return nil, err
}
v := ledger.RechargeOrderPO{OrderNo: orderNo, UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
if err := r.db(ctx).Create(&v).Error; err != nil {
return nil, err
}
@@ -185,7 +189,3 @@ func orderBy(v string) string {
}
return "live_recharge_orders.created_at DESC"
}
func number(p string) string {
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
}
+173
View File
@@ -2,6 +2,8 @@ package recharge
import (
"context"
"errors"
"regexp"
"testing"
"time"
@@ -95,3 +97,174 @@ func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
t.Fatalf("payment state was not joined: %+v", item)
}
}
func TestMarkPaidWalletFailureRollsBackCreditMarkerAndAllowsRetry(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.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
t.Fatal(err)
}
order := ledger.RechargeOrderPO{OrderNo: "CZ-ROLLBACK", UserID: 3, Diamonds: 60, AmountCents: 600}
if err = db.Create(&order).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 3, DiamondBalance: 10}).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err == nil {
t.Fatal("forced wallet update failure was ignored")
}
var stored ledger.RechargeOrderPO
if err = db.First(&stored, order.ID).Error; err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if stored.CreditedAt != nil || wallet.DiamondBalance != 10 || wallet.TotalRechargeCents != 0 {
t.Fatalf("failed fulfillment changed credit marker or wallet: order=%+v wallet=%+v", stored, wallet)
}
if err = db.Exec(`DROP TRIGGER reject_wallet_update`).Error; err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
t.Fatal(err)
}
}
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if err = db.First(&stored, order.ID).Error; err != nil {
t.Fatal(err)
}
if stored.CreditedAt == nil || wallet.DiamondBalance != 70 || wallet.TotalRechargeCents != 600 {
t.Fatalf("retry did not credit exactly once: order=%+v wallet=%+v", stored, wallet)
}
}
func TestRechargePlanStoragePreservesExplicitDisabledState(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.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}); err != nil {
t.Fatal(err)
}
if err = db.Table("live_recharge_plans").AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
disabled := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false}
if err = db.Create(&disabled).Error; err != nil {
t.Fatal(err)
}
var stored ledger.RechargePlanPO
if err = db.First(&stored, disabled.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Enabled {
t.Fatal("storage default enabled an explicitly disabled recharge plan")
}
repo := NewRepo(testProvider{db: db})
items, err := repo.Plans(context.Background())
if err != nil || len(items) != 0 {
t.Fatalf("public plans include a disabled plan: plans=%+v error=%v", items, err)
}
if _, err = repo.Create(context.Background(), 1, disabled.ID); !errors.Is(err, biz.ErrInvalid) {
t.Fatalf("disabled recharge plan accepted an order: error=%v", err)
}
var orders int64
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if orders != 0 {
t.Fatalf("disabled recharge plan created %d orders", orders)
}
}
func TestRechargeRepoSavePlansPreservesDomainEnabledValues(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.AutoMigrate(&ledger.RechargePlanPO{}); err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
if err = repo.SavePlans(context.Background(), []*biz.Plan{
{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: false},
{Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
}); err != nil {
t.Fatal(err)
}
var stored []ledger.RechargePlanPO
if err = db.Order("sort ASC").Find(&stored).Error; err != nil {
t.Fatal(err)
}
if len(stored) != 2 || stored[0].Enabled || !stored[1].Enabled {
t.Fatalf("saved plans lost explicit enabled values: %+v", stored)
}
items, err := repo.Plans(context.Background())
if err != nil || len(items) != 1 || items[0].Diamonds != 300 {
t.Fatalf("public plan filter returned disabled entries: plans=%+v error=%v", items, err)
}
}
func TestRechargeRepoConsecutiveOrdersHaveUniquePaymentSafeNumbers(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.AutoMigrate(&ledger.RechargePlanPO{}, &ledger.RechargeOrderPO{}, &ledger.SettingPO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`,
`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`,
`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
t.Fatal(err)
}
plan := ledger.RechargePlanPO{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}
if err = db.Create(&plan).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
pattern := regexp.MustCompile(`^CZ[A-Z2-7]{26}$`)
const count = 100
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
order, err := repo.Create(context.Background(), 3, plan.ID)
if err != nil {
t.Fatalf("order %d failed: %v", index, err)
}
if !pattern.MatchString(order.OrderNo) || len(order.OrderNo) > 32 {
t.Fatalf("invalid recharge order number: %q", order.OrderNo)
}
if _, exists := seen[order.OrderNo]; exists {
t.Fatalf("duplicate recharge order number: %q", order.OrderNo)
}
seen[order.OrderNo] = struct{}{}
}
var orders int64
if err = db.Model(&ledger.RechargeOrderPO{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if orders != count {
t.Fatalf("created orders=%d, want %d", orders, count)
}
}
+6 -4
View File
@@ -322,6 +322,11 @@ func (m *Manager) handleChat(session *melody.Session, payload chatSendPayload) {
m.send(session, "Error", map[string]any{"code": "CHAT_UNAVAILABLE", "message": "chat is only available in live rooms"})
return
}
available, err := m.roomCatalog.IsLive(context.Background(), roomID)
if err != nil || !available {
m.send(session, "Error", map[string]any{"code": "ROOM_UNAVAILABLE", "message": "live room is unavailable"})
return
}
item, err := m.chat.Send(context.Background(), roomID, p.userID, payload.Content)
if err != nil {
m.send(session, "Error", map[string]any{"code": "INVALID_CHAT", "message": err.Error()})
@@ -379,13 +384,10 @@ func (m *Manager) authorizeRoomPublisher(streamID string, userID uint) error {
if err != nil || id == 0 {
return errors.New("invalid live room stream id")
}
item, err := m.roomCatalog.Get(context.Background(), uint(id))
item, err := m.roomCatalog.GetPublic(context.Background(), uint(id))
if err != nil {
return errors.New("live room is unavailable")
}
if !item.Enabled {
return errors.New("live room is disabled")
}
if item.AnchorUserID != userID {
return errors.New("current user is not the room anchor")
}
+289 -8
View File
@@ -2,6 +2,7 @@ package live
import (
"context"
"encoding/json"
"sync"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/pion/webrtc/v4"
bizlive "kra/internal/biz/live"
bizchat "kra/internal/biz/livechat"
platformws "kra/pkg/websocket"
)
@@ -39,28 +41,41 @@ func (h *liveHubStub) Send(session *platformws.Session, message []byte) error {
type liveCatalogStub struct {
bizlive.CatalogRepo
room *bizlive.Room
err error
room *bizlive.Room
category *bizlive.Category
err error
categoryErr error
}
func (s *liveCatalogStub) GetRoom(context.Context, uint) (*bizlive.Room, error) {
func (s *liveCatalogStub) GetRoom(_ context.Context, id uint) (*bizlive.Room, error) {
if s.err != nil {
return nil, s.err
}
if s.room == nil || s.room.ID != id {
return nil, bizlive.ErrRoomNotFound
}
copy := *s.room
return &copy, nil
}
func (s *liveCatalogStub) GetCategory(context.Context, uint) (*bizlive.Category, error) {
if s.categoryErr != nil {
return nil, s.categoryErr
}
if s.category != nil {
copy := *s.category
return &copy, nil
}
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
mu sync.Mutex
streams map[string]*bizlive.Stream
users map[uint]struct{}
registerCalls int
publisherRegisterCalls int
}
func (s *liveStreamStub) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
@@ -86,6 +101,38 @@ func (s *liveStreamStub) List(context.Context) ([]*bizlive.Stream, error) {
}
return items, nil
}
func (s *liveStreamStub) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.streams == nil {
s.streams = make(map[string]*bizlive.Stream)
}
stream := s.streams[streamID]
if stream == nil {
stream = &bizlive.Stream{StreamID: streamID}
s.streams[streamID] = stream
}
if stream.PublisherOnline {
return nil, bizlive.ErrPublisherExists
}
s.publisherRegisterCalls++
stream.PublisherOnline, stream.PublisherID = true, userID
copy := *stream
return &copy, nil
}
func (s *liveStreamStub) UnregisterPublisher(_ context.Context, streamID string, userID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
stream := s.streams[streamID]
if stream == nil || !stream.PublisherOnline || stream.PublisherID != userID {
return bizlive.ErrPublisherNotFound
}
stream.PublisherOnline, stream.PublisherID = false, 0
return nil
}
func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -97,15 +144,49 @@ func (s *liveStreamStub) RegisterSubscriber(_ context.Context, streamID string,
return nil, bizlive.ErrSubscriberExists
}
s.users[userID] = struct{}{}
if stream := s.streams[streamID]; stream != nil {
stream.SubscriberCount = len(s.users)
}
return &bizlive.Stream{StreamID: streamID, SubscriberCount: len(s.users)}, nil
}
func (s *liveStreamStub) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[userID]; !exists {
return bizlive.ErrSubscriberNotFound
}
delete(s.users, userID)
if stream := s.streams[streamID]; stream != nil {
stream.SubscriberCount = len(s.users)
}
return nil
}
type liveChatStub struct {
bizchat.Repo
cleared []uint
messages []*bizchat.Message
}
func (s *liveChatStub) Clear(_ context.Context, roomID uint) error {
s.cleared = append(s.cleared, roomID)
return nil
}
func (s *liveChatStub) CreateChat(_ context.Context, roomID, userID uint, content string) (*bizchat.Message, error) {
item := &bizchat.Message{ID: uint(len(s.messages) + 1), RoomID: roomID, UserID: userID, Content: content, Type: bizchat.TypeChat}
s.messages = append(s.messages, item)
return item, 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),
chat: bizchat.NewUsecase(&liveChatStub{}),
rooms: make(map[string]*room),
peers: make(map[*melody.Session]*peer),
observers: make(map[*melody.Session]struct{}),
@@ -132,6 +213,11 @@ func TestAnonymousSubscribersReceiveDistinctRuntimeIdentities(t *testing.T) {
if len(streams.users) != 2 {
t.Fatalf("subscriber count = %d, want 2", len(streams.users))
}
for _, session := range []*melody.Session{first, second} {
if payload := lastMessagePayload(t, manager, session, "Joined"); payload["userId"] != float64(0) {
t.Fatalf("anonymous runtime identity leaked in response: %+v", payload)
}
}
}
func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
@@ -147,7 +233,7 @@ func TestSubscriberCannotCreateUnknownCatalogRoom(t *testing.T) {
func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
streams := &liveStreamStub{}
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true}}, streams)
manager := newAnonymousTestManager(&liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}, streams)
session := &melody.Session{Keys: map[string]any{}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
@@ -155,6 +241,9 @@ func TestSubscriberCannotCreateOfflineRuntimeState(t *testing.T) {
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)
}
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("offline join rejection=%+v", payload)
}
}
func TestAnonymousPublisherIsRejected(t *testing.T) {
@@ -168,6 +257,198 @@ func TestAnonymousPublisherIsRejected(t *testing.T) {
}
}
func TestBoundAnchorCanPublishOfflineRoomAndDisconnectRestoresReplay(t *testing.T) {
catalog := &liveCatalogStub{room: &bizlive.Room{ID: 7, CategoryID: 1, AnchorUserID: 42, Enabled: true, ReplayURL: "https://example.test/replay.mp4"}}
streams := &liveStreamStub{}
manager := newAnonymousTestManager(catalog, streams)
chat := &liveChatStub{}
manager.chat = bizchat.NewUsecase(chat)
publisher := &melody.Session{Keys: map[string]any{"user_id": uint(42)}}
viewer := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
before, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || before.Status != bizlive.RoomStatusOffline {
t.Fatalf("offline public room=%+v error=%v", before, err)
}
manager.join(publisher, joinPayload{Role: "publisher", StreamID: "room-7"})
peer := manager.peer(publisher)
if peer == nil || peer.role != "publisher" || !peer.authenticated || peer.userID != 42 {
t.Fatalf("bound anchor did not join offline room: peer=%+v", peer)
}
if payload := lastMessagePayload(t, manager, publisher, "Joined"); payload["role"] != "publisher" || payload["streamId"] != "room-7" {
t.Fatalf("publisher join response=%+v", payload)
}
live, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || live.Status != bizlive.RoomStatusLive || streams.publisherRegisterCalls != 1 {
t.Fatalf("published room=%+v registrations=%d error=%v", live, streams.publisherRegisterCalls, err)
}
manager.join(viewer, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(viewer) == nil {
t.Fatal("viewer could not join published room")
}
if payload := lastMessagePayload(t, manager, viewer, "Joined"); payload["userId"] != float64(9) {
t.Fatalf("authenticated viewer response=%+v", payload)
}
manager.handleChat(viewer, chatSendPayload{Content: "Hello"})
if len(chat.messages) != 1 || chat.messages[0].RoomID != 7 || chat.messages[0].UserID != 9 {
t.Fatalf("live chat messages=%+v", chat.messages)
}
manager.onDisconnect(publisher)
after, err := manager.roomCatalog.GetPublic(context.Background(), 7)
if err != nil || after.Status != bizlive.RoomStatusOffline || after.ReplayURL != before.ReplayURL {
t.Fatalf("room after disconnect=%+v error=%v", after, err)
}
if payload := lastMessagePayload(t, manager, viewer, "PublisherStatus"); payload["ready"] != false || payload["reason"] != "disconnected" {
t.Fatalf("offline notification=%+v", payload)
}
if available, err := manager.roomCatalog.IsLive(context.Background(), 7); err != nil || available {
t.Fatalf("room remained live after disconnect: live=%v error=%v", available, err)
}
manager.handleChat(viewer, chatSendPayload{Content: "Not live"})
if len(chat.messages) != 1 {
t.Fatalf("offline message was persisted: %+v", chat.messages)
}
if payload := lastMessagePayload(t, manager, viewer, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("offline chat rejection=%+v", payload)
}
manager.onDisconnect(viewer)
if len(manager.peers) != 0 || len(manager.rooms) != 0 || len(streams.users) != 0 {
t.Fatalf("disconnected peers remain: peers=%d rooms=%d users=%d", len(manager.peers), len(manager.rooms), len(streams.users))
}
if len(chat.cleared) != 2 || chat.cleared[0] != 7 || chat.cleared[1] != 7 {
t.Fatalf("chat reset rooms=%v", chat.cleared)
}
}
func TestPublisherJoinRequiresEnabledRoomCategoryAndBoundAnchor(t *testing.T) {
for _, test := range []struct {
name string
userID uint
enabled bool
category *bizlive.Category
roomErr error
categoryErr error
code string
}{
{name: "anonymous", enabled: true, code: "AUTH_REQUIRED"},
{name: "wrong anchor", userID: 9, enabled: true, code: "PUBLISH_FORBIDDEN"},
{name: "disabled room", userID: 42, code: "PUBLISH_FORBIDDEN"},
{name: "disabled category", userID: 42, enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}, code: "PUBLISH_FORBIDDEN"},
{name: "missing room", userID: 42, enabled: true, roomErr: bizlive.ErrRoomNotFound, code: "PUBLISH_FORBIDDEN"},
{name: "missing category", userID: 42, enabled: true, categoryErr: bizlive.ErrCategoryNotFound, code: "PUBLISH_FORBIDDEN"},
} {
t.Run(test.name, func(t *testing.T) {
streams := &liveStreamStub{}
manager := newAnonymousTestManager(&liveCatalogStub{
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: test.enabled, AnchorUserID: 42},
category: test.category,
err: test.roomErr,
categoryErr: test.categoryErr,
}, streams)
session := &melody.Session{Keys: map[string]any{"user_id": test.userID}}
manager.join(session, joinPayload{Role: "publisher", StreamID: "room-7"})
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.publisherRegisterCalls != 0 {
t.Fatalf("forbidden publisher created state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.publisherRegisterCalls)
}
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != test.code {
t.Fatalf("publisher rejection=%+v, want code=%s", payload, test.code)
}
})
}
}
func TestSubscriberJoinCannotBypassDisabledRoomOrCategory(t *testing.T) {
for _, test := range []struct {
name string
enabled bool
category *bizlive.Category
}{
{name: "disabled room"},
{name: "disabled category", enabled: true, category: &bizlive.Category{ID: 1, Enabled: false}},
} {
t.Run(test.name, func(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: test.enabled},
category: test.category,
}, streams)
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(session) != nil || len(manager.rooms) != 0 || streams.registerCalls != 0 {
t.Fatalf("unavailable room created subscriber state: peers=%d rooms=%d registrations=%d", len(manager.peers), len(manager.rooms), streams.registerCalls)
}
if payload := lastMessagePayload(t, manager, session, "JoinRejected"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("subscriber rejection=%+v", payload)
}
})
}
}
func TestChatRechecksRoomAndCategoryVisibilityAfterJoin(t *testing.T) {
for _, disableCategory := range []bool{false, true} {
name := "disabled room"
if disableCategory {
name = "disabled category"
}
t.Run(name, func(t *testing.T) {
catalog := &liveCatalogStub{
room: &bizlive.Room{ID: 7, CategoryID: 1, Enabled: true},
category: &bizlive.Category{ID: 1, Enabled: true},
}
streams := &liveStreamStub{streams: map[string]*bizlive.Stream{"room-7": {StreamID: "room-7", PublisherOnline: true}}}
manager := newAnonymousTestManager(catalog, streams)
chat := &liveChatStub{}
manager.chat = bizchat.NewUsecase(chat)
session := &melody.Session{Keys: map[string]any{"user_id": uint(9)}}
manager.join(session, joinPayload{Role: "subscriber", StreamID: "room-7"})
if manager.peer(session) == nil {
t.Fatal("viewer did not join enabled room")
}
if disableCategory {
catalog.category.Enabled = false
} else {
catalog.room.Enabled = false
}
manager.handleChat(session, chatSendPayload{Content: "Hello"})
if len(chat.messages) != 0 {
t.Fatalf("unavailable chat was persisted: %+v", chat.messages)
}
if payload := lastMessagePayload(t, manager, session, "Error"); payload["code"] != "ROOM_UNAVAILABLE" {
t.Fatalf("unavailable chat rejection=%+v", payload)
}
})
}
}
func lastMessagePayload(t *testing.T, manager *Manager, session *melody.Session, msgType string) map[string]any {
t.Helper()
hub := manager.hub.(*liveHubStub)
hub.mu.Lock()
defer hub.mu.Unlock()
messages := hub.messages[session]
for index := len(messages) - 1; index >= 0; index-- {
var msg struct {
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
if err := json.Unmarshal(messages[index], &msg); err != nil {
t.Fatal(err)
}
if msg.Type == msgType {
return msg.Payload
}
}
t.Fatalf("missing %s message in %q", msgType, messages)
return nil
}
func TestClosedSupersededPeerConnectionDoesNotRemovePeer(t *testing.T) {
oldPC, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
+1 -1
View File
@@ -83,7 +83,7 @@ func (h *Live) Like(c *gin.Context) {
// 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)
room, err := h.catalog.GetPublicRoom(c.Request.Context(), id)
if err != nil {
businessFail(c, err)
return
+13 -2
View File
@@ -10,7 +10,18 @@ import (
type CatalogService struct {
categories *bizlive.CategoryUsecase
rooms *bizlive.RoomUsecase
rooms roomCatalogUsecase
}
type roomCatalogUsecase interface {
List(context.Context, bizlive.RoomFilter, bizlive.ListOptions) ([]*bizlive.Room, int64, error)
Get(context.Context, uint) (*bizlive.Room, error)
GetPublic(context.Context, uint) (*bizlive.Room, error)
Create(context.Context, *bizlive.Room) (*bizlive.Room, error)
Update(context.Context, *bizlive.Room) (*bizlive.Room, error)
Delete(context.Context, uint) error
SetEnabled(context.Context, uint, bool) error
SaveSort(context.Context, map[uint]int) error
}
func NewCatalogService(categories *bizlive.CategoryUsecase, rooms *bizlive.RoomUsecase) *CatalogService {
@@ -78,7 +89,7 @@ func (s *CatalogService) ListRooms(ctx context.Context, input *dto.LiveRoomListR
}
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))
items, total, err := s.rooms.List(ctx, bizlive.RoomFilter{Name: input.Name, CategoryID: input.CategoryID, Status: input.Status, Enabled: &enabled, CategoryEnabled: &enabled}, liveListOptions(input.LiveListRequest))
if err != nil {
return nil, 0, err
}
+97
View File
@@ -0,0 +1,97 @@
package live
import (
"context"
"errors"
"testing"
bizlive "kra/internal/biz/live"
"kra/internal/service/dto"
)
type roomCatalogStub struct {
roomCatalogUsecase
filter bizlive.RoomFilter
options bizlive.ListOptions
items []*bizlive.Room
total int64
publicID uint
item *bizlive.Room
err error
}
func (s *roomCatalogStub) List(_ context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
s.filter, s.options = filter, options
return s.items, s.total, s.err
}
func (s *roomCatalogStub) GetPublic(_ context.Context, id uint) (*bizlive.Room, error) {
s.publicID = id
return s.item, s.err
}
func TestListPublicRoomsPreservesOptionalStatusAndEnforcesVisibility(t *testing.T) {
for _, status := range []string{"", bizlive.RoomStatusLive, bizlive.RoomStatusOffline, bizlive.RoomStatusDisabled} {
t.Run("status="+status, func(t *testing.T) {
rooms := &roomCatalogStub{
items: []*bizlive.Room{{ID: 7, Status: bizlive.RoomStatusOffline, ReplayURL: "https://example.test/replay.mp4"}},
total: 21,
}
service := &CatalogService{rooms: rooms}
disabled := false
result, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{
LiveListRequest: dto.LiveListRequest{Page: 2, PageSize: 20, OrderBy: "viewer_count_desc"},
Name: "Room",
CategoryID: 3,
Status: status,
Enabled: &disabled,
})
if err != nil {
t.Fatal(err)
}
if rooms.filter.Status != status || rooms.filter.Name != "Room" || rooms.filter.CategoryID != 3 {
t.Fatalf("public filter=%+v", rooms.filter)
}
if rooms.filter.Enabled == nil || !*rooms.filter.Enabled || rooms.filter.CategoryEnabled == nil || !*rooms.filter.CategoryEnabled {
t.Fatalf("public visibility filter=%+v", rooms.filter)
}
if rooms.options != (bizlive.ListOptions{Offset: 20, Limit: 20, OrderBy: "viewer_count_desc"}) {
t.Fatalf("list options=%+v", rooms.options)
}
if total != 21 || len(result) != 1 || result[0].ID != 7 || result[0].Status != bizlive.RoomStatusOffline || result[0].ReplayURL != rooms.items[0].ReplayURL {
t.Fatalf("public response total=%d rooms=%+v", total, result)
}
})
}
}
func TestGetPublicRoomPreservesOfflineReplayAndAnchorDetails(t *testing.T) {
rooms := &roomCatalogStub{item: &bizlive.Room{
ID: 7,
Name: "Offline Room",
CategoryID: 3,
AnchorUserID: 42,
ReplayURL: "https://example.test/replay.mp4",
Enabled: true,
Status: bizlive.RoomStatusOffline,
}}
service := &CatalogService{rooms: rooms}
result, err := service.GetPublicRoom(context.Background(), 7)
if err != nil {
t.Fatal(err)
}
if rooms.publicID != 7 || result.StreamID != "room-7" || result.Status != bizlive.RoomStatusOffline || result.AnchorUserID != 42 || result.ReplayURL != rooms.item.ReplayURL {
t.Fatalf("public detail=%+v requestedID=%d", result, rooms.publicID)
}
}
func TestPublicCatalogPropagatesUsecaseErrors(t *testing.T) {
service := &CatalogService{rooms: &roomCatalogStub{err: bizlive.ErrRoomDisabled}}
if items, total, err := service.ListPublicRooms(context.Background(), &dto.LiveRoomListRequest{}); items != nil || total != 0 || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("ListPublicRooms items=%+v total=%d error=%v", items, total, err)
}
if item, err := service.GetPublicRoom(context.Background(), 7); item != nil || !errors.Is(err, bizlive.ErrRoomDisabled) {
t.Fatalf("GetPublicRoom item=%+v error=%v", item, err)
}
}
+16
View File
@@ -0,0 +1,16 @@
package serial
import (
"crypto/rand"
"encoding/base32"
"fmt"
)
// New appends a 26-character base32 value with 128 random bits to prefix.
func New(prefix string) (string, error) {
var entropy [16]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", fmt.Errorf("generate serial number: %w", err)
}
return prefix + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(entropy[:]), nil
}
+96
View File
@@ -0,0 +1,96 @@
package serial
import (
"encoding/base32"
"regexp"
"strings"
"sync"
"testing"
)
var validSerial = regexp.MustCompile(`^(DS|CZ)[A-Z2-7]{26}$`)
func TestNewPreservesPrefixAndPaymentSafeLength(t *testing.T) {
for _, prefix := range []string{"DS", "CZ"} {
t.Run(prefix, func(t *testing.T) {
value, err := New(prefix)
if err != nil {
t.Fatal(err)
}
if len(value) != 28 || !strings.HasPrefix(value, prefix) || !validSerial.MatchString(value) {
t.Fatalf("unexpected serial format: %q", value)
}
entropy, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimPrefix(value, prefix))
if err != nil || len(entropy) != 16 {
t.Fatalf("serial does not carry 128 bits: bytes=%d error=%v", len(entropy), err)
}
})
}
}
func TestNewIsUniqueAcrossRapidSequentialCalls(t *testing.T) {
const count = 10000
for _, prefix := range []string{"DS", "CZ"} {
t.Run(prefix, func(t *testing.T) {
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
value, err := New(prefix)
if err != nil {
t.Fatal(err)
}
if _, exists := seen[value]; exists {
t.Fatalf("duplicate serial at call %d: %q", index, value)
}
seen[value] = struct{}{}
}
})
}
}
func TestNewIsUniqueAcrossParallelCalls(t *testing.T) {
const workers, perWorker = 32, 500
type result struct {
value string
err error
}
start := make(chan struct{})
results := make(chan result, workers)
var group sync.WaitGroup
for worker := 0; worker < workers; worker++ {
group.Add(1)
go func(worker int) {
defer group.Done()
prefix := "DS"
if worker%2 == 0 {
prefix = "CZ"
}
<-start
for index := 0; index < perWorker; index++ {
value, err := New(prefix)
results <- result{value: value, err: err}
}
}(worker)
}
go func() {
group.Wait()
close(results)
}()
close(start)
seen := make(map[string]struct{}, workers*perWorker)
for result := range results {
if result.err != nil {
t.Errorf("generate parallel serial: %v", result.err)
continue
}
if !validSerial.MatchString(result.value) || len(result.value) > 32 {
t.Errorf("invalid parallel serial format: %q", result.value)
}
if _, exists := seen[result.value]; exists {
t.Errorf("duplicate parallel serial: %q", result.value)
}
seen[result.value] = struct{}{}
}
if len(seen) != workers*perWorker {
t.Fatalf("unique serials=%d, want %d", len(seen), workers*perWorker)
}
}