fix: 完成商务迁移复核与接口优化并清理历史资料
This commit is contained in:
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("非本人绑定可读取流水")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package payment
|
||||
|
||||
import "github.com/go-kratos/kratos/v3/errors"
|
||||
|
||||
var ErrPaymentTestFailed = errors.BadRequest("PAYMENT_TEST_FAILED", "支付渠道测试失败,请检查配置和服务端日志")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user