Files
junhong_cmp_fiber/internal/application/packagesilence/first_purchase.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

203 lines
8.7 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"
"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)
}