feat: 迁移渠道列表新增和游戏累计分布

This commit is contained in:
Yvan
2026-11-03 21:32:51 +08:00
parent 5e58c6bcf3
commit 075d94a51e
39 changed files with 2216 additions and 12 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase)
+349
View File
@@ -0,0 +1,349 @@
package business
import (
"context"
"math/big"
"regexp"
"sort"
"strings"
"time"
"unicode"
"unicode/utf16"
"github.com/go-kratos/kratos/v3/errors"
)
var (
ErrChannelNotFound = errors.NotFound("SW_CHANNEL_NOT_FOUND", "渠道不存在")
ErrChannelScope = errors.Forbidden("SW_CHANNEL_SCOPE", "无权查看该渠道")
ErrChannelInput = errors.BadRequest("SW_CHANNEL_INPUT", "渠道查询参数不正确")
ErrChannelAccount = errors.BadRequest("SW_CHANNEL_ACCOUNT", "渠道账号需为中文/英文/数字,长度 4-30 位")
ErrChannelPassword = errors.BadRequest("SW_CHANNEL_PASSWORD", "渠道密码长度需为 6-15 位")
ErrChannelBinding = errors.BadRequest("SW_CHANNEL_BINDING", "请选择有效的游戏商务绑定")
ErrChannelExists = errors.BadRequest("SW_CHANNEL_EXISTS", "渠道账号已存在")
ErrChannelUnavailable = errors.BadRequest("SW_CHANNEL_UNAVAILABLE", "渠道数据尚未接入")
ErrChannelReportsUnavailable = errors.BadRequest("SW_CHANNEL_REPORTS_UNAVAILABLE", "游戏日汇总数据尚未接入")
ErrChannelBusy = errors.Conflict("SW_CHANNEL_BUSY", "该渠道正在创建,请稍后重试")
ErrChannelPlatformOwner = errors.BadRequest("SW_CHANNEL_PLATFORM_OWNER", "游戏商务不存在或渠道关系更新失败")
)
type ChannelKey struct {
SourceDB string
ID uint64
}
type Channel struct {
ChannelKey
Account, Nickname, ParentName, RealName string
Level, RegisterType, Status, LockStatus *int
ParentID, TopPromoteID, BusierID uint64
BusierUserID *uint
BindingAccount, BindingUserName string
GameCount, PlayerCount *int64
TotalPayAmount *string
}
func (c *Channel) BusinessDisplayName() string {
account, user := strings.TrimSpace(c.BindingAccount), strings.TrimSpace(c.BindingUserName)
if account == "" {
return user
}
if user == "" || account == user {
return account
}
return account + "(" + user + ")"
}
func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool {
if c.SourceDB != other.SourceDB {
return false
}
if c.ID == other.ID {
return true
}
if !includeSub || c.Level == nil || *c.Level >= 3 {
return false
}
if *c.Level == 2 {
return other.ParentID == c.ID && other.Level != nil && *other.Level == 3
}
return other.ParentID == c.ID || other.TopPromoteID == c.ID
}
type ChannelFilter struct {
Sources []string
BusierUserID *uint
BusierAccount, Keyword string
Level, RegisterType, Status, LockStatus *int
}
type ChannelPage struct {
Rows []*Channel
Total int64
Available bool
}
type ChannelPlayer struct {
ChannelKey
UserID uint64
IDCard string
}
type ChannelMetricData struct {
Games map[ChannelKey]int64
Players map[ChannelKey]int64
Pay map[ChannelKey]string
PlayerCards []ChannelPlayer
GamesAvailable, PlayersAvailable, PayAvailable bool
}
type ChannelGame struct {
ID uint64
Name string
RegisterCount int64
PayAmount string
}
type ChannelGameScope struct {
Root *Channel
IncludeSub, IDCardDedup bool
EndDate string
}
type ChannelRepo interface {
List(context.Context, UserScope, ChannelFilter, ...ListOption) (*ChannelPage, error)
Bindings(context.Context, UserScope, string) ([]*Binding, error)
Find(context.Context, UserScope, ChannelKey) (*Channel, error)
StatChannels(context.Context, []*Channel) ([]*Channel, error)
Metrics(context.Context, []ChannelKey, []ChannelKey, bool) (*ChannelMetricData, error)
Games(context.Context, ChannelGameScope) ([]*ChannelGame, error)
}
type ChannelCreate struct {
SourceDB, Account, Password string
BusierID uint64
RegisterType int
}
type ChannelCreateResult struct {
ID uint64
SourceDB string
}
type ChannelWrite struct {
ChannelCreate
Status, Level, SettlementType, SettlementDays int
ParentID, TopPromoteID uint64
GameIDs2 string
CreatedUnix, NextCountUnix int64
}
type ChannelWriteRepo interface {
Create(context.Context, *ChannelWrite) (uint64, error)
}
type PlatformPasswordEncoder interface{ Encode(string) (string, error) }
type ChannelUsecase struct {
repo ChannelRepo
writer ChannelWriteRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
passwords PlatformPasswordEncoder
now func() time.Time
}
func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder) *ChannelUsecase {
return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now}
}
func (uc *ChannelUsecase) scope(ctx context.Context) (UserScope, error) {
actor, a, err := uc.accounts.actor(ctx)
if err != nil {
return UserScope{}, err
}
if actor.AuthorityID == 888 {
return UserScope{All: true}, nil
}
return UserScope{SelfID: a.ID, IncludeSelf: true, OnlySelf: a.Level >= 3, DescendantPath: descendantPath(a)}, nil
}
func normalizeIDCard(raw string) string {
// Java isBlank 排除不换行空格;其余空白证件仍不计入去重人数。
if strings.TrimFunc(raw, func(r rune) bool { return unicode.IsSpace(r) && r != '\u00a0' && r != '\u2007' && r != '\u202f' }) == "" {
return ""
}
value := strings.TrimFunc(raw, func(r rune) bool { return r <= 32 })
if strings.HasSuffix(value, "X") {
value = strings.TrimSuffix(value, "X") + "x"
}
return value
}
func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, options ...ListOption) (*ChannelPage, error) {
scope, err := uc.scope(ctx)
if err != nil {
return nil, err
}
page, err := uc.repo.List(ctx, scope, filter, options...)
if err != nil || len(page.Rows) == 0 {
return page, err
}
pref, err := uc.preferences.Current(ctx)
if err != nil {
return nil, err
}
statChannels := page.Rows
if pref.IncludeSubPromote {
statChannels, err = uc.repo.StatChannels(ctx, page.Rows)
if err != nil {
return nil, err
}
}
roots, statKeys := []ChannelKey{}, []ChannelKey{}
groups := map[ChannelKey][]ChannelKey{}
seen := map[ChannelKey]bool{}
for _, root := range page.Rows {
roots = append(roots, root.ChannelKey)
groupSeen := map[ChannelKey]bool{root.ChannelKey: true}
groups[root.ChannelKey] = []ChannelKey{root.ChannelKey}
for _, candidate := range statChannels {
if root.IncludesStat(candidate, pref.IncludeSubPromote) && !groupSeen[candidate.ChannelKey] {
groups[root.ChannelKey] = append(groups[root.ChannelKey], candidate.ChannelKey)
groupSeen[candidate.ChannelKey] = true
}
}
for _, key := range groups[root.ChannelKey] {
if !seen[key] {
statKeys = append(statKeys, key)
seen[key] = true
}
}
}
metrics, err := uc.repo.Metrics(ctx, roots, statKeys, pref.IDCardDedup)
if err != nil {
return nil, err
}
cards := map[ChannelKey][]ChannelPlayer{}
for _, player := range metrics.PlayerCards {
cards[player.ChannelKey] = append(cards[player.ChannelKey], player)
}
for _, root := range page.Rows {
if metrics.GamesAvailable {
count := metrics.Games[root.ChannelKey]
root.GameCount = &count
}
count := int64(0)
userCards := map[uint64]string{}
pay := new(big.Rat)
for _, key := range groups[root.ChannelKey] {
count += metrics.Players[key]
for _, player := range cards[key] {
userCards[player.UserID] = normalizeIDCard(player.IDCard)
}
if amount := metrics.Pay[key]; amount != "" {
n, ok := new(big.Rat).SetString(amount)
if !ok {
return nil, ErrStorage
}
pay.Add(pay, n)
}
}
if metrics.PlayersAvailable {
if pref.IDCardDedup {
identities := map[string]bool{}
for _, card := range userCards {
if card != "" {
identities[card] = true
}
}
count = int64(len(identities))
}
root.PlayerCount = &count
}
if metrics.PayAvailable {
amount := pay.FloatString(2)
root.TotalPayAmount = &amount
}
}
return page, nil
}
func (uc *ChannelUsecase) BindOptions(ctx context.Context, source string) ([]*Binding, error) {
source, err := NormalizePlatform(source)
if err != nil {
return nil, err
}
scope, err := uc.scope(ctx)
if err != nil {
return nil, err
}
return uc.repo.Bindings(ctx, scope, source)
}
var channelAccountPattern = regexp.MustCompile(`^[\x{4e00}-\x{9fa5}a-zA-Z0-9]{4,30}$`)
func (uc *ChannelUsecase) Create(ctx context.Context, input *ChannelCreate) (*ChannelCreateResult, error) {
if input == nil {
return nil, ErrChannelInput
}
account := strings.TrimSpace(input.Account)
if !channelAccountPattern.MatchString(account) {
return nil, ErrChannelAccount
}
size := len(utf16.Encode([]rune(input.Password)))
if strings.TrimSpace(input.Password) == "" || size < 6 || size > 15 {
return nil, ErrChannelPassword
}
bindings, err := uc.BindOptions(ctx, input.SourceDB)
if err != nil {
return nil, err
}
valid := false
for _, binding := range bindings {
if binding.BusierID == input.BusierID {
valid = true
break
}
}
if !valid {
return nil, ErrChannelBinding
}
source, _ := NormalizePlatform(input.SourceDB)
password, err := uc.passwords.Encode(input.Password)
if err != nil {
return nil, err
}
now := uc.now().Unix()
write := &ChannelWrite{ChannelCreate: ChannelCreate{SourceDB: source, Account: account, Password: password, BusierID: input.BusierID, RegisterType: input.RegisterType}, Status: -3, Level: 1, SettlementType: 0, SettlementDays: 7, CreatedUnix: now, NextCountUnix: now + 7*24*60*60}
id, err := uc.writer.Create(ctx, write)
if err != nil {
return nil, err
}
return &ChannelCreateResult{ID: id, SourceDB: source}, nil
}
func (uc *ChannelUsecase) Games(ctx context.Context, key ChannelKey) ([]*ChannelGame, error) {
source, err := NormalizePlatform(key.SourceDB)
if err != nil {
return nil, err
}
key.SourceDB = source
scope, err := uc.scope(ctx)
if err != nil {
return nil, err
}
root, err := uc.repo.Find(ctx, scope, key)
if err != nil {
return nil, err
}
pref, err := uc.preferences.Current(ctx)
if err != nil {
return nil, err
}
day := uc.now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02")
games, err := uc.repo.Games(ctx, ChannelGameScope{Root: root, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, EndDate: day})
if err != nil {
return nil, err
}
amounts := map[uint64]*big.Rat{}
for _, game := range games {
game.RegisterCount = min(game.RegisterCount, int64(2147483647))
n, ok := new(big.Rat).SetString(game.PayAmount)
if !ok {
return nil, ErrStorage
}
game.PayAmount = n.FloatString(2)
// 原画像先把金额保留两位再排序;同额时按游戏 ID 稳定排序。
amounts[game.ID], _ = new(big.Rat).SetString(game.PayAmount)
}
sort.Slice(games, func(i, j int) bool {
order := amounts[games[i].ID].Cmp(amounts[games[j].ID])
if order != 0 {
return order > 0
}
return games[i].ID < games[j].ID
})
return games, nil
}
+142
View File
@@ -0,0 +1,142 @@
package business
import (
"context"
"testing"
"time"
)
type channelRepoFake struct {
ChannelRepo
roots, descendants []*Channel
metrics *ChannelMetricData
scope UserScope
bindings []*Binding
gameScope ChannelGameScope
}
func (r *channelRepoFake) List(_ context.Context, scope UserScope, _ ChannelFilter, _ ...ListOption) (*ChannelPage, error) {
r.scope = scope
rows := []*Channel{}
for _, root := range r.roots {
copy := *root
rows = append(rows, &copy)
}
return &ChannelPage{Rows: rows, Available: true}, nil
}
func (r *channelRepoFake) StatChannels(context.Context, []*Channel) ([]*Channel, error) {
return r.descendants, nil
}
func (r *channelRepoFake) Metrics(context.Context, []ChannelKey, []ChannelKey, bool) (*ChannelMetricData, error) {
return r.metrics, nil
}
func (r *channelRepoFake) Bindings(_ context.Context, scope UserScope, _ string) ([]*Binding, error) {
r.scope = scope
return r.bindings, nil
}
func (r *channelRepoFake) Find(context.Context, UserScope, ChannelKey) (*Channel, error) {
return r.roots[0], nil
}
func (r *channelRepoFake) Games(_ context.Context, scope ChannelGameScope) ([]*ChannelGame, error) {
r.gameScope = scope
return []*ChannelGame{{ID: 2, PayAmount: "10.004"}, {ID: 1, PayAmount: "10.003"}}, nil
}
type channelWriteFake struct{ input *ChannelWrite }
func (w *channelWriteFake) Create(_ context.Context, input *ChannelWrite) (uint64, error) {
w.input = input
return 123, nil
}
type channelEncoderFake struct{ received string }
func (e *channelEncoderFake) Encode(raw string) (string, error) {
e.received = raw
return "encoded", nil
}
type channelPreferenceFake struct{ pref UserPreference }
func (p *channelPreferenceFake) Find(context.Context, uint) (*UserPreference, error) {
copy := p.pref
return &copy, nil
}
func (p *channelPreferenceFake) Save(context.Context, uint, UserPreference) error { return nil }
func channelFixture() (*ChannelUsecase, *channelRepoFake, *channelPreferenceFake, *channelWriteFake, *channelEncoderFake) {
key := func(id uint64) ChannelKey { return ChannelKey{SourceDB: "lx", ID: id} }
r := &channelRepoFake{roots: []*Channel{{ChannelKey: key(100), Level: intPointer(1)}}, descendants: []*Channel{{ChannelKey: key(101), Level: intPointer(2), ParentID: 100, TopPromoteID: 100}, {ChannelKey: key(102), Level: intPointer(3), ParentID: 101, TopPromoteID: 100}, {ChannelKey: key(103), Level: intPointer(3), ParentID: 101}, {ChannelKey: ChannelKey{SourceDB: "hw", ID: 100}, Level: intPointer(1)}}, bindings: []*Binding{{UserID: 9, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 14}, Status: "0"}}}
r.metrics = &ChannelMetricData{GamesAvailable: true, PlayersAvailable: true, PayAvailable: true, Games: map[ChannelKey]int64{key(100): 2, key(101): 1}, Players: map[ChannelKey]int64{key(100): 1, key(101): 1, key(102): 1, key(103): 1}, Pay: map[ChannelKey]string{key(100): "10.10", key(101): "20.20", key(102): "30.30", key(103): "40.40"}, PlayerCards: []ChannelPlayer{{ChannelKey: key(100), UserID: 1, IDCard: " abcX "}, {ChannelKey: key(101), UserID: 2, IDCard: "abcx"}, {ChannelKey: key(102), UserID: 3, IDCard: ""}, {ChannelKey: key(103), UserID: 4, IDCard: "third"}}}
p := &channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}
w, e := &channelWriteFake{}, &channelEncoderFake{}
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}, 8: {ID: 8, Level: 2, IDPath: "0,7"}, 9: {ID: 9, Level: 3, IDPath: "0,7,8"}}}
uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e)
uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) }
return uc, r, p, w, e
}
func TestChannelMetricsHonorBothPreferencesWithoutChangingGameCount(t *testing.T) {
uc, _, pref, _, _ := channelFixture()
for _, tc := range []struct {
include, dedup bool
count int64
pay string
}{{true, true, 1, "60.60"}, {true, false, 3, "60.60"}, {false, true, 1, "10.10"}, {false, false, 1, "10.10"}} {
pref.pref = UserPreference{IncludeSubPromote: tc.include, IDCardDedup: tc.dedup}
page, err := uc.List(accountContext(8, 902), ChannelFilter{})
if err != nil {
t.Fatal(err)
}
row := page.Rows[0]
if *row.GameCount != 2 || *row.PlayerCount != tc.count || *row.TotalPayAmount != tc.pay {
t.Fatalf("偏好统计错误 include=%v dedup=%v row=%+v", tc.include, tc.dedup, row)
}
}
}
func TestChannelLevelTwoScopeAndUnavailableMetrics(t *testing.T) {
uc, r, _, _, _ := channelFixture()
r.roots = []*Channel{r.descendants[0]}
page, err := uc.List(accountContext(9, 903), ChannelFilter{})
if err != nil || *page.Rows[0].PlayerCount != 2 || *page.Rows[0].TotalPayAmount != "90.90" || !r.scope.OnlySelf {
t.Fatal("二级渠道仅聚合直属三级或商务范围错误")
}
r.metrics.PlayersAvailable = false
r.metrics.PayAvailable = false
page, err = uc.List(accountContext(9, 903), ChannelFilter{})
if err != nil || page.Rows[0].PlayerCount != nil || page.Rows[0].TotalPayAmount != nil || page.Rows[0].GameCount == nil {
t.Fatal("未接入指标不能伪造零,已接入指标应保留")
}
}
func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) {
uc, r, _, writer, encoder := channelFixture()
input := &ChannelCreate{SourceDB: " LX ", Account: " 中文渠道 ", Password: " pass123 ", BusierID: 14, RegisterType: 1}
result, err := uc.Create(accountContext(8, 902), input)
if err != nil || result.ID != 123 || result.SourceDB != "lx" {
t.Fatalf("创建失败: %+v %v", result, err)
}
if writer.input.Account != "中文渠道" || writer.input.Password != "encoded" || encoder.received != " pass123 " || writer.input.NextCountUnix-writer.input.CreatedUnix != 604800 || writer.input.Status != -3 || writer.input.Level != 1 || writer.input.SettlementDays != 7 || !r.scope.IncludeSelf {
t.Fatalf("创建参数或结算周期错误: %+v", writer.input)
}
input.BusierID = 999
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelBinding {
t.Fatal("选择了范围外的绑定")
}
input.BusierID = 14
input.Account = "bad_underscore"
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelAccount {
t.Fatal("渠道账号字符限制丢失")
}
input.Account = "valid123"
input.Password = "12345"
if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelPassword {
t.Fatal("密码长度未校验")
}
}
func TestChannelGamesUseShanghaiDateAndStableMoneyOrder(t *testing.T) {
uc, r, _, _, _ := channelFixture()
uc.now = func() time.Time { return time.Date(2026, 11, 3, 17, 0, 0, 0, time.UTC) }
games, err := uc.Games(accountContext(9, 903), ChannelKey{SourceDB: "lx", ID: 100})
if err != nil || r.gameScope.EndDate != "2026-11-04" || !r.gameScope.IDCardDedup || games[0].ID != 1 {
t.Fatal("累计日期、口径或并列排序错误")
}
}
+199
View File
@@ -0,0 +1,199 @@
package business
import (
"context"
"errors"
"strings"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type channelPO struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
Account, Nickname, ParentName, RealName string
PromoteLevel, RegisterType, Status, LockStatus *int
ParentID, TopPromoteID, BusierID uint64
CreateTime *int64
}
func (channelPO) TableName() string { return "tab_promote" }
func toBizChannel(p channelPO) *business.Channel {
return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID}
}
type channelRepo struct{ data Provider }
func NewChannelRepo(data Provider) business.ChannelRepo { return &channelRepo{data: data} }
func channelBindingQuery(db *gorm.DB, scope business.UserScope, filter business.ChannelFilter) *gorm.DB {
q := db.Table("sys_user_bind_platform b").Joins("LEFT JOIN sys_users u ON u.id=b.user_id").Where("b.status = ?", "0")
if !scope.All {
q = applyUserScope(q.Where("u.deleted_at IS NULL"), scope, "u.")
}
if filter.BusierUserID != nil {
q = q.Where("b.user_id = ?", *filter.BusierUserID)
}
if len(filter.Sources) > 0 {
q = q.Where("b.source_db IN ?", filter.Sources)
}
if keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount)); keyword != "" {
q = q.Where("(INSTR(LOWER(b.platform_busier_account),?) > 0 OR INSTR(LOWER(u.username),?) > 0)", keyword, keyword)
}
return q
}
func (r *channelRepo) Bindings(ctx context.Context, scope business.UserScope, source string) ([]*business.Binding, error) {
var pos []bindingPO
if err := channelBindingQuery(r.data.DB().WithContext(ctx), scope, business.ChannelFilter{Sources: []string{source}}).Select("b.*").Order("b.user_id,b.source_db,b.platform_busier_id").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
rows := make([]*business.Binding, 0, len(pos))
for _, po := range pos {
rows = append(rows, toBizBinding(po))
}
return rows, nil
}
func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter business.ChannelFilter, options ...business.ListOption) (*business.ChannelPage, error) {
db := r.data.DB().WithContext(ctx)
page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true}
bindings := channelBindingQuery(db, scope, filter)
constrain := !scope.All || filter.BusierUserID != nil || filter.BusierAccount != ""
if constrain {
var count int64
if err := bindings.Count(&count).Error; err != nil {
return nil, business.ErrStorage
}
if count == 0 {
return page, nil
}
}
available, err := businessTableAvailable(db, "tab_promote")
if err != nil {
return nil, err
}
if !available {
page.Available = false
return page, nil
}
q := db.Table("tab_promote p")
if constrain {
q = q.Where("EXISTS (?)", bindings.Select("1").Where("b.source_db=p.source_db AND b.platform_busier_id=p.busier_id"))
}
if len(filter.Sources) > 0 {
q = q.Where("p.source_db IN ?", filter.Sources)
}
if filter.Keyword != "" {
q = q.Where("p.account LIKE ?", "%"+filter.Keyword+"%")
}
for _, field := range []struct {
column string
value *int
}{{"promote_level", filter.Level}, {"register_type", filter.RegisterType}, {"status", filter.Status}, {"lock_status", filter.LockStatus}} {
if field.value != nil {
q = q.Where("p."+field.column+" = ?", *field.value)
}
}
if err := q.Count(&page.Total).Error; err != nil {
return nil, business.ErrStorage
}
var pos []channelPO
o := business.NewListOptions(options...)
if err := q.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
if len(pos) == 0 {
return page, nil
}
pairs := make([][]any, 0, len(pos))
for _, po := range pos {
pairs = append(pairs, []any{po.SourceDB, po.BusierID})
}
var bound []struct {
SourceDB string `gorm:"column:source_db"`
PlatformBusierID uint64
UserID uint
PlatformBusierAccount, UserName 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 {
return nil, business.ErrStorage
}
byKey := map[business.PlatformKey]int{}
for i, b := range bound {
key := business.PlatformKey{SourceDB: b.SourceDB, BusierID: b.PlatformBusierID}
if _, ok := byKey[key]; !ok {
byKey[key] = i
}
}
for _, po := range pos {
c := toBizChannel(po)
if i, ok := byKey[business.PlatformKey{SourceDB: po.SourceDB, BusierID: po.BusierID}]; ok {
b := bound[i]
c.BusierUserID = &b.UserID
c.BindingAccount = b.PlatformBusierAccount
c.BindingUserName = b.UserName
}
page.Rows = append(page.Rows, c)
}
return page, nil
}
func (r *channelRepo) Find(ctx context.Context, scope business.UserScope, key business.ChannelKey) (*business.Channel, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTableAvailable(db, "tab_promote")
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrChannelUnavailable
}
var po channelPO
if err := db.Where("source_db = ? AND id = ?", key.SourceDB, key.ID).Take(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, business.ErrChannelNotFound
}
return nil, business.ErrStorage
}
if !scope.All {
var count int64
if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: []string{key.SourceDB}}).Where("b.platform_busier_id = ?", po.BusierID).Count(&count).Error; err != nil {
return nil, business.ErrStorage
}
if count == 0 {
return nil, business.ErrChannelScope
}
}
return toBizChannel(po), nil
}
func channelPairs(keys []business.ChannelKey) [][]any {
pairs := make([][]any, 0, len(keys))
for _, key := range keys {
pairs = append(pairs, []any{key.SourceDB, key.ID})
}
return pairs
}
func (r *channelRepo) StatChannels(ctx context.Context, roots []*business.Channel) ([]*business.Channel, error) {
keys := make([]business.ChannelKey, 0, len(roots))
for _, root := range roots {
if root.Level != nil && *root.Level < 3 {
keys = append(keys, root.ChannelKey)
}
}
rows := []*business.Channel{}
seen := map[business.ChannelKey]bool{}
for start := 0; start < len(keys); start += 200 {
pairs := channelPairs(keys[start:min(start+200, len(keys))])
var pos []channelPO
err := r.data.DB().WithContext(ctx).Table("tab_promote").Select("id,source_db,promote_level,parent_id,top_promote_id").Where("(source_db,parent_id) IN ? OR (source_db,top_promote_id) IN ?", pairs, pairs).Scan(&pos).Error
if err != nil {
return nil, business.ErrStorage
}
for _, po := range pos {
c := toBizChannel(po)
if !seen[c.ChannelKey] {
rows = append(rows, c)
seen[c.ChannelKey] = true
}
}
}
return rows, nil
}
+179
View File
@@ -0,0 +1,179 @@
package business
import (
"context"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func (r *channelRepo) Metrics(ctx context.Context, roots, statKeys []business.ChannelKey, dedup bool) (*business.ChannelMetricData, error) {
db := r.data.DB().WithContext(ctx)
metrics := &business.ChannelMetricData{Games: map[business.ChannelKey]int64{}, Players: map[business.ChannelKey]int64{}, Pay: map[business.ChannelKey]string{}}
var err error
metrics.GamesAvailable, err = businessTableAvailable(db, "tab_promote_apply")
if err != nil {
return nil, err
}
metrics.PlayersAvailable, err = businessTableAvailable(db, "tab_user")
if err != nil {
return nil, err
}
metrics.PayAvailable, err = businessTableAvailable(db, "tab_spend")
if err != nil {
return nil, err
}
type countRow struct {
SourceDB string `gorm:"column:source_db"`
PromoteID uint64
Count int64
}
if metrics.GamesAvailable {
for start := 0; start < len(roots); start += 300 {
var rows []countRow
if err := db.Table("tab_promote_apply").Select("source_db,promote_id,COUNT(DISTINCT game_id) AS count").Where("status = 1 AND (source_db,promote_id) IN ?", channelPairs(roots[start:min(start+300, len(roots))])).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
metrics.Games[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Count
}
}
}
// 每批按平台/渠道一起查询,避免逐个渠道访问数据库,也避免参数数量无限增长。
for start := 0; start < len(statKeys); start += 300 {
pairs := channelPairs(statKeys[start:min(start+300, len(statKeys))])
if metrics.PlayersAvailable {
q := db.Table("tab_user").Where("(puid = 0 OR puid IS NULL) AND (source_db,promote_id) IN ?", pairs)
if dedup {
var players []struct {
SourceDB string `gorm:"column:source_db"`
PromoteID, UserID uint64
IDCard string `gorm:"column:idcard"`
}
if err := q.Select("source_db,promote_id,id AS user_id,idcard").Scan(&players).Error; err != nil {
return nil, business.ErrStorage
}
for _, p := range players {
metrics.PlayerCards = append(metrics.PlayerCards, business.ChannelPlayer{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, UserID: p.UserID, IDCard: p.IDCard})
}
} else {
var rows []countRow
if err := q.Select("source_db,promote_id,COUNT(*) AS count").Group("source_db,promote_id").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
metrics.Players[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Count
}
}
}
if metrics.PayAvailable {
var rows []struct {
SourceDB string `gorm:"column:source_db"`
PromoteID uint64
Amount string
}
if err := db.Table("tab_spend").Select("source_db,promote_id,COALESCE(SUM(pay_amount),0) AS amount").Where("pay_status = 1 AND (source_db,promote_id) IN ?", pairs).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
metrics.Pay[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Amount
}
}
}
return metrics, nil
}
func channelGameTree(db *gorm.DB, scope business.ChannelGameScope) *gorm.DB {
root := scope.Root
q := db.Table("tab_promote p").Where("p.source_db = ?", root.SourceDB)
if !scope.IncludeSub || root.Level == nil || *root.Level >= 3 {
return q.Where("p.id = ?", root.ID)
}
if *root.Level == 2 {
return q.Where("p.id = ? OR (p.parent_id = ? AND p.promote_level = 3)", root.ID, root.ID)
}
return q.Where("p.id = ? OR p.parent_id = ? OR p.top_promote_id = ?", root.ID, root.ID, root.ID)
}
func (r *channelRepo) Games(ctx context.Context, scope business.ChannelGameScope) ([]*business.ChannelGame, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTableAvailable(db, "tab_promote_apply")
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrChannelReportsUnavailable
}
ids := []uint64{}
if err := db.Table("tab_promote_apply").Where("source_db = ? AND promote_id = ? AND status = 1 AND game_id IS NOT NULL", scope.Root.SourceDB, scope.Root.ID).Distinct("game_id").Order("game_id").Pluck("game_id", &ids).Error; err != nil {
return nil, business.ErrStorage
}
result := []*business.ChannelGame{}
if len(ids) == 0 {
return result, nil
}
for _, table := range []string{"tab_game", "tab_sw_report_day", "tab_sw_report_day_key"} {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrChannelReportsUnavailable
}
}
mode := 0
if scope.IDCardDedup {
mode = 1
}
query := func(table string, dedup int) *gorm.DB {
return db.Table(table+" r").Where("r.source_db = ? AND r.game_id > 0 AND r.dedup_mode = ?", scope.Root.SourceDB, dedup).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.id=r.promote_id AND p.source_db=r.source_db"))
}
// 累计起点按当前人数口径的日汇总最早日期决定,与原画像查询一致。
var minimum struct{ FirstDate *string }
if err := query("tab_sw_report_day", mode).Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&minimum).Error; err != nil {
return nil, business.ErrStorage
}
begin := scope.EndDate
if minimum.FirstDate != nil && *minimum.FirstDate != "" {
begin = *minimum.FirstDate
}
byID := map[uint64]*business.ChannelGame{}
for _, id := range ids {
item := &business.ChannelGame{ID: id, PayAmount: "0.00"}
byID[id] = item
result = append(result, item)
}
for start := 0; start < len(ids); start += 300 {
batch := ids[start:min(start+300, len(ids))]
var names []struct {
ID uint64
GameName string
}
if err := db.Table("tab_game").Select("id,game_name").Where("source_db = ? AND id IN ?", scope.Root.SourceDB, batch).Scan(&names).Error; err != nil {
return nil, business.ErrStorage
}
for _, n := range names {
byID[n.ID].Name = n.GameName
}
var counts []struct {
GameID uint64
Count int64
}
if err := query("tab_sw_report_day_key", mode).Select("r.game_id,COUNT(DISTINCT r.identity_key) AS count").Where("r.metric = 1 AND r.stat_date >= ? AND r.stat_date <= ? AND r.game_id IN ?", begin, scope.EndDate, batch).Group("r.game_id").Scan(&counts).Error; err != nil {
return nil, business.ErrStorage
}
for _, c := range counts {
byID[c.GameID].RegisterCount = c.Count
}
var amounts []struct {
GameID uint64
Amount string
}
if err := query("tab_sw_report_day", 0).Select("r.game_id,COALESCE(SUM(r.total_pay),0) AS amount").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.game_id IN ?", begin, scope.EndDate, batch).Group("r.game_id").Scan(&amounts).Error; err != nil {
return nil, business.ErrStorage
}
for _, a := range amounts {
byID[a.GameID].PayAmount = a.Amount
}
}
return result, nil
}
+165
View File
@@ -0,0 +1,165 @@
package business
import (
"context"
"math/big"
"testing"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func prepareChannels(t *testing.T) (*gorm.DB, business.ChannelRepo) {
t.Helper()
db := openTestDB(t)
if err := db.AutoMigrate(&accountPO{}); err != nil {
t.Fatal(err)
}
for _, sql := range []string{
"INSERT INTO sys_users(id,username,level,parent_id,id_path) VALUES(1,'root',0,0,'0'),(7,'parent',1,0,'0'),(8,'branch',2,7,'0,7'),(9,'leaf',3,8,'0,7,8'),(10,'sibling',3,7,'0,7'),(99,'outsider',1,0,'0')",
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'lx-business','0'),(8,'hw',14,'hw-business','0'),(10,'lx',16,'sibling-business','0'),(99,'yq',14,'other-business','0'),(9,'yq',14,'pending','3')",
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,nickname TEXT,parent_name TEXT,real_name TEXT,promote_level INTEGER,register_type INTEGER DEFAULT 0,status INTEGER DEFAULT 1,lock_status INTEGER DEFAULT 0,parent_id INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER DEFAULT 1000,PRIMARY KEY(source_db,id))",
"INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(100,'lx','root-channel',1,0,0,14),(101,'lx','child-channel',2,100,100,14),(102,'lx','grandchild',3,101,100,14),(103,'lx','missing-top',3,101,0,14),(104,'lx','other-branch',2,999,999,16),(100,'hw','same-id',1,0,0,14),(100,'yq','outside',1,0,0,14)",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,1,1),('lx',100,2,1),('lx',100,3,0),('lx',101,3,1),('hw',100,99,1)",
"CREATE TABLE tab_user(source_db TEXT,id INTEGER,promote_id INTEGER,puid INTEGER,idcard TEXT,PRIMARY KEY(source_db,id))",
"INSERT INTO tab_user VALUES('lx',1,100,0,' abcX '),('lx',2,101,NULL,'abcx'),('lx',3,102,0,''),('lx',4,101,2,'subaccount'),('lx',5,103,0,'thirdX'),('hw',1,100,0,'different')",
"CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
"INSERT INTO tab_spend VALUES('lx',100,1,10.10),('lx',101,1,20.20),('lx',102,1,30.30),('lx',103,1,40.40),('hw',100,1,99.90),('lx',100,0,999)",
"CREATE TABLE tab_game(source_db TEXT,id INTEGER,game_name TEXT)",
"INSERT INTO tab_game VALUES('lx',1,'游戏甲'),('lx',2,'游戏乙'),('hw',1,'同号其他游戏')",
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-10-01',1.10),('lx',100,1,0,'2026-11-01',10.10),('lx',101,1,0,'2026-11-02',20.20),('lx',102,2,0,'2026-11-02',30.30),('lx',103,1,0,'2026-11-02',999),('lx',100,1,0,'2026-11-04',999),('lx',100,1,1,'2026-11-01',9999),('hw',100,1,0,'2026-11-01',999)",
"CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
"INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-01',1,'A'),('lx',101,1,1,'2026-11-02',1,'A'),('lx',102,2,1,'2026-11-02',1,'B'),('lx',102,2,1,'2026-11-02',2,'C'),('lx',103,1,1,'2026-11-02',1,'N'),('lx',100,1,1,'2026-11-04',1,'F'),('hw',100,1,1,'2026-11-01',1,'Z'),('lx',100,1,0,'2026-10-01',1,'old'),('lx',100,1,0,'2026-11-01',1,'u1'),('lx',101,1,0,'2026-11-02',1,'u2')",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
return db, NewChannelRepo(testData{db})
}
func channelTestScope() business.UserScope {
return business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"}
}
func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) {
db, repo := prepareChannels(t)
ctx := context.Background()
scope := channelTestScope()
page, err := repo.List(ctx, scope, business.ChannelFilter{})
if err != nil || page.Total != 5 {
t.Fatalf("本人及下级绑定范围错误: %+v %v", page, err)
}
user := uint(9)
page, err = repo.List(ctx, scope, business.ChannelFilter{BusierUserID: &user})
if err != nil || page.Total != 4 {
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)" {
t.Fatalf("归属展示错误: %+v", row)
}
}
for _, keyword := range []string{"LEAF", "lx-business"} {
page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: keyword})
if err != nil || page.Total != 4 {
t.Fatal("商务账号或 SW 用户名匹配失败")
}
}
page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: "%"})
if err != nil || page.Total != 0 {
t.Fatal("商务关键词被当成 SQL 通配符")
}
page, err = repo.List(ctx, business.UserScope{All: true}, business.ChannelFilter{})
if err != nil || page.Total != 7 {
t.Fatal("超管默认不限绑定")
}
page, err = repo.List(ctx, business.UserScope{All: true}, business.ChannelFilter{BusierAccount: "leaf"})
if err != nil || page.Total != 4 {
t.Fatal("超管输入商务账号后没有收窄")
}
if _, err := repo.Find(ctx, scope, business.ChannelKey{SourceDB: "yq", ID: 100}); err != business.ErrChannelScope {
t.Fatal("同号其他平台未拒绝")
}
if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
t.Fatal(err)
}
page, err = repo.List(ctx, scope, business.ChannelFilter{})
if err != nil || page.Available {
t.Fatal("缺渠道表未标记不可用")
}
page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: "not-found"})
if err != nil || !page.Available || page.Total != 0 {
t.Fatal("没有绑定是确定空集")
}
}
func TestChannelMetricStorageSeparatesSourcesAndMissingTables(t *testing.T) {
db, repo := prepareChannels(t)
ctx := context.Background()
keys := []business.ChannelKey{{SourceDB: "lx", ID: 100}, {SourceDB: "lx", ID: 101}, {SourceDB: "lx", ID: 102}}
metrics, err := repo.Metrics(ctx, keys[:1], keys, true)
if err != nil || metrics.Games[keys[0]] != 2 || len(metrics.PlayerCards) != 3 || metrics.Pay[keys[0]] != "10.1" {
t.Fatalf("原始指标查询范围错误: %+v %v", metrics, err)
}
var card string
for _, player := range metrics.PlayerCards {
if player.UserID == 1 {
card = player.IDCard
}
}
if card != " abcX " {
t.Fatal("idcard 存储字段未映射,导致身份证去重人数错误")
}
if err := db.Exec("DROP TABLE tab_user").Error; err != nil {
t.Fatal(err)
}
metrics, err = repo.Metrics(ctx, keys[:1], keys, false)
if err != nil || metrics.PlayersAvailable || !metrics.GamesAvailable || !metrics.PayAvailable {
t.Fatal("一个指标缺表不应掩盖其余指标")
}
}
func TestChannelGameDistributionUsesReportsAndCurrentDedupStart(t *testing.T) {
db, repo := prepareChannels(t)
ctx := context.Background()
root, err := repo.Find(ctx, channelTestScope(), business.ChannelKey{SourceDB: "lx", ID: 100})
if err != nil {
t.Fatal(err)
}
scope := business.ChannelGameScope{Root: root, IncludeSub: true, IDCardDedup: true, EndDate: "2026-11-03"}
money := func(raw string) string {
n, ok := new(big.Rat).SetString(raw)
if !ok {
return "invalid"
}
return n.FloatString(2)
}
games, err := repo.Games(ctx, scope)
if err != nil || len(games) != 2 || games[0].ID != 1 || games[0].Name != "游戏甲" || games[0].RegisterCount != 1 || money(games[0].PayAmount) != "30.30" || games[1].RegisterCount != 1 || money(games[1].PayAmount) != "30.30" {
t.Fatalf("报表日期、去重口径、直接推广范围或渠道树错误: %+v %v", games, err)
}
scope.IDCardDedup = false
games, err = repo.Games(ctx, scope)
if err != nil || games[0].RegisterCount != 3 || money(games[0].PayAmount) != "31.40" {
t.Fatal("不去重口径没有使用自己的累计起点")
}
scope.IDCardDedup = true
scope.IncludeSub = false
games, err = repo.Games(ctx, scope)
if err != nil || money(games[0].PayAmount) != "10.10" || money(games[1].PayAmount) != "0.00" {
t.Fatal("不包含下级仍汇总了子渠道")
}
if err := db.Exec("DELETE FROM tab_sw_report_day WHERE dedup_mode=1").Error; err != nil {
t.Fatal(err)
}
games, err = repo.Games(ctx, scope)
if err != nil || games[0].RegisterCount != 0 || money(games[0].PayAmount) != "0.00" {
t.Fatal("无当前口径日汇总时必须沿用旧接口的当天起点")
}
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Games(ctx, scope); err != business.ErrChannelReportsUnavailable {
t.Fatal("缺报表表不能回退原始流水冒充同口径")
}
}
+130
View File
@@ -0,0 +1,130 @@
package business
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
)
type platformChannelPO struct {
ID uint64 `gorm:"primaryKey"`
Account, Password string
Status int
BusierID uint64
CreateTime int64
RegisterType, PromoteLevel int
ParentID, TopPromoteID uint64
GameIDs2 string `gorm:"column:game_ids2"`
}
func (platformChannelPO) TableName() string { return "tab_promote" }
type channelSettlementPO struct {
ID uint64 `gorm:"primaryKey"`
PromoteID uint64
PromoteAccount string
TimeType, DayPeriod int
CreateTime, UpdateTime, NextCountTime int64
}
func (channelSettlementPO) TableName() string { return "tab_promote_settlement_time" }
func newPlatformChannel(input *business.ChannelWrite) platformChannelPO {
return platformChannelPO{Account: input.Account, Password: input.Password, Status: input.Status, BusierID: input.BusierID, CreateTime: input.CreatedUnix, RegisterType: input.RegisterType, PromoteLevel: input.Level, ParentID: input.ParentID, TopPromoteID: input.TopPromoteID, GameIDs2: input.GameIDs2}
}
func newChannelSettlement(input *business.ChannelWrite, promoteID uint64) channelSettlementPO {
return channelSettlementPO{PromoteID: promoteID, PromoteAccount: input.Account, TimeType: input.SettlementType, DayPeriod: input.SettlementDays, CreateTime: input.CreatedUnix, UpdateTime: input.CreatedUnix, NextCountTime: input.NextCountUnix}
}
type channelWriteRepo struct{ data PlatformProvider }
func NewChannelWriteRepo(data PlatformProvider) business.ChannelWriteRepo {
return &channelWriteRepo{data: data}
}
func (r *channelWriteRepo) Create(ctx context.Context, input *business.ChannelWrite) (uint64, error) {
db, err := r.data.Database(input.SourceDB)
if err != nil || db == nil {
return 0, business.ErrPlatformUnavailable
}
var id uint64
err = db.WithContext(ctx).Connection(func(conn *gorm.DB) error {
if conn.Dialector.Name() == "mysql" {
// 命名锁覆盖账号尚不存在时的竞争;固定借用连接,提交事务后才释放锁。
var lockName string
if err := conn.Raw("SELECT CONCAT('sw-channel:', MD5(CONCAT(DATABASE(), ':', LOWER(?))))", input.Account).Scan(&lockName).Error; err != nil {
return err
}
var locked int
if err := conn.Raw("SELECT GET_LOCK(?, 10)", lockName).Scan(&locked).Error; err != nil {
return err
}
if locked != 1 {
return business.ErrChannelBusy
}
defer func() {
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
conn.WithContext(cleanup).Exec("DO RELEASE_LOCK(?)", lockName)
}()
}
return conn.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&platformChannelPO{}).Where("account = ?", input.Account).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return business.ErrChannelExists
}
var owner struct {
ID uint64
PromoteIDs *string
}
if err := tx.Table("tab_promote_business").Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,promote_ids").Where("id = ?", input.BusierID).Take(&owner).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return business.ErrChannelPlatformOwner
}
return err
}
po := newPlatformChannel(input)
if err := tx.Create(&po).Error; err != nil {
var duplicate *mysql.MySQLError
if errors.Is(err, gorm.ErrDuplicatedKey) || (errors.As(err, &duplicate) && duplicate.Number == 1062) {
return business.ErrChannelExists
}
return err
}
settlement := newChannelSettlement(input, po.ID)
if err := tx.Create(&settlement).Error; err != nil {
return err
}
ids := strconv.FormatUint(po.ID, 10)
if owner.PromoteIDs != nil && *owner.PromoteIDs != "" {
ids = *owner.PromoteIDs + "," + ids
}
result := tx.Table("tab_promote_business").Where("id = ?", input.BusierID).Update("promote_ids", ids)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return business.ErrChannelPlatformOwner
}
id = po.ID
return nil
})
})
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed: tab_promote.account") {
return 0, business.ErrChannelExists
}
return 0, storageError(err)
}
return id, nil
}
@@ -0,0 +1,87 @@
package business
import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type channelDatabases struct{ platform *gorm.DB }
func (d channelDatabases) Database(name string) (*gorm.DB, error) {
if name == "lx" {
return d.platform, nil
}
return nil, errors.New("数据源未配置")
}
func TestChannelWriteIsAtomicInNamedPlatformDatabase(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "platform.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
t.Cleanup(func() { _ = pool.Close() })
if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil {
t.Fatal(err)
}
for _, sql := range []string{"CREATE UNIQUE INDEX channel_account ON tab_promote(account)", "CREATE TABLE tab_promote_business(id INTEGER PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(14,'5,6')"} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
repo := NewChannelWriteRepo(channelDatabases{db})
input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "中文渠道", Password: "encoded-password", BusierID: 14, RegisterType: 1}, Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800}
id, err := repo.Create(context.Background(), input)
if err != nil {
t.Fatal(err)
}
var channel platformChannelPO
if err := db.First(&channel, id).Error; err != nil {
t.Fatal(err)
}
if channel.Status != -3 || channel.PromoteLevel != 1 || channel.ParentID != 0 || channel.TopPromoteID != 0 || channel.Password != "encoded-password" || channel.CreateTime != 1000 || channel.RegisterType != 1 {
t.Fatalf("源平台渠道默认字段错误: %+v", channel)
}
var settlement channelSettlementPO
if err := db.Where("promote_id = ?", id).First(&settlement).Error; err != nil {
t.Fatal(err)
}
if settlement.TimeType != 0 || settlement.DayPeriod != 7 || settlement.NextCountTime != 605800 || settlement.PromoteAccount != "中文渠道" {
t.Fatalf("结算周期字段错误: %+v", settlement)
}
var related string
db.Table("tab_promote_business").Select("promote_ids").Where("id=14").Scan(&related)
if related != fmt.Sprintf("5,6,%d", id) {
t.Fatal("追加关系覆盖了已有渠道")
}
if _, err := repo.Create(context.Background(), input); err != business.ErrChannelExists {
t.Fatal("重复账号没有拒绝")
}
if err := db.Exec("DROP TABLE tab_promote_settlement_time").Error; err != nil {
t.Fatal(err)
}
input.Account = "rollback"
if _, err := repo.Create(context.Background(), input); err == nil {
t.Fatal("缺结算表应回滚")
}
var count int64
db.Model(&platformChannelPO{}).Count(&count)
if count != 1 {
t.Fatal("创建失败留下了渠道")
}
var after string
db.Table("tab_promote_business").Select("promote_ids").Where("id=14").Scan(&after)
if after != related {
t.Fatal("创建失败仍改动关联")
}
input.SourceDB = "hw"
if _, err := repo.Create(context.Background(), input); err != business.ErrPlatformUnavailable {
t.Fatal("未配置平台不应回退到其他数据源")
}
}
+2
View File
@@ -37,6 +37,8 @@ var ProviderSet = wire.NewSet(
databusiness.NewAlertRuleRepo,
databusiness.NewAlertRecordRepo,
databusiness.NewActivationRepo,
databusiness.NewChannelRepo,
databusiness.NewChannelWriteRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
@@ -13,12 +13,26 @@ import (
type Passwords struct{ AuthCode, MigrateAuthCode, TablePrefix string }
func NewPasswords() business.PlatformPasswordVerifier {
return loadPasswords()
}
func NewPasswordEncoder() business.PlatformPasswordEncoder {
return loadPasswords()
}
func loadPasswords() *Passwords {
prefix := os.Getenv("SW_SDK_TABLE_PREFIX")
if prefix == "" {
prefix = "sys_"
}
return &Passwords{AuthCode: os.Getenv("SW_SDK_AUTH_CODE"), MigrateAuthCode: os.Getenv("SW_SDK_MIGRATE_AUTH_CODE"), TablePrefix: prefix}
}
// Encode 保留源平台 cmfPassword 的存储格式,新增渠道不能改用 Kra 账号密码格式。
func (p *Passwords) Encode(password string) (string, error) {
if p.AuthCode == "" {
return "", business.ErrPlatformPasswordConfig
}
return "###" + md5Hex(md5Hex(p.AuthCode+password)), nil
}
func md5Hex(value string) string { sum := md5.Sum([]byte(value)); return hex.EncodeToString(sum[:]) }
func (p *Passwords) Verify(password, stored string) (bool, error) {
@@ -17,3 +17,14 @@ func TestLegacyPasswordFormats(t *testing.T) {
t.Fatal("缺少兼容盐时必须明确报错")
}
}
func TestNewChannelPasswordMatchesLegacyFixedVector(t *testing.T) {
passwords := &Passwords{AuthCode: "test-auth"}
hash, err := passwords.Encode("demo123")
if err != nil || hash != "###eef6d9b7160f32a869f3e03b7ea575a5" {
t.Fatal("新渠道密码与旧平台格式不兼容")
}
if _, err := (&Passwords{}).Encode("demo123"); err == nil {
t.Fatal("缺少盐不能写入不可登录的渠道密码")
}
}
+1
View File
@@ -24,6 +24,7 @@ import (
var ProviderSet = wire.NewSet(
gameplatform.NewPasswords,
gameplatform.NewPasswordEncoder,
gameplatform.NewPasswordSMS,
cache.New,
email.NewEmailRepo,
+1
View File
@@ -9,6 +9,7 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: "company", Sort: 2},
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3},
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1},
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4},
+2 -2
View File
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 10 || apis != 46 {
t.Fatalf("admin surface = %d menus/%d APIs, want 10/46", menus, apis)
if menus != 11 || apis != 50 {
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+4
View File
@@ -41,6 +41,10 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
"GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
"GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
"POST /sw/promote": {group: "渠道管理", description: "创建源平台渠道", audit: true},
"GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"},
"GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"},
"GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Channel: &handler.Channel{},
Activation: &handler.Activation{},
AlertRecord: &handler.AlertRecord{},
AlertRule: &handler.AlertRule{},
@@ -102,7 +103,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":229`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":233`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+61
View File
@@ -0,0 +1,61 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
"net/http"
)
type Channel struct{ service *business.ChannelService }
func NewChannel(service *business.ChannelService) *Channel { return &Channel{service: service} }
func (h *Channel) List(c *gin.Context) {
var req dto.ChannelQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "渠道查询参数不正确")
return
}
result, err := h.service.List(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, result)
}
func (h *Channel) Bindings(c *gin.Context) {
result, err := h.service.BindOptions(c.Request.Context(), c.Query("sourceDb"))
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, result)
}
func (h *Channel) Create(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
var req dto.ChannelCreateRequest
if c.ShouldBindJSON(&req) != nil {
Fail(c, "渠道参数不正确")
return
}
result, err := h.service.Create(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
Write(c, CodeSuccess, result, "添加成功,数据同步到列表可能有延迟")
}
func (h *Channel) Games(c *gin.Context) {
var req dto.ChannelGameQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "渠道查询参数不正确")
return
}
result, err := h.service.Games(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, result)
}
+1
View File
@@ -11,6 +11,7 @@ var ProviderSet = wire.NewSet(
NewAlertRule,
NewAlertRecord,
NewActivation,
NewChannel,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Channel *Channel
Activation *Activation
AlertRecord *AlertRecord
AlertRule *AlertRule
+14
View File
@@ -0,0 +1,14 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterChannel(private *gin.RouterGroup, h *handler.Channel) {
g := private.Group("/sw/promote")
g.GET("/list", h.List)
g.GET("/bindOptions", h.Bindings)
g.GET("/game-distribution", h.Games)
g.POST("", h.Create)
}
+1
View File
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() { RegisterChannel(private, r.handlers.Channel) },
func() {
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail)
+100
View File
@@ -0,0 +1,100 @@
package business
import (
"context"
"math"
"strconv"
"strings"
"unicode/utf16"
"kra/internal/biz/business"
"kra/internal/service/dto"
)
type ChannelUsecase interface {
List(context.Context, business.ChannelFilter, ...business.ListOption) (*business.ChannelPage, error)
BindOptions(context.Context, string) ([]*business.Binding, error)
Create(context.Context, *business.ChannelCreate) (*business.ChannelCreateResult, error)
Games(context.Context, business.ChannelKey) ([]*business.ChannelGame, error)
}
type ChannelService struct{ uc ChannelUsecase }
func NewChannelService(uc *business.ChannelUsecase) *ChannelService { return &ChannelService{uc: uc} }
func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto.ChannelPage, error) {
if req == nil {
return nil, business.ErrChannelInput
}
sources, err := normalizePlatforms(req.Sources)
if err != nil {
return nil, err
}
page, size := req.Page, req.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = 10
}
if size > 500 {
size = 500
}
if page > int(^uint(0)>>1)/size {
return nil, business.ErrChannelInput
}
filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus}
result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size))
if err != nil {
return nil, err
}
out := &dto.ChannelPage{Rows: []*dto.ChannelResponse{}, Total: result.Total, Available: result.Available}
for _, c := range result.Rows {
out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount})
}
return out, nil
}
func (s *ChannelService) BindOptions(ctx context.Context, source string) ([]*dto.ChannelBindOption, error) {
rows, err := s.uc.BindOptions(ctx, source)
if err != nil {
return nil, err
}
out := make([]*dto.ChannelBindOption, 0, len(rows))
for _, b := range rows {
out = append(out, &dto.ChannelBindOption{SourceDB: b.SourceDB, PlatformBusierID: b.BusierID, Account: b.Account, UserID: b.UserID})
}
return out, nil
}
func (s *ChannelService) Create(ctx context.Context, req *dto.ChannelCreateRequest) (*dto.ChannelCreateResult, error) {
if req == nil || len(utf16.Encode([]rune(req.SourceDB))) > 10 || len(utf16.Encode([]rune(req.Account))) > 30 {
return nil, business.ErrChannelInput
}
id, err := strconv.ParseUint(req.PlatformBusierID.String(), 10, 32)
if err != nil || id == 0 || id > math.MaxInt32 {
return nil, business.ErrChannelBinding
}
registerType := 0
if req.RegisterType != nil {
registerType = *req.RegisterType
}
if registerType > math.MaxInt32 || registerType < math.MinInt32 {
return nil, business.ErrChannelInput
}
result, err := s.uc.Create(ctx, &business.ChannelCreate{SourceDB: req.SourceDB, Account: req.Account, Password: req.Password, BusierID: id, RegisterType: registerType})
if err != nil {
return nil, err
}
return &dto.ChannelCreateResult{ID: result.ID, SourceDB: result.SourceDB}, nil
}
func (s *ChannelService) Games(ctx context.Context, req *dto.ChannelGameQuery) ([]*dto.ChannelGameResponse, error) {
if req == nil || req.PromoteID == 0 {
return nil, business.ErrChannelInput
}
games, err := s.uc.Games(ctx, business.ChannelKey{SourceDB: req.SourceDB, ID: req.PromoteID})
if err != nil {
return nil, err
}
out := make([]*dto.ChannelGameResponse, 0, len(games))
for _, game := range games {
out = append(out, &dto.ChannelGameResponse{GameID: game.ID, GameName: game.Name, RegisterCount: game.RegisterCount, PayAmount: game.PayAmount})
}
return out, nil
}
+68
View File
@@ -0,0 +1,68 @@
package dto
import "encoding/json"
type ChannelQuery struct {
Page int `form:"pageNum"`
PageSize int `form:"pageSize"`
Sources []string `form:"sourceDb"`
BusierUserID *uint `form:"busierUserId"`
BusierAccount string `form:"busierAccount"`
Keyword string `form:"keyword"`
Level *int `form:"promoteLevel"`
RegisterType *int `form:"registerType"`
Status *int `form:"status"`
LockStatus *int `form:"lockStatus"`
}
type ChannelResponse struct {
ID uint64 `json:"id"`
SourceDB string `json:"sourceDb"`
PlatformName string `json:"platformName"`
Account string `json:"account"`
Nickname string `json:"nickname"`
Level *int `json:"promoteLevel"`
ParentID uint64 `json:"parentId"`
ParentName string `json:"parentName"`
RealName string `json:"realName"`
RegisterType *int `json:"registerType"`
BusierID uint64 `json:"busierId"`
BusierUserID *uint `json:"busierUserId"`
BusierDisplayName string `json:"busierDisplayName"`
Status *int `json:"status"`
LockStatus *int `json:"lockStatus"`
GameCount *int64 `json:"gameCount"`
PlayerCount *int64 `json:"playerCount"`
TotalPayAmount *string `json:"totalPayAmount"`
}
type ChannelPage struct {
Rows []*ChannelResponse `json:"rows"`
Total int64 `json:"total"`
Available bool `json:"available"`
}
type ChannelBindOption struct {
SourceDB string `json:"sourceDb"`
PlatformBusierID uint64 `json:"platformBusierId"`
Account string `json:"platformBusierAccount"`
UserID uint `json:"userId"`
}
type ChannelCreateRequest struct {
SourceDB string `json:"sourceDb"`
PlatformBusierID json.Number `json:"platformBusierId"`
Account string `json:"account"`
Password string `json:"password"`
RegisterType *int `json:"registerType"`
}
type ChannelCreateResult struct {
ID uint64 `json:"id"`
SourceDB string `json:"sourceDb"`
}
type ChannelGameQuery struct {
SourceDB string `form:"sourceDb"`
PromoteID uint64 `form:"promoteId"`
}
type ChannelGameResponse struct {
GameID uint64 `json:"gameId"`
GameName string `json:"gameName"`
RegisterCount int64 `json:"registerCount"`
PayAmount string `json:"payAmount"`
}
+1
View File
@@ -20,6 +20,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAlertRuleService,
businessservice.NewAlertRecordService,
businessservice.NewActivationService,
businessservice.NewChannelService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,