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