修正数据
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s

This commit is contained in:
2026-04-27 18:10:08 +08:00
parent bb33232b1b
commit fe4c545308
57 changed files with 4477 additions and 226 deletions

View File

@@ -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:

View File

@@ -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:"卡ICCIDasset_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加油包时有值"`

View File

@@ -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:"设备名称"`

View File

@@ -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"`

View 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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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,