Files
junhong_cmp_fiber/internal/application/packagesilence/outbound.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

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