Files
junhong_cmp_fiber/internal/application/packagesilence/backfill.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +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
outbound *OutboundRecorder
now func() time.Time
}
// NewBackfill 创建回填用例。
func NewBackfill(db *gorm.DB, writer *audit.Writer) *Backfill {
return &Backfill{db: db, audit: writer, outbound: NewOutboundRecorder(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: "回填套餐沉默期",
})
})
}
// OutboundBackfillReport 是出库时间回填清单。
type OutboundBackfillReport 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"`
}
// OutboundBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填出库时间(排除绑定在设备上的卡),
// 插入 backfill 出库记录;只更新为空的,可重复执行。
func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*OutboundBackfillReport, error) {
report := &OutboundBackfillReport{}
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.outbound_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.outbound_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.outboundBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.OutboundAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil {
return nil, err
}
if err := b.outboundBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.OutboundAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil {
return nil, err
}
return report, nil
}
func (b *Backfill) outboundBatches(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.outbound_at IS NULL AND a.id > ? "+extra, lastID).
Order("a.id ASC").Limit(constants.SilenceOutboundBackfillBatchSize).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.outbound.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.SilenceOutboundBackfillBatchSize {
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
}