fix: align business pages and detail drawers with prototype

This commit is contained in:
Yvan
2026-11-05 16:18:39 +08:00
parent 27cacffc7f
commit db11b6eb54
28 changed files with 603 additions and 87 deletions
+53
View File
@@ -0,0 +1,53 @@
package business
import (
"strconv"
"strings"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func businessDisplayPaths(db *gorm.DB, owners map[uint]string) (map[uint][]string, error) {
ids := map[uint]bool{}
paths := map[uint][]uint{}
for owner, path := range owners {
seen := map[uint]bool{}
for _, part := range strings.Split(path, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
if err == nil && id > 0 && uint(id) != owner && !seen[uint(id)] {
paths[owner] = append(paths[owner], uint(id))
ids[uint(id)], seen[uint(id)] = true, true
}
}
paths[owner] = append(paths[owner], owner)
ids[owner] = true
}
keys := make([]uint, 0, len(ids))
for id := range ids {
keys = append(keys, id)
}
names := map[uint]string{}
for start := 0; start < len(keys); start += 300 {
var rows []accountPO
if err := db.Select("id,username,nick_name").Where("id IN ? AND level>0", keys[start:min(start+300, len(keys))]).Find(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
name := strings.TrimSpace(row.NickName)
if name == "" {
name = row.Username
}
names[row.ID] = name
}
}
result := map[uint][]string{}
for owner, ids := range paths {
for _, id := range ids {
if name := names[id]; name != "" {
result[owner] = append(result[owner], name)
}
}
}
return result, nil
}
+58 -5
View File
@@ -3,6 +3,7 @@ package business
import (
"context"
"errors"
"sort"
"strings"
"time"
@@ -140,11 +141,34 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
if filter.SortField == "createdAt" && filter.Ascending {
order = "p.create_time ASC,p.id ASC,p.source_db ASC"
}
keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit)
pageQuery := joinEffectiveness(db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery))
if err := pageQuery.Select("p.*,e.qualified_at").Order(order).Scan(&pos).Error; err != nil {
var pageQuery *gorm.DB
var metricKeys []business.ChannelKey
metricSort := filter.SortField == "playerCount" || filter.SortField == "totalPayAmount"
if metricSort {
metricKeys, err = channelMetricPage(db, q, filter, o)
if err != nil {
return nil, err
}
if len(metricKeys) == 0 {
return page, nil
}
pageQuery = joinEffectiveness(db.Table("tab_promote p").Where("(p.source_db,p.id) IN ?", channelPairs(metricKeys)))
} else {
keysQuery := q.Select("p.id,p.source_db").Order(order).Offset(o.Offset).Limit(o.Limit)
pageQuery = joinEffectiveness(db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery)).Order(order)
}
if err := pageQuery.Select("p.*,e.qualified_at").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
if metricSort {
ranks := map[business.ChannelKey]int{}
for i, key := range metricKeys {
ranks[key] = i
}
sort.Slice(pos, func(i, j int) bool {
return ranks[business.ChannelKey{SourceDB: pos[i].SourceDB, ID: pos[i].ID}] < ranks[business.ChannelKey{SourceDB: pos[j].SourceDB, ID: pos[j].ID}]
})
}
if len(pos) == 0 {
return page, nil
}
@@ -157,10 +181,19 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
PlatformBusierID uint64
UserID uint
PlatformBusierAccount, UserName string
IDPath string
}
if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil {
if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil {
return nil, business.ErrStorage
}
ownerPaths := map[uint]string{}
for _, b := range bound {
ownerPaths[b.UserID] = b.IDPath
}
paths, err := businessDisplayPaths(db, ownerPaths)
if err != nil {
return nil, err
}
byKey := map[business.PlatformKey]int{}
for i, b := range bound {
key := business.PlatformKey{SourceDB: b.SourceDB, BusierID: b.PlatformBusierID}
@@ -175,6 +208,7 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
c.BusierUserID = &b.UserID
c.BindingAccount = b.PlatformBusierAccount
c.BindingUserName = b.UserName
c.BusinessPath = paths[b.UserID]
}
page.Rows = append(page.Rows, c)
}
@@ -205,7 +239,26 @@ func (r *channelRepo) Find(ctx context.Context, scope business.UserScope, key bu
return nil, business.ErrChannelScope
}
}
return toBizChannel(po), nil
c := toBizChannel(po)
var bindings []struct {
UserID uint
PlatformBusierAccount, UserName, IDPath string
}
if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: []string{key.SourceDB}}).
Where("b.platform_busier_id = ?", po.BusierID).Order("b.user_id").Limit(1).
Select("b.user_id,b.platform_busier_account,u.username AS user_name,u.id_path").Scan(&bindings).Error; err != nil {
return nil, business.ErrStorage
}
if len(bindings) > 0 {
b := bindings[0]
paths, err := businessDisplayPaths(db, map[uint]string{b.UserID: b.IDPath})
if err != nil {
return nil, err
}
c.BusierUserID, c.BindingAccount, c.BindingUserName = &b.UserID, b.PlatformBusierAccount, b.UserName
c.BusinessPath = paths[b.UserID]
}
return c, nil
}
func channelPairs(keys []business.ChannelKey) [][]any {
pairs := make([][]any, 0, len(keys))
+167
View File
@@ -0,0 +1,167 @@
package business
import (
"database/sql"
"math/big"
"sort"
"gorm.io/gorm"
"kra/internal/biz/business"
)
// Build the same per-channel membership as Channel.IncludesStat. UNION removes
// descendants reached through both parent_id and top_promote_id.
func channelMetricMembership(db, roots *gorm.DB, includeSub bool) *gorm.DB {
self := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,p.id AS leaf_id")
if !includeSub {
return db.Table("(?) cm", self)
}
direct := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id").
Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.parent_id=p.id").
Where("p.promote_level<3 AND (p.promote_level<>2 OR leaf.promote_level=3)")
top := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id").
Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.top_promote_id=p.id").
Where("p.promote_level<3 AND p.promote_level<>2")
return db.Table("(? UNION ? UNION ?) cm", self, direct, top)
}
// Only keys and scores for visible rows are retained. Identity rows are streamed
// one channel at a time, using the same normalization as the displayed count.
func channelMetricPage(db, roots *gorm.DB, f business.ChannelFilter, o business.ListOptions) ([]business.ChannelKey, error) {
var channels []channelPO
if err := roots.Session(&gorm.Session{}).Select("p.id,p.source_db,p.create_time").Scan(&channels).Error; err != nil {
return nil, business.ErrStorage
}
if len(channels) == 0 {
return nil, nil
}
table := "tab_user"
if f.SortField == "totalPayAmount" {
table = "tab_spend"
}
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrChannelUnavailable
}
scores := map[business.ChannelKey]*big.Rat{}
q := channelMetricMembership(db, roots, f.IncludeSub)
// MySQL 5.7 may choose the narrow pay_status index and then fetch millions
// of full records. Prefer the existing covering index for this full scan.
metricTable := table
index := "idx_sw_user_report_identity"
if table == "tab_spend" {
index = "idx_sw_spend_promote_summary"
}
if db.Dialector.Name() == "mysql" && db.Migrator().HasIndex(table, index) {
metricTable += " FORCE INDEX (" + index + ")"
}
leaves := db.Table(metricTable)
if len(f.Sources) > 0 {
leaves = leaves.Where("source_db IN ?", f.Sources)
}
if f.SortField == "totalPayAmount" {
leaves = leaves.Where("pay_status=1")
} else {
leaves = leaves.Where("puid=0 OR puid IS NULL")
}
if f.SortField == "playerCount" && f.IDCardDedup {
join := "STRAIGHT_JOIN"
if db.Dialector.Name() == "sqlite" {
join = "JOIN"
}
// Stream the covering user index once, then look up its channel roots.
// Grouping raw identity strings first creates a large disk temporary table
// and still cannot apply the domain's exact whitespace/X normalization.
leaves = leaves.Select("source_db,promote_id,idcard")
members := q.Select("cm.source_db,cm.root_id,cm.leaf_id")
rows, err := db.Table("(?) u", leaves).Joins(join+" (?) cm ON cm.source_db=u.source_db AND cm.leaf_id=u.promote_id", members).
Select("cm.source_db,cm.root_id,u.idcard").Order("cm.source_db,cm.root_id").Rows()
if err != nil {
return nil, business.ErrStorage
}
defer rows.Close()
var previous business.ChannelKey
identities := map[string]struct{}{}
flush := func() { scores[previous] = big.NewRat(int64(len(identities)), 1) }
for rows.Next() {
var key business.ChannelKey
var card sql.NullString
if err := rows.Scan(&key.SourceDB, &key.ID, &card); err != nil {
return nil, business.ErrStorage
}
if key != previous {
flush()
identities = map[string]struct{}{}
previous = key
}
if identity := business.ChannelIdentity(card.String); identity != "" {
identities[identity] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, business.ErrStorage
}
flush()
} else {
var values []struct {
SourceDB string
RootID uint64
Score string
}
if f.SortField == "totalPayAmount" {
leaves = leaves.Select("source_db,promote_id,COALESCE(SUM(pay_amount),0) AS score")
} else {
leaves = leaves.Select("source_db,promote_id,COUNT(*) AS score")
}
leaves = leaves.Group("source_db,promote_id")
q = q.Joins("JOIN (?) leaf ON leaf.source_db=cm.source_db AND leaf.promote_id=cm.leaf_id", leaves).
Select("cm.source_db,cm.root_id,SUM(leaf.score) AS score")
if err := q.Group("cm.source_db,cm.root_id").Scan(&values).Error; err != nil {
return nil, business.ErrStorage
}
for _, value := range values {
score, ok := new(big.Rat).SetString(value.Score)
if !ok {
return nil, business.ErrStorage
}
scores[business.ChannelKey{SourceDB: value.SourceDB, ID: value.RootID}] = score
}
}
zero := new(big.Rat)
score := func(c channelPO) *big.Rat {
if n := scores[business.ChannelKey{SourceDB: c.SourceDB, ID: c.ID}]; n != nil {
return n
}
return zero
}
sort.Slice(channels, func(i, j int) bool {
a, b := channels[i], channels[j]
if compared := score(a).Cmp(score(b)); compared != 0 {
return (compared < 0) == f.Ascending
}
if a.CreateTime == nil || b.CreateTime == nil {
if (a.CreateTime == nil) != (b.CreateTime == nil) {
return a.CreateTime != nil
}
} else if *a.CreateTime != *b.CreateTime {
return *a.CreateTime > *b.CreateTime
}
if a.ID != b.ID {
return a.ID > b.ID
}
return a.SourceDB < b.SourceDB
})
begin := min(max(0, o.Offset), len(channels))
end := len(channels)
if o.Limit > 0 && o.Limit < end-begin {
end = begin + o.Limit
}
keys := make([]business.ChannelKey, 0, end-begin)
for _, row := range channels[begin:end] {
keys = append(keys, business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID})
}
return keys, nil
}
@@ -0,0 +1,71 @@
package business
import (
"context"
"reflect"
"testing"
"kra/internal/biz/business"
)
func TestChannelMetricSortingUsesFullScopeAndPreferencesBeforePagination(t *testing.T) {
_, repo := prepareChannels(t)
scope := business.UserScope{SelfID: 9, OnlySelf: true}
for _, sample := range []struct {
field string
include, dedup bool
want []uint64
}{
{"totalPayAmount", true, false, []uint64{101, 100, 103, 102}},
{"totalPayAmount", false, true, []uint64{103, 102, 101, 100}},
{"playerCount", true, false, []uint64{101, 100, 103, 102}},
{"playerCount", true, true, []uint64{101, 103, 100, 102}},
{"playerCount", false, true, []uint64{103, 101, 100, 102}},
} {
t.Run(sample.field+"/"+map[bool]string{true: "sub", false: "self"}[sample.include]+"/"+map[bool]string{true: "dedup", false: "accounts"}[sample.dedup], func(t *testing.T) {
filter := business.ChannelFilter{SortField: sample.field, IncludeSub: sample.include, IDCardDedup: sample.dedup}
var got []uint64
for offset := range sample.want {
page, err := repo.List(context.Background(), scope, filter, business.ListOffset(offset), business.ListLimit(1))
if err != nil || page.Total != 4 || len(page.Rows) != 1 || page.StatusCounts.Normal != 4 {
t.Fatalf("unexpected page at %d: %+v %v", offset, page, err)
}
if page.Rows[0].SourceDB != "lx" {
t.Fatal("metric sorting leaked another business or platform")
}
got = append(got, page.Rows[0].ID)
}
if !reflect.DeepEqual(got, sample.want) {
t.Fatalf("want %v, got %v", sample.want, got)
}
filter.Ascending = true
filter.Keyword = "grandchild"
page, err := repo.List(context.Background(), scope, filter, business.ListLimit(1))
if err != nil || page.Total != 1 || len(page.Rows) != 1 || page.Rows[0].ID != 102 {
t.Fatalf("metric sorting lost the name filter: %+v %v", page, err)
}
})
}
}
func TestChannelMetricSortRetainsZeroRowsAndExactIdentityNormalization(t *testing.T) {
db, repo := prepareChannels(t)
if err := db.Exec("INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','zero',1,0,0,14)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO tab_user VALUES('lx',1000,100,0,?),('lx',1001,100,0,?)", "\tabcX \r", "\t \r").Error; err != nil {
t.Fatal(err)
}
filter := business.ChannelFilter{SortField: "playerCount", IncludeSub: true, IDCardDedup: true, Ascending: true}
page, err := repo.List(context.Background(), business.UserScope{SelfID: 9, OnlySelf: true}, filter)
if err != nil {
t.Fatal(err)
}
var got []uint64
for _, row := range page.Rows {
got = append(got, row.ID)
}
if !reflect.DeepEqual(got, []uint64{200, 102, 103, 100, 101}) {
t.Fatalf("zero rows or identity normalization differ from displayed values: %v", got)
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) {
t.Fatalf("指定商务应只取本人绑定: %+v %v", page, err)
}
for _, row := range page.Rows {
if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "lx-business(leaf)" {
if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "parent→branch→leaf" {
t.Fatalf("归属展示错误: %+v", row)
}
}
+2 -2
View File
@@ -370,7 +370,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
names := map[business.PlatformKey]bindName{}
for start := 0; start < len(pairs); start += 300 {
var rows []bindName
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
if err := q.Scan(&rows).Error; err != nil {
return business.ErrStorage
}
@@ -399,7 +399,7 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
ancestors := map[uint]string{}
for start := 0; start < len(ids); start += 300 {
var rows []accountPO
if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {