feat: live module and jenkins pipeline to harbor deploy

This commit is contained in:
Yvan
2026-09-21 16:45:44 +08:00
parent 451c8e2abc
commit 829d75c657
145 changed files with 8984 additions and 269 deletions
+66
View File
@@ -0,0 +1,66 @@
package analytics
import (
"context"
"gorm.io/gorm"
biz "kra/internal/biz/analytics"
"kra/internal/data/ledger"
"strconv"
"time"
)
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
func (r *repo) Dashboard(ctx context.Context, from, to time.Time) (*biz.Dashboard, error) {
db := r.data.DB().WithContext(ctx)
out := &biz.Dashboard{DiamondRateCents: 10}
if err := db.Table("live_rooms").Where("deleted_at IS NULL").Count(&out.RoomTotal).Error; err != nil {
return nil, err
}
var set ledger.SettingPO
if err := db.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil {
if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil {
out.DiamondRateCents = v
}
}
var f struct{ RechargeCents, PaidOrderCount int64 }
if err := db.Model(&ledger.RechargeOrderPO{}).Select("COALESCE(SUM(amount_cents),0) AS recharge_cents,COUNT(*) AS paid_order_count").Where("credited_at IS NOT NULL AND credited_at BETWEEN ? AND ?", from, to).Scan(&f).Error; err != nil {
return nil, err
}
out.RechargeCents, out.PaidOrderCount = f.RechargeCents, f.PaidOrderCount
var g struct{ Diamonds, Count int64 }
if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(SUM(diamond_total),0) AS diamonds,COUNT(*) AS count").Where("created_at BETWEEN ? AND ?", from, to).Scan(&g).Error; err != nil {
return nil, err
}
out.GiftDiamonds, out.GiftSendCount = g.Diamonds, g.Count
start := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, to.Location()).AddDate(0, 0, -6)
var points []struct {
Date string
AmountCents int64
}
if err := db.Model(&ledger.RechargeOrderPO{}).Select("DATE(credited_at) AS date,COALESCE(SUM(amount_cents),0) AS amount_cents").Where("credited_at IS NOT NULL AND credited_at >= ?", start).Group("DATE(credited_at)").Order("DATE(credited_at)").Scan(&points).Error; err != nil {
return nil, err
}
m := map[string]int64{}
for _, p := range points {
m[p.Date] = p.AmountCents
}
for i := 0; i < 7; i++ {
d := start.AddDate(0, 0, i).Format("2006-01-02")
out.RechargeTrend = append(out.RechargeTrend, &biz.TrendPoint{Date: d, AmountCents: m[d]})
}
var ranks []struct {
RoomID uint
RoomName, AnchorName string
Diamonds int64
}
if err := db.Model(&ledger.GiftSendPO{}).Select("COALESCE(room_id,0) AS room_id,room_name,anchor_name,SUM(diamond_total) AS diamonds").Where("created_at BETWEEN ? AND ?", from, to).Group("room_id,room_name,anchor_name").Order("diamonds DESC").Limit(5).Scan(&ranks).Error; err != nil {
return nil, err
}
for _, x := range ranks {
out.GiftRanking = append(out.GiftRanking, &biz.GiftRank{RoomID: x.RoomID, RoomName: x.RoomName, AnchorName: x.AnchorName, Diamonds: x.Diamonds})
}
return out, nil
}
+5
View File
@@ -0,0 +1,5 @@
package analytics
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+144
View File
@@ -0,0 +1,144 @@
package asset
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
biz "kra/internal/biz/asset"
"kra/internal/data/ledger"
"time"
)
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
type row struct {
UserID uint `gorm:"column:user_id"`
Username string
Nickname string
Avatar string
Enable int
CreatedAt time.Time
DiamondBalance, TotalRechargeCents, TotalSpentDiamonds int64
LastLoginAt *time.Time
}
func query(db *gorm.DB, f biz.Filter) *gorm.DB {
q := db.Table("sys_users").Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id").Where("sys_users.deleted_at IS NULL")
if f.Keyword != "" {
v := "%" + f.Keyword + "%"
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
}
if f.UserID > 0 {
q = q.Where("sys_users.id = ?", f.UserID)
}
if f.Enabled != nil {
if *f.Enabled {
q = q.Where("sys_users.enable = 1")
} else {
q = q.Where("sys_users.enable <> 1")
}
}
if len(f.CreatedAt) == 2 {
q = q.Where("sys_users.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
}
return q
}
func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.User, int64, error) {
q := query(r.db(ctx), f)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []row
sel := "sys_users.id AS user_id,sys_users.username,sys_users.nick_name AS nickname,sys_users.header_img AS avatar,sys_users.enable,sys_users.created_at,COALESCE(live_wallets.diamond_balance,0) AS diamond_balance,COALESCE(live_wallets.total_recharge_cents,0) AS total_recharge_cents,COALESCE(live_wallets.total_spent_diamonds,0) AS total_spent_diamonds,(SELECT MAX(created_at) FROM sys_login_logs WHERE user_id=sys_users.id AND status=?) AS last_login_at"
if err := q.Select(sel, true).Order(userOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
return nil, 0, err
}
out := make([]*biz.User, 0, len(rows))
for i := range rows {
out = append(out, toBiz(&rows[i]))
}
return out, total, nil
}
func (r *repo) Get(ctx context.Context, id uint) (*biz.User, error) {
items, _, err := r.List(ctx, biz.Filter{UserID: id}, biz.ListOptions{Limit: 1})
if err != nil {
return nil, err
}
if len(items) == 0 {
return nil, biz.ErrNotFound
}
v := items[0]
var orders []ledger.RechargeOrderPO
if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&orders).Error; err != nil {
return nil, err
}
for _, x := range orders {
status := "pending"
if x.CreditedAt != nil {
status = "paid"
}
v.RecentOrders = append(v.RecentOrders, biz.OrderRecord{OrderNo: x.OrderNo, Diamonds: x.Diamonds, AmountCents: x.AmountCents, Status: status, CreatedAt: x.CreatedAt})
}
var sends []ledger.GiftSendPO
if err := r.db(ctx).Where("user_id = ?", id).Order("created_at DESC").Limit(20).Find(&sends).Error; err != nil {
return nil, err
}
for _, x := range sends {
v.RecentSends = append(v.RecentSends, biz.SendRecord{SerialNo: x.SerialNo, RoomName: x.RoomName, GiftName: x.GiftName, GiftIcon: x.GiftIcon, DiamondTotal: x.DiamondTotal, CreatedAt: x.CreatedAt})
}
return v, nil
}
func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error {
value := 2
if enabled {
value = 1
}
res := r.db(ctx).Table("sys_users").Where("id = ? AND deleted_at IS NULL", id).Update("enable", value)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return biz.ErrNotFound
}
return nil
}
func (r *repo) Adjust(ctx context.Context, v *biz.Adjustment) (*biz.User, error) {
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
wallet := ledger.WalletPO{UserID: v.UserID}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil {
return err
}
balance := wallet.DiamondBalance
if v.Type == "decrease" {
if balance < v.Amount {
return biz.ErrInsufficient
}
balance -= v.Amount
} else {
balance += v.Amount
}
if err := tx.Model(&wallet).Update("diamond_balance", balance).Error; err != nil {
return err
}
return tx.Create(&ledger.BalanceAdjustmentPO{UserID: v.UserID, OperatorUserID: v.OperatorUserID, Type: v.Type, Amount: v.Amount, Reason: v.Reason, BalanceAfter: balance}).Error
})
if err != nil {
return nil, err
}
return r.Get(ctx, v.UserID)
}
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"}
if x := m[v]; x != "" {
return x
}
return "sys_users.id ASC"
}
+53
View File
@@ -0,0 +1,53 @@
package asset
import (
"context"
"errors"
"testing"
bizasset "kra/internal/biz/asset"
"kra/internal/data/ledger"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type testProvider struct{ db *gorm.DB }
func (p testProvider) DB() *gorm.DB { return p.db }
func TestAdjustmentRejectsInsufficientBalanceWithoutAudit(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ledger.WalletPO{}, &ledger.BalanceAdjustmentPO{}, &ledger.RechargeOrderPO{}, &ledger.GiftSendPO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text, header_img text, enable integer, created_at datetime, deleted_at datetime)`,
`CREATE TABLE sys_login_logs (id integer primary key, user_id integer, status numeric, created_at datetime)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name,enable,created_at) VALUES (4,'user','User',1,CURRENT_TIMESTAMP)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 4, DiamondBalance: 20}).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
_, err = repo.Adjust(context.Background(), &bizasset.Adjustment{UserID: 4, OperatorUserID: 1, Type: "decrease", Amount: 30, Reason: "test reason"})
if !errors.Is(err, bizasset.ErrInsufficient) {
t.Fatalf("adjustment error = %v", err)
}
var wallet ledger.WalletPO
var audits int64
db.Where("user_id = ?", 4).First(&wallet)
db.Model(&ledger.BalanceAdjustmentPO{}).Count(&audits)
if wallet.DiamondBalance != 20 || audits != 0 {
t.Fatalf("failed adjustment changed state: wallet=%+v audits=%d", wallet, audits)
}
}
+13
View File
@@ -0,0 +1,13 @@
package asset
import (
"gorm.io/gorm"
"kra/internal/data/ledger"
"kra/pkg/database/migration"
)
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{})
}}}
}
+5
View File
@@ -0,0 +1,5 @@
package asset
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+24
View File
@@ -13,8 +13,16 @@ import (
"go.mongodb.org/mongo-driver/mongo"
"gorm.io/gorm"
"kra/internal/config"
dataanalytics "kra/internal/data/analytics"
dataasset "kra/internal/data/asset"
datgift "kra/internal/data/gift"
dataintegration "kra/internal/data/integration"
datalive "kra/internal/data/live"
datalivechat "kra/internal/data/livechat"
dataliveclient "kra/internal/data/liveclient"
datapayment "kra/internal/data/payment"
dataplatformsetting "kra/internal/data/platformsetting"
datarecharge "kra/internal/data/recharge"
datasystem "kra/internal/data/system"
datatask "kra/internal/data/task"
"kra/internal/integration/runtimeconfig"
@@ -30,7 +38,23 @@ var ProviderSet = wire.NewSet(
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
wire.Bind(new(dataintegration.Provider), new(*Data)),
wire.Bind(new(datapayment.Provider), new(*Data)),
wire.Bind(new(datalive.Provider), new(*Data)),
wire.Bind(new(datalivechat.Provider), new(*Data)),
wire.Bind(new(dataliveclient.Provider), new(*Data)),
wire.Bind(new(datgift.Provider), new(*Data)),
wire.Bind(new(datarecharge.Provider), new(*Data)),
wire.Bind(new(dataasset.Provider), new(*Data)),
wire.Bind(new(dataplatformsetting.Provider), new(*Data)),
wire.Bind(new(dataanalytics.Provider), new(*Data)),
datasystem.ProviderSet,
datalive.ProviderSet,
datalivechat.ProviderSet,
dataliveclient.ProviderSet,
datgift.ProviderSet,
datarecharge.ProviderSet,
dataasset.ProviderSet,
dataplatformsetting.ProviderSet,
dataanalytics.ProviderSet,
dataintegration.ProviderSet,
datatask.ProviderSet,
datapayment.ProviderSet,
+10 -1
View File
@@ -205,7 +205,16 @@ func openDatabase(c *config.Database, create bool, template string, appLogger *s
case "mssql":
bootstrap = "master"
}
dsn, err := databaseDSN(c, bootstrap)
bootstrapConfig := c
if driver == "mysql" {
// MySQL creates the target schema after connecting to the server. Do
// not include the not-yet-existing schema in the bootstrap DSN.
copy := *c
copy.Name = ""
copy.Source = ""
bootstrapConfig = &copy
}
dsn, err := databaseDSN(bootstrapConfig, bootstrap)
if err != nil {
return nil, err
}
+8 -12
View File
@@ -7,20 +7,16 @@ import (
"kra/internal/config"
)
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
dsn, err := databaseDSN(&config.Database{
Driver: "mysql",
User: "root",
Password: "secret",
Host: "mysql",
Name: "kra",
}, "")
func TestMySQLBootstrapDSNDoesNotRequireTargetDatabase(t *testing.T) {
value := &config.Database{Driver: "mysql", Host: "127.0.0.1", Port: "3306", User: "root", Password: "secret", Name: "new_database", Config: "parseTime=True"}
copy := *value
copy.Name = ""
copy.Source = ""
dsn, err := databaseDSN(&copy, "")
if err != nil {
t.Fatal(err)
}
for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} {
if !strings.Contains(dsn, option) {
t.Fatalf("DSN %q does not contain %q", dsn, option)
}
if strings.Contains(dsn, "/new_database?") || !strings.Contains(dsn, "/?parseTime=True") {
t.Fatalf("bootstrap DSN still selects target database: %q", dsn)
}
}
+310
View File
@@ -0,0 +1,310 @@
package gift
import (
"context"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
bizgift "kra/internal/biz/gift"
"kra/internal/data/ledger"
"strconv"
"strings"
"time"
)
type Provider interface{ DB() *gorm.DB }
type giftPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time `gorm:"index"`
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"size:60;uniqueIndex"`
Icon string `gorm:"size:1000"`
Price int64 `gorm:"index"`
Description string `gorm:"size:255"`
Tiers string `gorm:"type:text"`
Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"`
}
func (giftPO) TableName() string { return "live_gifts" }
type repo struct{ data Provider }
func NewRepo(data Provider) bizgift.Repo { return &repo{data: data} }
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
func (r *repo) List(ctx context.Context, f bizgift.Filter, o bizgift.ListOptions) ([]*bizgift.Gift, int64, error) {
q := r.db(ctx).Model(&giftPO{})
if f.Name != "" {
q = q.Where("name LIKE ?", "%"+f.Name+"%")
}
if f.MinPrice > 0 {
q = q.Where("price >= ?", f.MinPrice)
}
if f.MaxPrice > 0 {
q = q.Where("price <= ?", f.MaxPrice)
}
if f.Enabled != nil {
q = q.Where("enabled = ?", *f.Enabled)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []giftPO
if err := q.Order(giftOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Find(&rows).Error; err != nil {
return nil, 0, err
}
items := make([]*bizgift.Gift, 0, len(rows))
for i := range rows {
item := toBiz(&rows[i])
r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", item.ID).Count(&item.SendCount)
items = append(items, item)
}
return items, total, nil
}
func (r *repo) Get(ctx context.Context, id uint) (*bizgift.Gift, error) {
var row giftPO
if err := r.db(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, bizgift.ErrNotFound
}
return nil, err
}
item := toBiz(&row)
r.db(ctx).Model(&ledger.GiftSendPO{}).Where("gift_id = ?", id).Count(&item.SendCount)
return item, nil
}
func (r *repo) Save(ctx context.Context, item *bizgift.Gift) (*bizgift.Gift, error) {
row := newPO(item)
if item.ID == 0 {
if err := r.db(ctx).Create(row).Error; err != nil {
return nil, mapError(err)
}
item.ID = row.ID
} else {
if !item.Enabled {
if err := r.notLast(ctx, item.ID); err != nil {
return nil, err
}
}
res := r.db(ctx).Model(&giftPO{}).Where("id = ?", item.ID).Updates(map[string]any{"name": item.Name, "icon": item.Icon, "price": item.Price, "description": item.Description, "tiers": encode(item.Tiers), "sort": item.Sort, "enabled": item.Enabled})
if res.Error != nil {
return nil, mapError(res.Error)
}
if res.RowsAffected == 0 {
return nil, bizgift.ErrNotFound
}
}
return r.Get(ctx, item.ID)
}
func (r *repo) Delete(ctx context.Context, id uint) error {
if err := r.notLast(ctx, id); err != nil {
return err
}
res := r.db(ctx).Delete(&giftPO{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return bizgift.ErrNotFound
}
return nil
}
func (r *repo) SetEnabled(ctx context.Context, id uint, enabled bool) error {
if !enabled {
if err := r.notLast(ctx, id); err != nil {
return err
}
}
res := r.db(ctx).Model(&giftPO{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return bizgift.ErrNotFound
}
return nil
}
func (r *repo) SaveSort(ctx context.Context, values map[uint]int) error {
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
for id, v := range values {
if err := tx.Model(&giftPO{}).Where("id = ?", id).Update("sort", v).Error; err != nil {
return err
}
}
return nil
})
}
func (r *repo) notLast(ctx context.Context, id uint) error {
var row giftPO
if err := r.db(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return bizgift.ErrNotFound
}
return err
}
if !row.Enabled {
return nil
}
var count int64
if err := r.db(ctx).Model(&giftPO{}).Where("enabled = ?", true).Count(&count).Error; err != nil {
return err
}
if count <= 1 {
return bizgift.ErrLastEnabled
}
return nil
}
type sendRow struct {
ledger.GiftSendPO
Username string `gorm:"column:username"`
Nickname string `gorm:"column:nickname"`
RoomDeleted bool `gorm:"column:room_deleted"`
}
func (r *repo) ListSends(ctx context.Context, f bizgift.SendFilter, o bizgift.ListOptions) ([]*bizgift.Send, int64, *bizgift.Summary, error) {
q := sendQuery(r.db(ctx), f)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, nil, err
}
var sum struct {
Diamonds int64
AmountCents int64
}
if err := sendQuery(r.db(ctx), f).Select("COALESCE(SUM(live_gift_sends.diamond_total),0) AS diamonds,COALESCE(SUM(live_gift_sends.equivalent_cents),0) AS amount_cents").Scan(&sum).Error; err != nil {
return nil, 0, nil, err
}
var rows []sendRow
if err := q.Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,CASE WHEN live_rooms.id IS NULL THEN 1 ELSE 0 END AS room_deleted").Order(sendOrder(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
return nil, 0, nil, err
}
items := make([]*bizgift.Send, 0, len(rows))
for i := range rows {
items = append(items, sendToBiz(&rows[i]))
}
return items, total, &bizgift.Summary{Count: total, Diamonds: sum.Diamonds, AmountCents: sum.AmountCents}, nil
}
func sendQuery(db *gorm.DB, f bizgift.SendFilter) *gorm.DB {
q := db.Model(&ledger.GiftSendPO{}).Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Joins("LEFT JOIN live_rooms ON live_rooms.id = live_gift_sends.room_id AND live_rooms.deleted_at IS NULL")
if f.RoomName != "" {
q = q.Where("live_gift_sends.room_name LIKE ?", "%"+f.RoomName+"%")
}
if f.AnchorName != "" {
q = q.Where("live_gift_sends.anchor_name LIKE ?", "%"+f.AnchorName+"%")
}
if f.UserKeyword != "" {
v := "%" + f.UserKeyword + "%"
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
}
if f.GiftID > 0 {
q = q.Where("live_gift_sends.gift_id = ?", f.GiftID)
}
if len(f.CreatedAt) == 2 {
q = q.Where("live_gift_sends.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
}
return q
}
func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity int) (*bizgift.Send, error) {
var created uint
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
var room struct {
ID uint
Name string
AnchorUserID uint
AnchorName string
}
if err := tx.Table("live_rooms").Select("live_rooms.id,live_rooms.name,live_rooms.anchor_user_id,COALESCE(NULLIF(sys_users.nick_name,''),sys_users.username) AS anchor_name").Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id").Where("live_rooms.id = ? AND live_rooms.enabled = ? AND live_rooms.deleted_at IS NULL", roomID, true).Scan(&room).Error; err != nil {
return err
}
if room.ID == 0 {
return bizgift.ErrSendNotAllowed
}
var gift giftPO
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
}
rate := int64(10)
var setting ledger.SettingPO
if err := tx.Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&setting).Error; err == nil {
if v, e := strconv.ParseInt(setting.Value, 10, 64); e == nil {
rate = v
}
}
roomIDCopy, giftIDCopy := room.ID, gift.ID
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
if err := tx.Create(&row).Error; err != nil {
return err
}
if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance - ?", total), "total_spent_diamonds": gorm.Expr("total_spent_diamonds + ?", total)}).Error; err != nil {
return err
}
created = row.ID
return nil
})
if err != nil {
return nil, err
}
var row sendRow
if err := r.db(ctx).Model(&ledger.GiftSendPO{}).Select("live_gift_sends.*,sys_users.username,sys_users.nick_name AS nickname,0 AS room_deleted").Joins("LEFT JOIN sys_users ON sys_users.id = live_gift_sends.user_id").Where("live_gift_sends.id = ?", created).Scan(&row).Error; err != nil {
return nil, err
}
return sendToBiz(&row), nil
}
func newPO(v *bizgift.Gift) *giftPO {
return &giftPO{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: encode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled}
}
func toBiz(v *giftPO) *bizgift.Gift {
return &bizgift.Gift{ID: v.ID, Name: v.Name, Icon: v.Icon, Price: v.Price, Description: v.Description, Tiers: decode(v.Tiers), Sort: v.Sort, Enabled: v.Enabled, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt}
}
func sendToBiz(v *sendRow) *bizgift.Send {
return &bizgift.Send{ID: v.ID, SerialNo: v.SerialNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname, RoomID: v.RoomID, RoomName: v.RoomName, RoomDeleted: v.RoomDeleted, AnchorUserID: v.AnchorUserID, AnchorName: v.AnchorName, GiftID: v.GiftID, GiftName: v.GiftName, GiftIcon: v.GiftIcon, GiftPrice: v.GiftPrice, Quantity: v.Quantity, DiamondTotal: v.DiamondTotal, RateCents: v.RateCents, EquivalentCents: v.EquivalentCents, CreatedAt: v.CreatedAt}
}
func encode(v []int) string { b, _ := json.Marshal(v); return string(b) }
func decode(v string) []int { var out []int; _ = json.Unmarshal([]byte(v), &out); return out }
func mapError(err error) error {
s := strings.ToLower(err.Error())
if strings.Contains(s, "unique") || strings.Contains(s, "duplicate") {
return bizgift.ErrNameExists
}
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"}
if x := m[strings.ToLower(v)]; x != "" {
return x
}
return "sort ASC,created_at DESC"
}
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"}
if x := m[strings.ToLower(v)]; x != "" {
return x
}
return "created_at DESC"
}
func newNumber(prefix string) string {
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
}
+87
View File
@@ -0,0 +1,87 @@
package gift
import (
"context"
"errors"
"testing"
bizgift "kra/internal/biz/gift"
"kra/internal/data/ledger"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type testProvider struct{ db *gorm.DB }
func (p testProvider) DB() *gorm.DB { return p.db }
func openGiftTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&giftPO{}, &ledger.WalletPO{}, &ledger.GiftSendPO{}, &ledger.SettingPO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`,
`CREATE TABLE live_rooms (id integer primary key, name text, anchor_user_id integer, enabled numeric, deleted_at datetime)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestGiftRepoProtectsLastEnabledGift(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Rose", Icon: "rose", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
if err = repo.SetEnabled(context.Background(), item.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
t.Fatalf("disable last gift error = %v", err)
}
if err = repo.Delete(context.Background(), item.ID); !errors.Is(err, bizgift.ErrLastEnabled) {
t.Fatalf("delete last gift error = %v", err)
}
}
func TestGiftRepoSendUsesTierAndDebitsAtomically(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 5, Tiers: []int{1, 10}, Enabled: true})
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)
}
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 = repo.Send(context.Background(), 1, 7, item.ID, 10); err != nil {
t.Fatal(err)
}
var after ledger.WalletPO
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 {
t.Fatalf("atomic send result wallet=%+v sends=%d", after, sends)
}
}
+23
View File
@@ -0,0 +1,23 @@
package gift
import (
"gorm.io/gorm"
"kra/internal/data/ledger"
"kra/pkg/database/migration"
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190002_gift_schema", Migrate: func(db *gorm.DB) error {
if err := migration.CreateMissingTables(db, &giftPO{}, &ledger.GiftSendPO{}); err != nil {
return err
}
var n int64
if err := db.Model(&giftPO{}).Count(&n).Error; err != nil {
return err
}
if n > 0 {
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
}}}
}
+5
View File
@@ -0,0 +1,5 @@
package gift
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+81
View File
@@ -0,0 +1,81 @@
package ledger
import "time"
type WalletPO struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"uniqueIndex"`
DiamondBalance int64
TotalRechargeCents int64
TotalSpentDiamonds int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (WalletPO) TableName() string { return "live_wallets" }
type RechargePlanPO struct {
ID uint `gorm:"primaryKey"`
Sort int `gorm:"index"`
Diamonds int64
AmountCents int64
Enabled bool `gorm:"default:true"`
}
func (RechargePlanPO) TableName() string { return "live_recharge_plans" }
type RechargeOrderPO struct {
ID uint `gorm:"primaryKey"`
OrderNo string `gorm:"size:80;uniqueIndex"`
UserID uint `gorm:"index"`
Diamonds int64
AmountCents int64
RateCents int64
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
CreditedAt *time.Time `gorm:"index"`
}
func (RechargeOrderPO) TableName() string { return "live_recharge_orders" }
type GiftSendPO struct {
ID uint `gorm:"primaryKey"`
SerialNo string `gorm:"size:80;uniqueIndex"`
UserID uint `gorm:"index"`
RoomID *uint `gorm:"index"`
RoomName string `gorm:"size:120"`
AnchorUserID uint `gorm:"index"`
AnchorName string `gorm:"size:120"`
GiftID *uint `gorm:"index"`
GiftName string `gorm:"size:60"`
GiftIcon string `gorm:"size:1000"`
GiftPrice int64
Quantity int
DiamondTotal int64
RateCents int64
EquivalentCents int64
CreatedAt time.Time `gorm:"index"`
}
func (GiftSendPO) TableName() string { return "live_gift_sends" }
type BalanceAdjustmentPO struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index"`
OperatorUserID uint `gorm:"index"`
Type string `gorm:"size:20"`
Amount int64
Reason string `gorm:"size:255"`
BalanceAfter int64
CreatedAt time.Time `gorm:"index"`
}
func (BalanceAdjustmentPO) TableName() string { return "live_balance_adjustments" }
type SettingPO struct {
Key string `gorm:"primaryKey;size:100"`
Value string `gorm:"type:text"`
UpdatedAt time.Time
}
func (SettingPO) TableName() string { return "live_settings" }
+364
View File
@@ -0,0 +1,364 @@
package live
import (
"context"
"errors"
"strings"
"time"
bizlive "kra/internal/biz/live"
"gorm.io/gorm"
)
type Provider interface {
DB() *gorm.DB
}
type categoryPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"size:40;uniqueIndex"`
Icon string `gorm:"size:500"`
Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"`
}
func (categoryPO) TableName() string { return "live_categories" }
type roomPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"size:120;index"`
CategoryID uint `gorm:"index"`
CoverURL string `gorm:"size:1000"`
Intro string `gorm:"size:500"`
Sort int `gorm:"default:0;index"`
AnchorUserID uint `gorm:"index"`
StreamURL string `gorm:"size:1000"`
ReplayURL string `gorm:"size:1000"`
Enabled bool `gorm:"default:true;index"`
}
func (roomPO) TableName() string { return "live_rooms" }
type roomRow struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt
Name string
CategoryID uint
CoverURL string
Intro string
Sort int
AnchorUserID uint
StreamURL string
ReplayURL string
Enabled bool
CategoryName string `gorm:"column:category_name"`
AnchorName string `gorm:"column:anchor_name"`
AnchorAvatar string `gorm:"column:anchor_avatar"`
}
type categoryRow struct {
ID uint
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt
Name string
Icon string
Sort int
Enabled bool
RoomCount int64 `gorm:"column:room_count"`
}
type catalogRepo struct{ data Provider }
func NewCatalogRepo(data Provider) *catalogRepo { return &catalogRepo{data: data} }
func (r *catalogRepo) ListCategories(ctx context.Context, filter bizlive.CategoryFilter, options bizlive.ListOptions) ([]*bizlive.Category, int64, error) {
db := r.db(ctx)
if db == nil {
return nil, 0, gorm.ErrInvalidDB
}
query := db.Model(&categoryPO{})
if filter.Name != "" {
query = query.Where("live_categories.name LIKE ?", "%"+filter.Name+"%")
}
if filter.Enabled != nil {
query = query.Where("live_categories.enabled = ?", *filter.Enabled)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
order := categoryOrder(options.OrderBy)
var rows []categoryRow
err := query.
Select("live_categories.*, COUNT(live_rooms.id) AS room_count").
Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true).
Group("live_categories.id").Order(order).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error
if err != nil {
return nil, 0, err
}
items := make([]*bizlive.Category, 0, len(rows))
for index := range rows {
items = append(items, categoryToBiz(&rows[index]))
}
return items, total, nil
}
func (r *catalogRepo) GetCategory(ctx context.Context, id uint) (*bizlive.Category, error) {
var row categoryRow
err := r.db(ctx).Model(&categoryPO{}).
Select("live_categories.*, COUNT(live_rooms.id) AS room_count").
Joins("LEFT JOIN live_rooms ON live_rooms.category_id = live_categories.id AND live_rooms.deleted_at IS NULL AND live_rooms.enabled = ?", true).
Where("live_categories.id = ?", id).Group("live_categories.id").Scan(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, bizlive.ErrCategoryNotFound
}
return categoryToBiz(&row), nil
}
func (r *catalogRepo) CreateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) {
po := newCategory(item)
if err := r.db(ctx).Create(po).Error; err != nil {
return nil, mapCategoryError(err)
}
return r.GetCategory(ctx, po.ID)
}
func (r *catalogRepo) UpdateCategory(ctx context.Context, item *bizlive.Category) (*bizlive.Category, error) {
result := r.db(ctx).Model(&categoryPO{}).Where("id = ?", item.ID).Updates(map[string]any{
"name": item.Name, "icon": item.Icon, "sort": item.Sort, "enabled": item.Enabled,
})
if result.Error != nil {
return nil, mapCategoryError(result.Error)
}
if result.RowsAffected == 0 {
return nil, bizlive.ErrCategoryNotFound
}
return r.GetCategory(ctx, item.ID)
}
func (r *catalogRepo) DeleteCategory(ctx context.Context, id uint) error {
db := r.db(ctx)
var count int64
if err := db.Model(&roomPO{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return bizlive.ErrCategoryInUse
}
result := db.Delete(&categoryPO{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return bizlive.ErrCategoryNotFound
}
return nil
}
func (r *catalogRepo) ListRooms(ctx context.Context, filter bizlive.RoomFilter, options bizlive.ListOptions) ([]*bizlive.Room, int64, error) {
db := r.db(ctx)
query := db.Model(&roomPO{}).
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
Joins("LEFT JOIN 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+"%")
}
if filter.AnchorName != "" {
value := "%" + filter.AnchorName + "%"
query = query.Where("sys_users.nick_name LIKE ? OR sys_users.username LIKE ?", value, value)
}
if filter.CategoryID > 0 {
query = query.Where("live_rooms.category_id = ?", filter.CategoryID)
}
if filter.Enabled != nil {
query = query.Where("live_rooms.enabled = ?", *filter.Enabled)
}
if filter.Status == bizlive.RoomStatusDisabled {
query = query.Where("live_rooms.enabled = ?", false)
} else if filter.Status == bizlive.RoomStatusOffline || filter.Status == bizlive.RoomStatusLive {
query = query.Where("live_rooms.enabled = ?", true)
}
if len(filter.CreatedAt) == 2 {
query = query.Where("live_rooms.created_at BETWEEN ? AND ?", filter.CreatedAt[0], filter.CreatedAt[1])
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []roomRow
err := query.Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
Order(roomOrder(options.OrderBy)).Offset(options.Offset).Limit(options.Limit).Scan(&rows).Error
if err != nil {
return nil, 0, err
}
items := make([]*bizlive.Room, 0, len(rows))
for index := range rows {
items = append(items, roomToBiz(&rows[index]))
}
return items, total, nil
}
func (r *catalogRepo) GetRoom(ctx context.Context, id uint) (*bizlive.Room, error) {
var row roomRow
err := r.db(ctx).Model(&roomPO{}).
Select("live_rooms.*, live_categories.name AS category_name, COALESCE(NULLIF(sys_users.nick_name, ''), sys_users.username) AS anchor_name, sys_users.header_img AS anchor_avatar").
Joins("LEFT JOIN live_categories ON live_categories.id = live_rooms.category_id").
Joins("LEFT JOIN sys_users ON sys_users.id = live_rooms.anchor_user_id AND sys_users.deleted_at IS NULL").
Where("live_rooms.id = ?", id).Scan(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, bizlive.ErrRoomNotFound
}
return roomToBiz(&row), nil
}
func (r *catalogRepo) CreateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) {
if err := r.validateRoomRelations(ctx, item); err != nil {
return nil, err
}
po := newRoom(item)
if err := r.db(ctx).Create(po).Error; err != nil {
return nil, err
}
return r.GetRoom(ctx, po.ID)
}
func (r *catalogRepo) UpdateRoom(ctx context.Context, item *bizlive.Room) (*bizlive.Room, error) {
if err := r.validateRoomRelations(ctx, item); err != nil {
return nil, err
}
result := r.db(ctx).Model(&roomPO{}).Where("id = ?", item.ID).Updates(map[string]any{
"name": item.Name, "category_id": item.CategoryID, "cover_url": item.CoverURL, "intro": item.Intro,
"sort": item.Sort, "anchor_user_id": item.AnchorUserID, "stream_url": item.StreamURL, "replay_url": item.ReplayURL, "enabled": item.Enabled,
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, bizlive.ErrRoomNotFound
}
return r.GetRoom(ctx, item.ID)
}
func (r *catalogRepo) DeleteRoom(ctx context.Context, id uint) error {
result := r.db(ctx).Delete(&roomPO{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return bizlive.ErrRoomNotFound
}
return nil
}
func (r *catalogRepo) UpdateRoomEnabled(ctx context.Context, id uint, enabled bool) error {
result := r.db(ctx).Model(&roomPO{}).Where("id = ?", id).Update("enabled", enabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return bizlive.ErrRoomNotFound
}
return nil
}
func (r *catalogRepo) SaveRoomSort(ctx context.Context, values map[uint]int) error {
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
for id, sortValue := range values {
if err := tx.Model(&roomPO{}).Where("id = ?", id).Update("sort", sortValue).Error; err != nil {
return err
}
}
return nil
})
}
func (r *catalogRepo) validateRoomRelations(ctx context.Context, item *bizlive.Room) error {
db := r.db(ctx)
var category categoryPO
if err := db.First(&category, item.CategoryID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return bizlive.ErrCategoryNotFound
}
return err
}
if !category.Enabled {
return bizlive.ErrCategoryDisabled
}
var count int64
if err := db.Table("sys_users").Where("id = ? AND deleted_at IS NULL", item.AnchorUserID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return bizlive.ErrAnchorNotFound
}
return nil
}
func (r *catalogRepo) 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 newCategory(item *bizlive.Category) *categoryPO {
return &categoryPO{Name: item.Name, Icon: item.Icon, Sort: item.Sort, Enabled: item.Enabled}
}
func categoryToBiz(row *categoryRow) *bizlive.Category {
return &bizlive.Category{ID: row.ID, Name: row.Name, Icon: row.Icon, Sort: row.Sort, Enabled: row.Enabled, RoomCount: row.RoomCount, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func newRoom(item *bizlive.Room) *roomPO {
return &roomPO{Name: item.Name, CategoryID: item.CategoryID, CoverURL: item.CoverURL, Intro: item.Intro, Sort: item.Sort, AnchorUserID: item.AnchorUserID, StreamURL: item.StreamURL, ReplayURL: item.ReplayURL, Enabled: item.Enabled}
}
func roomToBiz(row *roomRow) *bizlive.Room {
return &bizlive.Room{ID: row.ID, Name: row.Name, CategoryID: row.CategoryID, CategoryName: row.CategoryName, CoverURL: row.CoverURL, Intro: row.Intro, Sort: row.Sort, AnchorUserID: row.AnchorUserID, AnchorName: row.AnchorName, AnchorAvatar: row.AnchorAvatar, StreamURL: row.StreamURL, ReplayURL: row.ReplayURL, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func categoryOrder(value string) string {
allowed := map[string]string{"sort_asc": "live_categories.sort ASC", "sort_desc": "live_categories.sort DESC", "created_at_asc": "live_categories.created_at ASC", "created_at_desc": "live_categories.created_at DESC"}
if order := allowed[strings.ToLower(value)]; order != "" {
return order
}
return "live_categories.sort ASC, live_categories.created_at DESC"
}
func roomOrder(value string) string {
allowed := map[string]string{
"sort_asc": "live_rooms.sort ASC", "sort_desc": "live_rooms.sort DESC", "id_asc": "live_rooms.id ASC", "id_desc": "live_rooms.id DESC",
"name_asc": "live_rooms.name ASC", "name_desc": "live_rooms.name DESC", "created_at_asc": "live_rooms.created_at ASC", "created_at_desc": "live_rooms.created_at DESC",
}
if order := allowed[strings.ToLower(value)]; order != "" {
return order
}
return "live_rooms.sort ASC, live_rooms.created_at DESC"
}
func mapCategoryError(err error) error {
if err == nil {
return nil
}
message := strings.ToLower(err.Error())
if strings.Contains(message, "unique") || strings.Contains(message, "duplicate") {
return bizlive.ErrCategoryNameExists
}
return err
}
+76
View File
@@ -0,0 +1,76 @@
package live
import (
"context"
"testing"
bizlive "kra/internal/biz/live"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type catalogTestProvider struct{ db *gorm.DB }
func (p catalogTestProvider) DB() *gorm.DB { return p.db }
func openCatalogTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&categoryPO{}, &roomPO{}); err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE sys_users (
id integer primary key,
username text,
nick_name text,
header_img text,
deleted_at datetime
)`).Error; err != nil {
t.Fatal(err)
}
return db
}
func TestCatalogRepoScansCategoryAndRoomFields(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)
}
category := categoryPO{Name: "Games", Icon: "game.png", Sort: 1, Enabled: true}
if err := db.Create(&category).Error; err != nil {
t.Fatal(err)
}
room := roomPO{Name: "Test Room", CategoryID: category.ID, CoverURL: "cover.png", Intro: "intro", Sort: 2, AnchorUserID: 2, Enabled: true}
if err := db.Create(&room).Error; err != nil {
t.Fatal(err)
}
repo := NewCatalogRepo(catalogTestProvider{db: db})
categories, total, err := repo.ListCategories(context.Background(), bizlive.CategoryFilter{}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(categories) != 1 || categories[0].ID != category.ID || categories[0].Name != category.Name || !categories[0].Enabled || categories[0].RoomCount != 1 {
t.Fatalf("category scan result total=%d categories=%+v", total, categories)
}
rooms, total, err := repo.ListRooms(context.Background(), bizlive.RoomFilter{}, bizlive.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(rooms) != 1 || rooms[0].ID != room.ID || rooms[0].Name != room.Name || rooms[0].CategoryName != category.Name || rooms[0].AnchorName != "Anchor" || !rooms[0].Enabled {
t.Fatalf("room list scan result total=%d rooms=%+v", total, rooms)
}
got, err := repo.GetRoom(context.Background(), room.ID)
if err != nil {
t.Fatal(err)
}
if got.ID != room.ID || got.Name != room.Name || got.AnchorUserID != 2 || got.AnchorAvatar != "avatar.png" || !got.Enabled {
t.Fatalf("room get scan result=%+v", got)
}
}
+22
View File
@@ -0,0 +1,22 @@
package live
import (
"gorm.io/gorm"
"kra/pkg/database/migration"
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190001_live_schema", Migrate: func(db *gorm.DB) error {
if err := migration.CreateMissingTables(db, &categoryPO{}, &roomPO{}); err != nil {
return err
}
var count int64
if err := db.Model(&categoryPO{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
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
}}}
}
+13
View File
@@ -0,0 +1,13 @@
package live
import (
bizlive "kra/internal/biz/live"
"github.com/google/wire"
)
var ProviderSet = wire.NewSet(
NewStreamRepo,
NewCatalogRepo,
wire.Bind(new(bizlive.CatalogRepo), new(*catalogRepo)),
)
+130
View File
@@ -0,0 +1,130 @@
package live
import (
"context"
"sort"
"sync"
"time"
bizlive "kra/internal/biz/live"
)
type streamState struct {
stream *bizlive.Stream
users map[uint]struct{}
}
type streamRepo struct {
mu sync.RWMutex
streams map[string]*streamState
}
func NewStreamRepo() bizlive.StreamRepo {
return &streamRepo{streams: make(map[string]*streamState)}
}
func (r *streamRepo) Get(_ context.Context, streamID string) (*bizlive.Stream, error) {
r.mu.RLock()
defer r.mu.RUnlock()
state := r.streams[streamID]
if state == nil {
return &bizlive.Stream{StreamID: streamID, UpdatedAt: time.Now()}, nil
}
return clone(state.stream), nil
}
func (r *streamRepo) List(_ context.Context) ([]*bizlive.Stream, error) {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]*bizlive.Stream, 0, len(r.streams))
for _, state := range r.streams {
if state.stream.PublisherOnline {
items = append(items, clone(state.stream))
}
}
sort.Slice(items, func(i, j int) bool { return items[i].StreamID < items[j].StreamID })
return items, nil
}
func (r *streamRepo) RegisterPublisher(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
r.mu.Lock()
defer r.mu.Unlock()
state := r.streams[streamID]
if state == nil {
if len(r.streams) >= bizlive.MaxStreams {
return nil, bizlive.ErrMaxStreams
}
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
r.streams[streamID] = state
}
if state.stream.PublisherOnline {
return nil, bizlive.ErrPublisherExists
}
state.stream.PublisherID = userID
state.stream.PublisherOnline = true
state.stream.UpdatedAt = time.Now()
return clone(state.stream), nil
}
func (r *streamRepo) UnregisterPublisher(_ context.Context, streamID string, userID uint) error {
r.mu.Lock()
defer r.mu.Unlock()
state := r.streams[streamID]
if state == nil || !state.stream.PublisherOnline || state.stream.PublisherID != userID {
return bizlive.ErrPublisherNotFound
}
state.stream.PublisherID = 0
state.stream.PublisherOnline = false
state.stream.UpdatedAt = time.Now()
if len(state.users) == 0 {
delete(r.streams, streamID)
}
return nil
}
func (r *streamRepo) RegisterSubscriber(_ context.Context, streamID string, userID uint) (*bizlive.Stream, error) {
r.mu.Lock()
defer r.mu.Unlock()
state := r.streams[streamID]
if state == nil {
if len(r.streams) >= bizlive.MaxStreams {
return nil, bizlive.ErrMaxStreams
}
state = &streamState{stream: &bizlive.Stream{StreamID: streamID}, users: make(map[uint]struct{})}
r.streams[streamID] = state
}
if _, exists := state.users[userID]; exists {
return nil, bizlive.ErrSubscriberExists
}
state.users[userID] = struct{}{}
state.stream.SubscriberCount = len(state.users)
state.stream.UpdatedAt = time.Now()
return clone(state.stream), nil
}
func (r *streamRepo) UnregisterSubscriber(_ context.Context, streamID string, userID uint) error {
r.mu.Lock()
defer r.mu.Unlock()
state := r.streams[streamID]
if state == nil {
return bizlive.ErrSubscriberNotFound
}
if _, exists := state.users[userID]; !exists {
return bizlive.ErrSubscriberNotFound
}
delete(state.users, userID)
state.stream.SubscriberCount = len(state.users)
state.stream.UpdatedAt = time.Now()
if !state.stream.PublisherOnline && len(state.users) == 0 {
delete(r.streams, streamID)
}
return nil
}
func clone(stream *bizlive.Stream) *bizlive.Stream {
if stream == nil {
return nil
}
value := *stream
return &value
}
+138
View File
@@ -0,0 +1,138 @@
package livechat
import (
"context"
"errors"
"time"
"gorm.io/gorm"
biz "kra/internal/biz/livechat"
)
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
type messagePO struct {
ID uint `gorm:"primaryKey"`
RoomID uint `gorm:"index"`
Type string `gorm:"size:20;index"`
UserID uint `gorm:"index"`
Username string `gorm:"size:120"`
Nickname string `gorm:"size:120"`
Avatar string `gorm:"size:1000"`
Content string `gorm:"size:255"`
GiftID uint
GiftName string `gorm:"size:60"`
GiftIcon string `gorm:"size:1000"`
Quantity int
DiamondTotal int64
CreatedAt time.Time `gorm:"index"`
}
func (messagePO) TableName() string { return "live_chat_messages" }
type userSnapshot struct {
ID uint
Username string
Nickname string
Avatar string
}
func (r *repo) ListRecent(ctx context.Context, roomID uint, limit int) ([]*biz.Message, error) {
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
}
out := make([]*biz.Message, 0, len(rows))
for i := len(rows) - 1; i >= 0; i-- {
out = append(out, toBiz(&rows[i]))
}
return out, nil
}
func (r *repo) CreateChat(ctx context.Context, roomID, userID uint, content string) (*biz.Message, error) {
if err := r.ensureRoom(ctx, roomID); err != nil {
return nil, err
}
user, err := r.user(ctx, userID)
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
}
func (r *repo) CreateGift(ctx context.Context, gift *biz.GiftSnapshot) (*biz.Message, error) {
if err := r.ensureRoom(ctx, gift.RoomID); err != nil {
return nil, err
}
user, err := r.user(ctx, gift.UserID)
if err != nil {
return nil, err
}
username, nickname := gift.Username, gift.Nickname
if username == "" {
username = user.Username
}
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
}
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) ensureRoom(ctx context.Context, roomID uint) error {
var count int64
if err := r.data.DB().WithContext(ctx).Table("live_rooms").Where("id = ? AND deleted_at IS NULL AND enabled = ?", roomID, true).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return biz.ErrRoomNotFound
}
return nil
}
func (r *repo) user(ctx context.Context, userID uint) (*userSnapshot, error) {
var row userSnapshot
if err := r.data.DB().WithContext(ctx).Table("sys_users").Select("id, username, nick_name AS nickname, header_img AS avatar").Where("id = ? AND deleted_at IS NULL AND enable = ?", userID, 1).Scan(&row).Error; err != nil {
return nil, err
}
if row.ID == 0 {
return nil, biz.ErrUserNotFound
}
return &row, nil
}
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}
}
+67
View File
@@ -0,0 +1,67 @@
package livechat
import (
"context"
"fmt"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
biz "kra/internal/biz/livechat"
)
type testProvider struct{ db *gorm.DB }
func (p testProvider) DB() *gorm.DB { return p.db }
func openChatTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&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)`,
`INSERT INTO live_rooms(id,enabled) VALUES (7,1)`,
`INSERT INTO sys_users(id,username,nick_name,header_img,enable) VALUES (3,'viewer','Viewer','avatar.png',1)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestChatKeepsLatestHundredMessages(t *testing.T) {
db := openChatTestDB(t)
repo := NewRepo(testProvider{db: db})
for index := 1; index <= 105; index++ {
if _, err := repo.CreateChat(context.Background(), 7, 3, fmt.Sprintf("message-%03d", index)); err != nil {
t.Fatal(err)
}
}
items, err := repo.ListRecent(context.Background(), 7, 100)
if err != nil {
t.Fatal(err)
}
if len(items) != 100 || items[0].Content != "message-006" || items[99].Content != "message-105" {
t.Fatalf("history length=%d first=%q last=%q", len(items), items[0].Content, items[len(items)-1].Content)
}
}
func TestGiftMessageUsesSnapshotAndUserAvatar(t *testing.T) {
db := openChatTestDB(t)
repo := NewRepo(testProvider{db: db})
item, err := repo.CreateGift(context.Background(), &biz.GiftSnapshot{RoomID: 7, UserID: 3, Username: "viewer", Nickname: "Viewer", GiftID: 8, GiftName: "Star", GiftIcon: "star.png", Quantity: 10, DiamondTotal: 50})
if err != nil {
t.Fatal(err)
}
if item.Type != biz.TypeGift || item.Avatar != "avatar.png" || item.GiftName != "Star" || item.Quantity != 10 || item.DiamondTotal != 50 {
t.Fatalf("gift message = %+v", item)
}
}
+12
View File
@@ -0,0 +1,12 @@
package livechat
import (
"gorm.io/gorm"
"kra/pkg/database/migration"
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190007_live_chat_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &messagePO{})
}}}
}
+5
View File
@@ -0,0 +1,5 @@
package livechat
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+165
View File
@@ -0,0 +1,165 @@
package liveclient
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
biz "kra/internal/biz/liveclient"
"kra/internal/data/ledger"
)
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
type userPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
UUID string
Username string
Password string
NickName string `gorm:"column:nick_name"`
HeaderImg string `gorm:"column:header_img"`
AuthorityID uint `gorm:"column:authority_id"`
Enable int
PasswordUpdatedAt *time.Time
MustChangePassword bool
}
func (userPO) TableName() string { return "sys_users" }
type authorityPO struct {
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
AuthorityName string
ParentID *uint
DataScope int
DefaultRouter string
}
func (authorityPO) TableName() string { return "sys_authorities" }
type userAuthorityPO struct {
UserID uint `gorm:"column:sys_user_id"`
AuthorityID uint `gorm:"column:sys_authority_authority_id"`
}
func (userAuthorityPO) TableName() string { return "sys_user_authority" }
type policyPO struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Ptype string
V0, V1, V2 string
V3, V4, V5 string
}
func (policyPO) TableName() string { return "casbin_rule" }
func (r *repo) Register(ctx context.Context, account *biz.Account, passwordHash string, initialDiamonds int64) (*biz.Account, error) {
now := time.Now()
user := userPO{UUID: uuid.NewString(), Username: account.Username, Password: passwordHash, NickName: account.Nickname, HeaderImg: account.Avatar, AuthorityID: biz.ClientAuthorityID, Enable: 1, PasswordUpdatedAt: &now}
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&userPO{}).Where("username = ?", account.Username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return biz.ErrUsernameExists
}
root := uint(0)
authority := authorityPO{AuthorityID: biz.ClientAuthorityID, AuthorityName: "直播用户", ParentID: &root, DataScope: 4, DefaultRouter: "404"}
if err := tx.Where("authority_id = ?", authority.AuthorityID).FirstOrCreate(&authority).Error; err != nil {
return err
}
for _, route := range clientRoutes() {
policy := policyPO{Ptype: "p", V0: strconv.FormatUint(uint64(biz.ClientAuthorityID), 10), V1: route.path, V2: route.method}
if err := tx.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).FirstOrCreate(&policy).Error; err != nil {
return err
}
}
if err := tx.Create(&user).Error; err != nil {
if duplicate(err) {
return biz.ErrUsernameExists
}
return err
}
if err := tx.Create(&userAuthorityPO{UserID: user.ID, AuthorityID: biz.ClientAuthorityID}).Error; err != nil {
return err
}
return tx.Create(&ledger.WalletPO{UserID: user.ID, DiamondBalance: initialDiamonds}).Error
})
if err != nil {
return nil, err
}
return r.Get(ctx, user.ID)
}
func clientRoutes() []struct{ method, path string } {
return []struct{ method, path string }{
{method: "GET", path: "/live/client/me"},
{method: "PUT", path: "/live/client/me"},
{method: "PUT", path: "/live/client/me/password"},
{method: "POST", path: "/live/rooms/:id/gifts"},
{method: "POST", path: "/live/recharge/orders"},
}
}
func (r *repo) Get(ctx context.Context, userID uint) (*biz.Account, error) {
var row struct {
ID uint
CreatedAt time.Time
UUID string
Username string
Nickname string
Avatar string
AuthorityID uint
Enable int
DiamondBalance int64
PasswordUpdatedAt *time.Time
}
err := r.data.DB().WithContext(ctx).Table("sys_users").
Select("sys_users.id, sys_users.created_at, sys_users.uuid, sys_users.username, sys_users.nick_name AS nickname, sys_users.header_img AS avatar, sys_users.authority_id, sys_users.enable, COALESCE(live_wallets.diamond_balance, 0) AS diamond_balance, sys_users.password_updated_at").
Joins("LEFT JOIN live_wallets ON live_wallets.user_id = sys_users.id").
Where("sys_users.id = ? AND sys_users.deleted_at IS NULL", userID).Scan(&row).Error
if err != nil {
return nil, err
}
if row.ID == 0 {
return nil, biz.ErrAccountNotFound
}
return &biz.Account{UserID: row.ID, UUID: row.UUID, Username: row.Username, Nickname: row.Nickname, Avatar: row.Avatar, AuthorityID: row.AuthorityID, Enabled: row.Enable == 1, DiamondBalance: row.DiamondBalance, CreatedAt: row.CreatedAt, PasswordUpdatedAt: row.PasswordUpdatedAt}, nil
}
func (r *repo) UpdateProfile(ctx context.Context, in *biz.ProfileInput) (*biz.Account, error) {
result := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Updates(map[string]any{"nick_name": in.Nickname, "header_img": in.Avatar})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
var count int64
if err := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", in.UserID).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, biz.ErrAccountNotFound
}
}
return r.Get(ctx, in.UserID)
}
func duplicate(err error) bool {
if err == nil || errors.Is(err, biz.ErrUsernameExists) {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "unique") || strings.Contains(text, "duplicate")
}
+72
View File
@@ -0,0 +1,72 @@
package liveclient
import (
"context"
"errors"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
biz "kra/internal/biz/liveclient"
"kra/internal/data/ledger"
)
type testProvider struct{ db *gorm.DB }
func (p testProvider) DB() *gorm.DB { return p.db }
func openAccountTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&userPO{}, &authorityPO{}, &userAuthorityPO{}, &policyPO{}, &ledger.WalletPO{}); err != nil {
t.Fatal(err)
}
return db
}
func TestRegisterCreatesCanonicalUserRoleWalletAndPolicies(t *testing.T) {
db := openAccountTestDB(t)
repo := NewRepo(testProvider{db: db})
account, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "password-hash", biz.InitialDiamonds)
if err != nil {
t.Fatal(err)
}
if account.Username != "viewer" || account.AuthorityID != biz.ClientAuthorityID || account.DiamondBalance != biz.InitialDiamonds {
t.Fatalf("account = %+v", account)
}
var user userPO
var wallet ledger.WalletPO
var links, policies int64
if err = db.First(&user, account.UserID).Error; err != nil {
t.Fatal(err)
}
if err = db.Where("user_id = ?", account.UserID).First(&wallet).Error; err != nil {
t.Fatal(err)
}
db.Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", account.UserID, biz.ClientAuthorityID).Count(&links)
db.Model(&policyPO{}).Where("ptype = ? AND v0 = ?", "p", "1000").Count(&policies)
if user.AuthorityID != biz.ClientAuthorityID || wallet.DiamondBalance != biz.InitialDiamonds || links != 1 || policies != int64(len(clientRoutes())) {
t.Fatalf("registration state user=%+v wallet=%+v links=%d policies=%d", user, wallet, links, policies)
}
}
func TestRegisterDuplicateUsernameDoesNotCreateAnotherWallet(t *testing.T) {
db := openAccountTestDB(t)
repo := NewRepo(testProvider{db: db})
if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Viewer"}, "hash", 100); err != nil {
t.Fatal(err)
}
if _, err := repo.Register(context.Background(), &biz.Account{Username: "viewer", Nickname: "Other"}, "hash", 100); !errors.Is(err, biz.ErrUsernameExists) {
t.Fatalf("duplicate error = %v", err)
}
var users, wallets int64
db.Model(&userPO{}).Count(&users)
db.Model(&ledger.WalletPO{}).Count(&wallets)
if users != 1 || wallets != 1 {
t.Fatalf("duplicate registration users=%d wallets=%d", users, wallets)
}
}
+5
View File
@@ -0,0 +1,5 @@
package liveclient
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+3 -3
View File
@@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = migrateAll(db, testCatalog()); err != nil {
t.Fatalf("second migration run: %v", err)
}
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} {
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events", "live_chat_messages"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("migration did not create %s", table)
}
@@ -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 != 13 {
t.Fatalf("migration versions = %d, want 13", versions)
if versions != 22 {
t.Fatalf("migration versions = %d, want 22", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
@@ -0,0 +1,18 @@
package platformsetting
import (
"gorm.io/gorm"
"kra/internal/data/ledger"
"kra/pkg/database/migration"
"time"
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190005_platform_setting_schema", Migrate: func(db *gorm.DB) error {
if err := migration.CreateMissingTables(db, &ledger.SettingPO{}); err != nil {
return err
}
row := ledger.SettingPO{Key: key, Value: "10", UpdatedAt: time.Now()}
return db.Where(&ledger.SettingPO{Key: key}).FirstOrCreate(&row).Error
}}}
}
@@ -0,0 +1,5 @@
package platformsetting
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+38
View File
@@ -0,0 +1,38 @@
package platformsetting
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
biz "kra/internal/biz/platformsetting"
"kra/internal/data/ledger"
"strconv"
"time"
)
const key = "diamond_rate_cents"
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
func (r *repo) Get(ctx context.Context) (*biz.Setting, error) {
var row ledger.SettingPO
err := r.data.DB().WithContext(ctx).Where(&ledger.SettingPO{Key: key}).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return &biz.Setting{DiamondRateCents: 10}, nil
}
if err != nil {
return nil, err
}
v, e := strconv.ParseInt(row.Value, 10, 64)
if e != nil {
v = 10
}
return &biz.Setting{DiamondRateCents: v}, nil
}
func (r *repo) Save(ctx context.Context, v *biz.Setting) error {
row := ledger.SettingPO{Key: key, Value: strconv.FormatInt(v.DiamondRateCents, 10), UpdatedAt: time.Now()}
return r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "key"}}, DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"})}).Create(&row).Error
}
+57
View File
@@ -0,0 +1,57 @@
package recharge
import (
"time"
"gorm.io/gorm"
"kra/internal/data/ledger"
"kra/pkg/database/migration"
)
// rechargeOrderV1 freezes the original schema owned by the first migration.
type rechargeOrderV1 struct {
ID uint `gorm:"primaryKey"`
OrderNo string `gorm:"size:80;uniqueIndex"`
UserID uint `gorm:"index"`
Diamonds int64
AmountCents int64
RateCents int64
PaymentMethod string `gorm:"size:40;index"`
Status string `gorm:"size:30;index"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
CompletedAt *time.Time
}
func (rechargeOrderV1) TableName() string { return "live_recharge_orders" }
func Migrations() []migration.Step {
return []migration.Step{{ID: "202609190003_recharge_schema", Migrate: func(db *gorm.DB) error {
if err := migration.CreateMissingTables(db, &ledger.RechargePlanPO{}, &rechargeOrderV1{}); err != nil {
return err
}
var n int64
if err := db.Model(&ledger.RechargePlanPO{}).Count(&n).Error; err != nil {
return err
}
if n > 0 {
return nil
}
return db.Create(&[]ledger.RechargePlanPO{{Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true}, {Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true}, {Sort: 3, Diamonds: 980, AmountCents: 9800, Enabled: true}, {Sort: 4, Diamonds: 1980, AmountCents: 19800, Enabled: true}, {Sort: 5, Diamonds: 3280, AmountCents: 32800, Enabled: true}, {Sort: 6, Diamonds: 6980, AmountCents: 69800, Enabled: true}}).Error
}}, {ID: "202609190007_recharge_payment_unification", Migrate: func(db *gorm.DB) error {
migrator := db.Migrator()
if !migrator.HasColumn(&ledger.RechargeOrderPO{}, "CreditedAt") {
if err := migrator.AddColumn(&ledger.RechargeOrderPO{}, "CreditedAt"); err != nil {
return err
}
}
for _, column := range []string{"payment_method", "status", "completed_at"} {
if migrator.HasColumn(&rechargeOrderV1{}, column) {
if err := migrator.DropColumn(&rechargeOrderV1{}, column); err != nil {
return err
}
}
}
return nil
}}}
}
+5
View File
@@ -0,0 +1,5 @@
package recharge
import "github.com/google/wire"
var ProviderSet = wire.NewSet(NewRepo)
+191
View File
@@ -0,0 +1,191 @@
package recharge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
biz "kra/internal/biz/recharge"
"kra/internal/data/ledger"
)
type Provider interface{ DB() *gorm.DB }
type repo struct{ data Provider }
func NewRepo(data Provider) biz.Repo { return &repo{data: data} }
func (r *repo) db(ctx context.Context) *gorm.DB { return r.data.DB().WithContext(ctx) }
func (r *repo) Plans(ctx context.Context) ([]*biz.Plan, error) {
var rows []ledger.RechargePlanPO
if err := r.db(ctx).Where("enabled = ?", true).Order("sort ASC,id ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*biz.Plan, 0, len(rows))
for _, v := range rows {
out = append(out, &biz.Plan{ID: v.ID, Sort: v.Sort, Diamonds: v.Diamonds, AmountCents: v.AmountCents, Enabled: v.Enabled})
}
return out, nil
}
func (r *repo) SavePlans(ctx context.Context, plans []*biz.Plan) error {
return r.db(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&ledger.RechargePlanPO{}).Error; err != nil {
return err
}
for _, p := range plans {
if err := tx.Create(&ledger.RechargePlanPO{Sort: p.Sort, Diamonds: p.Diamonds, AmountCents: p.AmountCents, Enabled: true}).Error; err != nil {
return err
}
}
return nil
})
}
type row struct {
ledger.RechargeOrderPO
Username string `gorm:"column:username"`
Nickname string `gorm:"column:nickname"`
Provider string `gorm:"column:provider"`
ProviderTradeNo string `gorm:"column:provider_trade_no"`
PaymentStatus string `gorm:"column:payment_status"`
FulfillmentStatus string `gorm:"column:fulfillment_status"`
RefundStatus string `gorm:"column:refund_status"`
}
func query(db *gorm.DB, f biz.Filter) *gorm.DB {
q := db.Model(&ledger.RechargeOrderPO{}).
Joins("LEFT JOIN sys_users ON sys_users.id = live_recharge_orders.user_id").
Joins("LEFT JOIN pay_orders ON pay_orders.business_type = ? AND pay_orders.business_id = live_recharge_orders.order_no AND pay_orders.trade_no = live_recharge_orders.order_no", biz.BusinessType)
if f.OrderNo != "" {
q = q.Where("live_recharge_orders.order_no = ?", f.OrderNo)
}
if f.UserKeyword != "" {
v := "%" + f.UserKeyword + "%"
q = q.Where("sys_users.username LIKE ? OR sys_users.nick_name LIKE ?", v, v)
}
if f.PaymentStatus != "" {
q = q.Where("pay_orders.payment_status = ?", f.PaymentStatus)
}
if f.Provider != "" {
q = q.Where("pay_orders.provider = ?", f.Provider)
}
if len(f.CreatedAt) == 2 {
q = q.Where("live_recharge_orders.created_at BETWEEN ? AND ?", f.CreatedAt[0], f.CreatedAt[1])
}
return q
}
const orderSelect = "live_recharge_orders.*,sys_users.username,sys_users.nick_name AS nickname,pay_orders.provider,pay_orders.provider_trade_no,pay_orders.payment_status,pay_orders.fulfillment_status,pay_orders.refund_status"
func (r *repo) List(ctx context.Context, f biz.Filter, o biz.ListOptions) ([]*biz.Order, int64, *biz.Summary, error) {
q := query(r.db(ctx), f)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, nil, err
}
var s struct{ PaidCount, AmountCents int64 }
if err := query(r.db(ctx), f).Select("COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN 1 ELSE 0 END),0) AS paid_count,COALESCE(SUM(CASE WHEN live_recharge_orders.credited_at IS NOT NULL THEN live_recharge_orders.amount_cents ELSE 0 END),0) AS amount_cents").Scan(&s).Error; err != nil {
return nil, 0, nil, err
}
var rows []row
if err := q.Select(orderSelect).Order(orderBy(o.OrderBy)).Offset(o.Offset).Limit(o.Limit).Scan(&rows).Error; err != nil {
return nil, 0, nil, err
}
out := make([]*biz.Order, 0, len(rows))
for i := range rows {
out = append(out, toBiz(&rows[i]))
}
return out, total, &biz.Summary{Count: total, PaidCount: s.PaidCount, AmountCents: s.AmountCents}, nil
}
func (r *repo) Get(ctx context.Context, no string) (*biz.Order, error) {
var v row
if err := query(r.db(ctx), biz.Filter{OrderNo: no}).Select(orderSelect).Scan(&v).Error; err != nil {
return nil, err
}
if v.ID == 0 {
return nil, biz.ErrNotFound
}
return toBiz(&v), nil
}
func (r *repo) Create(ctx context.Context, userID, planID uint) (*biz.Order, error) {
var plan ledger.RechargePlanPO
if err := r.db(ctx).Where("id = ? AND enabled = ?", planID, true).First(&plan).Error; err != nil {
return nil, biz.ErrInvalid
}
rate := int64(10)
var set ledger.SettingPO
if err := r.db(ctx).Where(&ledger.SettingPO{Key: "diamond_rate_cents"}).First(&set).Error; err == nil {
if v, e := strconv.ParseInt(set.Value, 10, 64); e == nil {
rate = v
}
}
v := ledger.RechargeOrderPO{OrderNo: number("CZ"), UserID: userID, Diamonds: plan.Diamonds, AmountCents: plan.AmountCents, RateCents: rate}
if err := r.db(ctx).Create(&v).Error; err != nil {
return nil, err
}
return r.Get(ctx, v.OrderNo)
}
func (r *repo) MarkPaid(ctx context.Context, no string) (*biz.Order, error) {
var credited ledger.RechargeOrderPO
err := r.db(ctx).Transaction(func(tx *gorm.DB) error {
var v ledger.RechargeOrderPO
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("order_no = ?", no).First(&v).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return biz.ErrNotFound
}
return err
}
if v.CreditedAt != nil {
credited = v
return nil
}
now := time.Now()
v.CreditedAt = &now
if err := tx.Save(&v).Error; err != nil {
return err
}
wallet := ledger.WalletPO{UserID: v.UserID}
if err := tx.Where("user_id = ?", v.UserID).FirstOrCreate(&wallet).Error; err != nil {
return err
}
if err := tx.Model(&wallet).Updates(map[string]any{"diamond_balance": gorm.Expr("diamond_balance + ?", v.Diamonds), "total_recharge_cents": gorm.Expr("total_recharge_cents + ?", v.AmountCents)}).Error; err != nil {
return err
}
credited = v
return nil
})
if err != nil {
return nil, err
}
return toBiz(&row{RechargeOrderPO: credited}), nil
}
func toBiz(v *row) *biz.Order {
return &biz.Order{
ID: v.ID, OrderNo: v.OrderNo, UserID: v.UserID, Username: v.Username, Nickname: v.Nickname,
Diamonds: v.Diamonds, AmountCents: v.AmountCents, Provider: v.Provider, ProviderTradeNo: v.ProviderTradeNo,
PaymentStatus: v.PaymentStatus, FulfillmentStatus: v.FulfillmentStatus, RefundStatus: v.RefundStatus,
CreatedAt: v.CreatedAt, CreditedAt: v.CreditedAt,
}
}
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"}
if x := m[strings.ToLower(v)]; x != "" {
return x
}
return "live_recharge_orders.created_at DESC"
}
func number(p string) string {
return fmt.Sprintf("%s%s%06d", p, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
}
+97
View File
@@ -0,0 +1,97 @@
package recharge
import (
"context"
"testing"
"time"
biz "kra/internal/biz/recharge"
"kra/internal/data/ledger"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type testProvider struct{ db *gorm.DB }
func (p testProvider) DB() *gorm.DB { return p.db }
func TestMarkPaidCreditsWalletOnlyOnce(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}, &ledger.WalletPO{}); err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
t.Fatal(err)
}
order := ledger.RechargeOrderPO{OrderNo: "CZ-1", UserID: 3, Diamonds: 60, AmountCents: 600}
if err = db.Create(&order).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
t.Fatal(err)
}
if _, err = repo.MarkPaid(context.Background(), order.OrderNo); err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 3).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 60 || wallet.TotalRechargeCents != 600 {
t.Fatalf("wallet credited more than once: %+v", wallet)
}
}
func TestListReadsPaymentStateFromKRAOrders(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ledger.RechargeOrderPO{}); err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE sys_users (id integer primary key, username text, nick_name text)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TABLE pay_orders (trade_no text, business_type text, business_id text, provider text, provider_trade_no text, payment_status text, fulfillment_status text, refund_status text)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (3,'buyer','Buyer')`).Error; err != nil {
t.Fatal(err)
}
creditedAt := time.Now()
order := ledger.RechargeOrderPO{OrderNo: "CZ-2", UserID: 3, Diamonds: 300, AmountCents: 3000, CreditedAt: &creditedAt}
if err = db.Create(&order).Error; err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
pending, err := repo.Get(context.Background(), order.OrderNo)
if err != nil {
t.Fatal(err)
}
if pending.Provider != "" || pending.PaymentStatus != "" {
t.Fatalf("new business order unexpectedly has payment state: %+v", pending)
}
if err = db.Exec(`INSERT INTO pay_orders(trade_no,business_type,business_id,provider,provider_trade_no,payment_status,fulfillment_status,refund_status) VALUES (?,?,?,?,?,?,?,?)`, order.OrderNo, biz.BusinessType, order.OrderNo, "wechat-v3", "WX-1", "paid", "succeeded", "none").Error; err != nil {
t.Fatal(err)
}
items, total, summary, err := repo.List(context.Background(), biz.Filter{Provider: "wechat-v3", PaymentStatus: "paid"}, biz.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || summary.PaidCount != 1 || summary.AmountCents != 3000 {
t.Fatalf("unexpected list result: total=%d items=%d summary=%+v", total, len(items), summary)
}
item := items[0]
if item.Provider != "wechat-v3" || item.ProviderTradeNo != "WX-1" || item.PaymentStatus != "paid" || item.FulfillmentStatus != "succeeded" || item.RefundStatus != "none" {
t.Fatalf("payment state was not joined: %+v", item)
}
}
+44
View File
@@ -127,6 +127,50 @@ func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthori
})
}
// RemoveAdminMenus removes obsolete menu metadata without touching business
// data. It is used by module migrations when a menu is intentionally deleted
// from the current admin surface.
func RemoveAdminMenus(db *gorm.DB, names ...string) error {
if db == nil || len(names) == 0 || !db.Migrator().HasTable(&menuPO{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
var menus []menuPO
if err := tx.Where("name IN ?", names).Find(&menus).Error; err != nil {
return err
}
if len(menus) == 0 {
return nil
}
ids := make([]uint, 0, len(menus))
for _, menu := range menus {
ids = append(ids, menu.ID)
}
if tx.Migrator().HasTable(&authorityMenuPO{}) {
if err := tx.Where("sys_base_menu_id IN ?", ids).Delete(&authorityMenuPO{}).Error; err != nil {
return err
}
}
if tx.Migrator().HasTable(&authorityButtonPO{}) {
if err := tx.Where("sys_menu_id IN ?", ids).Delete(&authorityButtonPO{}).Error; err != nil {
return err
}
}
if tx.Migrator().HasTable(&menuButtonPO{}) {
if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuButtonPO{}).Error; err != nil {
return err
}
}
if tx.Migrator().HasTable(&menuParameterPO{}) {
if err := tx.Unscoped().Where("sys_base_menu_id IN ?", ids).Delete(&menuParameterPO{}).Error; err != nil {
return err
}
}
return tx.Unscoped().Where("id IN ?", ids).Delete(&menuPO{}).Error
})
}
// InheritAPIPolicy grants a new route only to authorities that already own
// the supplied predecessor route. This preserves existing read-access intent
// without broadening unrelated write permissions.