This commit is contained in:
Yvan
2026-09-23 17:42:55 +08:00
parent 53e8599bdd
commit 0a8ecbe09b
75 changed files with 2299 additions and 276 deletions
+40 -3
View File
@@ -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
+49
View File
@@ -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)
}
}
+91 -23
View File
@@ -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 {
+160
View File
@@ -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 &copy, 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, &copy)
}
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 &copy, 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)
}
}
}
+58
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+11
View File
@@ -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)
}
+10
View File
@@ -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)}
+8
View File
@@ -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)
}
+8 -2
View File
@@ -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
}
+106
View File
@@ -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 = &copy
return &copy, 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(&registerSecurityRepoStub{}, nil, &registerSettingsStub{}, nil)
auth := system.NewAuthenticationUsecase(nil, security, &registerIssuerStub{}, nil)
return NewUsecase(repo, security, auth, nil)
}
func TestRegisterUsesTrimmedNickname(t *testing.T) {
repo := &registerRepoStub{}
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 := &registerRepoStub{}
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 := &registerRepoStub{}
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")
}
})
}
}
+1 -1
View File
@@ -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
View File
@@ -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
}}}
}
+5 -15
View File
@@ -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)
+36 -5
View File
@@ -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)
}
}
+55 -1
View File
@@ -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
}
+5 -1
View File
@@ -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 {
+36
View File
@@ -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)
}
}
+76
View File
@@ -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}
}
+2
View File
@@ -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{})
}}}
}
+1
View File
@@ -9,5 +9,6 @@ import (
var ProviderSet = wire.NewSet(
NewStreamRepo,
NewCatalogRepo,
NewFollowRepo,
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
)
+19
View File
@@ -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
+45 -34
View File
@@ -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 &copy
}
+34 -3
View File
@@ -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)
}
}
+7
View File
@@ -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"},
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
}
+30
View File
@@ -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 {
+122 -45
View File
@@ -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
+201
View File
@@ -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 &copy, 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 &copy, 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, &copy)
}
}
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)
+4 -4
View File
@@ -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))
+15 -1
View File
@@ -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})
+71
View File
@@ -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)
}
}
+9 -1
View File
@@ -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
View File
@@ -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",
+12
View File
@@ -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
}
+96 -15
View File
@@ -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 {
+6 -6
View File
@@ -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{}})
}
+14
View File
@@ -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 {
+36
View File
@@ -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 {
+32 -1
View File
@@ -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.
+34
View File
@@ -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))
}
}
+5 -3
View File
@@ -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)
+3 -1
View File
@@ -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)
}
+3
View File
@@ -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)
+1 -1
View File
@@ -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) },
+22 -13
View File
@@ -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)
}
+21 -18
View File
@@ -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"`
+5
View File
@@ -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"`
+16
View File
@@ -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)
}
}
+4
View File
@@ -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"`
}
+20 -1
View File
@@ -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}
}
+32
View File
@@ -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)
}
+1 -1
View File
@@ -2,4 +2,4 @@ package live
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService)
var ProviderSet = wire.NewSet(NewStreamService, NewCatalogService, NewFollowService)
+4
View File
@@ -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)
}
+9 -1
View File
@@ -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)
}
}
+58 -2
View File
@@ -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 {