refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "锁定套餐载体失败")
|
||||
}
|
||||
|
||||
@@ -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, "校验资产数据范围失败")
|
||||
}
|
||||
|
||||
202
internal/application/packagesilence/first_purchase.go
Normal file
202
internal/application/packagesilence/first_purchase.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user