refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s

- 字段 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>
This commit is contained in:
邹进
2026-10-27 16:47:02 +09:00
parent 909f4d0ec2
commit 62ec94adec
46 changed files with 570 additions and 560 deletions

View File

@@ -95,13 +95,13 @@ type Service struct {
silence SilenceRecorder
}
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与出库时间(无人参与路径只用默认值)。
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与首次购买套餐时间(无人参与路径只用默认值)。
type SilenceRecorder interface {
ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error
RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error
}
// SetSilenceRecorder 注入沉默期与出库时间写入接缝;未注入时不记录。
// SetSilenceRecorder 注入沉默期与首次购买套餐时间写入接缝;未注入时不记录。
func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) {
s.silence = recorder
}

View File

@@ -14,18 +14,18 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与出库时间回填,均先出清单、只处理空值、可重复执行。
// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与首次购买套餐时间回填,均先出清单、只处理空值、可重复执行。
// 可在总开关关闭时运行;不给上线宽限,截止早于上线日且期内未实名的,打开总开关后过期。
type Backfill struct {
db *gorm.DB
audit *audit.Writer
outbound *OutboundRecorder
now func() time.Time
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, outbound: NewOutboundRecorder(writer), now: time.Now}
return &Backfill{db: db, audit: writer, firstPurchase: NewFirstPurchaseRecorder(writer), now: time.Now}
}
// SilenceBackfillReport 是沉默期回填影响清单。
@@ -173,8 +173,8 @@ func (b *Backfill) backfillOne(ctx context.Context, row *silenceCandidate, now t
})
}
// OutboundBackfillReport 是出库时间回填清单。
type OutboundBackfillReport struct {
// FirstPurchaseBackfillReport 是首次购买套餐时间回填清单。
type FirstPurchaseBackfillReport struct {
CardBackfillable int `json:"card_backfillable"` // 可回填的卡(当前世代有已支付或已退款的单卡订单,未绑定设备)
CardWithoutOrder int `json:"card_without_order"` // 无订单、保持为空的卡
CardBoundToDevice int `json:"card_bound_to_device"` // 绑定在设备上、不记录的卡
@@ -192,14 +192,14 @@ type firstOrderRow struct {
OrderNo string `gorm:"column:order_no"`
}
// OutboundBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填出库时间(排除绑定在设备上的卡),
// 插入 backfill 出库记录;只更新为空的,可重复执行。
func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*OutboundBackfillReport, error) {
report := &OutboundBackfillReport{}
// 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.outbound_at IS NULL "+extra).
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
@@ -214,20 +214,20 @@ func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*Outboun
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 {
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.outboundBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.OutboundAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil {
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.outboundBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.OutboundAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil {
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) outboundBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error {
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
@@ -239,10 +239,10 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column
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
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, "查询待回填出库时间的资产失败")
return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填首次购买套餐时间的资产失败")
}
if len(rows) == 0 {
return nil
@@ -254,7 +254,7 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column
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)
inserted, err := b.firstPurchase.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo)
if inserted {
*written++
}
@@ -264,7 +264,7 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column
return err
}
}
if len(rows) < constants.SilenceOutboundBackfillBatchSize {
if len(rows) < constants.SilenceFirstPurchaseBackfillBatchSize {
return nil
}
}

View File

@@ -11,15 +11,15 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ExchangeHook 是换货流程中的沉默期与出库时间接缝(技术方案 §9、§17.3.2)。
// ExchangeHook 是换货流程中的沉默期与首次购买套餐时间接缝(技术方案 §9、§17.3.2)。
type ExchangeHook struct {
setter *Setter
outbound *OutboundRecorder
setter *Setter
firstPurchase *FirstPurchaseRecorder
}
// NewExchangeHook 创建换货接缝。
func NewExchangeHook(setter *Setter, outbound *OutboundRecorder) *ExchangeHook {
return &ExchangeHook{setter: setter, outbound: outbound}
func NewExchangeHook(setter *Setter, firstPurchase *FirstPurchaseRecorder) *ExchangeHook {
return &ExchangeHook{setter: setter, firstPurchase: firstPurchase}
}
// OnUsagesMigratedInTx 换货迁移套餐记录后写沉默期流水(exchange):沉默期字段随记录迁移,不重新起算,截止时间不变;
@@ -52,27 +52,19 @@ func (h *ExchangeHook) OnUsagesMigratedInTx(ctx context.Context, tx *gorm.DB, or
return nil
}
// OnNewAssetInTx 换货完成时为新资产写出库时间(换货完成时间)与 exchange 出库记录;已有出库时间的不覆盖。
func (h *ExchangeHook) OnNewAssetInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, order *model.ExchangeOrder, completedAt time.Time) error {
if h == nil || h.outbound == nil {
return nil
}
return h.outbound.RecordForExchangeInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, order.ID, order.ExchangeNo, completedAt)
}
// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空出库时间并结束旧世代出库记录。
// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代首次购买套餐记录。
func (h *ExchangeHook) OnOldAssetRenewedInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, at time.Time) error {
if h == nil || h.outbound == nil {
if h == nil || h.firstPurchase == nil {
return nil
}
return h.outbound.EndGenerationInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, oldGeneration, at)
return h.firstPurchase.EndGenerationInTx(ctx, tx, normalizeFirstPurchaseAssetType(assetType), assetID, oldGeneration, at)
}
func normalizeOutboundAssetType(assetType string) string {
func normalizeFirstPurchaseAssetType(assetType string) string {
switch assetType {
case "card", constants.OutboundAssetTypeIotCard:
return constants.OutboundAssetTypeIotCard
case "card", constants.FirstPurchaseAssetTypeIotCard:
return constants.FirstPurchaseAssetTypeIotCard
default:
return constants.OutboundAssetTypeDevice
return constants.FirstPurchaseAssetTypeDevice
}
}

View File

@@ -182,7 +182,7 @@ func lockCarrier(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) er
return nil
}
var id uint
if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}).
if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").Where("id = ?", assetID).Scan(&id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐载体失败")
}

View File

@@ -132,7 +132,7 @@ func (s *ExtensionService) loadUsage(ctx context.Context, db *gorm.DB, usageID u
return &usage, nil
}
var count int64
query := db.WithContext(ctx).Table(outboundTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID)
query := db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID)
if err := middleware.ApplyStrictShopFilter(ctx, query).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
}

View File

@@ -0,0 +1,202 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// FirstPurchaseRecorder 写资产首次购买套餐时间与首次购买套餐记录(技术方案 §17)。
// 首次购买套餐时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。
type FirstPurchaseRecorder struct {
audit *audit.Writer
}
// NewFirstPurchaseRecorder 创建首次购买套餐时间写入用例。
func NewFirstPurchaseRecorder(writer *audit.Writer) *FirstPurchaseRecorder {
return &FirstPurchaseRecorder{audit: writer}
}
// RecordForOrderInTx 在订单发放套餐的事务内写首次购买套餐时间:只在为空且世代与订单一致时写入,
// 影响行数为 1 时插入首次购买套餐记录;当前绑定在设备上的卡不记录(套餐在设备上)。
func (r *FirstPurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if r == nil || order == nil {
return nil
}
paidAt := time.Now()
if order.PaidAt != nil {
paidAt = *order.PaidAt
}
generation := order.Generation
if generation <= 0 {
generation = 1
}
orderID := order.ID
switch {
case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil:
bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID)
if err != nil || bound {
return err
}
return r.write(ctx, tx, constants.FirstPurchaseAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil:
return r.write(ctx, tx, constants.FirstPurchaseAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
default:
return nil
}
}
// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代记录。
// oldGeneration 为换货前的世代。
func (r *FirstPurchaseRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error {
if r == nil {
return nil
}
table := firstPurchaseTable(assetType)
if table == "" {
return nil
}
if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("first_purchase_at", nil).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产首次购买套餐时间失败")
}
affected, err := postgres.NewAssetFirstPurchaseRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.FirstPurchaseEndReasonExchange)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代首次购买套餐记录失败")
}
if affected > 0 && r.audit != nil {
record := &model.AssetFirstPurchaseRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration}
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseEnded, "换货回库结束首次购买套餐记录",
audit.AssetFirstPurchaseResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.FirstPurchaseEndReasonExchange}), nil)
}
return nil
}
// RecordBackfillInTx 历史回填:只更新为空的首次购买套餐时间并插入 backfill 记录,返回是否写入。
func (r *FirstPurchaseRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, orderID uint, orderNo string) (bool, error) {
id := orderID
before, err := r.countRecords(ctx, tx, assetType, assetID, generation)
if err != nil {
return false, err
}
if err := r.write(ctx, tx, assetType, assetID, generation, firstPurchaseAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceBackfill, OrderID: &id, OrderNo: orderNo,
}); err != nil {
return false, err
}
after, err := r.countRecords(ctx, tx, assetType, assetID, generation)
return after > before, err
}
func (r *FirstPurchaseRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AssetFirstPurchaseRecord{}).
Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error
return count, err
}
func (r *FirstPurchaseRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, record *model.AssetFirstPurchaseRecord) error {
table := firstPurchaseTable(assetType)
result := tx.WithContext(ctx).Table(table).
Where("id = ? AND generation = ? AND first_purchase_at IS NULL AND deleted_at IS NULL", assetID, generation).
Update("first_purchase_at", firstPurchaseAt)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产首次购买套餐时间失败")
}
if result.RowsAffected != 1 {
return nil
}
identifier, shopID, err := firstPurchaseAssetSnapshot(ctx, tx, assetType, assetID)
if err != nil {
return err
}
record.AssetType = assetType
record.AssetID = assetID
record.AssetIdentifierSnapshot = identifier
record.Generation = generation
record.FirstPurchaseAt = firstPurchaseAt
record.ShopID = shopID
record.CreatedAt = time.Now()
inserted, err := postgres.NewAssetFirstPurchaseRecordStore(tx).CreateIfAbsent(ctx, record)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入资产首次购买套餐记录失败")
}
if inserted && r.audit != nil {
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseRecorded, "记录资产首次购买套餐时间",
audit.AssetFirstPurchaseResource(record, map[string]any{"first_purchase_at": firstPurchaseAt, "source": record.Source, "order_no": record.OrderNo}), nil)
}
return nil
}
func firstPurchaseTable(assetType string) string {
switch assetType {
case constants.FirstPurchaseAssetTypeIotCard:
return "tb_iot_card"
case constants.FirstPurchaseAssetTypeDevice:
return "tb_device"
default:
return ""
}
}
func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
return count > 0, nil
}
func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) {
var generation int
if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败")
}
return generation, nil
}
func firstPurchaseAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) {
if assetType == constants.FirstPurchaseAssetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return identifier, device.ShopID, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败")
}
return card.ICCID, card.ShopID, nil
}
// PurchaseRecorder 组合沉默期设置与首次购买套餐时间写入,供续费等其他发放套餐路径注入。
type PurchaseRecorder struct {
Setter *Setter
FirstPurchase *FirstPurchaseRecorder
}
// ApplyOnUsageCreatedInTx 设置沉默期。
func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger)
}
// RecordForOrderInTx 写首次购买套餐时间。
func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
return r.FirstPurchase.RecordForOrderInTx(ctx, tx, order)
}

View File

@@ -671,7 +671,7 @@ func isUniqueViolation(err error) bool {
func assetShopID(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) uint {
assetType, assetID := usageAsset(usage)
var shopID *uint
_ = db.WithContext(ctx).Table(outboundTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error
_ = db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error
if shopID == nil {
return 0
}

View File

@@ -1,223 +0,0 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// OutboundRecorder 写资产出库时间与出库记录(技术方案 §17)。
// 出库时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。
type OutboundRecorder struct {
audit *audit.Writer
}
// NewOutboundRecorder 创建出库时间写入用例。
func NewOutboundRecorder(writer *audit.Writer) *OutboundRecorder {
return &OutboundRecorder{audit: writer}
}
// RecordForOrderInTx 在订单发放套餐的事务内写出库时间:只在为空且世代与订单一致时写入,
// 影响行数为 1 时插入出库记录;当前绑定在设备上的卡不记录(套餐在设备上)。
func (r *OutboundRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if r == nil || order == nil {
return nil
}
paidAt := time.Now()
if order.PaidAt != nil {
paidAt = *order.PaidAt
}
generation := order.Generation
if generation <= 0 {
generation = 1
}
orderID := order.ID
switch {
case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil:
bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID)
if err != nil || bound {
return err
}
return r.write(ctx, tx, constants.OutboundAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil:
return r.write(ctx, tx, constants.OutboundAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
default:
return nil
}
}
// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空出库时间并结束旧世代记录。
// oldGeneration 为换货前的世代。
func (r *OutboundRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error {
if r == nil {
return nil
}
table := outboundTable(assetType)
if table == "" {
return nil
}
if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("outbound_at", nil).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产出库时间失败")
}
affected, err := postgres.NewAssetOutboundRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.OutboundEndReasonExchange)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代出库记录失败")
}
if affected > 0 && r.audit != nil {
record := &model.AssetOutboundRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration, Source: constants.OutboundSourceExchange}
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundEnded, "换货回库结束出库记录",
audit.AssetOutboundResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.OutboundEndReasonExchange}), nil)
}
return nil
}
// RecordForExchangeInTx 为换货后的新资产写出库时间(换货完成时间)并插入 exchange 记录;已有出库时间的不覆盖。
func (r *OutboundRecorder) RecordForExchangeInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, exchangeOrderID uint, exchangeNo string, completedAt time.Time) error {
if r == nil {
return nil
}
if assetType == constants.OutboundAssetTypeIotCard {
bound, err := cardBoundToDevice(ctx, tx, assetID)
if err != nil || bound {
return err
}
}
generation, err := currentGeneration(ctx, tx, assetType, assetID)
if err != nil {
return err
}
id := exchangeOrderID
return r.write(ctx, tx, assetType, assetID, generation, completedAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceExchange, ExchangeOrderID: &id, ExchangeNo: exchangeNo,
})
}
// RecordBackfillInTx 历史回填:只更新为空的出库时间并插入 backfill 记录,返回是否写入。
func (r *OutboundRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, orderID uint, orderNo string) (bool, error) {
id := orderID
before, err := r.countRecords(ctx, tx, assetType, assetID, generation)
if err != nil {
return false, err
}
if err := r.write(ctx, tx, assetType, assetID, generation, outboundAt, &model.AssetOutboundRecord{
Source: constants.OutboundSourceBackfill, OrderID: &id, OrderNo: orderNo,
}); err != nil {
return false, err
}
after, err := r.countRecords(ctx, tx, assetType, assetID, generation)
return after > before, err
}
func (r *OutboundRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AssetOutboundRecord{}).
Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error
return count, err
}
func (r *OutboundRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, record *model.AssetOutboundRecord) error {
table := outboundTable(assetType)
result := tx.WithContext(ctx).Table(table).
Where("id = ? AND generation = ? AND outbound_at IS NULL AND deleted_at IS NULL", assetID, generation).
Update("outbound_at", outboundAt)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产出库时间失败")
}
if result.RowsAffected != 1 {
return nil
}
identifier, shopID, err := outboundAssetSnapshot(ctx, tx, assetType, assetID)
if err != nil {
return err
}
record.AssetType = assetType
record.AssetID = assetID
record.AssetIdentifierSnapshot = identifier
record.Generation = generation
record.OutboundAt = outboundAt
record.ShopID = shopID
record.CreatedAt = time.Now()
inserted, err := postgres.NewAssetOutboundRecordStore(tx).CreateIfAbsent(ctx, record)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入资产出库记录失败")
}
if inserted && r.audit != nil {
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundRecorded, "记录资产出库时间",
audit.AssetOutboundResource(record, map[string]any{"outbound_at": outboundAt, "source": record.Source, "order_no": record.OrderNo, "exchange_no": record.ExchangeNo}), nil)
}
return nil
}
func outboundTable(assetType string) string {
switch assetType {
case constants.OutboundAssetTypeIotCard:
return "tb_iot_card"
case constants.OutboundAssetTypeDevice:
return "tb_device"
default:
return ""
}
}
func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
return count > 0, nil
}
func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) {
var generation int
if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败")
}
return generation, nil
}
func outboundAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) {
if assetType == constants.OutboundAssetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return identifier, device.ShopID, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败")
}
return card.ICCID, card.ShopID, nil
}
// PurchaseRecorder 组合沉默期设置与出库时间写入,供续费等其他发放套餐路径注入。
type PurchaseRecorder struct {
Setter *Setter
Outbound *OutboundRecorder
}
// ApplyOnUsageCreatedInTx 设置沉默期。
func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger)
}
// RecordForOrderInTx 写出库时间。
func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
return r.Outbound.RecordForOrderInTx(ctx, tx, order)
}