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
+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 {