This commit is contained in:
@@ -165,9 +165,12 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||
WalletBalance: resolved.WalletBalance,
|
||||
ActivatedAt: resolved.Asset.ActivatedAt,
|
||||
CurrentPackage: resolved.Asset.CurrentPackage,
|
||||
PackageTotalMB: resolved.Asset.PackageTotalMB,
|
||||
PackageUsedMB: resolved.Asset.PackageUsedMB,
|
||||
PackageRemainMB: resolved.Asset.PackageRemainMB,
|
||||
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
|
||||
RealTotalMB: resolved.Asset.RealTotalMB,
|
||||
RealUsedMB: resolved.Asset.RealUsedMB,
|
||||
VirtualTotalMB: resolved.Asset.VirtualTotalMB,
|
||||
VirtualUsedMB: resolved.Asset.VirtualUsedMB,
|
||||
ReductionPct: resolved.Asset.ReductionPct,
|
||||
DeviceName: resolved.Asset.DeviceName,
|
||||
IMEI: resolved.Asset.IMEI,
|
||||
SN: resolved.Asset.SN,
|
||||
@@ -340,12 +343,13 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||
list := make([]dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
pkg := packageMap[usage.PackageID]
|
||||
ratio := 1.0
|
||||
pkgName := ""
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
pkgName := usage.PackageName
|
||||
pkgType := ""
|
||||
if pkg != nil {
|
||||
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||
pkgName = pkg.PackageName
|
||||
if pkgName == "" {
|
||||
pkgName = pkg.PackageName
|
||||
}
|
||||
pkgType = pkg.PackageType
|
||||
}
|
||||
|
||||
@@ -357,12 +361,11 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
DataLimitMB: usage.DataLimitMB,
|
||||
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
|
||||
DataUsageMB: usage.DataUsageMB,
|
||||
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
|
||||
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
|
||||
VirtualRatio: ratio,
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
@@ -581,13 +584,6 @@ func nonZeroTimePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func safeVirtualRatio(ratio float64) float64 {
|
||||
if ratio <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func packageStatusName(status int) string {
|
||||
switch status {
|
||||
case constants.PackageUsageStatusPending:
|
||||
|
||||
@@ -23,9 +23,12 @@ type AssetResolveResponse struct {
|
||||
RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"`
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按virtual_ratio换算"`
|
||||
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按virtual_ratio换算"`
|
||||
PackageRemainMB float64 `json:"package_remain_mb" description:"当前套餐剩余虚流量(MB),已按virtual_ratio换算"`
|
||||
CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"`
|
||||
RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"`
|
||||
RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"`
|
||||
VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"`
|
||||
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"`
|
||||
// 绑定关系字段
|
||||
ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"`
|
||||
@@ -105,13 +108,11 @@ type AssetPackageResponse struct {
|
||||
UsageType string `json:"usage_type" description:"使用类型:single_card/device"`
|
||||
Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
|
||||
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB),按virtual_ratio换算"`
|
||||
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"`
|
||||
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
|
||||
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
|
||||
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
|
||||
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"`
|
||||
|
||||
@@ -33,10 +33,13 @@ type AssetInfoResponse struct {
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
|
||||
// === 套餐信息(通用) ===
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按虚流量比例换算"`
|
||||
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按虚流量比例换算"`
|
||||
PackageRemainMB float64 `json:"package_remain_mb" description:"当前剩余虚流量(MB),已按虚流量比例换算"`
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"`
|
||||
RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"`
|
||||
RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"`
|
||||
VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"`
|
||||
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"`
|
||||
|
||||
// === 设备专属字段(asset_type=device 时有效) ===
|
||||
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
|
||||
|
||||
@@ -36,7 +36,7 @@ type Package struct {
|
||||
PackageType string `gorm:"column:package_type;type:varchar(50);not null;comment:套餐类型 formal-正式套餐 addon-附加套餐" json:"package_type"`
|
||||
DurationMonths int `gorm:"column:duration_months;type:int;not null;comment:套餐时长(月数) 1-月套餐 12-年套餐" json:"duration_months"`
|
||||
RealDataMB int64 `gorm:"column:real_data_mb;type:bigint;default:0;comment:真流量额度(MB)" json:"real_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,用于停机判断)" json:"virtual_data_mb"`
|
||||
VirtualDataMB int64 `gorm:"column:virtual_data_mb;type:bigint;default:0;comment:虚流量额度(MB,业务停机阈值)" json:"virtual_data_mb"`
|
||||
EnableVirtualData bool `gorm:"column:enable_virtual_data;type:boolean;default:false;not null;comment:是否启用虚流量" json:"enable_virtual_data"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"`
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
@@ -47,7 +47,7 @@ type Package struct {
|
||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);default:'monthly';comment:流量重置周期 daily-每日 monthly-每月 yearly-每年 none-不重置" json:"data_reset_cycle"`
|
||||
// ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:虚流量比例(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
|
||||
VirtualRatio float64 `gorm:"column:virtual_ratio;type:decimal(18,6);default:1.0;comment:展示倍率(real_data_mb/virtual_data_mb),创建套餐时计算存储" json:"virtual_ratio"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
@@ -68,10 +68,11 @@ type PackageUsage struct {
|
||||
UsageType string `gorm:"column:usage_type;type:varchar(20);not null;comment:使用类型 single_card-单卡套餐 device-设备级套餐" json:"usage_type"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐时有值)" json:"iot_card_id"`
|
||||
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐时有值)" json:"device_id"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:流量限额(MB)" json:"data_limit_mb"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:已使用流量(MB)" json:"data_usage_mb"`
|
||||
RealDataUsageMB int64 `gorm:"column:real_data_usage_mb;type:bigint;default:0;comment:真流量使用(MB)" json:"real_data_usage_mb"`
|
||||
VirtualDataUsageMB int64 `gorm:"column:virtual_data_usage_mb;type:bigint;default:0;comment:虚流量使用(MB)" json:"virtual_data_usage_mb"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb;type:bigint;not null;comment:真总量快照(MB)" json:"data_limit_mb"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:真已用(MB)" json:"data_usage_mb"`
|
||||
VirtualTotalMBSnapshot int64 `gorm:"column:virtual_total_mb_snapshot;type:bigint;default:0;comment:虚总量快照(MB,业务停机阈值)" json:"virtual_total_mb_snapshot"`
|
||||
DisplayGainRatioSnapshot float64 `gorm:"column:display_gain_ratio_snapshot;type:decimal(18,6);default:1.0;comment:展示倍率快照(real_total_mb/virtual_total_mb)" json:"display_gain_ratio_snapshot"`
|
||||
EnableVirtualDataSnapshot bool `gorm:"column:enable_virtual_data_snapshot;type:boolean;default:false;comment:是否启用虚流量快照" json:"enable_virtual_data_snapshot"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_at"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"`
|
||||
|
||||
96
internal/model/package_traffic_semantics.go
Normal file
96
internal/model/package_traffic_semantics.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package model
|
||||
|
||||
import "math"
|
||||
|
||||
const unlimitedRemainingQuotaMB = int64(^uint64(0) >> 1)
|
||||
|
||||
// PackageTrafficMetrics 套餐流量统一展示值
|
||||
// 统一封装对外使用的真值、虚值和展示比例,避免各层重复实现公式。
|
||||
type PackageTrafficMetrics struct {
|
||||
RealTotalMB int64
|
||||
RealUsedMB int64
|
||||
VirtualTotalMB int64
|
||||
VirtualUsedMB float64
|
||||
ReductionPct float64
|
||||
}
|
||||
|
||||
// CalculateDisplayGainRatio 计算展示倍率
|
||||
// 启用虚流量且虚总量大于 0 时,倍率 = 真总量 / 虚总量;否则退化为 1。
|
||||
func CalculateDisplayGainRatio(enableVirtualData bool, realTotalMB, virtualTotalMB int64) float64 {
|
||||
if enableVirtualData && virtualTotalMB > 0 {
|
||||
return float64(realTotalMB) / float64(virtualTotalMB)
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// BuildPackageUsageSnapshotValues 计算套餐使用记录应写入的快照值。
|
||||
func BuildPackageUsageSnapshotValues(pkg *Package) (virtualTotalMB int64, displayGainRatio float64, enableVirtualData bool) {
|
||||
if pkg == nil {
|
||||
return 0, 1.0, false
|
||||
}
|
||||
|
||||
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
|
||||
return pkg.VirtualDataMB, CalculateDisplayGainRatio(true, pkg.RealDataMB, pkg.VirtualDataMB), true
|
||||
}
|
||||
|
||||
return pkg.RealDataMB, 1.0, false
|
||||
}
|
||||
|
||||
// EffectiveThresholdMB 返回套餐业务耗尽阈值。
|
||||
// 启用虚流量时按虚总量快照判断;否则按真总量判断。
|
||||
func (u *PackageUsage) EffectiveThresholdMB() int64 {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
if u.EnableVirtualDataSnapshot && u.VirtualTotalMBSnapshot > 0 {
|
||||
return u.VirtualTotalMBSnapshot
|
||||
}
|
||||
return u.DataLimitMB
|
||||
}
|
||||
|
||||
// RemainingQuotaMB 返回套餐剩余业务额度。
|
||||
func (u *PackageUsage) RemainingQuotaMB() int64 {
|
||||
if u == nil {
|
||||
return 0
|
||||
}
|
||||
threshold := u.EffectiveThresholdMB()
|
||||
if threshold <= 0 {
|
||||
return unlimitedRemainingQuotaMB
|
||||
}
|
||||
return threshold - u.DataUsageMB
|
||||
}
|
||||
|
||||
// IsTrafficDepleted 判断套餐是否达到业务耗尽阈值。
|
||||
func (u *PackageUsage) IsTrafficDepleted() bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
threshold := u.EffectiveThresholdMB()
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
return u.DataUsageMB >= threshold
|
||||
}
|
||||
|
||||
// BuildTrafficMetrics 组装统一五字段和 reduction_pct。
|
||||
func (u *PackageUsage) BuildTrafficMetrics() PackageTrafficMetrics {
|
||||
metrics := PackageTrafficMetrics{}
|
||||
if u == nil {
|
||||
return metrics
|
||||
}
|
||||
|
||||
metrics.RealTotalMB = u.DataLimitMB
|
||||
metrics.RealUsedMB = u.DataUsageMB
|
||||
|
||||
if u.EnableVirtualDataSnapshot && u.VirtualTotalMBSnapshot > 0 {
|
||||
metrics.VirtualTotalMB = u.VirtualTotalMBSnapshot
|
||||
metrics.VirtualUsedMB = math.Min(float64(u.DataUsageMB)*u.DisplayGainRatioSnapshot, float64(u.DataLimitMB))
|
||||
metrics.ReductionPct = u.DisplayGainRatioSnapshot - 1
|
||||
return metrics
|
||||
}
|
||||
|
||||
metrics.VirtualTotalMB = u.DataLimitMB
|
||||
metrics.VirtualUsedMB = float64(u.DataUsageMB)
|
||||
metrics.ReductionPct = 0
|
||||
return metrics
|
||||
}
|
||||
@@ -297,16 +297,21 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
return
|
||||
resp.CurrentPackageUsageID = &usage.ID
|
||||
resp.CurrentPackage = usage.PackageName
|
||||
if resp.CurrentPackage == "" {
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
if pkgErr == nil && pkg != nil {
|
||||
resp.CurrentPackage = pkg.PackageName
|
||||
}
|
||||
}
|
||||
|
||||
resp.CurrentPackage = pkg.PackageName
|
||||
ratio := safeVirtualRatio(pkg.VirtualRatio)
|
||||
resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio)
|
||||
resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio
|
||||
resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
resp.RealTotalMB = metrics.RealTotalMB
|
||||
resp.RealUsedMB = metrics.RealUsedMB
|
||||
resp.VirtualTotalMB = metrics.VirtualTotalMB
|
||||
resp.VirtualUsedMB = metrics.VirtualUsedMB
|
||||
resp.ReductionPct = metrics.ReductionPct
|
||||
}
|
||||
|
||||
// fillShopName 填充店铺名称
|
||||
@@ -698,15 +703,14 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
|
||||
all := make([]*dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, u := range usages {
|
||||
pkg := pkgMap[u.PackageID]
|
||||
ratio := 1.0
|
||||
pkgName := ""
|
||||
metrics := u.BuildTrafficMetrics()
|
||||
pkgName := u.PackageName
|
||||
pkgType := ""
|
||||
enableVirtualData := false
|
||||
if pkg != nil {
|
||||
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||
pkgName = pkg.PackageName
|
||||
if pkgName == "" {
|
||||
pkgName = pkg.PackageName
|
||||
}
|
||||
pkgType = pkg.PackageType
|
||||
enableVirtualData = pkg.EnableVirtualData
|
||||
}
|
||||
item := &dto.AssetPackageResponse{
|
||||
PackageUsageID: u.ID,
|
||||
@@ -720,13 +724,11 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
|
||||
UsageType: u.UsageType,
|
||||
Status: u.Status,
|
||||
StatusName: packageStatusName(u.Status),
|
||||
DataLimitMB: u.DataLimitMB,
|
||||
VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio),
|
||||
DataUsageMB: u.DataUsageMB,
|
||||
VirtualUsedMB: float64(u.DataUsageMB) / ratio,
|
||||
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
|
||||
VirtualRatio: ratio,
|
||||
EnableVirtualData: enableVirtualData,
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
ActivatedAt: u.ActivatedAt,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
MasterUsageID: u.MasterUsageID,
|
||||
@@ -779,15 +781,14 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
|
||||
}
|
||||
|
||||
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
|
||||
ratio := 1.0
|
||||
pkgName := ""
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
pkgName := usage.PackageName
|
||||
pkgType := ""
|
||||
enableVirtualData := false
|
||||
if pkgErr == nil && pkg != nil {
|
||||
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||
pkgName = pkg.PackageName
|
||||
if pkgName == "" {
|
||||
pkgName = pkg.PackageName
|
||||
}
|
||||
pkgType = pkg.PackageType
|
||||
enableVirtualData = pkg.EnableVirtualData
|
||||
}
|
||||
return &dto.AssetPackageResponse{
|
||||
PackageUsageID: usage.ID,
|
||||
@@ -801,13 +802,11 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
DataLimitMB: usage.DataLimitMB,
|
||||
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
|
||||
DataUsageMB: usage.DataUsageMB,
|
||||
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
|
||||
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
|
||||
VirtualRatio: ratio,
|
||||
EnableVirtualData: enableVirtualData,
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
@@ -838,14 +837,6 @@ func nonZeroTimePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// safeVirtualRatio 安全获取虚流量比例,避免除零
|
||||
func safeVirtualRatio(ratio float64) float64 {
|
||||
if ratio <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
// GetOrders 查询资产历史订单,支持换货链跨代追溯
|
||||
// page/pageSize 仅对本代订单生效;前代订单每代最多返回20条
|
||||
// 最多追溯10代,超出后 truncated=true
|
||||
|
||||
@@ -195,18 +195,23 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.
|
||||
}
|
||||
}
|
||||
|
||||
pkg, err := s.packageUsageStore.GetActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
|
||||
packages, err := s.packageUsageStore.ListActiveOrDepletedByCarrier(ctx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if pkg.Status == constants.PackageUsageStatusDepleted {
|
||||
return true, nil
|
||||
if len(packages) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB, nil
|
||||
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
if !pkg.IsTrafficDepleted() {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isRealnameOK 检查卡是否满足实名要求
|
||||
@@ -221,16 +226,7 @@ func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
|
||||
func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) {
|
||||
var reasons []string
|
||||
|
||||
// 条件A:无有效套餐(优先级最高)
|
||||
hasPackage, err := s.hasValidPackage(ctx, card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPackage {
|
||||
reasons = append(reasons, constants.StopReasonNoPackage)
|
||||
}
|
||||
|
||||
// 条件B:虚流量耗尽
|
||||
// 条件A:流量耗尽
|
||||
exhausted, err := s.isTrafficExhausted(ctx, card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -239,6 +235,15 @@ func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.Io
|
||||
reasons = append(reasons, constants.StopReasonTrafficExhausted)
|
||||
}
|
||||
|
||||
// 条件B:无有效套餐(仅在未命中流量耗尽时判断)
|
||||
hasPackage, err := s.hasValidPackage(ctx, card)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasPackage && !exhausted {
|
||||
reasons = append(reasons, constants.StopReasonNoPackage)
|
||||
}
|
||||
|
||||
// 条件C:非行业卡且未实名
|
||||
if !s.isRealnameOK(card) {
|
||||
reasons = append(reasons, constants.StopReasonNotRealname)
|
||||
|
||||
@@ -2018,6 +2018,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
}
|
||||
|
||||
// 创建套餐使用记录
|
||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
@@ -2029,6 +2030,9 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
|
||||
PackageName: pkg.PackageName,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
|
||||
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
|
||||
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
@@ -2103,6 +2107,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
expiresAt = now
|
||||
}
|
||||
|
||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
@@ -2114,6 +2119,9 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
|
||||
PackageName: pkg.PackageName,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
|
||||
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
|
||||
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
MasterUsageID: &mainPackage.ID,
|
||||
|
||||
@@ -740,8 +740,5 @@ func formatAmount(amountFen int64) string {
|
||||
// calculateVirtualRatio 计算虚流量比例
|
||||
// enable_virtual_data=true 且 virtual_data_mb>0 时 = real_data_mb/virtual_data_mb,否则 = 1.0
|
||||
func calculateVirtualRatio(enableVirtualData bool, realDataMB, virtualDataMB int64) float64 {
|
||||
if enableVirtualData && virtualDataMB > 0 {
|
||||
return float64(realDataMB) / float64(virtualDataMB)
|
||||
}
|
||||
return 1.0
|
||||
return model.CalculateDisplayGainRatio(enableVirtualData, realDataMB, virtualDataMB)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,11 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
shouldSuspend := false
|
||||
suspendCarrierType := ""
|
||||
var suspendCarrierID uint
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -96,7 +100,8 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
}
|
||||
|
||||
// 计算当前套餐剩余额度
|
||||
remainingQuota := pkg.DataLimitMB - pkg.DataUsageMB
|
||||
effectiveThresholdMB := pkg.EffectiveThresholdMB()
|
||||
remainingQuota := pkg.RemainingQuotaMB()
|
||||
if remainingQuota <= 0 {
|
||||
// 套餐已用完,标记为已用完
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
@@ -120,7 +125,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
}
|
||||
|
||||
// 检查是否用完
|
||||
if newUsage >= pkg.DataLimitMB {
|
||||
if effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB {
|
||||
updates["status"] = constants.PackageUsageStatusDepleted
|
||||
}
|
||||
|
||||
@@ -139,6 +144,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
zap.Uint("usage_id", pkg.ID),
|
||||
zap.Int64("deduct_mb", deductFromPkg),
|
||||
zap.Int64("new_usage_mb", newUsage),
|
||||
zap.Int64("effective_threshold_mb", effectiveThresholdMB),
|
||||
zap.Int64("data_limit_mb", pkg.DataLimitMB))
|
||||
}
|
||||
|
||||
@@ -153,12 +159,25 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
|
||||
}
|
||||
|
||||
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
|
||||
if err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID); err != nil {
|
||||
shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shouldSuspend = shouldSuspendCurrent
|
||||
suspendCarrierType = targetCarrierType
|
||||
suspendCarrierID = targetCarrierID
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shouldSuspend {
|
||||
s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertToDeductMB 将浮点增量转换为可扣减的整数 MB
|
||||
@@ -328,70 +347,98 @@ func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packa
|
||||
}
|
||||
|
||||
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
|
||||
// 查询是否还有生效中的套餐
|
||||
var activeCount int64
|
||||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
|
||||
query := tx.Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive)
|
||||
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
|
||||
|
||||
if carrierType == constants.AssetTypeIotCard {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
} else if carrierType == constants.AssetTypeDevice {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
return false, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||||
}
|
||||
|
||||
if err := query.Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐数量失败")
|
||||
var packages []*model.PackageUsage
|
||||
if err := query.Find(&packages).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
|
||||
}
|
||||
|
||||
// 如果没有生效中的套餐,触发停机操作
|
||||
if activeCount == 0 {
|
||||
hasAvailablePackage := false
|
||||
for _, pkg := range packages {
|
||||
if pkg == nil {
|
||||
continue
|
||||
}
|
||||
if pkg.IsTrafficDepleted() {
|
||||
if pkg.Status != constants.PackageUsageStatusDepleted {
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
hasAvailablePackage = true
|
||||
break
|
||||
}
|
||||
|
||||
if !hasAvailablePackage {
|
||||
s.logger.Warn("所有套餐已用完,触发停机",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID))
|
||||
|
||||
if carrierType == constants.AssetTypeIotCard {
|
||||
if s.stopResumeCallback != nil {
|
||||
go func() {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else if carrierType == constants.AssetTypeDevice {
|
||||
if s.stopResumeCallback == nil {
|
||||
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
|
||||
zap.Uint("device_id", carrierID))
|
||||
} else {
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询设备绑定卡失败",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Error(err))
|
||||
} else if len(bindings) == 0 {
|
||||
s.logger.Debug("设备无绑定卡,跳过停机",
|
||||
zap.Uint("device_id", carrierID))
|
||||
} else {
|
||||
for _, b := range bindings {
|
||||
cardID := b.IotCardID
|
||||
go func(cID uint) {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", cID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(cardID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
|
||||
func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) {
|
||||
if s.stopResumeCallback == nil {
|
||||
if carrierType == constants.AssetTypeDevice {
|
||||
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
|
||||
zap.Uint("device_id", carrierID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetTypeIotCard {
|
||||
go func() {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType != constants.AssetTypeDevice {
|
||||
return
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
|
||||
if err != nil {
|
||||
s.logger.Error("查询设备绑定卡失败",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
s.logger.Debug("设备无绑定卡,跳过停机",
|
||||
zap.Uint("device_id", carrierID))
|
||||
return
|
||||
}
|
||||
|
||||
for _, b := range bindings {
|
||||
cardID := b.IotCardID
|
||||
go func(cID uint) {
|
||||
stopCtx := context.Background()
|
||||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
|
||||
s.logger.Error("调用停机服务失败",
|
||||
zap.Uint("card_id", cID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}(cardID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,28 @@ func (s *PackageUsageStore) GetActiveOrDepletedByCarrier(ctx context.Context, ca
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// ListActiveOrDepletedByCarrier 查询载体下所有生效中或已用完套餐。
|
||||
func (s *PackageUsageStore) ListActiveOrDepletedByCarrier(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
|
||||
var packages []*model.PackageUsage
|
||||
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where("status IN ?", []int{
|
||||
constants.PackageUsageStatusActive,
|
||||
constants.PackageUsageStatusDepleted,
|
||||
}).
|
||||
Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, activated_at ASC")
|
||||
|
||||
if carrierType == "device" {
|
||||
query = query.Where("device_id = ?", carrierID)
|
||||
} else {
|
||||
query = query.Where("iot_card_id = ?", carrierID)
|
||||
}
|
||||
|
||||
if err := query.Find(&packages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// ListByCarrier 查询载体的套餐使用记录(可按状态筛选)
|
||||
func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType string, carrierID uint, status *int) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
|
||||
@@ -504,6 +504,7 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
// REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查)
|
||||
// ExpiryBase 仅影响后台囤货路径,此处无需判断
|
||||
|
||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
@@ -515,6 +516,9 @@ func (h *AutoPurchaseHandler) activateMainPackage(
|
||||
PackageName: pkg.PackageName,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
|
||||
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
|
||||
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
|
||||
Status: status,
|
||||
Priority: priority,
|
||||
DataResetCycle: pkg.DataResetCycle,
|
||||
@@ -577,6 +581,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
priority := maxPriority + 1
|
||||
expiresAt := mainPackage.ExpiresAt
|
||||
|
||||
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{
|
||||
Creator: order.Creator,
|
||||
@@ -588,6 +593,9 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
|
||||
PackageName: pkg.PackageName,
|
||||
UsageType: order.OrderType,
|
||||
DataLimitMB: pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
|
||||
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
|
||||
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
|
||||
Status: constants.PackageUsageStatusActive,
|
||||
Priority: priority,
|
||||
MasterUsageID: &mainPackage.ID,
|
||||
|
||||
Reference in New Issue
Block a user