对接
This commit is contained in:
@@ -136,7 +136,7 @@ func toBiz(v *row) *biz.User {
|
||||
return &biz.User{UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, Avatar: v.Avatar, Enabled: v.Enable == 1, DiamondBalance: v.DiamondBalance, TotalRechargeCents: v.TotalRechargeCents, TotalSpentDiamonds: v.TotalSpentDiamonds, CreatedAt: v.CreatedAt, LastLoginAt: v.LastLoginAt}
|
||||
}
|
||||
func userOrder(v string) string {
|
||||
m := map[string]string{"id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
m := map[string]string{"id_asc": "sys_users.id ASC", "id_desc": "sys_users.id DESC", "balance_asc": "diamond_balance ASC", "balance_desc": "diamond_balance DESC", "recharge_asc": "total_recharge_cents ASC", "recharge_desc": "total_recharge_cents DESC", "spent_asc": "total_spent_diamonds ASC", "spent_desc": "total_spent_diamonds DESC", "created_asc": "sys_users.created_at ASC", "created_desc": "sys_users.created_at DESC", "created_at_asc": "sys_users.created_at ASC", "created_at_desc": "sys_users.created_at DESC", "last_login_asc": "last_login_at ASC", "last_login_desc": "last_login_at DESC"}
|
||||
if x := m[v]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -9,5 +9,14 @@ import (
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202609190004_asset_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{})
|
||||
}}, {ID: "202609220003_live_wallet_backfill", Migrate: func(db *gorm.DB) error {
|
||||
// Historical client accounts predate live_wallets. Backfill only missing
|
||||
// rows with the same 100-diamond welcome balance as a new registration;
|
||||
// existing balances and administrative adjustments remain untouched.
|
||||
return db.Exec(`INSERT INTO live_wallets (user_id, diamond_balance, total_recharge_cents, total_spent_diamonds, created_at, updated_at)
|
||||
SELECT u.id, 100, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM sys_users u
|
||||
WHERE u.deleted_at IS NULL AND u.authority_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM live_wallets w WHERE w.user_id = u.id)`, 1000).Error
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -228,23 +228,13 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
|
||||
if err := tx.Where("id = ? AND enabled = ?", giftID, true).First(&gift).Error; err != nil {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
allowedQuantity := false
|
||||
for _, tier := range decode(gift.Tiers) {
|
||||
if tier == quantity {
|
||||
allowedQuantity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowedQuantity {
|
||||
return bizgift.ErrSendNotAllowed
|
||||
}
|
||||
wallet := ledger.WalletPO{UserID: userID}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).FirstOrCreate(&wallet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
total := gift.Price * int64(quantity)
|
||||
if wallet.DiamondBalance < total {
|
||||
return bizgift.ErrInsufficientBalance
|
||||
return &bizgift.InsufficientBalanceError{Required: total, Balance: wallet.DiamondBalance, Missing: total - wallet.DiamondBalance}
|
||||
}
|
||||
rate := int64(10)
|
||||
var setting ledger.SettingPO
|
||||
@@ -292,18 +282,18 @@ func mapError(err error) error {
|
||||
return err
|
||||
}
|
||||
func giftOrder(v string) string {
|
||||
m := map[string]string{"sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC"}
|
||||
m := map[string]string{"sort_asc": "sort ASC", "sort_desc": "sort DESC", "name_asc": "name ASC", "name_desc": "name DESC", "price_asc": "price ASC", "price_desc": "price DESC", "updated_at_asc": "updated_at ASC", "updated_at_desc": "updated_at DESC", "created_at_asc": "created_at ASC", "created_at_desc": "created_at DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "sort ASC,created_at DESC"
|
||||
return "sort ASC,created_at DESC,id ASC"
|
||||
}
|
||||
func sendOrder(v string) string {
|
||||
m := map[string]string{"created_at_asc": "created_at ASC", "price_asc": "gift_price ASC", "price_desc": "gift_price DESC", "quantity_asc": "quantity ASC", "quantity_desc": "quantity DESC", "total_asc": "diamond_total ASC", "total_desc": "diamond_total DESC"}
|
||||
m := map[string]string{"created_at_asc": "live_gift_sends.created_at ASC", "created_at_desc": "live_gift_sends.created_at DESC", "price_asc": "live_gift_sends.gift_price ASC", "price_desc": "live_gift_sends.gift_price DESC", "quantity_asc": "live_gift_sends.quantity ASC", "quantity_desc": "live_gift_sends.quantity DESC", "total_asc": "live_gift_sends.diamond_total ASC", "total_desc": "live_gift_sends.diamond_total DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
return "created_at DESC"
|
||||
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
|
||||
}
|
||||
func newNumber(prefix string) string {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
|
||||
|
||||
@@ -67,12 +67,12 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); !errors.Is(err, bizgift.ErrSendNotAllowed) {
|
||||
t.Fatalf("unsupported quantity error = %v", err)
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 2); err != nil {
|
||||
t.Fatalf("custom quantity should be accepted: %v", err)
|
||||
}
|
||||
var before ledger.WalletPO
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 100 {
|
||||
t.Fatalf("wallet changed after rejected send: wallet=%+v err=%v", before, err)
|
||||
if err = db.Where("user_id = ?", 1).First(&before).Error; err != nil || before.DiamondBalance != 90 {
|
||||
t.Fatalf("wallet did not debit custom quantity: wallet=%+v err=%v", before, err)
|
||||
}
|
||||
if _, err = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -81,7 +81,38 @@ func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
|
||||
var sends int64
|
||||
db.Where("user_id = ?", 1).First(&after)
|
||||
db.Model(&ledger.GiftSendPO{}).Count(&sends)
|
||||
if after.DiamondBalance != 50 || after.TotalSpentDiamonds != 50 || sends != 1 {
|
||||
if after.DiamondBalance != 40 || after.TotalSpentDiamonds != 60 || sends != 2 {
|
||||
t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
|
||||
db := openGiftTestDB(t)
|
||||
custom := giftPO{Name: "玫瑰", Icon: "custom", Price: 99, Description: "admin configured", Tiers: "[1]", Sort: 88, Enabled: false}
|
||||
if err := db.Create(&custom).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&giftPO{}).Where("id = ?", custom.ID).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seedDefaultCatalog(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&giftPO{}).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 24 {
|
||||
t.Fatalf("gift count = %d, want 24", count)
|
||||
}
|
||||
var rose giftPO
|
||||
if err := db.Where("name = ?", "玫瑰").First(&rose).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rose.Icon != "custom" || rose.Price != 99 || rose.Sort != 88 || rose.Enabled {
|
||||
t.Fatalf("existing gift was overwritten: %+v", rose)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,59 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]giftPO{{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true}, {Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true}, {Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true}, {Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true}}).Error
|
||||
}}}
|
||||
}}, {ID: "202609220001_live_gift_catalog_seed", Migrate: seedDefaultCatalog}}
|
||||
}
|
||||
|
||||
// seedDefaultCatalog is intentionally additive. Existing installations may
|
||||
// have edited names, prices, icons, tiers, sort, or enabled state in the
|
||||
// administration UI; migration must never overwrite those choices. We only
|
||||
// insert a missing named gift so the client can rely on the six quick gifts
|
||||
// and the 24-item "more gifts" grid being available after an upgrade.
|
||||
func seedDefaultCatalog(db *gorm.DB) error {
|
||||
defaults := []giftPO{
|
||||
{Name: "玫瑰", Icon: "🌹", Price: 1, Description: "一朵玫瑰,为主播送上心意", Tiers: "[1,10,66,1314]", Sort: 1, Enabled: true},
|
||||
{Name: "星星", Icon: "⭐", Price: 10, Description: "点亮一颗星,助力主播上榜", Tiers: "[1,10,66,1314]", Sort: 2, Enabled: true},
|
||||
{Name: "跑车", Icon: "🚗", Price: 66, Description: "豪车进场,全场焦点", Tiers: "[1,10,66,1314]", Sort: 3, Enabled: true},
|
||||
{Name: "火箭", Icon: "🚀", Price: 520, Description: "火箭升空,点燃直播间", Tiers: "[1,10,66,520]", Sort: 4, Enabled: true},
|
||||
{Name: "城堡", Icon: "🏰", Price: 1314, Description: "城堡降临,点亮直播间", Tiers: "[1,10,66,1314]", Sort: 5, Enabled: true},
|
||||
{Name: "皇冠", Icon: "👑", Price: 5200, Description: "皇冠加冕,彰显特别支持", Tiers: "[1,10,66,1314]", Sort: 6, Enabled: true},
|
||||
{Name: "爱心", Icon: "❤️", Price: 5, Description: "把喜欢送到主播身边", Tiers: "[1,10,66,1314]", Sort: 7, Enabled: true},
|
||||
{Name: "棒棒糖", Icon: "🍭", Price: 8, Description: "甜甜的支持送给你", Tiers: "[1,10,66,1314]", Sort: 8, Enabled: true},
|
||||
{Name: "咖啡", Icon: "☕", Price: 15, Description: "为主播续上一杯咖啡", Tiers: "[1,10,66,1314]", Sort: 9, Enabled: true},
|
||||
{Name: "蛋糕", Icon: "🍰", Price: 20, Description: "分享一份甜蜜祝福", Tiers: "[1,10,66,1314]", Sort: 10, Enabled: true},
|
||||
{Name: "气球", Icon: "🎈", Price: 30, Description: "让快乐在直播间起飞", Tiers: "[1,10,66,1314]", Sort: 11, Enabled: true},
|
||||
{Name: "钻戒", Icon: "💍", Price: 88, Description: "闪耀的心意送给你", Tiers: "[1,10,66,1314]", Sort: 12, Enabled: true},
|
||||
{Name: "麦克风", Icon: "🎤", Price: 99, Description: "为精彩歌声喝彩", Tiers: "[1,10,66,1314]", Sort: 13, Enabled: true},
|
||||
{Name: "吉他", Icon: "🎸", Price: 120, Description: "伴着旋律一起摇摆", Tiers: "[1,10,66,1314]", Sort: 14, Enabled: true},
|
||||
{Name: "花束", Icon: "💐", Price: 166, Description: "把鲜花和掌声送给主播", Tiers: "[1,10,66,1314]", Sort: 15, Enabled: true},
|
||||
{Name: "甜甜圈", Icon: "🍩", Price: 188, Description: "甜甜圈住每一份快乐", Tiers: "[1,10,66,1314]", Sort: 16, Enabled: true},
|
||||
{Name: "烟花", Icon: "🎆", Price: 233, Description: "烟花为精彩时刻喝彩", Tiers: "[1,10,66,1314]", Sort: 17, Enabled: true},
|
||||
{Name: "豪华游艇", Icon: "🛥️", Price: 520, Description: "乘风破浪驶入直播间", Tiers: "[1,10,66,520]", Sort: 18, Enabled: true},
|
||||
{Name: "直升机", Icon: "🚁", Price: 666, Description: "高调登场,气氛拉满", Tiers: "[1,10,66,666]", Sort: 19, Enabled: true},
|
||||
{Name: "热气球", Icon: "🎈", Price: 888, Description: "带着祝福飞向天空", Tiers: "[1,10,66,888]", Sort: 20, Enabled: true},
|
||||
{Name: "月亮", Icon: "🌙", Price: 999, Description: "月光陪伴每一个夜晚", Tiers: "[1,10,66,999]", Sort: 21, Enabled: true},
|
||||
{Name: "太阳", Icon: "☀️", Price: 1024, Description: "把热烈的能量送给主播", Tiers: "[1,10,66,1024]", Sort: 22, Enabled: true},
|
||||
{Name: "彩虹", Icon: "🌈", Price: 1888, Description: "愿每一次相遇都如彩虹", Tiers: "[1,10,66,1314]", Sort: 23, Enabled: true},
|
||||
{Name: "宝箱", Icon: "🎁", Price: 2888, Description: "打开宝箱收获惊喜", Tiers: "[1,10,66,1314]", Sort: 24, Enabled: true},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
var existing giftPO
|
||||
err := db.Unscoped().Where("name = ?", item.Name).First(&existing).Error
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
// A concurrent migration/admin write may win the unique race. The
|
||||
// named gift is present then, so leave its configuration untouched.
|
||||
var raced giftPO
|
||||
if db.Unscoped().Where("name = ?", item.Name).First(&raced).Error == nil {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -174,7 +174,8 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
|
||||
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL")
|
||||
if filter.Name != "" {
|
||||
query = query.Where("live_rooms.name LIKE ?", "%"+filter.Name+"%")
|
||||
value := "%" + filter.Name + "%"
|
||||
query = query.Where("live_rooms.name LIKE ? OR CAST(live_rooms.id AS CHAR) LIKE ?", value, value)
|
||||
}
|
||||
if filter.AnchorName != "" {
|
||||
value := "%" + filter.AnchorName + "%"
|
||||
@@ -186,6 +187,9 @@ func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter,
|
||||
if filter.Enabled != nil {
|
||||
query = query.Where("live_rooms.enabled = ?", *filter.Enabled)
|
||||
}
|
||||
if filter.CategoryEnabled != nil {
|
||||
query = query.Where("live_categories.enabled = ?", *filter.CategoryEnabled)
|
||||
}
|
||||
if filter.Status == bizlive.RoomStatusDisabled {
|
||||
query = query.Where("live_rooms.enabled = ?", false)
|
||||
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
|
||||
|
||||
@@ -74,3 +74,39 @@ func TestCatalogRepoScansCategoryAndRoomFields(t *testing.T) {
|
||||
t.Fatalf("room get scan result=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRepoFiltersRoomsByCategoryEnabled(t *testing.T) {
|
||||
db := openCatalogTestDB(t)
|
||||
if err := db.Exec(`INSERT INTO sys_users(id, username, nick_name, header_img) VALUES (2, 'anchor', 'Anchor', 'avatar.png')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabledCategory := categoryPO{Name: "Games", Enabled: true}
|
||||
disabledCategory := categoryPO{Name: "Hidden", Enabled: false}
|
||||
if err := db.Create(&enabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&disabledCategory).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&disabledCategory).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, room := range []*roomPO{
|
||||
{Name: "Visible", CategoryID: enabledCategory.ID, CoverURL: "visible.png", AnchorUserID: 2, Enabled: true},
|
||||
{Name: "Hidden", CategoryID: disabledCategory.ID, CoverURL: "hidden.png", AnchorUserID: 2, Enabled: true},
|
||||
} {
|
||||
if err := db.Create(room).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
repo := NewCatalogRepo(catalogTestProvider{db: db})
|
||||
enabled := true
|
||||
rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{CategoryEnabled: &enabled}, bizlive.ListOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(rooms) != 1 || rooms[0].Name != "Visible" {
|
||||
t.Fatalf("enabled-category rooms total=%d rooms=%+v", total, rooms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
bizlive "kra/internal/biz/live"
|
||||
)
|
||||
|
||||
type followPO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
|
||||
RoomID uint `gorm:"not null;uniqueIndex:idx_live_room_follow_user_room"`
|
||||
AnchorUserID uint `gorm:"not null;index"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (followPO) TableName() string { return "live_room_follows" }
|
||||
|
||||
type followRepo struct{ data Provider }
|
||||
|
||||
func NewFollowRepo(data Provider) bizlive.FollowRepo { return &followRepo{data: data} }
|
||||
|
||||
func (r *followRepo) Get(ctx context.Context, userID, roomID uint) (*bizlive.Follow, error) {
|
||||
var row followPO
|
||||
err := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, bizlive.ErrFollowNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return followToBiz(&row), nil
|
||||
}
|
||||
|
||||
func (r *followRepo) Create(ctx context.Context, item *bizlive.Follow) (*bizlive.Follow, error) {
|
||||
var room roomPO
|
||||
if err := r.db(ctx).First(&room, item.RoomID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, bizlive.ErrRoomNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
item.AnchorUserID = room.AnchorUserID
|
||||
po := &followPO{UserID: item.UserID, RoomID: item.RoomID, AnchorUserID: item.AnchorUserID}
|
||||
if err := r.db(ctx).Create(po).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return r.Get(ctx, item.UserID, item.RoomID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return followToBiz(po), nil
|
||||
}
|
||||
|
||||
func (r *followRepo) Delete(ctx context.Context, userID, roomID uint) error {
|
||||
result := r.db(ctx).Where("user_id = ? AND room_id = ?", userID, roomID).Delete(&followPO{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return bizlive.ErrFollowNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *followRepo) db(ctx context.Context) *gorm.DB {
|
||||
if r == nil || r.data == nil || r.data.DB() == nil {
|
||||
return nil
|
||||
}
|
||||
return r.data.DB().WithContext(ctx)
|
||||
}
|
||||
|
||||
func followToBiz(row *followPO) *bizlive.Follow {
|
||||
return &bizlive.Follow{ID: row.ID, UserID: row.UserID, RoomID: row.RoomID, AnchorUserID: row.AnchorUserID}
|
||||
}
|
||||
@@ -18,5 +18,7 @@ func Migrations() []migration.Step {
|
||||
return nil
|
||||
}
|
||||
return db.Create(&[]categoryPO{{Name: "游戏竞技", Icon: "🎮", Sort: 1, Enabled: true}, {Name: "音乐区", Icon: "🎵", Sort: 2, Enabled: true}, {Name: "闲聊电台", Icon: "🌙", Sort: 3, Enabled: true}, {Name: "舞蹈才艺", Icon: "💃", Sort: 4, Enabled: true}, {Name: "娱乐直播", Icon: "🎪", Sort: 5, Enabled: true}}).Error
|
||||
}}, {ID: "202609220002_live_follow_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &followPO{})
|
||||
}}}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewStreamRepo,
|
||||
NewCatalogRepo,
|
||||
NewFollowRepo,
|
||||
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
|
||||
)
|
||||
|
||||
@@ -62,6 +62,11 @@ func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userI
|
||||
}
|
||||
state.stream.PublisherID = userID
|
||||
state.stream.PublisherOnline = true
|
||||
// A new broadcast starts with a fresh in-room like counter. Keeping the
|
||||
// stream state object is useful when viewers connected before the publisher,
|
||||
// but likes must never leak into the next broadcast session.
|
||||
state.stream.LikeCount = 0
|
||||
state.stream.StartedAt = time.Now()
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return clone(state.stream), nil
|
||||
}
|
||||
@@ -75,6 +80,7 @@ func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, use
|
||||
}
|
||||
state.stream.PublisherID = 0
|
||||
state.stream.PublisherOnline = false
|
||||
state.stream.StartedAt = time.Time{}
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
if len(state.users) == 0 {
|
||||
delete(r.streams, streamID)
|
||||
@@ -121,6 +127,19 @@ func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, us
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *streamRepo) Like(_ context.Context, streamID string) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
state := r.streams[streamID]
|
||||
if state == nil {
|
||||
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
|
||||
r.streams[streamID] = state
|
||||
}
|
||||
state.stream.LikeCount++
|
||||
state.stream.UpdatedAt = time.Now()
|
||||
return state.stream.LikeCount, nil
|
||||
}
|
||||
|
||||
func clone(stream *bizlive.Stream) *bizlive.Stream {
|
||||
if stream == nil {
|
||||
return nil
|
||||
|
||||
@@ -2,7 +2,7 @@ package livechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,9 +12,14 @@ import (
|
||||
|
||||
type Provider interface{ DB() *gorm.DB }
|
||||
|
||||
type repo struct{ data Provider }
|
||||
type repo struct {
|
||||
data Provider
|
||||
mu sync.RWMutex
|
||||
nextID uint
|
||||
rooms map[uint][]*biz.Message
|
||||
}
|
||||
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
|
||||
func NewRepo(data Provider) biz.Repo { return &repo{data: data, rooms: make(map[uint][]*biz.Message)} }
|
||||
|
||||
type messagePO struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
@@ -46,14 +51,16 @@ func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.M
|
||||
if err := r.ensureRoom(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []messagePO
|
||||
if err := r.data.DB().WithContext(ctx).Where("room_id = ?", roomID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
r.mu.RLock()
|
||||
items := r.rooms[roomID]
|
||||
if len(items) > limit {
|
||||
items = items[len(items)-limit:]
|
||||
}
|
||||
out := make([]*biz.Message, 0, len(rows))
|
||||
for i := len(rows) - 1; i >= 0; i-- {
|
||||
out = append(out, toBiz(&rows[i]))
|
||||
out := make([]*biz.Message, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, cloneMessage(item))
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -65,11 +72,7 @@ func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := &messagePO{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: roomID, Type: biz.TypeChat, UserID: user.ID, Username: user.Username, Nickname: user.Nickname, Avatar: user.Avatar, Content: content}), nil
|
||||
}
|
||||
|
||||
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) {
|
||||
@@ -87,28 +90,28 @@ func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Mes
|
||||
if nickname == "" {
|
||||
nickname = user.Nickname
|
||||
}
|
||||
row := &messagePO{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}
|
||||
if err = r.createAndPrune(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toBiz(row), nil
|
||||
return r.append(&biz.Message{RoomID: gift.RoomID, Type: biz.TypeGift, UserID: gift.UserID, Username: username, Nickname: nickname, Avatar: user.Avatar, GiftID: gift.GiftID, GiftName: gift.GiftName, GiftIcon: gift.GiftIcon, Quantity: gift.Quantity, DiamondTotal: gift.DiamondTotal}), nil
|
||||
}
|
||||
|
||||
func (r *repo) createAndPrune(ctx context.Context, row *messagePO) error {
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var boundary messagePO
|
||||
err := tx.Select("id").Where("room_id = ?", row.RoomID).Order("id DESC").Offset(99).Limit(1).Take(&boundary).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("room_id = ? AND id < ?", row.RoomID, boundary.ID).Delete(&messagePO{}).Error
|
||||
})
|
||||
func (r *repo) append(item *biz.Message) *biz.Message {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.nextID++
|
||||
item.ID = r.nextID
|
||||
item.CreatedAt = time.Now()
|
||||
items := append(r.rooms[item.RoomID], cloneMessage(item))
|
||||
if len(items) > 100 {
|
||||
items = append([]*biz.Message(nil), items[len(items)-100:]...)
|
||||
}
|
||||
r.rooms[item.RoomID] = items
|
||||
return cloneMessage(item)
|
||||
}
|
||||
|
||||
func (r *repo) Clear(_ context.Context, roomID uint) error {
|
||||
r.mu.Lock()
|
||||
delete(r.rooms, roomID)
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repo) ensureRoom(ctx context.Context, roomID uint) error {
|
||||
@@ -136,3 +139,11 @@ func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) {
|
||||
func toBiz(row *messagePO) *biz.Message {
|
||||
return &biz.Message{ID: row.ID, RoomID: row.RoomID, Type: row.Type, UserID: row.UserID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, Content: row.Content, GiftID: row.GiftID, GiftName: row.GiftName, GiftIcon: row.GiftIcon, Quantity: row.Quantity, DiamondTotal: row.DiamondTotal, CreatedAt: row.CreatedAt}
|
||||
}
|
||||
|
||||
func cloneMessage(item *biz.Message) *biz.Message {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *item
|
||||
return ©
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&messagePO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE live_rooms (id integer primary key, enabled numeric, deleted_at datetime)`,
|
||||
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, deleted_at datetime)`,
|
||||
@@ -37,6 +34,22 @@ func openChatTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestChatHistoryIsProcessLocalNotPersisted(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
first := NewRepo(testProvider{db: db})
|
||||
if _, err := first.CreateChat(context.Background(), 7, 3, "current session"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewRepo(testProvider{db: db})
|
||||
items, err := second.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("chat leaked across runtime sessions: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatKeepsLatestHundredMessages(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
@@ -65,3 +78,21 @@ func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
|
||||
t.Fatalf("gift message = %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearDropsRoomMessagesWhenBroadcastEnds(t *testing.T) {
|
||||
db := openChatTestDB(t)
|
||||
repo := NewRepo(testProvider{db: db})
|
||||
if _, err := repo.CreateChat(context.Background(), 7, 3, "before close"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Clear(context.Background(), 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repo.ListRecent(context.Background(), 7, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("messages remained after room close: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,18 @@ func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash
|
||||
|
||||
func clientRoutes() []struct{ method, path string } {
|
||||
return []struct{ method, path string }{
|
||||
{method: "POST", path: "/live/client/auth/logout"},
|
||||
{method: "GET", path: "/live/client/me"},
|
||||
{method: "GET", path: "/live/client/balance"},
|
||||
{method: "PUT", path: "/live/client/me"},
|
||||
{method: "PUT", path: "/live/client/me/password"},
|
||||
{method: "POST", path: "/live/rooms/:id/gifts"},
|
||||
{method: "GET", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/rooms/:id/follow"},
|
||||
{method: "DELETE", path: "/live/rooms/:id/follow"},
|
||||
{method: "POST", path: "/live/recharge/orders"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo"},
|
||||
{method: "GET", path: "/live/recharge/orders/:orderNo/status"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 22 {
|
||||
t.Fatalf("migration versions = %d, want 22", versions)
|
||||
if versions != 26 {
|
||||
t.Fatalf("migration versions = %d, want 26", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -179,7 +179,7 @@ func toBiz(v *row) *biz.Order {
|
||||
}
|
||||
|
||||
func orderBy(v string) string {
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC"}
|
||||
m := map[string]string{"diamonds_asc": "live_recharge_orders.diamonds ASC", "diamonds_desc": "live_recharge_orders.diamonds DESC", "amount_asc": "live_recharge_orders.amount_cents ASC", "amount_desc": "live_recharge_orders.amount_cents DESC", "created_at_asc": "live_recharge_orders.created_at ASC", "created_at_desc": "live_recharge_orders.created_at DESC", "id_asc": "live_recharge_orders.id ASC", "id_desc": "live_recharge_orders.id DESC"}
|
||||
if x := m[strings.ToLower(v)]; x != "" {
|
||||
return x
|
||||
}
|
||||
|
||||
@@ -206,6 +206,36 @@ func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetM
|
||||
})
|
||||
}
|
||||
|
||||
// GrantAuthorityAPIs idempotently grants an explicit API set to one authority.
|
||||
// Keeping this helper in data/system preserves ownership of the Casbin storage
|
||||
// shape while allowing module migrations to add narrowly scoped client routes.
|
||||
func GrantAuthorityAPIs(db *gorm.DB, authorityID uint, apis []platformmodule.API) error {
|
||||
if db == nil || authorityID == 0 || len(apis) == 0 || !db.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, api := range apis {
|
||||
method := strings.ToUpper(strings.TrimSpace(api.Method))
|
||||
path := strings.TrimSpace(api.Path)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
exists, err := policyExists(tx, authorityID, path, method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
rule := newPolicyRule(authorityID, path, method)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GrantMenuAPIs makes a visible administration menu usable by granting only
|
||||
// its explicitly supplied API set to authorities that already own the menu.
|
||||
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
|
||||
|
||||
Reference in New Issue
Block a user