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