Files
junhong_cmp_fiber/internal/application/packagesilence/backfill.go
邹进 62ec94adec
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名
- 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段
- 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:47:02 +09:00

291 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package packagesilence
import (
"context"
"sort"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与首次购买套餐时间回填,均先出清单、只处理空值、可重复执行。
// 可在总开关关闭时运行;不给上线宽限,截止早于上线日且期内未实名的,打开总开关后过期。
type Backfill struct {
db *gorm.DB
audit *audit.Writer
firstPurchase *FirstPurchaseRecorder
now func() time.Time
}
// NewBackfill 创建回填用例。
func NewBackfill(db *gorm.DB, writer *audit.Writer) *Backfill {
return &Backfill{db: db, audit: writer, firstPurchase: NewFirstPurchaseRecorder(writer), now: time.Now}
}
// SilenceBackfillReport 是沉默期回填影响清单。
type SilenceBackfillReport struct {
Candidates int `json:"candidates"` // 待回填的套餐记录数(待实名、实名即生效主套餐、沉默期为空)
UnconfiguredPackages map[string]int `json:"unconfigured_packages"` // 套餐未配置默认沉默期、不回填的记录数(按套餐)
UnconfiguredCount int `json:"unconfigured_count"` // 未配置套餐的记录合计
Backfillable int `json:"backfillable"` // 可回填记录数
EndedInPeriod int `json:"ended_in_period"` // 截止前已实名,记为结束
RealnamedWithoutTime int `json:"realnamed_without_time"` // 已实名但实名时间为空(视为期内实名并补写)
WillExpire int `json:"will_expire"` // 打开总开关后将过期
WillExpireNoPaidExtend int `json:"will_expire_no_paid_extend"` // 将过期且无法付费延期(只能特殊延期)
Waiting int `json:"waiting"` // 回填后仍在期内等待实名
ByShop map[string]int `json:"by_shop"` // 按代理(资产当前店铺)分布
ByPackage map[string]int `json:"by_package"` // 按套餐分布
Written int `json:"written"` // 执行模式:实际写入数
SkippedConcurrentUpdate int `json:"skipped_concurrent_update"` // 执行模式:已被其他入口写入而跳过
}
type silenceCandidate struct {
model.PackageUsage
OrderPaidAt *time.Time `gorm:"column:order_paid_at"`
PkgDays *int `gorm:"column:pkg_days"`
PkgName string `gorm:"column:pkg_name"`
PkgSeries uint `gorm:"column:pkg_series"`
ShopName string `gorm:"column:shop_name"`
}
// SilenceBackfill 生成影响清单;execute 为 true 时按批写入(只处理沉默期为空的记录,可重复执行)。
func (b *Backfill) SilenceBackfill(ctx context.Context, execute bool) (*SilenceBackfillReport, error) {
report := &SilenceBackfillReport{UnconfiguredPackages: map[string]int{}, ByShop: map[string]int{}, ByPackage: map[string]int{}}
now := b.now()
lastID := uint(0)
policyCache := map[uint]PolicyMatch{}
for {
var rows []silenceCandidate
err := b.db.WithContext(ctx).Table("tb_package_usage AS u").
Joins("LEFT JOIN tb_order AS o ON o.id = u.order_id").
Joins("LEFT JOIN tb_package AS p ON p.id = u.package_id").
Joins("LEFT JOIN tb_iot_card AS c ON c.id = u.iot_card_id AND COALESCE(u.device_id, 0) = 0").
Joins("LEFT JOIN tb_device AS d ON d.id = u.device_id AND COALESCE(u.device_id, 0) > 0").
Joins("LEFT JOIN tb_shop AS s ON s.id = COALESCE(d.shop_id, c.shop_id)").
Select("u.*, o.paid_at AS order_paid_at, p.silence_period_days AS pkg_days, COALESCE(p.package_name, u.package_name) AS pkg_name, COALESCE(p.series_id, 0) AS pkg_series, COALESCE(s.shop_name, '未分配店铺') AS shop_name").
Where("u.deleted_at IS NULL AND u.status = ? AND u.pending_realname_activation = ? AND u.master_usage_id IS NULL AND u.expiry_base_snapshot = ? AND u.silence_status IS NULL AND u.id > ?",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation, lastID).
Order("u.id ASC").Limit(constants.SilenceBackfillBatchSize).Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待回填沉默期的套餐记录失败")
}
if len(rows) == 0 {
break
}
for index := range rows {
row := &rows[index]
lastID = row.ID
report.Candidates++
if row.PkgDays == nil {
report.UnconfiguredPackages[row.PkgName]++
report.UnconfiguredCount++
continue
}
report.Backfillable++
report.ByShop[row.ShopName]++
report.ByPackage[row.PkgName]++
if err := b.backfillOne(ctx, row, now, execute, report, policyCache); err != nil {
return nil, err
}
}
}
return report, nil
}
func (b *Backfill) backfillOne(ctx context.Context, row *silenceCandidate, now time.Time, execute bool, report *SilenceBackfillReport, policyCache map[uint]PolicyMatch) error {
start := row.CreatedAt
if row.OrderPaidAt != nil {
start = *row.OrderPaidAt
}
days := *row.PkgDays
deadline := packagedomain.SilenceDeadline(start, days)
facts, err := loadRealnameFacts(ctx, b.db, &row.PackageUsage)
if err != nil {
return err
}
waiting := constants.SilenceStatusWaiting
judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{
Status: waiting, DeadlineAt: &deadline, Realnamed: facts.realnamed, RealnameAt: facts.realnameAt, Now: now,
})
status := constants.SilenceStatusWaiting
switch judgement.Decision {
case packagedomain.SilenceDecisionEnd:
status = constants.SilenceStatusEnded
report.EndedInPeriod++
if judgement.BackfillRealnameAt || len(facts.backfillCardIDs) > 0 {
report.RealnamedWithoutTime++
}
case packagedomain.SilenceDecisionExpire:
report.WillExpire++
match, ok := policyCache[row.PackageID]
if !ok {
match, err = matchPolicy(ctx, b.db, row.PackageID, row.PkgSeries, now)
if err != nil {
return err
}
policyCache[row.PackageID] = match
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.CanExtend {
report.WillExpireNoPaidExtend++
}
default:
report.Waiting++
}
if !execute {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL AND status = ?", row.ID, constants.PackageUsageStatusPending).
Updates(map[string]any{
"silence_status": status, "silence_start_at": start, "silence_period_days": days,
"silence_deadline_at": deadline, "updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填套餐沉默期失败")
}
if result.RowsAffected != 1 {
report.SkippedConcurrentUpdate++
return nil
}
if status == constants.SilenceStatusEnded {
if _, err := backfillRealnameAt(ctx, tx, facts, now); err != nil {
return err
}
}
usage := row.PackageUsage
usage.SilenceStatus, usage.SilenceStartAt, usage.SilencePeriodDays, usage.SilenceDeadlineAt = &status, &start, &days, &deadline
report.Written++
return writeRecord(ctx, tx, b.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventBackfill, Trigger: constants.SilenceTriggerBackfill,
DefaultDays: &days, RealnameAt: facts.realnameAt, Reason: "历史数据回填:按购买时间起算,不给上线宽限",
Extra: map[string]any{"decision": string(judgement.Decision)},
AuditAction: constants.AuditActionUsageSilenceBackfilled, AuditSummary: "回填套餐沉默期",
})
})
}
// FirstPurchaseBackfillReport 是首次购买套餐时间回填清单。
type FirstPurchaseBackfillReport struct {
CardBackfillable int `json:"card_backfillable"` // 可回填的卡(当前世代有已支付或已退款的单卡订单,未绑定设备)
CardWithoutOrder int `json:"card_without_order"` // 无订单、保持为空的卡
CardBoundToDevice int `json:"card_bound_to_device"` // 绑定在设备上、不记录的卡
DeviceBackfillable int `json:"device_backfillable"` // 可回填的设备
DeviceWithoutOrder int `json:"device_without_order"` // 无订单、保持为空的设备
CardWritten int `json:"card_written"` // 执行模式:写入的卡
DeviceWritten int `json:"device_written"` // 执行模式:写入的设备
}
type firstOrderRow struct {
AssetID uint `gorm:"column:asset_id"`
Generation int `gorm:"column:generation"`
PaidAt time.Time `gorm:"column:paid_at"`
OrderID uint `gorm:"column:order_id"`
OrderNo string `gorm:"column:order_no"`
}
// FirstPurchaseBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填首次购买套餐时间(排除绑定在设备上的卡),
// 插入 backfill 首次购买套餐记录;只更新为空的,可重复执行。
func (b *Backfill) FirstPurchaseBackfill(ctx context.Context, execute bool) (*FirstPurchaseBackfillReport, error) {
report := &FirstPurchaseBackfillReport{}
db := b.db.WithContext(ctx)
countWithout := func(table, orderType, column string, extra string) (int64, error) {
var count int64
err := db.Table(table+" AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL "+extra).
Where("NOT EXISTS (SELECT 1 FROM tb_order o WHERE o.order_type = ? AND o."+column+" = a.id AND o.generation = a.generation AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL)",
orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).Count(&count).Error
return count, err
}
notBound := "AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)"
cardWithout, err := countWithout("tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的卡失败")
}
deviceWithout, err := countWithout("tb_device", model.OrderTypeDevice, "device_id", "")
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的设备失败")
}
var bound int64
if err := db.Table("tb_iot_card AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)").Count(&bound).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计绑定在设备上的卡失败")
}
report.CardWithoutOrder, report.DeviceWithoutOrder, report.CardBoundToDevice = int(cardWithout), int(deviceWithout), int(bound)
if err := b.firstPurchaseBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.FirstPurchaseAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil {
return nil, err
}
if err := b.firstPurchaseBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.FirstPurchaseAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil {
return nil, err
}
return report, nil
}
func (b *Backfill) firstPurchaseBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error {
lastID := uint(0)
for {
var rows []firstOrderRow
err := b.db.WithContext(ctx).Table(table+" AS a").
Joins(`JOIN LATERAL (
SELECT o.id AS order_id, o.order_no, o.paid_at FROM tb_order o
WHERE o.order_type = ? AND o.`+column+` = a.id AND o.generation = a.generation
AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL
ORDER BY o.paid_at ASC, o.id ASC LIMIT 1
) AS f ON TRUE`, orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).
Select("a.id AS asset_id, a.generation, f.paid_at, f.order_id, f.order_no").
Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND a.id > ? "+extra, lastID).
Order("a.id ASC").Limit(constants.SilenceFirstPurchaseBackfillBatchSize).Scan(&rows).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填首次购买套餐时间的资产失败")
}
if len(rows) == 0 {
return nil
}
for _, row := range rows {
lastID = row.AssetID
*backfillable++
if !execute {
continue
}
err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
inserted, err := b.firstPurchase.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo)
if inserted {
*written++
}
return err
})
if err != nil {
return err
}
}
if len(rows) < constants.SilenceFirstPurchaseBackfillBatchSize {
return nil
}
}
}
// SortedCounts 把分布按数量倒序输出,便于清单阅读。
func SortedCounts(values map[string]int) []struct {
Name string
Count int
} {
result := make([]struct {
Name string
Count int
}, 0, len(values))
for name, count := range values {
result = append(result, struct {
Name string
Count int
}{name, count})
}
sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count })
return result
}