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>
224 lines
9.2 KiB
Go
224 lines
9.2 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"
|
||
)
|
||
|
||
// 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)
|
||
}
|