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 == "" {
+16
View File
@@ -0,0 +1,16 @@
package config
import "strings"
// SwaggerEnabled exposes API documentation only in explicitly selected local/test environments.
func SwaggerEnabled(admin *Admin) bool {
if admin == nil || admin.App == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(admin.App.Env)) {
case "development", "dev", "test", "local":
return true
default:
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
package config
import "testing"
func TestSwaggerRequiresExplicitDevelopmentEnvironment(t *testing.T) {
if SwaggerEnabled(nil) || SwaggerEnabled(&Admin{}) {
t.Fatal("missing environment exposed Swagger")
}
for _, env := range []string{"", "production", "prod", "staging", "qa", "uat", "unknown"} {
if SwaggerEnabled(&Admin{App: &App{Env: env}}) {
t.Errorf("Swagger enabled in %q", env)
}
}
for _, env := range []string{"development", "dev", "test", "local", " DEV "} {
if !SwaggerEnabled(&Admin{App: &App{Env: env}}) {
t.Errorf("Swagger disabled in %q", env)
}
}
}
+10 -6
View File
@@ -37,9 +37,13 @@ func NewActivationRepo(data Provider, databases PlatformProvider) business.Activ
return &activationRepo{data: data, databases: databases}
}
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) {
accounts := []string{}
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("platform_busier_account").Pluck("platform_busier_account", &accounts).Error
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]business.ActivationAccount, error) {
var rows []struct{ SourceDB, Account string }
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("source_db,platform_busier_account AS account").Scan(&rows).Error
accounts := make([]business.ActivationAccount, 0, len(rows))
for _, row := range rows {
accounts = append(accounts, business.ActivationAccount{SourceDB: row.SourceDB, Account: row.Account})
}
return accounts, storageError(err)
}
@@ -68,11 +72,11 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop
}
q := db.Model(&activationPO{}).Where("deleted_at IS NULL")
if !scope.All {
accounts := make([]string, 0, len(scope.Accounts))
accounts := make([][]any, 0, len(scope.Accounts))
for _, account := range scope.Accounts {
accounts = append(accounts, strings.ToLower(account))
accounts = append(accounts, []any{strings.ToLower(account.SourceDB), strings.ToLower(account.Account)})
}
q = q.Where("LOWER(busier_account) IN ?", accounts)
q = q.Where("(LOWER(platform_name),LOWER(busier_account)) IN ?", accounts)
}
if len(filter.PlatformNames) > 0 {
q = q.Where("platform_name IN ?", filter.PlatformNames)
+3 -3
View File
@@ -59,11 +59,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
db, _, repo := prepareActivations(t)
ctx := context.Background()
accounts, err := repo.OwnAccounts(ctx, 7)
if err != nil || len(accounts) != 1 || accounts[0] != "Own" {
if err != nil || len(accounts) != 1 || accounts[0] != (business.ActivationAccount{SourceDB: "lx", Account: "Own"}) {
t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err)
}
page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{})
if err != nil || page.Total != 2 || page.Rows[0].ID != 2 {
page, err := repo.List(ctx, business.ActivationScope{Accounts: []business.ActivationAccount{{SourceDB: "lx", Account: "OWN"}}}, business.ActivationFilter{})
if err != nil || page.Total != 1 || page.Rows[0].ID != 1 {
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
}
page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}})
+168
View File
@@ -0,0 +1,168 @@
package business
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
)
type alertTriggerRepo struct{ data Provider }
func NewAlertTriggerRepo(data Provider) business.AlertTriggerRepo {
return &alertTriggerRepo{data: data}
}
type alertTriggerLockPO struct {
SourceDB string `gorm:"column:source_db;primaryKey;size:50"`
PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"`
RuleType string `gorm:"primaryKey;size:50"`
}
func (alertTriggerLockPO) TableName() string { return "sys_business_alert_locks" }
func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInput, error) {
db := r.data.DB().WithContext(ctx)
out := &business.AlertTriggerInput{}
var rules []alertRulePO
if err := db.Where("enabled=1 AND is_deleted=0").Order("id").Find(&rules).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rules {
out.Rules = append(out.Rules, toBizAlertRule(row))
}
if len(rules) == 0 {
return out, nil
}
var accounts []accountPO
if err := db.Select("id,username,level,parent_id,id_path,authority_id").Find(&accounts).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range accounts {
out.Accounts = append(out.Accounts, toBizAccount(row))
}
var bindings []bindingPO
if err := db.Where("status=?", "0").Find(&bindings).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range bindings {
out.Bindings = append(out.Bindings, toBizBinding(row))
}
if len(bindings) == 0 {
return out, nil
}
available, err := businessTableAvailable(db, "tab_promote")
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrAlertChannelsUnavailable
}
var channels []struct {
ID, BusierID uint64
SourceDB string `gorm:"column:source_db"`
Account string
LastLoginTime, CreateTime *int64
}
bound := db.Table("sys_user_bind_platform b").Select("1").Where("b.status=? AND b.source_db=p.source_db AND b.platform_busier_id=p.busier_id", "0")
if err := db.Table("tab_promote p").Select("p.id,p.source_db,p.account,p.busier_id,p.last_login_time,p.create_time").Where("p.promote_level=1 AND p.status=1").Where("EXISTS (?)", bound).Order("p.source_db,p.id").Scan(&channels).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range channels {
channel := &business.AlertTriggerChannel{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Account: row.Account, BusierID: row.BusierID}
if row.LastLoginTime != nil {
channel.LastLoginUnix = *row.LastLoginTime
}
if row.CreateTime != nil {
channel.CreatedUnix = *row.CreateTime
}
out.Channels = append(out.Channels, channel)
}
return out, nil
}
func (r *alertTriggerRepo) Comparison(ctx context.Context, metric int, channels []business.ChannelKey, today time.Time, days int) (map[business.ChannelKey]business.AlertComparison, error) {
db := r.data.DB().WithContext(ctx)
table, stamp, amount, condition := "tab_user", "register_time", "1", "(puid=0 OR puid IS NULL)"
if metric == 2 {
table, stamp, amount, condition = "tab_spend", "pay_time", "pay_amount", "pay_status=1"
} else if metric != 1 {
return nil, business.ErrAlertRuleInput
}
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrReportBuildUnavailable
}
result := map[business.ChannelKey]business.AlertComparison{}
currentStart, previousStart := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix()
for start := 0; start < len(channels); start += 500 {
var rows []struct {
SourceDB string `gorm:"column:source_db"`
PromoteID uint64
FirstUnix *int64
CurrentValue, PreviousValue string
}
// 原始主账号与成功支付口径,不扩展下级渠道,也不使用身份证偏好。
selection := "source_db,promote_id,MIN(" + stamp + ") AS first_unix," +
"COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "<? THEN " + amount + " ELSE 0 END),0) AS current_value," +
"COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "<? THEN " + amount + " ELSE 0 END),0) AS previous_value"
if err := db.Table(table).Select(selection, currentStart, today.Unix(), previousStart, currentStart).Where(condition).Where("(source_db,promote_id) IN ?", channelPairs(channels[start:min(start+500, len(channels))])).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, row := range rows {
value := business.AlertComparison{Current: row.CurrentValue, Previous: row.PreviousValue}
if row.FirstUnix != nil {
value.FirstUnix = *row.FirstUnix
}
result[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = value
}
}
return result, nil
}
func (r *alertTriggerRepo) StoreHit(ctx context.Context, hit *business.AlertRecord, open []int, shouldWrite func(*business.AlertRecord) bool) (bool, error) {
written := false
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
key := alertTriggerLockPO{SourceDB: hit.PlatformCode, PromoteID: hit.TargetPromoteID, RuleType: hit.RuleType}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&key).Error; err != nil {
return business.ErrStorage
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db=? AND promote_id=? AND rule_type=?", key.SourceDB, key.PromoteID, key.RuleType).Take(&key).Error; err != nil {
return business.ErrStorage
}
var previous alertRecordPO
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("platform_code=? AND target_promote_id=? AND rule_type=? AND status IN ?", key.SourceDB, key.PromoteID, key.RuleType, open).Order("id").Take(&previous).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return business.ErrStorage
}
var current *business.AlertRecord
if err == nil {
current = toBizAlertRecord(previous)
}
if !shouldWrite(current) {
return nil
}
day := time.Date(hit.CreatedAt.Year(), hit.CreatedAt.Month(), hit.CreatedAt.Day(), 0, 0, 0, 0, hit.CreatedAt.Location())
row := alertRecordPO{PlatformCode: hit.PlatformCode, PlatformName: hit.PlatformName, RuleID: hit.RuleID, RuleName: hit.RuleName, RuleType: hit.RuleType,
MonitorMetric: hit.MonitorMetric, TargetPromoteID: hit.TargetPromoteID, TargetPromoteName: hit.TargetPromoteName, BusinessID: hit.BusinessID, BusinessName: hit.BusinessName,
AlertContent: hit.AlertContent, Status: hit.Status, AlertDate: &day, CreatedAt: hit.CreatedAt, UpdatedAt: hit.UpdatedAt}
if current == nil {
err = tx.Create(&row).Error
} else {
row.ID = previous.ID
err = tx.Model(&alertRecordPO{}).Where("id=?", previous.ID).Select("platform_name", "rule_id", "rule_name", "rule_type", "monitor_metric", "target_promote_name", "business_id", "business_name", "alert_content", "status", "alert_date", "created_at", "updated_at").Updates(&row).Error
}
if err != nil {
return business.ErrStorage
}
written = true
return nil
})
return written, err
}
@@ -0,0 +1,110 @@
package business
import (
"context"
"testing"
"time"
"kra/internal/biz/business"
)
func TestAlertTriggerStorageUsesRawMetricsAndPreservesWorkflow(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&accountPO{}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
for _, sql := range []string{
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)",
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT)",
"CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
"INSERT INTO sys_users(id,username,level,parent_id,id_path,authority_id) VALUES(10,'一级',1,0,'0',901),(20,'二级',2,10,'0,10',902),(30,'三级',3,20,'0,10,20',903)",
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')",
"INSERT INTO tab_promote VALUES(10,'lx',7,'正常渠道',1,1,0,1),(11,'lx',7,'停用渠道',1,0,0,1),(12,'lx',7,'下级渠道',2,1,0,1)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
today := time.Date(2026, 11, 4, 0, 0, 0, 0, now.Location())
if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',10,0,?),(2,'lx',10,0,?),(3,'lx',10,NULL,?),(4,'lx',10,1,?),(5,'hw',10,0,?)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO tab_spend VALUES('lx',10,?,1,100),('lx',10,?,1,110),('lx',10,?,0,999),('hw',10,?,1,999)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil {
t.Fatal(err)
}
day, direction := 1, 1
threshold := "10"
rule := alertRulePO{ID: 1, RuleName: "充值规则", RuleType: "充值", MonitorMetric: 2, CreatorID: 10, StatPeriodDays: &day, MomDirection: &direction, MomThreshold: &threshold, Enabled: 1}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
r := NewAlertTriggerRepo(testData{db})
input, err := r.Input(context.Background())
if err != nil || len(input.Channels) != 1 {
t.Fatalf("渠道查询错误: %+v %v", input, err)
}
metrics, err := r.Comparison(context.Background(), 1, []business.ChannelKey{{SourceDB: "lx", ID: 10}}, today, 1)
if err != nil || metrics[business.ChannelKey{SourceDB: "lx", ID: 10}].Current != "2" {
t.Fatalf("主账号、NULL父账号或跨平台口径错误: %+v %v", metrics, err)
}
uc := business.NewAlertTriggerUsecase(r)
result, err := uc.Detect(context.Background(), now)
if err != nil || result.Written != 1 {
t.Fatalf("首次命中: %+v %v", result, err)
}
var first alertRecordPO
if err := db.First(&first).Error; err != nil {
t.Fatal(err)
}
if first.BusinessID != 10 || first.TargetPromoteID != 10 || first.PlatformCode != "lx" {
t.Fatalf("归属错误: %+v", first)
}
result, err = uc.Detect(context.Background(), now.Add(time.Hour))
var count int64
db.Model(&alertRecordPO{}).Count(&count)
if err != nil || result.Written != 1 || count != 1 {
t.Fatalf("重复命中必须刷新同一未处理记录: %+v count=%d %v", result, count, err)
}
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 2).Error; err != nil {
t.Fatal(err)
}
result, err = uc.Detect(context.Background(), now.Add(2*time.Hour))
if err != nil || result.Written != 0 {
t.Fatalf("处理中预警被覆盖: %+v %v", result, err)
}
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 4).Error; err != nil {
t.Fatal(err)
}
result, err = uc.Detect(context.Background(), now.Add(3*time.Hour))
db.Model(&alertRecordPO{}).Count(&count)
if err != nil || result.Written != 0 || count != 1 {
t.Fatalf("驳回仍是未关闭记录,不应重复生成: %+v count=%d %v", result, count, err)
}
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 5).Error; err != nil {
t.Fatal(err)
}
result, err = uc.Detect(context.Background(), now.Add(3*time.Hour))
db.Model(&alertRecordPO{}).Count(&count)
if err != nil || result.Written != 1 || count != 2 {
t.Fatalf("已关闭后应允许新一轮预警: %+v count=%d %v", result, count, err)
}
}
func TestAlertTriggerWriteFailureRollsBack(t *testing.T) {
db := openTestDB(t)
if err := db.Exec("CREATE TRIGGER reject_alert BEFORE INSERT ON sys_alert_record BEGIN SELECT RAISE(ABORT,'test write failure'); END").Error; err != nil {
t.Fatal(err)
}
r := NewAlertTriggerRepo(testData{db})
hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleType: "注册", Status: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}
_, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true })
if err == nil {
t.Fatal("写入失败应返回错误")
}
var count int64
db.Model(&alertTriggerLockPO{}).Count(&count)
if count != 0 {
t.Fatalf("事务未回滚键锁: %d", count)
}
}
+52 -25
View File
@@ -4,6 +4,7 @@ import (
"context"
"sort"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
"kra/internal/biz/business"
)
@@ -80,6 +81,9 @@ func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.Anal
if kind == business.ChannelAnalytics {
id = "r.promote_id"
if scope.IncludeSub {
if pay {
q = analyticsPayLeaves(db, q)
}
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
if pay {
@@ -89,6 +93,12 @@ func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.Anal
}
return q, id
}
func analyticsPayLeaves(db, q *gorm.DB) *gorm.DB {
// 金额可先按原渠道加总再归并树,减少按每天/游戏重复关联渠道资料。
leaves := q.Select("r.source_db,r.promote_id,COALESCE(SUM(r.total_pay),0) AS total_pay").Group("r.source_db,r.promote_id")
return db.Table("(?) r", leaves)
}
func putAnalyticsCount(counts *business.PortraitCounts, metric int, count int64) {
switch metric {
case 1:
@@ -134,44 +144,61 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
}
return rowMap[key]
}
var pays []analyticsAggPO
q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true)
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
return nil, business.ErrStorage
var pays, counts []analyticsAggPO
var global []portraitCountPO
globalPay := "0"
withGlobal := kind == business.ChannelAnalytics && scope.Viewer.All
group, queryContext := errgroup.WithContext(ctx)
// 每个请求最多两个并行查询,复用同一存储客户端;结果读取完后再组装 DO。
group.SetLimit(2)
queryDB := db.WithContext(queryContext)
group.Go(func() error {
q, id := analyticsRoot(queryDB, kind, scope, f, "tab_sw_report_day", 0, true)
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
return business.ErrStorage
}
return nil
})
group.Go(func() error {
q, id := analyticsRoot(queryDB, kind, scope, f, "tab_sw_report_day_key", mode, false)
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
return business.ErrStorage
}
return nil
})
if withGlobal {
group.Go(func() error {
q := analyticsQuery(queryDB, scope, f, "tab_sw_report_day_key", mode)
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
return business.ErrStorage
}
if err := analyticsQuery(queryDB, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&globalPay).Error; err != nil {
return business.ErrStorage
}
return nil
})
}
if err := group.Wait(); err != nil {
return nil, err
}
for _, po := range pays {
if po.ID == nil {
continue
if po.ID != nil {
rowFor(po).Pay = po.Amount
}
rowFor(po).Pay = po.Amount
}
var counts []analyticsAggPO
q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false)
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range counts {
if po.ID == nil {
continue
if po.ID != nil {
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
}
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
}
if kind == business.ChannelAnalytics && !scope.Viewer.All {
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
return nil, err
}
}
if kind == business.ChannelAnalytics && scope.Viewer.All {
var global []portraitCountPO
q := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode)
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
return nil, business.ErrStorage
}
if withGlobal {
totals := toPortraitCounts(global)
result.GlobalCounts = &totals
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&result.GlobalPay).Error; err != nil {
return nil, business.ErrStorage
}
result.GlobalCounts, result.GlobalPay = &totals, globalPay
}
if len(rowMap) == 0 {
return result, nil
+10 -2
View File
@@ -100,7 +100,9 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
}
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 {
keysQuery := q.Select("p.id,p.source_db").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit)
pageQuery := db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery)
if err := pageQuery.Select("p.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").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
if len(pos) == 0 {
@@ -184,7 +186,13 @@ func (r *channelRepo) StatChannels(ctx context.Context, roots []*business.Channe
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
db := r.data.DB().WithContext(ctx)
columns := "id,source_db,promote_level,parent_id,top_promote_id"
parents := db.Table("tab_promote").Select(columns).Where("(source_db,parent_id) IN ?", pairs)
tops := db.Table("tab_promote").Select(columns).Where("(source_db,top_promote_id) IN ?", pairs)
// 分别走 parent_id / top_promote_id 索引,避免跨列 OR 退化为全表扫描。
// 同时命中两条关系的渠道继续由下面的 ChannelKey 集合去重。
err := db.Raw("? UNION ALL ?", parents, tops).Scan(&pos).Error
if err != nil {
return nil, business.ErrStorage
}
+3 -1
View File
@@ -75,7 +75,9 @@ func (r *channelWriteRepo) Create(ctx context.Context, input *business.ChannelWr
conn.WithContext(cleanup).Exec("DO RELEASE_LOCK(?)", lockName)
}()
}
return conn.Transaction(func(tx *gorm.DB) error {
// GET_LOCK 的标量 Scan 会修改固定连接上的 Statement.Model。
// 清空查询状态后再开事务,仍复用持锁连接,避免后续结构体 Take 读取到标量模型。
return conn.Session(&gorm.Session{NewDB: true}).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
+27 -8
View File
@@ -87,11 +87,24 @@ type registrationPO struct {
PromoteID *int64
}
func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter) *gorm.DB {
func registrationTimeExpression(db *gorm.DB) (string, error) {
cast := "CAST(r.register_time AS INTEGER)"
if db.Dialector.Name() == "mysql" {
cast = "CAST(r.register_time AS UNSIGNED)"
var count int64
if err := db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name='sw_register_time_unix'").Scan(&count).Error; err != nil {
return "", business.ErrStorage
}
if count > 0 {
// 直接使用等价生成列,使 COUNT 和分页主键查询走覆盖索引。
// CAST 虽可用于范围定位,但 MySQL 5.7 仍会回表读取原字符串。
cast = "r.sw_register_time_unix"
}
}
return cast, nil
}
func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, cast string) *gorm.DB {
q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
if filter.UserID != nil {
q = q.Where("r.id = ?", *filter.UserID)
@@ -135,7 +148,11 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
page.Available = false
return page, nil
}
q := registrationQuery(db, scope, filter)
timeExpression, err := registrationTimeExpression(db)
if err != nil {
return nil, err
}
q := registrationQuery(db, scope, filter, timeExpression)
if err := q.Count(&page.Total).Error; err != nil {
return nil, business.ErrStorage
}
@@ -146,15 +163,14 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
if filter.Ascending {
direction = " ASC"
}
order := "CAST(r.register_time AS INTEGER)"
if db.Dialector.Name() == "mysql" {
order = "CAST(r.register_time AS UNSIGNED)"
}
order := timeExpression
if filter.SortField == "totalPayAmount" {
order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)"
}
var pos []registrationPO
if err := q.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
keysQuery := q.Select("r.id,r.source_db").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit)
pageQuery := db.Table("tab_user r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery)
if err := pageQuery.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
meta := []ledgerMetaTarget{}
@@ -280,7 +296,10 @@ func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, fil
order += " DESC"
}
var pos []spendPO
if err := q.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
// 先在窄查询中选出当前页主键,再读取明细列,避免为排序读取整段流水。
keysQuery := q.Select("r.id,r.source_db").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit)
pageQuery := db.Table("tab_spend r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery)
if err := pageQuery.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
meta := []ledgerMetaTarget{}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
@@ -0,0 +1,279 @@
package business
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
drivermysql "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"kra/internal/biz/business"
)
// 显式设置本地隔离 MySQL DSN 才执行;每项测试只创建并清理自己的随机数据库。
func openRehearsalMySQL(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("未配置隔离 MySQL")
}
cfg, err := drivermysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
t.Fatal("MySQL 演练测试只接受本地隔离实例")
}
cfg.DBName = ""
cfg.ParseTime = true
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
admin, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("sw_test_business_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
_ = admin.Close()
t.Fatal(err)
}
t.Cleanup(func() {
_, err := admin.Exec("DROP DATABASE `" + name + "`")
if err != nil {
t.Error(err)
}
_ = admin.Close()
})
cfg.DBName = name
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
pool.SetMaxOpenConns(20)
t.Cleanup(func() { _ = pool.Close() })
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
t.Fatal(err)
}
}
return db
}
func TestMySQLAlertTriggerConcurrentDeduplication(t *testing.T) {
db := openRehearsalMySQL(t)
if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) VIRTUAL, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil {
t.Fatal(err)
}
r := NewAlertTriggerRepo(testData{db})
now := time.Now().Truncate(time.Millisecond)
hit := &business.AlertRecord{RuleID: 1, PlatformCode: "lx", PlatformName: "龙翔", TargetPromoteID: 10, RuleType: "注册", Status: 1, BusinessID: 10, CreatedAt: now, UpdatedAt: now}
decide := func(current *business.AlertRecord) bool { return current == nil || current.Status == 1 }
errors := make(chan error, 16)
var group sync.WaitGroup
for i := 0; i < 16; i++ {
group.Add(1)
go func() {
defer group.Done()
_, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide)
errors <- err
}()
}
group.Wait()
close(errors)
for err := range errors {
if err != nil {
t.Fatal(err)
}
}
var count int64
if err := db.Model(&alertRecordPO{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("并发重复预警 count=%d error=%v", count, err)
}
if err := db.Model(&alertRecordPO{}).Where("platform_code='lx'").Update("status", 2).Error; err != nil {
t.Fatal(err)
}
changed := *hit
changed.AlertContent = "不应覆盖处理中记录"
written, err := r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide)
if err != nil || written {
t.Fatalf("在途记录被刷新: %v %v", written, err)
}
var row alertRecordPO
if err := db.First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Status != 2 || row.AlertContent != "" {
t.Fatalf("状态或快照发生变化: %+v", row)
}
if err := db.Model(&row).Update("status", 4).Error; err != nil {
t.Fatal(err)
}
written, err = r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide)
if err != nil || written {
t.Fatalf("状态 4 是驳回,不能另建或覆盖未关闭记录: %v %v", written, err)
}
if err := db.Model(&row).Update("status", 5).Error; err != nil {
t.Fatal(err)
}
written, err = r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide)
db.Model(&alertRecordPO{}).Count(&count)
if err != nil || !written || count != 2 {
t.Fatalf("关闭后新一轮预警失败: %v count=%d %v", written, count, err)
}
}
func TestMySQLRegistrationIndexPreservesCastAndUsesRange(t *testing.T) {
db := openRehearsalMySQL(t)
for _, statement := range []string{
"CREATE TABLE tab_user(id INT,source_db VARCHAR(10),puid INT,register_time VARCHAR(11),PRIMARY KEY(id,source_db),KEY(register_time))",
"INSERT INTO tab_user VALUES(1,'lx',0,'1793635200'),(2,'lx',0,'01793635200'),(3,'lx',0,'1793635200 '),(4,'lx',1,'1793635200'),(5,'hw',0,'1793635200'),(6,'lx',0,'1793000000')",
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
// 让命中比例接近实际分页查询,避免微型表上优化器合理选择全表扫描。
outside := make([]map[string]any, 1000)
for i := range outside {
outside[i] = map[string]any{"id": 100 + i, "source_db": "lx", "puid": 0, "register_time": "1793000000"}
}
if err := db.Table("tab_user").CreateInBatches(outside, 200).Error; err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
pool.SetMaxOpenConns(1)
var beforeMode string
if err := db.Raw("SELECT @@session.sql_mode").Scan(&beforeMode).Error; err != nil {
t.Fatal(err)
}
query := "SELECT id,register_time FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599 ORDER BY id"
var before, after []struct {
ID int
RegisterTime string
}
if err := db.Raw(query).Scan(&before).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := migrateRegistrationIndex(db); err != nil {
t.Fatal(err)
}
}
var afterMode string
if err := db.Raw("SELECT @@session.sql_mode").Scan(&afterMode).Error; err != nil || beforeMode != afterMode {
t.Fatalf("索引创建未恢复会话模式: %v", err)
}
if err := db.Raw(query).Scan(&after).Error; err != nil {
t.Fatal(err)
}
if len(before) != 3 || fmt.Sprint(before) != fmt.Sprint(after) {
t.Fatalf("时间转换或原字符串改变: before=%+v after=%+v", before, after)
}
var plan []struct{ Key, Type string }
if err := db.Raw("EXPLAIN SELECT COUNT(*) FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599").Scan(&plan).Error; err != nil {
t.Fatal(err)
}
if len(plan) != 1 || plan[0].Key != "idx_sw_user_registration" || plan[0].Type != "range" {
t.Fatalf("未使用时间范围索引: %+v", plan)
}
queryAll, err := reportRegistrationQuery(db)
if err != nil {
t.Fatal(err)
}
var allIDs []int
if err := queryAll.Select("id").Where("puid=0 AND CAST(register_time AS UNSIGNED) BETWEEN ? AND ?", 1793635200, 1793721599).Order("id").Scan(&allIDs).Error; err != nil {
t.Fatal(err)
}
if fmt.Sprint(allIDs) != "[1 2 3 5]" {
t.Fatalf("跨平台重建筛选丢失记录: %v", allIDs)
}
}
func TestMySQLChannelCreationSerializesAccount(t *testing.T) {
db := openRehearsalMySQL(t)
if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{"CREATE TABLE tab_promote_business(id BIGINT PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(7,'5,6')"} {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
repo := NewChannelWriteRepo(channelDatabases{db})
input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "concurrentChannel", Password: "encoded", BusierID: 7},
Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800}
results := make(chan error, 8)
var group sync.WaitGroup
for i := 0; i < 8; i++ {
group.Add(1)
go func() {
defer group.Done()
_, err := repo.Create(context.Background(), input)
results <- err
}()
}
group.Wait()
close(results)
created, duplicates := 0, 0
for err := range results {
if err == nil {
created++
} else if err == business.ErrChannelExists {
duplicates++
} else {
t.Fatal(err)
}
}
var channelCount, settlementCount int64
db.Model(&platformChannelPO{}).Count(&channelCount)
db.Model(&channelSettlementPO{}).Count(&settlementCount)
if created != 1 || duplicates != 7 || channelCount != 1 || settlementCount != 1 {
t.Fatalf("并发创建结果错误: created=%d duplicate=%d channels=%d settlements=%d", created, duplicates, channelCount, settlementCount)
}
var relation string
db.Table("tab_promote_business").Select("promote_ids").Where("id=7").Scan(&relation)
if relation != "5,6,1" {
t.Fatalf("商务渠道关系未完整保留: %s", relation)
}
if err := db.Exec("CREATE TRIGGER fail_settlement BEFORE INSERT ON tab_promote_settlement_time FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'").Error; err != nil {
t.Fatal(err)
}
failed := *input
failed.Account = "rollbackChannel"
if _, err := repo.Create(context.Background(), &failed); err == nil {
t.Fatal("结算写入失败应该回滚")
}
db.Model(&platformChannelPO{}).Count(&channelCount)
if channelCount != 1 {
t.Fatal("失败的渠道记录没有回滚")
}
if err := db.Exec("DROP TRIGGER fail_settlement").Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Create(context.Background(), &failed); err != nil {
t.Fatalf("失败后命名锁未释放或无法重试: %v", err)
}
}
func TestMySQLServerReportIndexIsAdditiveAndIdempotent(t *testing.T) {
db := openRehearsalMySQL(t)
if err := db.Exec("CREATE TABLE tab_user_play_info(id INT PRIMARY KEY,create_time INT,play_time INT,KEY(play_time))").Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := migrateServerReportIndex(db); err != nil {
t.Fatal(err)
}
}
var count int64
db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&count)
if count != 1 || !db.Migrator().HasIndex("tab_user_play_info", "play_time") {
t.Fatalf("重复创建索引或覆盖旧索引: %d", count)
}
}
+70
View File
@@ -0,0 +1,70 @@
package business
import (
"fmt"
"strings"
"gorm.io/gorm"
)
type businessQueryIndex struct {
table, name string
columns []string
}
var businessQueryIndexes = []businessQueryIndex{
{"tab_promote", "idx_sw_promote_created", []string{"source_db", "create_time", "id"}},
{"tab_spend", "idx_sw_spend_time_summary", []string{"pay_status", "pay_time", "source_db", "pay_amount"}},
{"tab_spend", "idx_sw_spend_promote_summary", []string{"promote_id", "pay_status", "source_db", "pay_amount"}},
{"tab_sw_report_day", "idx_sw_report_pay_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "total_pay"}},
{"tab_sw_report_day_key", "idx_sw_report_key_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "metric", "identity_key"}},
{"tab_sw_report_server_day", "idx_sw_server_pay_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "total_pay"}},
{"tab_sw_report_server_day_key", "idx_sw_server_key_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "metric", "identity_key"}},
}
// 只为已经接入的业务表增加索引,不创建平台表、不改写记录。
// 按实际筛选前缀排列,并覆盖聚合读取列,避免大范围回表。
func migrateBusinessQueryIndexes(db *gorm.DB) error {
if db.Dialector.Name() != "mysql" {
return nil
}
groups := make(map[string][]businessQueryIndex)
tables := []string{}
for _, index := range businessQueryIndexes {
if _, exists := groups[index.table]; !exists {
tables = append(tables, index.table)
}
groups[index.table] = append(groups[index.table], index)
}
for _, table := range tables {
available, err := businessTableAvailable(db, table)
if err != nil {
return err
}
if !available {
continue
}
additions := []string{}
for _, index := range groups[table] {
var existing []string
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&existing).Error; err != nil {
return err
}
if len(existing) > 0 {
if strings.Join(existing, ",") != strings.Join(index.columns, ",") {
return fmt.Errorf("%s.%s 的现有索引列不符", index.table, index.name)
}
continue
}
columns := "`" + strings.Join(index.columns, "`,`") + "`"
additions = append(additions, "ADD INDEX `"+index.name+"` ("+columns+")")
}
// 同一大表的多个索引合并构建,减少重复扫描。
if len(additions) > 0 {
if err := db.Exec("ALTER TABLE `" + table + "` " + strings.Join(additions, ",") + ", ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil {
return fmt.Errorf("add query indexes on %s: %w", table, err)
}
}
}
return nil
}
@@ -0,0 +1,50 @@
package business
import (
"strings"
"testing"
)
func TestMySQLBusinessQueryIndexesAreAdditiveAndIdempotent(t *testing.T) {
db := openRehearsalMySQL(t)
definitions := map[string]string{
"tab_promote": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,create_time BIGINT,PRIMARY KEY(id,source_db)",
"tab_spend": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,promote_id BIGINT,pay_status INT,pay_time BIGINT,pay_amount DECIMAL(20,2),PRIMARY KEY(id,source_db)",
"tab_sw_report_day": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,total_pay DECIMAL(20,2)",
"tab_sw_report_day_key": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,metric INT,identity_key VARCHAR(64)",
"tab_sw_report_server_day": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),total_pay DECIMAL(20,2)",
"tab_sw_report_server_day_key": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),metric INT,identity_key VARCHAR(64)",
}
for table, columns := range definitions {
if err := db.Exec("CREATE TABLE `" + table + "` (" + columns + ") ENGINE=InnoDB").Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO tab_spend VALUES(77,'lx',55,1,123456,12.30)").Error; err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if err := migrateBusinessQueryIndexes(db); err != nil {
t.Fatal(err)
}
}
for _, index := range businessQueryIndexes {
var columns []string
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&columns).Error; err != nil {
t.Fatal(err)
}
if strings.Join(columns, ",") != strings.Join(index.columns, ",") {
t.Fatalf("index %s has unexpected columns: %v", index.name, columns)
}
}
var amount string
if err := db.Raw("SELECT pay_amount FROM tab_spend WHERE id=77 AND source_db='lx'").Scan(&amount).Error; err != nil || amount != "12.30" {
t.Fatalf("index migration changed business data: %s, %v", amount, err)
}
if err := db.Exec("ALTER TABLE tab_spend DROP INDEX idx_sw_spend_time_summary, ADD INDEX idx_sw_spend_time_summary(pay_time)").Error; err != nil {
t.Fatal(err)
}
if err := migrateBusinessQueryIndexes(db); err == nil || !strings.Contains(err.Error(), "idx_sw_spend_time_summary") {
t.Fatalf("must reject a conflicting existing index: %v", err)
}
}
+61 -44
View File
@@ -2,6 +2,8 @@ package business
import (
"context"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
"kra/internal/biz/business"
)
@@ -44,7 +46,10 @@ func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.A
return analyticsQuery(db, scope, f, table, mode)
}
all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true}
return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
// 同一渠道会出现在多级商务的范围中,先去重并一次性展开可见渠道。
// 避免对每一条报表身份记录重复扫描整棵商务关系树。
visible := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT p.source_db,p.id")
return analyticsQuery(db, all, f, table, mode).Joins("JOIN (?) visible_promotes ON visible_promotes.source_db=r.source_db AND visible_promotes.id=r.promote_id", visible)
}
func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) {
db := r.data.DB().WithContext(ctx)
@@ -109,60 +114,72 @@ func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, sc
return out, nil
}
}
group, queryContext := errgroup.WithContext(ctx)
group.SetLimit(2)
queryDB := db.WithContext(queryContext)
for _, period := range []struct {
begin, end string
target map[business.AnalyticsKey]*business.RankingMetrics
}{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} {
query := f.AnalyticsFilter
query.Begin, query.End = period.begin, period.end
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
if period.target[key] == nil {
period.target[key] = &business.RankingMetrics{}
}
return period.target[key]
}
for _, pay := range []bool{true, false} {
table, mode := "tab_sw_report_day", 0
if !pay {
table = "tab_sw_report_day_key"
if scope.IDCardDedup {
mode = 1
group.Go(func() error {
query := f.AnalyticsFilter
query.Begin, query.End = period.begin, period.end
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
if period.target[key] == nil {
period.target[key] = &business.RankingMetrics{}
}
return period.target[key]
}
q := rankingReportQuery(db, kind, scope, query, table, mode)
id := "r.promote_id"
if kind == business.GameRanking {
id = "r.game_id"
}
if kind == business.ChannelRanking {
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
}
selects := "r.source_db," + id + " AS entity_id"
group := "r.source_db," + id
if pay {
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
} else {
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
group += ",r.metric"
}
var rows []analyticsAggPO
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range rows {
if po.ID == nil || (pay && *po.ID < 0) {
continue
for _, pay := range []bool{true, false} {
table, mode := "tab_sw_report_day", 0
if !pay {
table = "tab_sw_report_day_key"
if scope.IDCardDedup {
mode = 1
}
}
row := rowFor(po)
q := rankingReportQuery(queryDB, kind, scope, query, table, mode)
id := "r.promote_id"
if kind == business.GameRanking {
id = "r.game_id"
}
if kind == business.ChannelRanking {
if pay {
q = analyticsPayLeaves(queryDB, q)
}
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
}
selects := "r.source_db," + id + " AS entity_id"
group := "r.source_db," + id
if pay {
row.Pay = po.Amount
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
} else {
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
group += ",r.metric"
}
var rows []analyticsAggPO
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
return business.ErrStorage
}
for _, po := range rows {
if po.ID == nil || (pay && *po.ID < 0) {
continue
}
row := rowFor(po)
if pay {
row.Pay = po.Amount
} else {
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
}
}
}
}
return nil
})
}
if err := group.Wait(); err != nil {
return nil, err
}
if kind != business.BusinessRanking && len(out.Current) > 0 {
table, column := "tab_promote", "account"
@@ -0,0 +1,70 @@
package business
import (
"errors"
"fmt"
"strings"
"gorm.io/gorm"
)
// 保留旧 register_time 字符串及其 CAST 语义,用虚拟列索引避免逐行扫描主账号。
// MySQL 可将查询中相同的 CAST 表达式匹配到该索引,既有查询无需更改统计口径。
func migrateRegistrationIndex(db *gorm.DB) error {
if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user") {
return nil
}
const column = "sw_register_time_unix"
const index = "idx_sw_user_registration"
if !db.Migrator().HasColumn("tab_user", column) {
if err := db.Exec("ALTER TABLE tab_user ADD COLUMN sw_register_time_unix BIGINT UNSIGNED GENERATED ALWAYS AS (CAST(register_time AS UNSIGNED)) VIRTUAL, ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil {
return err
}
} else {
var expression string
if err := db.Raw("SELECT generation_expression FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name=?", column).Scan(&expression).Error; err != nil {
return err
}
normalized := strings.NewReplacer("`", "", " ", "", "\n", "", "\t", "").Replace(strings.ToLower(expression))
if normalized != "cast(register_timeasunsigned)" {
return fmt.Errorf("tab_user.%s 的现有定义与注册时间转换不符", column)
}
}
if !db.Migrator().HasIndex("tab_user", index) {
return addRegistrationIndex(db)
}
var columns []string
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user' AND index_name=? ORDER BY seq_in_index", index).Scan(&columns).Error; err != nil {
return err
}
if strings.Join(columns, ",") != "source_db,puid,sw_register_time_unix,id" {
return fmt.Errorf("tab_user.%s 的现有索引列不符", index)
}
return nil
}
func addRegistrationIndex(db *gorm.DB) error {
// 旧 SELECT CAST 会以警告方式接受尾部空格等历史值。建索引属于 DDL,
// 严格模式会把同一警告升级为错误;仅在固定连接的索引构建期间保持旧转换行为。
return db.Connection(func(conn *gorm.DB) (result error) {
var previous string
if err := conn.Raw("SELECT @@session.sql_mode").Scan(&previous).Error; err != nil {
return err
}
modes := []string{}
for _, mode := range strings.Split(previous, ",") {
if mode != "STRICT_TRANS_TABLES" && mode != "STRICT_ALL_TABLES" {
modes = append(modes, mode)
}
}
if err := conn.Exec("SET SESSION sql_mode=?", strings.Join(modes, ",")).Error; err != nil {
return err
}
defer func() {
if err := conn.Exec("SET SESSION sql_mode=?", previous).Error; err != nil {
result = errors.Join(result, err)
}
}()
return conn.Exec("ALTER TABLE tab_user ADD INDEX idx_sw_user_registration(source_db,puid,sw_register_time_unix,id), ALGORITHM=INPLACE, LOCK=NONE").Error
})
}
+45 -9
View File
@@ -41,14 +41,12 @@ func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuild
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
rawTable = "tab_user_play_info"
}
for _, table := range []string{reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game"} {
available, err := businessTableAvailable(tx, table)
if err != nil {
return err
}
if !available {
return business.ErrReportBuildUnavailable
}
available, err := businessTablesAvailable(tx, reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game")
if err != nil {
return err
}
if !available {
return business.ErrReportBuildUnavailable
}
statDate := day.Format("2006-01-02")
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
@@ -133,7 +131,11 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time)
cast = "CAST(register_time AS UNSIGNED)"
}
var registers, actives []reportEventPO
if err := db.Table("tab_user").Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(&registers).Error; err != nil {
registerQuery, err := reportRegistrationQuery(db)
if err != nil {
return nil, err
}
if err := registerQuery.Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(&registers).Error; err != nil {
return nil, business.ErrStorage
}
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
@@ -204,3 +206,37 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time)
}
return input, nil
}
func reportRegistrationQuery(db *gorm.DB) (*gorm.DB, error) {
q := db.Table("tab_user")
if db.Dialector.Name() != "mysql" || !db.Migrator().HasIndex("tab_user", "idx_sw_user_registration") {
return q, nil
}
// 跨平台任务也必须提供组合索引的首列,避免又回到数百万主账号扫描。
// 平台值取同一事务中的实际数据,不写死平台名单,也保留历史 NULL。
var rows []struct {
SourceDB *string `gorm:"column:source_db"`
}
if err := db.Table("tab_user").Distinct("source_db").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
sources := []string{}
hasNull := false
for _, row := range rows {
if row.SourceDB == nil {
hasNull = true
} else {
sources = append(sources, *row.SourceDB)
}
}
if len(sources) == 0 {
if hasNull {
return q.Where("source_db IS NULL"), nil
}
return q.Where("1=0"), nil
}
if hasNull {
return q.Where("(source_db IN ? OR source_db IS NULL)", sources), nil
}
return q.Where("source_db IN ?", sources), nil
}
+26 -4
View File
@@ -19,14 +19,36 @@ func storageError(err error) error {
}
func businessTableAvailable(db *gorm.DB, name string) (bool, error) {
tables, err := db.Migrator().GetTables()
return businessTablesAvailable(db, name)
}
// 一次检查请求所需的表,不为每个依赖重复枚举整个业务库。
// 不缓存结果,保留迁移后新建表和运行时连接异常的可见性。
func businessTablesAvailable(db *gorm.DB, names ...string) (bool, error) {
if len(names) == 0 {
return true, nil
}
var tables []string
var err error
switch db.Dialector.Name() {
case "mysql":
err = db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ?", names).Scan(&tables).Error
case "sqlite":
err = db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name IN ?", names).Scan(&tables).Error
default:
tables, err = db.Migrator().GetTables()
}
if err != nil {
return false, business.ErrStorage
}
found := make(map[string]bool, len(tables))
for _, table := range tables {
if table == name {
return true, nil
found[table] = true
}
for _, name := range names {
if !found[name] {
return false, nil
}
}
return false, nil
return true, nil
}
+49
View File
@@ -0,0 +1,49 @@
package business
import (
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func TestBusinessTablesAvailabilityIsFreshAndPropagatesFailures(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "schema.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
pool, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pool.Close() })
check := func(want bool, names ...string) {
t.Helper()
got, err := businessTablesAvailable(db, names...)
if err != nil || got != want {
t.Fatalf("tables %v: got %v, %v; want %v", names, got, err, want)
}
}
check(true)
check(false, "first", "second")
for _, table := range []string{"first", "second"} {
if err := db.Exec("CREATE TABLE " + table + " (id INTEGER)").Error; err != nil {
t.Fatal(err)
}
}
check(true, "first", "second", "first")
check(false, "first", "missing")
check(false, "first') OR 1=1 --")
if err := db.Exec("DROP TABLE second").Error; err != nil {
t.Fatal(err)
}
check(false, "first", "second")
if err := pool.Close(); err != nil {
t.Fatal(err)
}
if available, err := businessTablesAvailable(db, "first"); available || err != business.ErrStorage {
t.Fatalf("connection error must not become a missing-table result: %v, %v", available, err)
}
}
@@ -0,0 +1,18 @@
package business
import "gorm.io/gorm"
// 区服汇总按创角时间读取原始记录;旧表只有 play_time 索引,导致每日创角扫描全表。
func migrateServerReportIndex(db *gorm.DB) error {
if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user_play_info") {
return nil
}
var existing int64
if err := db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&existing).Error; err != nil {
return err
}
if existing > 0 {
return nil
}
return db.Exec("ALTER TABLE tab_user_play_info ADD INDEX idx_sw_play_create_time(create_time), ALGORITHM=INPLACE, LOCK=NONE").Error
}
+103 -59
View File
@@ -4,6 +4,8 @@ import (
"context"
"time"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
"kra/internal/biz/business"
)
@@ -23,13 +25,8 @@ func workbenchAvailable(db *gorm.DB, scope business.AnalyticsScope, sources []st
if kind == "" || kind == business.WorkbenchRegister {
tables = append(tables, "tab_sw_report_day_key")
}
for _, table := range tables {
available, err := businessTableAvailable(db, table)
if err != nil || !available {
return false, available, err
}
}
return false, true, nil
available, err = businessTablesAvailable(db, tables...)
return false, available, err
}
func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.WorkbenchWindow) (*business.WorkbenchTotals, error) {
db := r.data.DB().WithContext(ctx)
@@ -46,47 +43,83 @@ func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsSco
if scope.IDCardDedup {
mode = 1
}
for _, period := range []struct {
day time.Time
target *business.WorkbenchDay
}{{w.Today, &out.Today}, {w.Yesterday, &out.Yesterday}} {
f := business.AnalyticsFilter{Sources: sources, Begin: period.day.Format("2006-01-02"), End: period.day.Format("2006-01-02")}
var counts []portraitCountPO
if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Where("r.metric IN (1,2)").Group("r.metric").Scan(&counts).Error; err != nil {
return nil, business.ErrStorage
}
period.target.Counts = toPortraitCounts(counts)
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&period.target.Pay).Error; err != nil {
return nil, business.ErrStorage
}
// 活跃渠道按自身注册或正充值统计,不能归并一级渠道后再计数。
active := map[business.AnalyticsKey]bool{}
var registrations, pays []analyticsAggPO
if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Where("r.metric=1").Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("COUNT(DISTINCT r.identity_key)>0").Scan(&registrations).Error; err != nil {
return nil, business.ErrStorage
}
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range append(registrations, pays...) {
if po.ID != nil {
active[business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}] = true
}
}
period.target.ActiveChannels = int64(len(active))
if err := analyticsBasePromotes(db, scope, f, "p").Where("p.create_time BETWEEN ? AND ?", period.day.Unix(), period.day.AddDate(0, 0, 1).Unix()-1).Count(&period.target.NewChannels).Error; err != nil {
return nil, business.ErrStorage
date := func(value time.Time) string { return value.Format("2006-01-02") }
today, yesterday := date(w.Today), date(w.Yesterday)
monthStart, previousStart, previousEnd := date(w.MonthStart), date(w.PreviousMonthStart), date(w.PreviousMonthEnd)
dayFilter := business.AnalyticsFilter{Sources: sources, Begin: yesterday, End: today}
type datedCount struct {
StatDate string
Metric int
Count int64
}
type datedChannel struct {
StatDate string
SourceDB string `gorm:"column:source_db"`
ID *int64 `gorm:"column:entity_id"`
}
var counts []datedCount
var registrations, pays []datedChannel
var amounts struct{ TodayPay, YesterdayPay, MonthPay, PreviousMonthPay string }
var created struct{ TodayCount, YesterdayCount int64 }
group, queryContext := errgroup.WithContext(ctx)
group.SetLimit(2)
queryDB := db.WithContext(queryContext)
group.Go(func() error {
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode).
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").
Where("r.metric IN (1,2)").Group("r.stat_date,r.metric").Scan(&counts).Error)
})
group.Go(func() error {
f := business.AnalyticsFilter{Sources: sources, Begin: previousStart, End: today}
// 同时取得两日和两个自然月区间的金额;月初的昨日须额外纳入扫描范围。
return storageError(analyticsQuery(queryDB, scope, f, "tab_sw_report_day", 0).
Where("((r.stat_date BETWEEN ? AND ?) OR (r.stat_date BETWEEN ? AND ?) OR r.stat_date=?)", monthStart, today, previousStart, previousEnd, yesterday).
Select("COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS today_pay,"+
"COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS yesterday_pay,"+
"COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS month_pay,"+
"COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS previous_month_pay",
today, yesterday, monthStart, today, previousStart, previousEnd).Scan(&amounts).Error)
})
group.Go(func() error {
// COUNT(DISTINCT identity_key)>0 等价于存在非 NULL 的注册键;保留空字符串的旧语义。
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode).
Where("r.metric=1 AND r.identity_key IS NOT NULL").Distinct().
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id").Scan(&registrations).Error)
})
group.Go(func() error {
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day", 0).
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id").
Group("r.stat_date,r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error)
})
group.Go(func() error {
todayEnd := w.Today.AddDate(0, 0, 1).Unix() - 1
yesterdayEnd := w.Yesterday.AddDate(0, 0, 1).Unix() - 1
return storageError(analyticsBasePromotes(queryDB, scope, dayFilter, "p").
Where("p.create_time BETWEEN ? AND ?", w.Yesterday.Unix(), todayEnd).
Select("COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS today_count,"+
"COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS yesterday_count",
w.Today.Unix(), todayEnd, w.Yesterday.Unix(), yesterdayEnd).Scan(&created).Error)
})
if err := group.Wait(); err != nil {
return nil, err
}
days := map[string]*business.WorkbenchDay{today: &out.Today, yesterday: &out.Yesterday}
for _, row := range counts {
if day := days[row.StatDate]; day != nil {
putAnalyticsCount(&day.Counts, row.Metric, row.Count)
}
}
for _, period := range []struct {
begin, end time.Time
target *string
}{{w.MonthStart, w.Today, &out.MonthPay}, {w.PreviousMonthStart, w.PreviousMonthEnd, &out.PreviousMonthPay}} {
f := business.AnalyticsFilter{Sources: sources, Begin: period.begin.Format("2006-01-02"), End: period.end.Format("2006-01-02")}
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.target).Error; err != nil {
return nil, business.ErrStorage
active := map[string]map[business.AnalyticsKey]bool{today: {}, yesterday: {}}
for _, row := range append(registrations, pays...) {
if keys := active[row.StatDate]; keys != nil && row.ID != nil {
keys[business.AnalyticsKey{SourceDB: row.SourceDB, ID: *row.ID}] = true
}
}
out.Today.ActiveChannels, out.Yesterday.ActiveChannels = int64(len(active[today])), int64(len(active[yesterday]))
out.Today.Pay, out.Yesterday.Pay = amounts.TodayPay, amounts.YesterdayPay
out.Today.NewChannels, out.Yesterday.NewChannels = created.TodayCount, created.YesterdayCount
out.MonthPay, out.PreviousMonthPay = amounts.MonthPay, amounts.PreviousMonthPay
return out, nil
}
@@ -118,12 +151,12 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
}
if kind == business.WorkbenchPayLow || kind == business.WorkbenchUnlogin {
var channels []workbenchPromotePO
if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.account,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil {
if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range channels {
row := rowFor(po.SourceDB, po.ID)
row.Name, row.CreatedUnix, row.LastLoginUnix = po.Account, po.CreateTime, po.LastLoginTime
row.CreatedUnix, row.LastLoginUnix = po.CreateTime, po.LastLoginTime
}
}
if kind != business.WorkbenchUnlogin {
@@ -144,6 +177,7 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE root_p.id END"
}
} else {
q = analyticsPayLeaves(db, q)
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
}
@@ -160,22 +194,32 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
rowFor(po.SourceDB, *po.ID).Value = po.Amount
}
}
keys := make([]business.AnalyticsKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
for start := 0; start < len(keys); start += 300 {
var names []workbenchPromotePO
if err := db.Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&names).Error; err != nil {
return nil, business.ErrStorage
}
for _, po := range names {
rowFor(po.SourceDB, po.ID).Name = po.Account
}
}
}
for _, row := range rows {
out.Rows = append(out.Rows, row)
}
return out, nil
}
func (r *workbenchRepo) FillRankNames(ctx context.Context, rows []*business.WorkbenchRankRow) error {
if len(rows) == 0 {
return nil
}
keys := make([]business.AnalyticsKey, 0, len(rows))
for _, row := range rows {
keys = append(keys, row.AnalyticsKey)
}
var names []workbenchPromotePO
if err := r.data.DB().WithContext(ctx).Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys)).Scan(&names).Error; err != nil {
return business.ErrStorage
}
byKey := make(map[business.AnalyticsKey]string, len(names))
for _, po := range names {
byKey[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.Account
}
for _, row := range rows {
row.Name = byKey[row.AnalyticsKey]
}
return nil
}
+29
View File
@@ -85,3 +85,32 @@ func TestWorkbenchStorageSummaryAndRankRules(t *testing.T) {
t.Fatal("六卡缺表应明确未接入")
}
}
func TestWorkbenchSummaryMonthStartAndNullableRegistrationKeys(t *testing.T) {
db, _ := prepareChannels(t)
for _, statement := range []string{
"DELETE FROM tab_sw_report_day",
"DELETE FROM tab_sw_report_day_key",
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-10-01',10),('lx',100,1,0,'2026-10-02',999),('lx',100,1,0,'2026-10-31',30),('lx',100,1,0,'2026-11-01',20),('lx',103,1,0,'2026-11-01',-1)",
"INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-01',1,NULL),('lx',999,1,1,'2026-11-01',1,''),('lx',100,1,1,'2026-10-31',1,'A')",
} {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
day := time.Date(2026, 11, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
if err := db.Exec("UPDATE tab_promote SET create_time=CASE WHEN id=100 THEN ? ELSE ? END WHERE source_db='lx' AND id IN(100,101)", day.Unix(), day.AddDate(0, 0, -1).Unix()).Error; err != nil {
t.Fatal(err)
}
window := business.WorkbenchWindow{Today: day, Yesterday: day.AddDate(0, 0, -1), MonthStart: day, PreviousMonthStart: day.AddDate(0, -1, 0), PreviousMonthEnd: day.AddDate(0, -1, 0)}
out, err := NewWorkbenchRepo(testData{db}).Summary(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}, []string{"lx"}, window)
if err != nil {
t.Fatal(err)
}
if portraitTestMoney(out.Today.Pay) != "19.00" || portraitTestMoney(out.Yesterday.Pay) != "30.00" || portraitTestMoney(out.MonthPay) != "19.00" || portraitTestMoney(out.PreviousMonthPay) != "10.00" {
t.Fatalf("month-first comparison mixed yesterday with the previous-month window: %+v", out)
}
if out.Today.ActiveChannels != 2 || out.Today.Counts.Register != 1 || out.Yesterday.ActiveChannels != 1 || out.Today.NewChannels != 1 || out.Yesterday.NewChannels != 1 {
t.Fatalf("daily groups or NULL/empty registration-key behavior changed: %+v", out)
}
}
+1
View File
@@ -47,6 +47,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewWorkbenchRepo,
databusiness.NewRankingRepo,
databusiness.NewReportBuildRepo,
databusiness.NewAlertTriggerRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+29 -7
View File
@@ -10,9 +10,11 @@ import (
)
const (
dataScopeAuditQueueSize = 1024
dataScopeAuditBatchSize = 100
dataScopeAuditInterval = 2 * time.Second
dataScopeAuditQueueSize = 1024
dataScopeAuditBatchSize = 100
dataScopeAuditInterval = 2 * time.Second
dataScopeAuditWriteTimeout = 2 * time.Second
dataScopeAuditCloseTimeout = 5 * time.Second
)
// dataScopeAuditWriter keeps row-scope auditing best-effort: callbacks only
@@ -26,6 +28,8 @@ type dataScopeAuditWriter struct {
stop chan struct{}
done chan struct{}
closeOnce sync.Once
enqueueMu sync.RWMutex
closed bool
}
func newDataScopeAuditWriter(data *Data, logger *slog.Logger) *dataScopeAuditWriter {
@@ -57,6 +61,11 @@ func (w *dataScopeAuditWriter) Enqueue(record dataAccessLogPO) {
if w == nil {
return
}
w.enqueueMu.RLock()
defer w.enqueueMu.RUnlock()
if w.closed {
return
}
select {
case w.queue <- record:
default:
@@ -68,8 +77,19 @@ func (w *dataScopeAuditWriter) Close() {
if w == nil {
return
}
w.closeOnce.Do(func() { close(w.stop) })
<-w.done
w.closeOnce.Do(func() {
w.enqueueMu.Lock()
w.closed = true
close(w.stop)
w.enqueueMu.Unlock()
})
timer := time.NewTimer(dataScopeAuditCloseTimeout)
defer timer.Stop()
select {
case <-w.done:
case <-timer.C:
w.log().Warn("等待数据权限审计写入超时", "mod", "data-scope")
}
}
func (w *dataScopeAuditWriter) run() {
@@ -78,6 +98,7 @@ func (w *dataScopeAuditWriter) run() {
defer ticker.Stop()
batch := make([]dataAccessLogPO, 0, w.batchSize)
flush := func() {
defer func() { batch = batch[:0] }()
if len(batch) == 0 || w.data == nil || w.data.gormDB == nil {
return
}
@@ -85,10 +106,11 @@ func (w *dataScopeAuditWriter) run() {
if db == nil {
return
}
if err := db.WithContext(context.Background()).Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&batch).Error; err != nil {
ctx, cancel := context.WithTimeout(context.Background(), dataScopeAuditWriteTimeout)
defer cancel()
if err := db.WithContext(ctx).Session(&gorm.Session{NewDB: true, SkipHooks: true}).Create(&batch).Error; err != nil {
w.log().Warn("数据权限审计批量写入失败", "mod", "data-scope", "error", err)
}
batch = batch[:0]
}
for {
select {
+29
View File
@@ -76,3 +76,32 @@ func TestDataScopeAuditWriterUsesReloadedDatabase(t *testing.T) {
t.Fatalf("audit log was written to retired database: %d", firstCount)
}
}
func TestDataScopeAuditCloseCancelsBlockedWrite(t *testing.T) {
db := openDataScopeAuditTestDB(t, "data-scope-audit-blocked")
entered := make(chan struct{})
if err := db.Callback().Create().Before("gorm:create").Register("test:block", func(tx *gorm.DB) {
close(entered)
<-tx.Statement.Context.Done()
tx.AddError(tx.Statement.Context.Err())
}); err != nil {
t.Fatal(err)
}
w := newDataScopeAuditWriterWithOptions(&Data{gormDB: newReloadableDB(db)}, slog.New(slog.NewTextHandler(io.Discard, nil)), 8, 1, time.Hour)
w.Enqueue(dataAccessLogPO{EventType: "blocked_write"})
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("writer did not start")
}
w.Close()
select {
case <-w.done:
default:
t.Fatal("database write did not honor its deadline")
}
w.Enqueue(dataAccessLogPO{EventType: "after_close"})
if len(w.queue) != 0 {
t.Fatal("closed writer accepted a new event")
}
}
@@ -52,6 +52,26 @@ func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
}
}
func TestCertificateAliasesMaskAndRoundTrip(t *testing.T) {
old := map[string]any{"app_cert_content": "cert", "key_content": "key", "pkcs12_file": "bundle", "template_content": "keep"}
values := map[string]any{}
for key, value := range old {
values[key] = value
}
maskIntegrationSecrets("payment", "alipay", values)
for _, key := range []string{"app_cert_content", "key_content", "pkcs12_file"} {
if values[key] == old[key] {
t.Fatalf("unmasked alias: %s", key)
}
}
mergeIntegrationSecrets("payment", "alipay", values, old)
for key, value := range old {
if values[key] != value {
t.Fatalf("masked edit changed %s", key)
}
}
}
func TestCommunicationDefaultsIncludeKafka(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 23 {
t.Fatalf("migration versions = %d, want 23", versions)
if versions != 27 {
t.Fatalf("migration versions = %d, want 27", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
+4 -1
View File
@@ -125,6 +125,9 @@ func (r *mediaRepo) UpdateMediaName(ctx context.Context, id uint, name string) e
// not-found error instead of silently succeeding with RowsAffected=0.
var po mediaPO
if err := r.data.DB().WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return system.ErrMediaNotFound
}
return err
}
return r.data.DB().WithContext(ctx).Model(&po).Update("name", name).Error
@@ -147,7 +150,7 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*system.MediaF
func (r *mediaRepo) SaveCategory(ctx context.Context, v *system.AttachmentCategory) error {
db := r.data.DB().WithContext(ctx)
var count int64
query := db.Model(&categoryPO{}).Where("name = ? AND pid = ?", v.Name, v.ParentID)
query := db.Model(&categoryPO{}).Where("name = ? AND pid = ? AND id <> ?", v.Name, v.ParentID, v.ID)
if err := query.Count(&count).Error; err != nil {
return err
}
+40
View File
@@ -0,0 +1,40 @@
package system
import (
"context"
"kra/internal/biz/system"
"path/filepath"
"testing"
)
func TestMediaEditsKeepUnchangedCategoryNameAndMapMissingFile(t *testing.T) {
db, err := openWithDriver("sqlite", filepath.Join(t.TempDir(), "media.db"))
if err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
t.Cleanup(func() { _ = pool.Close() })
if err := db.AutoMigrate(&mediaPO{}, &categoryPO{}); err != nil {
t.Fatal(err)
}
r := NewMediaRepo(&Data{gormDB: newReloadableDB(db, nil)})
ctx := context.Background()
category := &system.AttachmentCategory{Name: "screenshots"}
if err := r.SaveCategory(ctx, category); err != nil {
t.Fatal(err)
}
var row categoryPO
if err := db.First(&row).Error; err != nil {
t.Fatal(err)
}
category.ID = row.ID
if err := r.SaveCategory(ctx, category); err != nil {
t.Fatalf("unchanged category rejected: %v", err)
}
if err := r.SaveCategory(ctx, &system.AttachmentCategory{Name: "screenshots"}); err == nil {
t.Fatal("duplicate category accepted")
}
if err := r.UpdateMediaName(ctx, 999, "missing"); err != system.ErrMediaNotFound {
t.Fatalf("missing file: %v", err)
}
}
+2 -2
View File
@@ -25,8 +25,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 23 {
t.Fatalf("module migrations = %d, want 23", len(got))
if got := catalog.MigrationSteps(); len(got) != 27 {
t.Fatalf("module migrations = %d, want 27", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
+1 -5
View File
@@ -113,11 +113,7 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
logger.Info("websocket endpoint initialized", "mod", "websocket",
"enabled", ws != nil && ws.Enabled(), "path", websocketRoutePath(prefix, ws))
}
env := ""
if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil {
env = strings.ToLower(strings.TrimSpace(snapshot.Admin.App.Env))
}
if env == "" || env == "development" || env == "dev" || env == "test" || env == "local" {
if snapshot != nil && config.SwaggerEnabled(snapshot.Admin) {
registerSwagger(engine, prefix, version, logger)
}
staticfiles.Register(engine, runtime)
+34 -8
View File
@@ -51,7 +51,7 @@ func emptyHandlers() *handler.Set {
}
func TestGinRouteContract(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
routes := engine.Routes()
actual := make([]string, 0, len(routes))
seen := make(map[string]struct{}, len(routes))
@@ -85,7 +85,7 @@ func TestGinRouteContract(t *testing.T) {
}
func TestGinRoutesMatchSharedCatalog(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
registered := make(map[string]struct{}, len(engine.Routes()))
for _, route := range engine.Routes() {
key := route.Method + " " + route.Path
@@ -102,10 +102,36 @@ func TestGinRoutesMatchSharedCatalog(t *testing.T) {
}
}
func TestEveryPrivateRouteRequiresAuthentication(t *testing.T) {
for _, prefix := range []string{"", "/api"} {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: prefix}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
for _, descriptor := range routecatalog.Descriptors() {
if descriptor.Public {
continue
}
path := descriptor.Path
parts := strings.Split(path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = "1"
}
}
path = prefix + strings.Join(parts, "/")
t.Run(descriptor.Method+" "+path, func(t *testing.T) {
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(descriptor.Method, path, nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous private request returned %d: %s", recorder.Code, recorder.Body.String())
}
})
}
}
}
func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, logger, "test")
text := output.String()
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)
@@ -116,7 +142,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
}
func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
engine.ServeHTTP(response, request)
@@ -132,7 +158,7 @@ func TestSwaggerUsesRegisteredGinRoutes(t *testing.T) {
}
func TestSwaggerSupportsRouterPrefix(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin", App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
for _, path := range []string{"/admin/swagger/index.html", "/admin/swagger/doc.json"} {
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
@@ -158,7 +184,7 @@ func TestHealthEndpointKeepsRootProbePathWithRouterPrefix(t *testing.T) {
}
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/swagger/doc.json", nil))
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"basePath":"/"`) {
@@ -167,7 +193,7 @@ func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
}
func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine.GET("/client-ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
request := httptest.NewRequest(http.MethodGet, "/client-ip", nil)
request.RemoteAddr = "203.0.113.10:4321"
@@ -181,7 +207,7 @@ func TestGinDoesNotTrustForwardedIPByDefault(t *testing.T) {
}
func TestAnnouncementDataSourceRequiresAuthentication(t *testing.T) {
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{App: &config.App{Env: "test"}}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
response := httptest.NewRecorder()
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/info/getInfoDataSource", nil))
if response.Code != http.StatusUnauthorized {
@@ -59,3 +59,16 @@ func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) {
t.Fatal("payment test route was not recognized as sensitive")
}
}
func TestCertificateAliasesAreRedactedInNestedPayloads(t *testing.T) {
raw := []byte(`{"config":{"app_cert_content":"secret-cert","keys":[{"key_content":"secret-key"}],"cert_path":"private-file","template_content":"keep"}}`)
got := redactJSON(raw, "application/json", 4096)
for _, secret := range []string{"secret-cert", "secret-key", "private-file"} {
if strings.Contains(got, secret) {
t.Fatalf("certificate alias leaked: %s", got)
}
}
if !strings.Contains(got, `"template_content":"keep"`) {
t.Fatal("ordinary content was masked")
}
}
+7 -1
View File
@@ -1,6 +1,9 @@
package middleware
import "strings"
import (
"kra/internal/biz/integration"
"strings"
)
// redactedValue replaces a sensitive value in every log line and audit record.
const redactedValue = "***"
@@ -36,6 +39,9 @@ func isSensitiveHeader(key string) bool {
// isSensitivePayloadKey also matches every key ending in "token" so provider
// specific token names stay masked without being enumerated.
func isSensitivePayloadKey(key string) bool {
if integration.IsIntegrationSecretKey(key) {
return true
}
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if _, sensitive := sensitivePayloadKeys[normalized]; sensitive {
return true
+3
View File
@@ -59,6 +59,9 @@ func (s *AccountService) List(ctx context.Context, req *dto.BusinessAccountQuery
}
*field.target = &n
}
if (req.BeginTime == "") != (req.EndTime == "") {
return nil, business.ErrAccountInput
}
if req.BeginTime != "" && req.EndTime != "" {
begin, err := time.ParseInLocation("2006-01-02 15:04:05", req.BeginTime, time.Local)
if err != nil {
+4 -1
View File
@@ -26,6 +26,9 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error)
}
f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")}
begin, end := strings.TrimSpace(req.BeginTime), strings.TrimSpace(req.EndTime)
if (begin == "") != (end == "") {
return f, business.ErrAnalyticsDate
}
if begin != "" && end != "" {
location := time.FixedZone("Asia/Shanghai", 8*3600)
b, err := time.ParseInLocation("2006-01-02", begin, location)
@@ -33,7 +36,7 @@ func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error)
return f, business.ErrAnalyticsDate
}
e, err := time.ParseInLocation("2006-01-02", end, location)
if err != nil {
if err != nil || e.Before(b) {
return f, business.ErrAnalyticsDate
}
f.StartDate = &b
@@ -0,0 +1,33 @@
package business
import (
"context"
"kra/internal/service/dto"
"testing"
)
func TestRejectIncompleteAndReversedDateRanges(t *testing.T) {
for _, dates := range [][2]string{{"2026-11-01", ""}, {"", "2026-11-01"}, {"2026-11-03", "2026-11-01"}, {"bad", "2026-11-01"}} {
if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err == nil {
t.Errorf("analytics accepted %v", dates)
}
for _, spend := range []bool{false, true} {
_, _, err := ledgerRequest(&dto.LedgerQuery{BeginRegisterTime: dates[0], EndRegisterTime: dates[1], BeginPayTime: dates[0], EndPayTime: dates[1]}, spend)
if err == nil {
t.Errorf("ledger accepted %v (spend=%v)", dates, spend)
}
}
}
for _, dates := range [][2]string{{"", ""}, {"2026-11-01", "2026-11-01"}} {
if _, err := analyticsRequest(&dto.AnalyticsQuery{BeginTime: dates[0], EndTime: dates[1]}); err != nil {
t.Fatal(err)
}
if _, _, err := ledgerRequest(&dto.LedgerQuery{BeginPayTime: dates[0], EndPayTime: dates[1]}, true); err != nil {
t.Fatal(err)
}
}
s := &AccountService{}
if _, err := s.List(context.Background(), &dto.BusinessAccountQuery{BeginTime: "2026-11-01 00:00:00"}); err == nil {
t.Fatal("account query silently ignored incomplete date range")
}
}
+4 -1
View File
@@ -31,6 +31,9 @@ func ledgerRequest(req *dto.LedgerQuery, spend bool) (business.LedgerFilter, bus
if spend {
begin, end = req.BeginPayTime, req.EndPayTime
}
if (strings.TrimSpace(begin) == "") != (strings.TrimSpace(end) == "") {
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
if strings.TrimSpace(begin) != "" && strings.TrimSpace(end) != "" {
location := time.FixedZone("Asia/Shanghai", 8*3600)
b, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(begin), location)
@@ -38,7 +41,7 @@ func ledgerRequest(req *dto.LedgerQuery, spend bool) (business.LedgerFilter, bus
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
e, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(end), location)
if err != nil {
if err != nil || e.Before(b) {
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
f.StartDate = &b
@@ -55,6 +55,10 @@ func ledgerWorkbook(sheet string, headers []string, rows [][]ledgerCell) ([]byte
}
}
last, _ := excelize.ColumnNumberToName(len(headers))
// 流式读取器依赖 dimension;Excelize 的初始 A1 范围不会随写入单元格自动扩展。
if err := file.SetSheetDimension(sheet, "A1:"+last+strconv.Itoa(len(rows)+1)); err != nil {
return nil, err
}
if err := file.SetColWidth(sheet, "A", last, 20); err != nil {
return nil, err
}
@@ -32,6 +32,9 @@ func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) {
t.Fatal(err)
}
defer f.Close()
if dimension, err := f.GetSheetDimension("注册明细"); err != nil || dimension != "A1:M2" {
t.Fatalf("注册导出范围不完整: %s %v", dimension, err)
}
amount, _ := f.GetCellValue("注册明细", "H2", excelize.Options{RawCellValue: true})
formula, _ := f.GetCellFormula("注册明细", "K2")
text, _ := f.GetCellValue("注册明细", "K2")
@@ -51,6 +54,9 @@ func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) {
t.Fatal(err)
}
defer pay.Close()
if dimension, err := pay.GetSheetDimension("充值明细"); err != nil || dimension != "A1:R2" {
t.Fatalf("充值导出范围不完整: %s %v", dimension, err)
}
order, _ := pay.GetCellValue("充值明细", "E2")
if order != "000000000000000001" {
t.Fatal("订单号前导零丢失")
+10 -1
View File
@@ -222,12 +222,21 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque
func (s *PaymentService) TestProvider(ctx context.Context, provider string) (*dto.PaymentTestResult, error) {
result, err := s.uc.TestProvider(ctx, provider)
// The usecase records provider diagnostics in server logs. Arbitrary adapter
// errors can contain URLs, credentials or SQL; never forward them over HTTP.
if err != nil {
err = paymentbiz.ErrPaymentTestFailed
}
if result == nil {
return nil, err
}
response := &dto.PaymentTestResult{Provider: result.Provider, TradeNo: result.TradeNo, Passed: result.Passed, FullFlow: result.FullFlow, Mode: result.Mode, Stages: make([]*dto.PaymentTestStage, 0, len(result.Stages))}
for _, stage := range result.Stages {
response.Stages = append(response.Stages, &dto.PaymentTestStage{Name: stage.Name, Status: stage.Status, Message: stage.Message, TradeNo: stage.TradeNo, Duration: stage.Duration})
message := stage.Message
if stage.Status == "failed" {
message = "该步骤失败,请检查配置和服务端日志"
}
response.Stages = append(response.Stages, &dto.PaymentTestStage{Name: stage.Name, Status: stage.Status, Message: message, TradeNo: stage.TradeNo, Duration: stage.Duration})
}
response.Result = paymentResultResponse(result.Result)
return response, err
+28
View File
@@ -0,0 +1,28 @@
package payment
import (
"context"
"errors"
paymentbiz "kra/internal/biz/payment"
"testing"
)
type diagnosticRepo struct{ paymentbiz.PaymentRepo }
func (diagnosticRepo) TestProvider(context.Context, string) (*paymentbiz.PaymentTestResult, error) {
return &paymentbiz.PaymentTestResult{Provider: "alipay", Stages: []paymentbiz.PaymentTestStage{
{Name: "config", Status: "passed", Message: "配置已检查"},
{Name: "create", Status: "failed", Message: "https://merchant:secret@example.test/private?token=secret"},
}}, errors.New("provider response contains a secret")
}
func TestProviderDiagnosticsDoNotExposeRawErrors(t *testing.T) {
s := NewPaymentService(paymentbiz.NewPaymentUsecase(diagnosticRepo{}, nil, nil, nil, nil, nil))
result, err := s.TestProvider(context.Background(), "alipay")
if err != paymentbiz.ErrPaymentTestFailed {
t.Fatalf("unsafe diagnostic error: %v", err)
}
if result == nil || len(result.Stages) != 2 || result.Stages[0].Message != "配置已检查" || result.Stages[1].Message != "该步骤失败,请检查配置和服务端日志" {
t.Fatalf("unexpected diagnostic response: %+v", result)
}
}
+32 -2
View File
@@ -211,7 +211,34 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e
if e.methodOrphaned(task.ID) {
return errTaskOrphaned
}
runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
options, err := taskbiz.MethodExecutionOptions(task.Params)
if err != nil {
return err
}
for attempt := 0; attempt <= options.MaxRetryTimes; attempt++ {
err = e.runMethodAttempt(ctx, task, method, options.Timeout)
if err == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || e.methodOrphaned(task.ID) {
return err
}
if attempt < options.MaxRetryTimes {
slog.WarnContext(ctx, "任务执行失败,准备重试", "task_id", task.ID, "method", task.MethodName, "attempt", attempt+1, "max_retry_times", options.MaxRetryTimes, "error", err)
timer := time.NewTimer(options.RetryInterval)
select {
case <-ctx.Done():
timer.Stop()
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return errTaskTimeout
}
return ctx.Err()
case <-timer.C:
}
}
}
return err
}
func (e *TaskExecutor) runMethodAttempt(ctx context.Context, task *taskbiz.TimedTask, method platformtask.MethodFunc, timeout time.Duration) error {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
done := make(chan error, 1)
go func() {
@@ -224,9 +251,12 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e
}()
select {
case err := <-done:
if errors.Is(err, context.DeadlineExceeded) {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return errTaskTimeout
}
if runCtx.Err() != nil {
return runCtx.Err()
}
return err
case <-runCtx.Done():
e.markMethodOrphan(task.ID, done)
+43
View File
@@ -52,6 +52,49 @@ func TestRunMethodConvertsPanicToError(t *testing.T) {
}
}
func TestRunMethodUsesImportedDeadlineAndRetries(t *testing.T) {
registry := platformtask.NewRegistry()
attempts := 0
registry.Register(platformtask.Method{Name: "migrated-retry", Run: func(ctx context.Context, _ json.RawMessage) error {
attempts++
deadline, ok := ctx.Deadline()
if !ok || time.Until(deadline) < 1590*time.Second || time.Until(deadline) > 1600*time.Second {
return errors.New("迁移的 1600 秒超时未生效")
}
if attempts < 3 {
return errors.New("temporary")
}
return nil
}})
executor := &TaskExecutor{methods: registry}
task := &taskbiz.TimedTask{MethodName: "migrated-retry", Params: []byte(`{"_execution":{"timeoutSeconds":1600,"maxRetryTimes":3,"retryIntervalSeconds":0}}`)}
if err := executor.runMethod(context.Background(), task); err != nil || attempts != 3 {
t.Fatalf("迁移重试未生效: attempts=%d err=%v", attempts, err)
}
}
func TestRunMethodCancellationStopsRetryDelay(t *testing.T) {
registry := platformtask.NewRegistry()
started := make(chan struct{})
registry.Register(platformtask.Method{Name: "cancel-retry", Run: func(context.Context, json.RawMessage) error { close(started); return errors.New("temporary") }})
executor := &TaskExecutor{methods: registry}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- executor.runMethod(ctx, &taskbiz.TimedTask{MethodName: "cancel-retry", Params: []byte(`{"_execution":{"maxRetryTimes":3,"retryIntervalSeconds":60}}`)})
}()
<-started
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("取消重试错误: %v", err)
}
case <-time.After(time.Second):
t.Fatal("重试等待没有响应取消")
}
}
func TestRunMethodUsesParentContext(t *testing.T) {
const methodName = "worker-test-parent-context"
started := make(chan struct{})
+12 -2
View File
@@ -20,10 +20,11 @@ type TaskMethods struct {
media *system.MediaUsecase
runtime *config.Store
reports *business.ReportBuildUsecase
alerts *business.AlertTriggerUsecase
}
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase) *TaskMethods {
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports}
func NewTaskMethods(tasks *taskbiz.TaskUsecase, maintenance *system.MaintenanceUsecase, media *system.MediaUsecase, runtime *config.Store, reports *business.ReportBuildUsecase, alerts *business.AlertTriggerUsecase) *TaskMethods {
return &TaskMethods{tasks: tasks, maintenance: maintenance, media: media, runtime: runtime, reports: reports, alerts: alerts}
}
func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
@@ -31,6 +32,15 @@ func (methods *TaskMethods) RegisterTasks(registry *platformtask.Registry) {
return
}
methods.registerBusinessReports(registry)
registry.Register(platformtask.Method{Name: "alertTriggerTask", Description: "按商务范围检测并生成预警", Run: func(ctx context.Context, _ json.RawMessage) error {
if methods.alerts == nil {
return errors.New("预警任务依赖未就绪")
}
if ctx == nil {
ctx = context.Background()
}
return methods.alerts.Run(ctx)
}})
descriptions := map[string]string{}
for _, task := range taskmodule.TimedTasks() {
descriptions[task.MethodName] = task.Description