fix: 完成商务迁移复核与接口优化并清理历史资料

This commit is contained in:
Yvan
2026-11-04 14:45:02 +08:00
parent 5a96be6a79
commit 297e80827d
176 changed files with 6803 additions and 6002 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, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase)
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, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
+14 -9
View File
@@ -32,9 +32,13 @@ func (a *Activation) EndAt() *time.Time {
return a.UnbindAt
}
type ActivationAccount struct {
SourceDB, Account string
}
type ActivationScope struct {
All bool
Accounts []string
Accounts []ActivationAccount
}
type ActivationFilter struct {
PlatformNames, Statuses []string
@@ -59,7 +63,7 @@ type ActivationSpendDetail struct {
}
type ActivationRepo interface {
OwnAccounts(context.Context, uint) ([]string, error)
OwnAccounts(context.Context, uint) ([]ActivationAccount, error)
List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error)
Find(context.Context, uint64) (*Activation, error)
Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error)
@@ -87,11 +91,12 @@ func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error)
return ActivationScope{}, err
}
scope := ActivationScope{}
seen := map[string]bool{}
seen := map[ActivationAccount]bool{}
for _, account := range accounts {
account = strings.TrimSpace(account)
key := strings.ToLower(account)
if key != "" && !seen[key] {
account.Account = strings.TrimSpace(account.Account)
account.SourceDB, err = NormalizePlatform(account.SourceDB)
key := ActivationAccount{SourceDB: account.SourceDB, Account: strings.ToLower(account.Account)}
if err == nil && key.Account != "" && !seen[key] {
scope.Accounts = append(scope.Accounts, account)
seen[key] = true
}
@@ -105,10 +110,10 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter,
return nil, err
}
if !scope.All && filter.BusierAccount != "" {
matched := []string{}
matched := []ActivationAccount{}
keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount))
for _, account := range scope.Accounts {
if strings.Contains(strings.ToLower(account), keyword) {
if strings.Contains(strings.ToLower(account.Account), keyword) {
matched = append(matched, account)
}
}
@@ -139,7 +144,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
if !scope.All {
found := false
for _, account := range scope.Accounts {
if strings.EqualFold(account, strings.TrimSpace(activation.BusierAccount)) {
if strings.EqualFold(account.SourceDB, strings.TrimSpace(activation.PlatformName)) && strings.EqualFold(account.Account, strings.TrimSpace(activation.BusierAccount)) {
found = true
break
}
+9 -4
View File
@@ -8,7 +8,7 @@ import (
type activationRepoFake struct {
ActivationRepo
accounts []string
accounts []ActivationAccount
requested uint
scope ActivationScope
filter ActivationFilter
@@ -18,7 +18,7 @@ type activationRepoFake struct {
listCalls int
}
func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]string, error) {
func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]ActivationAccount, error) {
r.requested = id
return r.accounts, nil
}
@@ -35,7 +35,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([
}
func activationFixture() (*ActivationUsecase, *activationRepoFake) {
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}}
r := &activationRepoFake{accounts: []string{" Own ", "own", "Other", ""}}
r := &activationRepoFake{accounts: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}}
return NewActivationUsecase(r, NewAccountUsecase(a)), r
}
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
@@ -43,7 +43,7 @@ func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil {
t.Fatal(err)
}
if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != "Own" || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" {
if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != (ActivationAccount{"lx", "Own"}) || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" {
t.Fatalf("本人账号或中文状态筛选错误: %+v", r)
}
page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"})
@@ -69,6 +69,11 @@ func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) {
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
t.Fatal("解绑截止错误")
}
r.record.PlatformName = "hw"
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
t.Fatal("同名商务不能读取另一平台的激活流水")
}
r.record.PlatformName = "lx"
r.record.BusierAccount = "not-owned"
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
t.Fatal("非本人绑定可读取流水")
+13 -5
View File
@@ -76,6 +76,14 @@ type AlertRecordRepo interface {
type AlertAction string
const (
AlertUnprocessed = 1
AlertProcessing = 2
AlertPendingAudit = 3
AlertAuditRejected = 4
AlertProcessed = 5
)
const (
AlertStart AlertAction = "start"
AlertSubmit AlertAction = "submit"
@@ -148,15 +156,15 @@ func alertTransition(status int, action AlertAction) (int, error) {
var message string
switch action {
case AlertStart:
from, to, message = 1, 2, "仅未处理状态的预警可开始处理"
from, to, message = AlertUnprocessed, AlertProcessing, "仅未处理状态的预警可开始处理"
case AlertSubmit:
from, to, message = 2, 3, "仅处理中状态的预警可提交审核"
from, to, message = AlertProcessing, AlertPendingAudit, "仅处理中状态的预警可提交审核"
case AlertApprove:
from, to, message = 3, 5, "仅待审核状态的预警可审核通过"
from, to, message = AlertPendingAudit, AlertProcessed, "仅待审核状态的预警可审核通过"
case AlertReject:
from, to, message = 3, 4, "仅待审核状态的预警可审核驳回"
from, to, message = AlertPendingAudit, AlertAuditRejected, "仅待审核状态的预警可审核驳回"
case AlertReprocess:
from, to, message = 4, 2, "仅审核驳回状态的预警可重新处理"
from, to, message = AlertAuditRejected, AlertProcessing, "仅审核驳回状态的预警可重新处理"
default:
return status, ErrAlertRecordInput
}
+270
View File
@@ -0,0 +1,270 @@
package business
import (
"context"
"fmt"
"log/slog"
"math/big"
"strconv"
"strings"
"time"
)
type AlertTriggerChannel struct {
ChannelKey
Account string
BusierID uint64
LastLoginUnix, CreatedUnix int64
}
type AlertTriggerInput struct {
Rules []*AlertRule
Accounts []*Account
Bindings []*Binding
Channels []*AlertTriggerChannel
}
type AlertComparison struct {
FirstUnix int64
Current, Previous string
}
type AlertTriggerRepo interface {
Input(context.Context) (*AlertTriggerInput, error)
Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error)
// 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。
StoreHit(context.Context, *AlertRecord, []int, func(*AlertRecord) bool) (bool, error)
}
type AlertTriggerResult struct{ Rules, Written, Skipped int }
type AlertTriggerUsecase struct{ repo AlertTriggerRepo }
func NewAlertTriggerUsecase(repo AlertTriggerRepo) *AlertTriggerUsecase {
return &AlertTriggerUsecase{repo: repo}
}
func (uc *AlertTriggerUsecase) Run(ctx context.Context) error {
_, err := uc.Detect(ctx, time.Now())
return err
}
func validTriggerRule(rule *AlertRule) bool {
if rule == nil || rule.CreatorID == 0 || strings.TrimSpace(rule.Type) == "" {
return false
}
if rule.Metric == 3 {
return rule.UnloginDays != nil && *rule.UnloginDays > 0
}
if rule.Metric != 1 && rule.Metric != 2 {
return false
}
if rule.StatPeriodDays == nil || *rule.StatPeriodDays <= 0 || rule.MomDirection == nil || (*rule.MomDirection != 1 && *rule.MomDirection != 2) || rule.MomThreshold == nil {
return false
}
threshold, ok := new(big.Rat).SetString(*rule.MomThreshold)
// 历史规则检测允许零阈值,保持与旧任务一致;新增规则另有边界校验。
return ok && threshold.Sign() >= 0
}
func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]*Account) {
accounts := map[uint]*Account{}
for _, account := range input.Accounts {
if account != nil {
accounts[account.ID] = account
}
}
bindings := map[PlatformKey]uint{}
conflicts := map[PlatformKey]bool{}
for _, binding := range input.Bindings {
if binding == nil || binding.Status != "0" || binding.UserID == 0 || binding.BusierID == 0 {
continue
}
source, err := NormalizePlatform(binding.SourceDB)
if err != nil {
continue
}
key := PlatformKey{SourceDB: source, BusierID: binding.BusierID}
if existing, ok := bindings[key]; ok && existing != binding.UserID {
conflicts[key] = true
}
bindings[key] = binding.UserID
}
owners := map[ChannelKey]*Account{}
for _, channel := range input.Channels {
if channel == nil || channel.ID == 0 {
continue
}
source, err := NormalizePlatform(channel.SourceDB)
if err != nil {
continue
}
channel.SourceDB = source
key := PlatformKey{SourceDB: source, BusierID: channel.BusierID}
account := accounts[bindings[key]]
if conflicts[key] || account == nil || account.Level != 3 {
continue
}
path := strings.Split(account.IDPath, ",")
if len(path) < 2 {
continue
}
id, err := strconv.ParseUint(path[1], 10, 64)
owner := accounts[uint(id)]
if err != nil || owner == nil || owner.Level != 1 {
continue
}
owners[channel.ChannelKey] = owner
}
return owners, accounts
}
// 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。
func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*AlertTriggerResult, error) {
result := &AlertTriggerResult{}
input, err := uc.repo.Input(ctx)
if err != nil {
return result, err
}
if input == nil {
return result, ErrStorage
}
zone := time.FixedZone("Asia/Shanghai", 8*3600)
now = now.In(zone)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, zone)
owners, accounts := triggerOwners(input)
type snapshotKey struct {
creator uint
metric, days int
}
snapshots := map[snapshotKey]map[ChannelKey]AlertComparison{}
var failures []string
for _, rule := range input.Rules {
if err := ctx.Err(); err != nil {
return result, err
}
if !validTriggerRule(rule) {
result.Skipped++
continue
}
creator := accounts[rule.CreatorID]
if creator == nil || (creator.AuthorityID != 888 && creator.Level != 1) {
result.Skipped++
continue
}
channels := []*AlertTriggerChannel{}
keys := []ChannelKey{}
for _, channel := range input.Channels {
if channel == nil {
continue
}
owner := owners[channel.ChannelKey]
if owner != nil && (creator.AuthorityID == 888 || owner.ID == creator.ID) {
channels = append(channels, channel)
keys = append(keys, channel.ChannelKey)
}
}
if len(channels) == 0 {
continue
}
result.Rules++
var snapshot map[ChannelKey]AlertComparison
if rule.Metric != 3 {
key := snapshotKey{creator.ID, rule.Metric, *rule.StatPeriodDays}
var exists bool
snapshot, exists = snapshots[key]
if !exists {
snapshot, err = uc.repo.Comparison(ctx, rule.Metric, keys, today, key.days)
if err != nil {
failures = append(failures, fmt.Sprintf("规则 %d 读取指标失败: %v", rule.ID, err))
continue
}
snapshots[key] = snapshot
}
}
for _, channel := range channels {
if err := ctx.Err(); err != nil {
return result, err
}
content, hit, err := triggerContent(rule, channel, snapshot[channel.ChannelKey], today)
if err != nil {
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d: %v", rule.ID, channel.SourceDB, channel.ID, err))
continue
}
if !hit {
continue
}
owner := owners[channel.ChannelKey]
record := &AlertRecord{RuleID: rule.ID, RuleName: rule.Name, RuleType: strings.TrimSpace(rule.Type), MonitorMetric: &rule.Metric,
PlatformCode: channel.SourceDB, PlatformName: PlatformName(channel.SourceDB), TargetPromoteID: channel.ID, TargetPromoteName: channel.Account,
BusinessID: owner.ID, BusinessName: owner.Username, AlertContent: content, Status: AlertUnprocessed, CreatedAt: now, UpdatedAt: now}
written, err := uc.repo.StoreHit(ctx, record, []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}, func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed })
if err != nil {
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", rule.ID, channel.SourceDB, channel.ID, err))
continue
}
if written {
result.Written++
}
}
}
if len(failures) > 0 {
for _, failure := range failures {
slog.ErrorContext(ctx, "预警检测失败", "detail", failure)
}
return result, fmt.Errorf("预警规则检测存在 %d 项失败:%s", len(failures), strings.Join(failures[:min(5, len(failures))], ";"))
}
return result, nil
}
func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertComparison, today time.Time) (string, bool, error) {
if rule.Metric == 3 {
stamp := channel.LastLoginUnix
if stamp <= 0 {
stamp = channel.CreatedUnix
}
if stamp <= 0 {
return "", false, nil
}
day := time.Unix(stamp, 0).In(today.Location())
day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, today.Location())
days := int(today.Sub(day) / (24 * time.Hour))
if days < *rule.UnloginDays {
return "", false, nil
}
return fmt.Sprintf("连续%d天未登录", days), true, nil
}
days := *rule.StatPeriodDays
if sample.FirstUnix <= 0 || time.Unix(sample.FirstUnix, 0).In(today.Location()).Format("2006-01-02") > today.AddDate(0, 0, -2*days).Format("2006-01-02") {
return "", false, nil
}
previous, err := portraitNumber(sample.Previous)
if err != nil {
return "", false, err
}
if previous.Sign() <= 0 {
return "", false, nil
}
current, err := portraitNumber(sample.Current)
if err != nil {
return "", false, err
}
rate := new(big.Rat).Quo(new(big.Rat).Sub(current, previous), previous)
// 先对比值保留四位再转百分比,与旧 BigDecimal HALF_UP 的阈值边界一致。
rate, _ = new(big.Rat).SetString(portraitDecimal(rate, 4))
rate.Mul(rate, big.NewRat(100, 1))
threshold, _ := new(big.Rat).SetString(*rule.MomThreshold)
if (*rule.MomDirection == 1 && rate.Cmp(threshold) < 0) || (*rule.MomDirection == 2 && rate.Cmp(new(big.Rat).Neg(threshold)) > 0) {
return "", false, nil
}
direction := "上升"
if rate.Sign() < 0 {
direction = "下降"
}
percent := portraitDecimal(new(big.Rat).Abs(rate), 2)
if rule.Metric == 1 {
return fmt.Sprintf("截至昨日%d个完整自然日注册%s人,较此前%d个完整自然日(%s人)环比%s%s%%", days, portraitDecimal(current, 0), days, portraitDecimal(previous, 0), direction, percent), true, nil
}
yuan := big.NewRat(10000, 1)
return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s万,较此前%d个完整自然日(¥%s万)环比%s%s%%", days, portraitDecimal(new(big.Rat).Quo(current, yuan), 2), days, portraitDecimal(new(big.Rat).Quo(previous, yuan), 2), direction, percent), true, nil
}
+112
View File
@@ -0,0 +1,112 @@
package business
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type triggerRepoFake struct {
input *AlertTriggerInput
metrics map[ChannelKey]AlertComparison
hits []*AlertRecord
metricCalls int
current *AlertRecord
failure error
}
func (r *triggerRepoFake) Input(context.Context) (*AlertTriggerInput, error) { return r.input, nil }
func (r *triggerRepoFake) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) {
r.metricCalls++
return r.metrics, r.failure
}
func (r *triggerRepoFake) StoreHit(_ context.Context, hit *AlertRecord, _ []int, decide func(*AlertRecord) bool) (bool, error) {
if !decide(r.current) {
return false, nil
}
r.hits = append(r.hits, hit)
return true, r.failure
}
func triggerFixture() (*triggerRepoFake, time.Time) {
now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
r := &triggerRepoFake{input: &AlertTriggerInput{Accounts: []*Account{
{ID: 1, AuthorityID: 888}, {ID: 10, Level: 1, Username: "一级甲"}, {ID: 11, Level: 1, Username: "一级乙"},
{ID: 20, Level: 2, IDPath: "0,10"}, {ID: 30, Level: 3, IDPath: "0,10,20"}, {ID: 31, Level: 3, IDPath: "0,11,21"},
}, Bindings: []*Binding{
{UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 7}, Status: "0"},
{UserID: 31, PlatformKey: PlatformKey{SourceDB: "hw", BusierID: 7}, Status: "0"},
{UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"},
{UserID: 31, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"},
}, Channels: []*AlertTriggerChannel{
{ChannelKey: ChannelKey{"lx", 10}, BusierID: 7, Account: "甲渠道", CreatedUnix: now.AddDate(0, 0, -3).Unix()},
{ChannelKey: ChannelKey{"hw", 10}, BusierID: 7, Account: "乙渠道", LastLoginUnix: now.AddDate(0, 0, -10).Unix()},
{ChannelKey: ChannelKey{"lx", 11}, BusierID: 8, Account: "冲突渠道", CreatedUnix: now.AddDate(0, 0, -10).Unix()},
}}}
return r, now
}
func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T) {
r, now := triggerFixture()
days := 3
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}, {ID: 2, CreatorID: 20, Type: "越级", Metric: 3, UnloginDays: &days}}
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 {
t.Fatalf("检测结果 %+v %v", result, err)
}
hit := r.hits[0]
if hit.BusinessID != 10 || hit.PlatformCode != "lx" || hit.TargetPromoteID != 10 || hit.AlertContent != "连续3天未登录" {
t.Fatalf("归属或自然日错误: %+v", hit)
}
r.hits = nil
r.current = &AlertRecord{Status: 2}
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
if err != nil || result.Written != 0 || len(r.hits) != 0 {
t.Fatalf("在途记录不应刷新: %+v %v", result, err)
}
}
func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
r, now := triggerFixture()
days, direction := 2, 1
threshold := "10"
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "充值", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold},
{ID: 2, CreatorID: 10, Type: "另一个充值规则", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}}
r.metrics = map[ChannelKey]AlertComparison{{"lx", 10}: {FirstUnix: now.AddDate(0, 0, -4).Unix(), Current: "110000", Previous: "100000"}}
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
if err != nil || result.Written != 2 || r.metricCalls != 1 {
t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err)
}
if r.hits[0].AlertContent != "截至昨日2个完整自然日充值¥11.00万,较此前2个完整自然日(¥10.00万)环比上升10.00%" {
t.Fatal(r.hits[0].AlertContent)
}
r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"}
r.hits = nil
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
if err != nil || result.Written != 0 {
t.Fatalf("不足双周期不应触发: %+v %v", result, err)
}
r.failure = errors.New("指标不可用")
_, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
if err == nil || !strings.Contains(err.Error(), "2 项失败") {
t.Fatalf("读取失败不能报告成功: %v", err)
}
}
func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) {
days, direction := 1, 1
threshold := "10"
rule := &AlertRule{Metric: 1, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}
_, now := triggerFixture()
for _, test := range []struct {
current, previous string
hit bool
}{{"109.995", "100", true}, {"109.994", "100", false}, {"1", "0", false}, {"1", "-1", false}} {
_, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{FirstUnix: now.AddDate(0, 0, -2).Unix(), Current: test.current, Previous: test.previous}, now)
if err != nil || hit != test.hit {
t.Fatalf("%+v: hit=%v err=%v", test, hit, err)
}
}
}
+5 -5
View File
@@ -200,12 +200,12 @@ func buildRankingItems(kind RankingKind, metric string, current, previous map[An
if err != nil {
return nil, err
}
if metric == "rate" || valid {
// 四个榜单统一使用相对上期的增长率。上期为零或无有效
// 活跃基数时不能计算环比,保留 nil 供页面显示无可比数据。
if valid && prior.Sign() > 0 {
change := new(big.Rat).Sub(value, prior)
if metric != "rate" {
change.Mul(change, big.NewRat(100, 1))
change.Quo(change, prior)
}
change.Mul(change, big.NewRat(100, 1))
change.Quo(change, prior)
item.MoM = stringPointer(portraitDecimal(change, 2))
rounded, _ := portraitNumber(*item.MoM)
status := 0
+35 -1
View File
@@ -39,7 +39,7 @@ func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) {
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
}
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
t.Fatal("等长上期或百分点差错误")
t.Fatal("等长上期或付费率环比错误")
}
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
@@ -74,3 +74,37 @@ func TestRankingTopTwentyAndStatusThreshold(t *testing.T) {
t.Fatal("持平环比错误")
}
}
func TestRankingPayRateRelativeGrowth(t *testing.T) {
for _, tc := range []struct {
name string
current PortraitCounts
previous *RankingMetrics
want string
status int
}{
{"increase", PortraitCounts{Pay: 12, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "20.00", 1},
{"decrease", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 20, Active: 100}}, "-50.00", -1},
{"unchanged", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "0.00", 0},
{"no current payers", PortraitCounts{Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "-100.00", -1},
{"zero prior rate", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Active: 100}}, "", 0},
{"no prior active players", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 1}}, "", 0},
{"no prior row", PortraitCounts{Pay: 10, Active: 100}, nil, "", 0},
} {
t.Run(tc.name, func(t *testing.T) {
key := AnalyticsKey{SourceDB: "lx", ID: 1}
rows, err := buildRankingItems(ChannelRanking, "rate", map[AnalyticsKey]*RankingMetrics{key: {Counts: tc.current}}, map[AnalyticsKey]*RankingMetrics{key: tc.previous})
if err != nil || len(rows) != 1 {
t.Fatalf("ranking: %v, %v", rows, err)
}
row := rows[0]
if tc.want == "" {
if row.MoM != nil || row.MoMStatus != nil {
t.Fatal("missing or zero baseline must not produce a growth percentage")
}
} else if row.MoM == nil || *row.MoM != tc.want || row.MoMStatus == nil || *row.MoMStatus != tc.status {
t.Fatalf("expected %s%% with status %d, got %+v", tc.want, tc.status, row)
}
})
}
}
+57 -20
View File
@@ -1,6 +1,7 @@
package business
import (
"container/heap"
"context"
"math/big"
"sort"
@@ -57,6 +58,7 @@ type WorkbenchRankData struct {
type WorkbenchRepo interface {
Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
FillRankNames(context.Context, []*WorkbenchRankRow) error
}
type WorkbenchUsecase struct {
repo WorkbenchRepo
@@ -185,9 +187,8 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period
if !data.Available {
return nil, ErrWorkbenchUnavailable
}
rows := make([]*WorkbenchRankRow, 0, len(data.Rows))
values := map[*WorkbenchRankRow]*big.Rat{}
for _, row := range data.Rows {
top := &workbenchRankHeap{ascending: kind == WorkbenchPayLow}
for index, row := range data.Rows {
if kind == WorkbenchUnlogin {
row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
}
@@ -201,32 +202,68 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period
if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
continue
}
values[row] = n
rows = append(rows, row)
candidate := workbenchRankCandidate{row: row, value: n, index: index}
if top.Len() < 10 {
heap.Push(top, candidate)
} else if top.better(candidate, top.rows[0]) {
top.rows[0] = candidate
heap.Fix(top, 0)
}
}
sort.Slice(top.rows, func(i, j int) bool { return top.better(top.rows[i], top.rows[j]) })
rows := make([]*WorkbenchRankRow, len(top.rows))
for i, candidate := range top.rows {
rows[i] = candidate.row
}
if err := uc.repo.FillRankNames(ctx, rows); err != nil {
return nil, err
}
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if c := values[a].Cmp(values[b]); c != 0 {
if kind == WorkbenchPayLow {
return c < 0
}
return c > 0
}
if a.ID != b.ID {
return a.ID < b.ID
}
return a.SourceDB < b.SourceDB
})
rows = rows[:min(10, len(rows))]
for i, row := range rows {
row.Rank = i + 1
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
if kind != WorkbenchUnlogin {
row.Value = portraitDecimal(values[row], 2)
row.Value = portraitDecimal(top.rows[i].value, 2)
}
}
return rows, nil
}
type workbenchRankCandidate struct {
row *WorkbenchRankRow
value *big.Rat
index int
}
// 堆顶保留最差的入选行,只维护 TOP10;比较规则与完整稳定排序一致。
type workbenchRankHeap struct {
rows []workbenchRankCandidate
ascending bool
}
func (h workbenchRankHeap) better(a, b workbenchRankCandidate) bool {
if c := a.value.Cmp(b.value); c != 0 {
if h.ascending {
return c < 0
}
return c > 0
}
if a.row.ID != b.row.ID {
return a.row.ID < b.row.ID
}
if a.row.SourceDB != b.row.SourceDB {
return a.row.SourceDB < b.row.SourceDB
}
return a.index < b.index
}
func (h workbenchRankHeap) Len() int { return len(h.rows) }
func (h workbenchRankHeap) Less(i, j int) bool { return h.better(h.rows[j], h.rows[i]) }
func (h workbenchRankHeap) Swap(i, j int) { h.rows[i], h.rows[j] = h.rows[j], h.rows[i] }
func (h *workbenchRankHeap) Push(v any) { h.rows = append(h.rows, v.(workbenchRankCandidate)) }
func (h *workbenchRankHeap) Pop() any {
last := h.rows[len(h.rows)-1]
h.rows = h.rows[:len(h.rows)-1]
return last
}
func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 {
stamp := row.LastLoginUnix
if stamp == nil || *stamp <= 0 {
+51
View File
@@ -3,6 +3,8 @@ package business
import (
"context"
"fmt"
"math/big"
"sort"
"testing"
"time"
)
@@ -13,6 +15,52 @@ type workbenchRepoFake struct {
window WorkbenchWindow
filter AnalyticsFilter
scope AnalyticsScope
named []*WorkbenchRankRow
}
func TestWorkbenchTopTenMatchesFullStableSort(t *testing.T) {
for _, kind := range []WorkbenchRankKind{WorkbenchPayHigh, WorkbenchPayLow} {
uc, repo := workbenchFixture()
for i := 0; i < 3000; i++ {
repo.data.Rows = append(repo.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{ID: int64(i%11 + 1), SourceDB: []string{"lx", "hw", "yq"}[i%3]}, Name: fmt.Sprintf("candidate-%04d", i), Value: big.NewRat(int64(i%7-2), 100).FloatString(2)})
}
expected := make([]*WorkbenchRankRow, 0, len(repo.data.Rows))
for _, row := range repo.data.Rows {
n, _ := new(big.Rat).SetString(row.Value)
if kind == WorkbenchPayLow || n.Sign() > 0 {
expected = append(expected, row)
}
}
sort.SliceStable(expected, func(i, j int) bool {
a, b := expected[i], expected[j]
av, _ := new(big.Rat).SetString(a.Value)
bv, _ := new(big.Rat).SetString(b.Value)
if order := av.Cmp(bv); order != 0 {
if kind == WorkbenchPayLow {
return order < 0
}
return order > 0
}
if a.ID != b.ID {
return a.ID < b.ID
}
return a.SourceDB < b.SourceDB
})
actual, err := uc.Ranks(accountContext(7, 901), []string{"lx", "hw", "yq"}, "last30", kind)
if err != nil || len(actual) != 10 {
t.Fatalf("rank failed: %v", err)
}
for i, row := range actual {
if row.AnalyticsKey != expected[i].AnalyticsKey || row.Value != expected[i].Value || row.Name != expected[i].Name || row.Rank != i+1 {
t.Fatalf("%s rank %d differs from full stable sort: %+v vs %+v", kind, i, row, expected[i])
}
}
}
}
func (r *workbenchRepoFake) FillRankNames(_ context.Context, rows []*WorkbenchRankRow) error {
r.named = rows
return nil
}
func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) {
@@ -64,6 +112,9 @@ func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) {
if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
t.Fatal("榜单排序或 TOP10 截断错误")
}
if len(r.named) != 10 {
t.Fatal("仅应补充已入选 TOP10 的渠道名称")
}
rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
t.Fatal("最低榜应包含零值,默认近30天")
@@ -501,7 +501,12 @@ func IsIntegrationSecretKey(key string) bool {
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "private") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "token") {
return true
}
for _, item := range []string{"key_pem", "api_key", "mch_key", "client_key", "certificate", "cert", "cert_pem", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} {
// Providers accept inline PEM, file and path aliases of the same secret.
// Strip only these transport suffixes; ordinary content/path fields stay public.
for _, suffix := range []string{"_content", "_path", "_file", "_pem"} {
normalized = strings.TrimSuffix(normalized, suffix)
}
for _, item := range []string{"key", "api_key", "mch_key", "client_key", "certificate", "cert", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} {
if normalized == item || strings.HasSuffix(normalized, "_"+item) {
return true
}
@@ -0,0 +1,16 @@
package integration
import "testing"
func TestIntegrationCertificateAliases(t *testing.T) {
for _, key := range []string{"app_cert_content", "root_cert_path", "alipay_public_cert_content", "pkcs12_content", "p12_file", "cert_content", "key_content", "cert_path", "key_file", "client_key", "public_key_pem"} {
if !IsIntegrationSecretKey(key) {
t.Errorf("secret alias was not recognized: %s", key)
}
}
for _, key := range []string{"content", "path", "notify_url", "template_content", "content_type", "platform_serial_no", "public_key_id"} {
if IsIntegrationSecretKey(key) {
t.Errorf("public field was masked: %s", key)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package payment
import "github.com/go-kratos/kratos/v3/errors"
var ErrPaymentTestFailed = errors.BadRequest("PAYMENT_TEST_FAILED", "支付渠道测试失败,请检查配置和服务端日志")
+59
View File
@@ -0,0 +1,59 @@
package task
import (
"bytes"
"encoding/json"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var ErrTaskExecutionOptions = errors.BadRequest("TASK_EXECUTION_OPTIONS_INVALID", "执行参数不正确:超时须为 1–86400 秒,重试次数为 0–10,重试间隔为 0–3600 秒")
type ExecutionOptions struct {
Timeout time.Duration
MaxRetryTimes int
RetryInterval time.Duration
}
// MethodExecutionOptions 承接迁移 SQL 保留的 _execution 元数据;普通方法维持原默认行为。
func MethodExecutionOptions(params []byte) (ExecutionOptions, error) {
options := ExecutionOptions{Timeout: 5 * time.Minute, RetryInterval: time.Second}
params = bytes.TrimSpace(params)
if len(params) == 0 || params[0] != '{' {
return options, nil
}
var envelope struct {
Execution *struct {
TimeoutSeconds *int64 `json:"timeoutSeconds"`
MaxRetryTimes *int `json:"maxRetryTimes"`
RetryIntervalSeconds *int64 `json:"retryIntervalSeconds"`
} `json:"_execution"`
}
if err := json.Unmarshal(params, &envelope); err != nil {
return options, ErrTaskExecutionOptions
}
if envelope.Execution == nil {
return options, nil
}
value := envelope.Execution
if value.TimeoutSeconds != nil {
if *value.TimeoutSeconds < 1 || *value.TimeoutSeconds > 86400 {
return options, ErrTaskExecutionOptions
}
options.Timeout = time.Duration(*value.TimeoutSeconds) * time.Second
}
if value.MaxRetryTimes != nil {
if *value.MaxRetryTimes < 0 || *value.MaxRetryTimes > 10 {
return options, ErrTaskExecutionOptions
}
options.MaxRetryTimes = *value.MaxRetryTimes
}
if value.RetryIntervalSeconds != nil {
if *value.RetryIntervalSeconds < 0 || *value.RetryIntervalSeconds > 3600 {
return options, ErrTaskExecutionOptions
}
options.RetryInterval = time.Duration(*value.RetryIntervalSeconds) * time.Second
}
return options, nil
}
+24
View File
@@ -0,0 +1,24 @@
package task
import (
"testing"
"time"
)
func TestMethodExecutionOptionsPreservesImportedPolicy(t *testing.T) {
options, err := MethodExecutionOptions([]byte(`{"startDate":"2026-11-03","_execution":{"timeoutSeconds":1600,"maxRetryTimes":3,"retryIntervalSeconds":1}}`))
if err != nil || options.Timeout != 1600*time.Second || options.MaxRetryTimes != 3 || options.RetryInterval != time.Second {
t.Fatalf("迁移任务策略丢失: %+v %v", options, err)
}
for _, params := range []string{"", `{}`, `[]`, `"value"`} {
options, err := MethodExecutionOptions([]byte(params))
if err != nil || options.Timeout != 5*time.Minute || options.MaxRetryTimes != 0 {
t.Fatalf("普通方法默认行为变化: %q %+v %v", params, options, err)
}
}
for _, params := range []string{`{"_execution":{"timeoutSeconds":0}}`, `{"_execution":{"timeoutSeconds":86401}}`, `{"_execution":{"maxRetryTimes":11}}`, `{"_execution":{"retryIntervalSeconds":-1}}`, `{"_execution":"invalid"}`} {
if _, err := MethodExecutionOptions([]byte(params)); err == nil {
t.Fatalf("无效执行参数未拒绝: %s", params)
}
}
}
+3
View File
@@ -108,6 +108,9 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error {
if len(value.Params) > 0 && !json.Valid(value.Params) {
return errors.New("params 必须是合法 JSON")
}
if _, err := MethodExecutionOptions(value.Params); err != nil {
return err
}
case TaskExecutorHTTP:
parsed, parseErr := url.Parse(value.HTTPURL)
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {