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", "无权查看该渠道") ErrChannelCreateScope = errors.Forbidden("SW_CHANNEL_CREATE_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 Phone string BusinessPath []string SettlementDays *int Level, RegisterType, Status, LockStatus *int ParentID, TopPromoteID, BusierID uint64 BusierUserID *uint BindingAccount, BindingUserName string GameCount, PlayerCount *int64 TotalPayAmount *string CreatedUnix, LastLoginUnix *int64 QualifiedAt *int64 } 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) ParentDisplayName() string { if c.Level != nil && *c.Level == 1 { if len(c.BusinessPath) > 0 { return c.BusinessPath[len(c.BusinessPath)-1] } return c.BusinessDisplayName() } return c.ParentName } func ChannelLockStatusLabel(status *int) string { if status == nil || *status == 0 { return "正常" } if *status == -2 { return "解除合作" } return "锁定" } 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 { IncludeSub, IDCardDedup bool Effective *bool Sources []string BusierUserID *uint BusierAccount, Keyword string Phone, SortField string SettlementDays *int CreatedBegin, CreatedEnd *time.Time LoginBegin, LoginEnd *time.Time Ascending bool Level, RegisterType, Status, LockStatus *int } type ChannelStatusCounts struct { Normal, Locked, Terminated int64 } type ChannelPage struct { Rows []*Channel Total int64 Available bool StatusCounts *ChannelStatusCounts } 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 BeginDate string 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 effectiveness *EffectivenessUsecase now func() time.Time } func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder, effectiveness *EffectivenessUsecase) *ChannelUsecase { return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now, effectiveness: effectiveness} } 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 } // ChannelIdentity is shared by list metrics and storage-side metric ordering. func ChannelIdentity(raw string) string { return normalizeIDCard(raw) } func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, options ...ListOption) (*ChannelPage, error) { scope, err := uc.scope(ctx) if err != nil { return nil, err } if uc.effectiveness != nil { if err := uc.effectiveness.Ensure(ctx); err != nil { return nil, err } } pref, err := uc.preferences.Current(ctx) if err != nil { return nil, err } filter.IncludeSub, filter.IDCardDedup = pref.IncludeSubPromote, pref.IDCardDedup page, err := uc.repo.List(ctx, scope, filter, options...) if err != nil || len(page.Rows) == 0 { return page, 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 } actor, accountOwner, err := uc.accounts.actor(ctx) if err != nil { return nil, err } if actor.AuthorityID != 888 && accountOwner.Level != 3 { return nil, ErrChannelCreateScope } 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 } return normalizeChannelGames(games) } func normalizeChannelGames(games []*ChannelGame) ([]*ChannelGame, error) { 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 }