199 lines
7.8 KiB
Go
199 lines
7.8 KiB
Go
package business
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"kra/internal/biz/business"
|
|
)
|
|
|
|
type effectivenessRepo struct{ data Provider }
|
|
|
|
func NewEffectivenessRepo(data Provider) business.EffectivenessRepo {
|
|
return &effectivenessRepo{data: data}
|
|
}
|
|
func (r *effectivenessRepo) State(ctx context.Context) (*business.EffectivenessState, error) {
|
|
var po effectivenessStatePO
|
|
if err := r.data.DB().WithContext(ctx).First(&po, "id=1").Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
s := &business.EffectivenessState{RuleKey: po.RuleKey, Revision: po.Revision}
|
|
if po.BuiltAt != nil {
|
|
s.BuiltAt = *po.BuiltAt
|
|
}
|
|
return s, nil
|
|
}
|
|
func (r *effectivenessRepo) Candidates(ctx context.Context, key string, duration, now int64, full bool) ([]*business.EffectivenessCandidate, error) {
|
|
db := r.data.DB().WithContext(ctx)
|
|
available, err := businessTablesAvailable(db, "tab_promote", "tab_spend")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !available {
|
|
return nil, business.ErrEffectivenessUnavailable
|
|
}
|
|
q := db.Table("tab_promote p").Joins("LEFT JOIN sys_business_channel_effectiveness e ON e.rule_key=? AND e.source_db=p.source_db AND e.promote_id=p.id", key).Where("p.source_db IN ?", []string{"lx", "hw", "yq"})
|
|
if !full {
|
|
q = q.Where("e.promote_id IS NULL OR e.complete=?", false)
|
|
}
|
|
var pos []struct {
|
|
ID uint64
|
|
SourceDB string `gorm:"column:source_db"`
|
|
CreateTime, QualifiedAt *int64
|
|
WindowPay string
|
|
}
|
|
if err := q.Select("p.id,p.source_db,p.create_time,e.qualified_at,e.window_pay").Order("p.id,p.source_db").Scan(&pos).Error; err != nil {
|
|
return nil, business.ErrStorage
|
|
}
|
|
rows := make([]*business.EffectivenessCandidate, 0, len(pos))
|
|
for _, po := range pos {
|
|
rows = append(rows, &business.EffectivenessCandidate{ChannelKey: business.ChannelKey{ID: po.ID, SourceDB: po.SourceDB}, CreatedAt: po.CreateTime, PreviousAt: po.QualifiedAt, PreviousPay: po.WindowPay})
|
|
}
|
|
return rows, nil
|
|
}
|
|
func (r *effectivenessRepo) Charges(ctx context.Context, candidates []*business.EffectivenessCandidate, duration, now int64, full bool, consume func(business.EffectivenessCharge) error) error {
|
|
db := r.data.DB().WithContext(ctx)
|
|
// Bound by now as future charges are excluded. Avoid unsigned subtraction
|
|
// on legacy pay_time columns and overflow for very large configured windows.
|
|
windowSeconds := min(duration, now+1)
|
|
// The full-history scan is reserved for explicit rebuilds. Minute jobs
|
|
// must keep their candidate restriction in SQL, even with many open windows.
|
|
if full && len(candidates) > 3000 {
|
|
return r.scanAllCharges(db, candidates, windowSeconds, now, consume)
|
|
}
|
|
for start := 0; start < len(candidates); start += 300 {
|
|
keys := [][]any{}
|
|
ids := []uint64{}
|
|
for _, c := range candidates[start:min(start+300, len(candidates))] {
|
|
if c.CreatedAt != nil && *c.CreatedAt > 0 && *c.CreatedAt <= now {
|
|
keys = append(keys, []any{c.SourceDB, c.ID})
|
|
ids = append(ids, c.ID)
|
|
}
|
|
}
|
|
if len(keys) == 0 {
|
|
continue
|
|
}
|
|
q := db.Table("tab_spend s").Joins("JOIN tab_promote p ON p.source_db=s.source_db AND p.id=s.promote_id").Where("s.promote_id IN ? AND (s.source_db,s.promote_id) IN ? AND s.pay_status=1", ids, keys).Where("s.pay_time>=p.create_time AND s.pay_time<=? AND s.pay_time<p.create_time+?", now, windowSeconds)
|
|
rows, err := q.Select("s.source_db,s.promote_id,s.pay_time,CAST(COALESCE(s.pay_amount,0) AS CHAR)").Order("s.promote_id,s.source_db,s.pay_time,s.id").Rows()
|
|
if err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
for rows.Next() {
|
|
var c business.EffectivenessCharge
|
|
if err := rows.Scan(&c.SourceDB, &c.ID, &c.PaidAt, &c.Amount); err != nil {
|
|
rows.Close()
|
|
return business.ErrStorage
|
|
}
|
|
if err := consume(c); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
}
|
|
err = rows.Err()
|
|
rows.Close()
|
|
if err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Large rebuilds stream the covering index once. Repeating hundreds of IN
|
|
// batches can make the optimizer repeatedly sort large payment ranges.
|
|
func (r *effectivenessRepo) scanAllCharges(db *gorm.DB, candidates []*business.EffectivenessCandidate, windowSeconds, now int64, consume func(business.EffectivenessCharge) error) error {
|
|
selected := make(map[business.ChannelKey]bool, len(candidates))
|
|
for _, c := range candidates {
|
|
selected[c.ChannelKey] = true
|
|
}
|
|
table, join := "tab_spend s", "JOIN tab_promote p ON p.source_db=s.source_db AND p.id=s.promote_id"
|
|
if db.Dialector.Name() == "mysql" {
|
|
join = "STRAIGHT_JOIN tab_promote p ON p.source_db=s.source_db AND p.id=s.promote_id"
|
|
if db.Migrator().HasIndex("tab_spend", "idx_sw_spend_effective_scan") {
|
|
table += " FORCE INDEX (idx_sw_spend_effective_scan)"
|
|
}
|
|
}
|
|
q := db.Table(table).Joins(join).Where("s.pay_status=1 AND s.source_db IN ? AND p.create_time>0 AND s.pay_time>=p.create_time AND s.pay_time<=? AND s.pay_time<p.create_time+?", []string{"lx", "hw", "yq"}, now, windowSeconds)
|
|
rows, err := q.Select("s.source_db,s.promote_id,s.pay_time,CAST(COALESCE(s.pay_amount,0) AS CHAR)").Order("s.promote_id,s.source_db,s.pay_time,s.id").Rows()
|
|
if err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c business.EffectivenessCharge
|
|
if err := rows.Scan(&c.SourceDB, &c.ID, &c.PaidAt, &c.Amount); err != nil {
|
|
return business.ErrStorage
|
|
}
|
|
if selected[c.ChannelKey] {
|
|
if err := consume(c); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return storageError(rows.Err())
|
|
}
|
|
func (r *effectivenessRepo) Publish(ctx context.Context, expected *business.EffectivenessState, rule business.OtherSettings, key string, rows []business.EffectivenessResult, save bool) (*business.OtherSettings, error) {
|
|
var saved = rule
|
|
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var state effectivenessStatePO
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&state, "id=1").Error; err != nil {
|
|
return err
|
|
}
|
|
if state.Revision != expected.Revision {
|
|
return business.ErrEffectivenessConflict
|
|
}
|
|
if !save {
|
|
var current otherSettingsPO
|
|
result := tx.Where(map[string]any{"key": otherSettingsKey}).Limit(1).Find(¤t)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
active := business.DefaultOtherSettings()
|
|
if result.RowsAffected > 0 {
|
|
v, err := toBizOtherSettings(current)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
active = *v
|
|
}
|
|
if business.EffectivenessRuleKey(active) != key {
|
|
return business.ErrEffectivenessConflict
|
|
}
|
|
}
|
|
now := time.Now()
|
|
for start := 0; start < len(rows); start += 500 {
|
|
pos := make([]effectivenessPO, 0, min(500, len(rows)-start))
|
|
for _, row := range rows[start:min(start+500, len(rows))] {
|
|
pos = append(pos, effectivenessPO{RuleKey: key, SourceDB: row.SourceDB, PromoteID: row.ID, QualifiedAt: row.QualifiedAt, WindowPay: row.WindowPay, Complete: row.Complete, UpdatedAt: now})
|
|
}
|
|
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "rule_key"}, {Name: "source_db"}, {Name: "promote_id"}}, DoUpdates: clause.AssignmentColumns([]string{"qualified_at", "window_pay", "complete", "updated_at"})}).Create(&pos).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if save {
|
|
po := newOtherSettings(rule)
|
|
var old otherSettingsPO
|
|
result := tx.Where(map[string]any{"key": otherSettingsKey}).Limit(1).Find(&old)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
if err := tx.Create(&po).Error; err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := tx.Model(&old).Updates(map[string]any{"value": po.Value, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
saved.UpdatedAt = now
|
|
}
|
|
return tx.Model(&effectivenessStatePO{}).Where("id=1").Updates(map[string]any{"rule_key": key, "revision": state.Revision + 1, "built_at": now}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, storageError(err)
|
|
}
|
|
return &saved, nil
|
|
}
|