feat: 迁移日汇总任务并修复商务菜单图标
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package business
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。
|
||||
var businessMenuIcons = map[string]string{
|
||||
"sw_BusMgmt": "Briefcase",
|
||||
"sw_BusinessList": "UserFilled",
|
||||
"sw_ChannelList": "Connection",
|
||||
"sw_ChannelActivationList": "CircleCheck",
|
||||
"sw_DataAnalysis": "DataAnalysis",
|
||||
"sw_ChannelDataStatistics": "Histogram",
|
||||
"sw_RechargeDetails": "Money",
|
||||
"sw_RegistrationDetails": "User",
|
||||
"sw_GameAnalytics": "TrendCharts",
|
||||
"sw_ServerAnalysis": "Monitor",
|
||||
"sw_RankingAnalysis": "Star",
|
||||
"sw_AlertCenter": "Bell",
|
||||
"sw_AlertList": "Warning",
|
||||
"sw_AlertRules": "Setting",
|
||||
}
|
||||
|
||||
func MenuIcon(name string) string { return businessMenuIcons[name] }
|
||||
|
||||
func migrateBusinessMenuIcons(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("sys_base_menus") {
|
||||
return nil
|
||||
}
|
||||
// 仅替换旧迁移和早期模块默认值,保留管理员后来选取的其他图标。
|
||||
legacy := []string{"", "category", "email", "chart", "company", "international", "color", "dict", "cascader", "education", "excel", "star", "process-definition", "documentation", "example", "data-analysis", "user", "money", "warn"}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for name, icon := range businessMenuIcons {
|
||||
if err := tx.Table("sys_base_menus").Where("name=? AND (icon IS NULL OR icon IN ?)", name, legacy).Update("icon", icon).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package business
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBusinessMenuIconsRepairLegacyAndPreserveCustom(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.Exec("CREATE TABLE sys_base_menus(id INTEGER PRIMARY KEY,name TEXT,icon TEXT)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO sys_base_menus VALUES(1,'sw_BusMgmt','category'),(2,'sw_BusinessList','email'),(3,'sw_ChannelList','Connection'),(4,'sw_ChannelActivationList','CirclePlus'),(5,'sw_DataAnalysis',NULL),(6,'dashboard','category')",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := migrateBusinessMenuIcons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var rows []struct {
|
||||
ID int
|
||||
Icon string
|
||||
}
|
||||
if err := db.Table("sys_base_menus").Order("id").Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []string{"Briefcase", "UserFilled", "Connection", "CirclePlus", "DataAnalysis", "category"} {
|
||||
if rows[i].Icon != want {
|
||||
t.Fatalf("菜单%d图标错误或误改自定义值: %+v", i, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||
return []migration.Step{{ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
||||
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type reportBuildRepo struct{ data Provider }
|
||||
|
||||
func NewReportBuildRepo(data Provider) business.ReportBuildRepo { return &reportBuildRepo{data: data} }
|
||||
|
||||
type reportBuildLockPO struct {
|
||||
Kind string `gorm:"size:16;primaryKey"`
|
||||
StatDate string `gorm:"type:date;primaryKey"`
|
||||
}
|
||||
|
||||
func (reportBuildLockPO) TableName() string { return "sys_business_report_build_locks" }
|
||||
|
||||
type reportEventPO struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
UserID, PromoteID, GameID *int64
|
||||
ServerID, PayAmount string
|
||||
}
|
||||
|
||||
func toReportEvent(p reportEventPO) business.ReportEvent {
|
||||
return business.ReportEvent{SourceDB: p.SourceDB, UserID: p.UserID, PromoteID: p.PromoteID, GameID: p.GameID, ServerNum: p.ServerID, Amount: p.PayAmount}
|
||||
}
|
||||
func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuildKind, day time.Time, compile func(*business.ReportInput) ([]*business.ReportBuiltRow, error)) error {
|
||||
if kind != business.DailyReport && kind != business.ServerReport {
|
||||
return business.ErrAnalyticsInput
|
||||
}
|
||||
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
reportTable, keyTable := "tab_sw_report_day", "tab_sw_report_day_key"
|
||||
rawTable := "tab_user_day_login"
|
||||
if kind == business.ServerReport {
|
||||
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
|
||||
rawTable = "tab_user_play_info"
|
||||
}
|
||||
for _, table := range []string{reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game"} {
|
||||
available, err := businessTableAvailable(tx, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !available {
|
||||
return business.ErrReportBuildUnavailable
|
||||
}
|
||||
}
|
||||
statDate := day.Format("2006-01-02")
|
||||
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&lock).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("kind=? AND stat_date=?", lock.Kind, lock.StatDate).Take(&lock).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
input, err := loadReportInput(tx, kind, day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := compile(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM "+keyTable+" WHERE stat_date=?", statDate).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM "+reportTable+" WHERE stat_date=?", statDate).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
reports := make([]map[string]any, 0, len(rows))
|
||||
keys := []map[string]any{}
|
||||
for _, row := range rows {
|
||||
base := func() map[string]any {
|
||||
out := map[string]any{"stat_date": statDate, "source_db": row.SourceDB, "promote_id": row.PromoteID, "game_id": row.GameID, "game_name": row.GameName, "dedup_mode": row.Mode}
|
||||
if kind == business.ServerReport {
|
||||
out["server_num"] = row.ServerNum
|
||||
}
|
||||
return out
|
||||
}
|
||||
report := base()
|
||||
report["new_register_cnt"], report["active_cnt"], report["pay_cnt"], report["total_pay"] = row.Counts.Register, row.Counts.Active, row.Counts.Pay, row.Pay
|
||||
if kind == business.DailyReport {
|
||||
report["new_pay_cnt"] = row.Counts.NewPay
|
||||
}
|
||||
reports = append(reports, report)
|
||||
for metric := 1; metric <= 4; metric++ {
|
||||
for _, identity := range row.Keys[metric] {
|
||||
key := base()
|
||||
key["metric"], key["identity_key"] = metric, identity
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 汇总和身份键始终同事务替换,失败或取消会恢复原日报。
|
||||
for _, batch := range []struct {
|
||||
table string
|
||||
rows []map[string]any
|
||||
}{{reportTable, reports}, {keyTable, keys}} {
|
||||
if len(batch.rows) > 0 {
|
||||
if err := tx.Table(batch.table).CreateInBatches(batch.rows, 100).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time) (*business.ReportInput, error) {
|
||||
input := &business.ReportInput{Users: map[business.ReportUserKey]business.ReportUser{}, Games: map[business.AnalyticsKey]string{}}
|
||||
begin, end := day.Unix(), day.AddDate(0, 0, 1).Unix()-1
|
||||
userIDs, gameIDs := map[int64]bool{}, map[int64]bool{}
|
||||
collect := func(rows []reportEventPO, target *[]business.ReportEvent) {
|
||||
for _, po := range rows {
|
||||
*target = append(*target, toReportEvent(po))
|
||||
if po.UserID != nil {
|
||||
userIDs[*po.UserID] = true
|
||||
}
|
||||
if po.GameID != nil {
|
||||
gameIDs[*po.GameID] = true
|
||||
} else {
|
||||
gameIDs[0] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind == business.DailyReport {
|
||||
cast := "CAST(register_time AS INTEGER)"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
cast = "CAST(register_time AS UNSIGNED)"
|
||||
}
|
||||
var registers, actives []reportEventPO
|
||||
if err := db.Table("tab_user").Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(®isters).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
collect(registers, &input.Registers)
|
||||
collect(actives, &input.Actives)
|
||||
} else {
|
||||
for _, query := range []struct {
|
||||
column string
|
||||
target *[]business.ReportEvent
|
||||
}{{"create_time", &input.Registers}, {"play_time", &input.Actives}} {
|
||||
var rows []reportEventPO
|
||||
if err := db.Table("tab_user_play_info").Select("user_id,source_db,promote_id,game_id,server_id").Where("server_id<>''").Where(query.column+" BETWEEN ? AND ?", begin, end).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
collect(rows, query.target)
|
||||
}
|
||||
}
|
||||
pay := db.Table("tab_spend").Where("pay_status=1 AND pay_time BETWEEN ? AND ?", begin, end)
|
||||
columns := "user_id,source_db,promote_id,game_id,pay_amount"
|
||||
if kind == business.ServerReport {
|
||||
pay = pay.Where("server_id<>''")
|
||||
columns += ",server_id"
|
||||
}
|
||||
var pays []reportEventPO
|
||||
if err := pay.Select(columns).Scan(&pays).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
collect(pays, &input.Payments)
|
||||
ids := make([]int64, 0, len(userIDs))
|
||||
for id := range userIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for start := 0; start < len(ids); start += 500 {
|
||||
var users []struct {
|
||||
ID int64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
Puid, PromoteID *int64
|
||||
IDCard string `gorm:"column:idcard"`
|
||||
}
|
||||
if err := db.Table("tab_user").Select("id,source_db,puid,promote_id,idcard").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&users).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range users {
|
||||
key := business.ReportUserKey{SourceDB: po.SourceDB, ID: po.ID}
|
||||
input.Users[key] = business.ReportUser{ReportUserKey: key, ParentID: po.Puid, PromoteID: po.PromoteID, IDCard: po.IDCard}
|
||||
}
|
||||
}
|
||||
ids = make([]int64, 0, len(gameIDs))
|
||||
for id := range gameIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
for start := 0; start < len(ids); start += 500 {
|
||||
var games []struct {
|
||||
ID int64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
GameName string
|
||||
}
|
||||
if err := db.Table("tab_game").Select("id,source_db,game_name").Where("id IN ?", ids[start:min(start+500, len(ids))]).Scan(&games).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range games {
|
||||
if strings.TrimSpace(po.SourceDB) != "" && strings.TrimSpace(po.GameName) != "" {
|
||||
input.Games[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName
|
||||
}
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportBuildStorageReplacesBothTablesAtomically(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
day := time.Date(2026, 11, 4, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)",
|
||||
"CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)",
|
||||
"CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)",
|
||||
"CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','当前游戏')",
|
||||
"CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
|
||||
"CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO tab_user VALUES(1,'lx',0,10,1,'cardX',?),(2,'lx',1,10,1,'sub',?),(3,'lx',NULL,10,1,'null',?)",
|
||||
} {
|
||||
if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO tab_user_day_login VALUES(1,'lx',NULL,1,?),(2,'lx',10,1,?),(3,'lx',10,1,?)",
|
||||
"INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,10),(2,'lx',10,1,'001',?,1,20),(3,'lx',10,1,'001',?,0,999)",
|
||||
} {
|
||||
if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("INSERT INTO tab_user_play_info VALUES(2,'lx',10,1,' 001 ',?,?)", day.Unix(), day.Unix()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uc := business.NewReportBuildUsecase(NewReportBuildRepo(testData{db}))
|
||||
run := func(kind business.ReportBuildKind) error {
|
||||
return uc.Run(ctx, kind, "backfill", "2026-11-04", "2026-11-04")
|
||||
}
|
||||
if err := run(business.DailyReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var counts []struct {
|
||||
DedupMode, NewRegisterCnt, ActiveCnt, PayCnt, NewPayCnt int
|
||||
TotalPay string
|
||||
GameName string
|
||||
}
|
||||
if err := db.Table("tab_sw_report_day").Order("dedup_mode").Scan(&counts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(counts) != 2 || counts[0].NewRegisterCnt != 1 || counts[0].ActiveCnt != 1 || counts[0].PayCnt != 2 || counts[0].NewPayCnt != 1 || portraitTestMoney(counts[0].TotalPay) != "30.00" || counts[0].GameName != "当前游戏" {
|
||||
t.Fatalf("日报主账号/付费/金额规则错误: %+v", counts)
|
||||
}
|
||||
if err := run(business.ServerReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var server struct {
|
||||
NewRegisterCnt, ActiveCnt, PayCnt int
|
||||
ServerNum string
|
||||
}
|
||||
if err := db.Table("tab_sw_report_server_day").Where("dedup_mode=0").Take(&server).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if server.NewRegisterCnt != 1 || server.ActiveCnt != 1 || server.PayCnt != 2 || server.ServerNum != "001" {
|
||||
t.Fatalf("区服创角应包含子账号: %+v", server)
|
||||
}
|
||||
if err := run(business.DailyReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int64
|
||||
if err := db.Table("tab_sw_report_day").Where("stat_date=?", "2026-11-04").Count(&before).Error; err != nil || before != 2 {
|
||||
t.Fatal("回跑日期必须保持自然日格式且不能重复累积行")
|
||||
}
|
||||
db.Table("tab_sw_report_day_key").Count(&before)
|
||||
if err := db.Exec("CREATE TRIGGER reject_report_key BEFORE INSERT ON tab_sw_report_day_key BEGIN SELECT RAISE(ABORT,'测试写入失败'); END").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_spend SET pay_amount=100 WHERE user_id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run(business.DailyReport); err == nil {
|
||||
t.Fatal("身份键写入失败必须回滚")
|
||||
}
|
||||
var after int64
|
||||
db.Table("tab_sw_report_day_key").Count(&after)
|
||||
var amount string
|
||||
db.Table("tab_sw_report_day").Select("total_pay").Where("dedup_mode=0").Scan(&amount)
|
||||
if before != after || portraitTestMoney(amount) != "30.00" {
|
||||
t.Fatal("失败后丢失原身份键或覆盖原汇总")
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewServerAnalysisRepo,
|
||||
databusiness.NewWorkbenchRepo,
|
||||
databusiness.NewRankingRepo,
|
||||
databusiness.NewReportBuildRepo,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
|
||||
@@ -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 != 21 {
|
||||
t.Fatalf("migration versions = %d, want 21", versions)
|
||||
if versions != 23 {
|
||||
t.Fatalf("migration versions = %d, want 23", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user