diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 501e785..b7e0255 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -56,12 +56,13 @@ func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) { } // resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑 +// 不传递 include_usage_summary,始终为 false,不影响其他 Handler 性能 func (h *AssetHandler) resolveByIdentifier(c *fiber.Ctx) (*dto.AssetResolveResponse, error) { identifier := c.Params("identifier") if identifier == "" { return nil, errors.New(errors.CodeInvalidParam, "标识符不能为空") } - return h.assetService.Resolve(c.UserContext(), identifier) + return h.assetService.Resolve(c.UserContext(), identifier, false) } // Resolve 通过任意标识符解析资产(设备或卡) @@ -72,7 +73,8 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "标识符不能为空") } - result, err := h.assetService.Resolve(c.UserContext(), identifier) + includeUsageSummary := c.QueryBool("include_usage_summary", false) + result, err := h.assetService.Resolve(c.UserContext(), identifier, includeUsageSummary) if err != nil { return err } @@ -378,7 +380,7 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam) } - asset, err := h.assetService.Resolve(c.UserContext(), identifier) + asset, err := h.assetService.Resolve(c.UserContext(), identifier, false) if err != nil { return err } @@ -424,7 +426,7 @@ func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "无效的实名认证策略") } - asset, err := h.assetService.Resolve(c.UserContext(), identifier) + asset, err := h.assetService.Resolve(c.UserContext(), identifier, false) if err != nil { return err } @@ -467,7 +469,7 @@ func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "无效的实名状态") } - asset, err := h.assetService.Resolve(c.UserContext(), identifier) + asset, err := h.assetService.Resolve(c.UserContext(), identifier, false) if err != nil { return err } diff --git a/internal/handler/admin/asset_wallet.go b/internal/handler/admin/asset_wallet.go index 8c6f7ad..eab687d 100644 --- a/internal/handler/admin/asset_wallet.go +++ b/internal/handler/admin/asset_wallet.go @@ -46,7 +46,7 @@ func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error { return errors.New(errors.CodeInternalError, "资产服务未配置") } - asset, err := h.assetService.Resolve(c.UserContext(), identifier) + asset, err := h.assetService.Resolve(c.UserContext(), identifier, false) if err != nil { return err } @@ -76,7 +76,7 @@ func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error { return errors.New(errors.CodeInternalError, "资产服务未配置") } - asset, err := h.assetService.Resolve(c.UserContext(), identifier) + asset, err := h.assetService.Resolve(c.UserContext(), identifier, false) if err != nil { return err } diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 12b66f6..2ea0c57 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -98,7 +98,7 @@ func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier } skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) - assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) + assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier, false) if err != nil { return nil, err } diff --git a/internal/handler/app/client_device.go b/internal/handler/app/client_device.go index 5cd4e5c..dbe880e 100644 --- a/internal/handler/app/client_device.go +++ b/internal/handler/app/client_device.go @@ -69,7 +69,7 @@ func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier strin ctx := c.UserContext() // 通过标识符解析资产 - asset, err := h.assetService.Resolve(ctx, identifier) + asset, err := h.assetService.Resolve(ctx, identifier, false) if err != nil { return nil, err } diff --git a/internal/handler/app/client_realname.go b/internal/handler/app/client_realname.go index e8f4ed5..d4c6f0d 100644 --- a/internal/handler/app/client_realname.go +++ b/internal/handler/app/client_realname.go @@ -84,7 +84,7 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { ctx := c.UserContext() // 3. 通过标识符解析资产 - asset, err := h.assetService.Resolve(ctx, req.Identifier) + asset, err := h.assetService.Resolve(ctx, req.Identifier, false) if err != nil { return err } diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index cb81c3d..c76ca2c 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -540,7 +540,7 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie } skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) - assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) + assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier, false) if err != nil { return nil, err } diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index b20cbb4..d81361a 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -68,6 +68,9 @@ type AssetResolveResponse struct { NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机(asset_type=card时有效)"` GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` + // 流量汇总字段(仅在 ?include_usage_summary=true 时返回非 null 值) + TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"` + TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"` } // BoundCardInfo 设备绑定的卡信息 diff --git a/internal/service/agent_open_api/service.go b/internal/service/agent_open_api/service.go index 1f06add..fd92814 100644 --- a/internal/service/agent_open_api/service.go +++ b/internal/service/agent_open_api/service.go @@ -484,7 +484,7 @@ func (s *Service) resolveOpenAPICard(ctx context.Context, cardNo string) (*model return nil, errors.New(errors.CodeInternalError, "开放接口资产解析依赖未配置") } - resolved, err := s.assetService.Resolve(ctx, identifier) + resolved, err := s.assetService.Resolve(ctx, identifier, false) if err != nil { if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeNotFound { return s.resolveOpenAPICardByIdentifier(ctx, identifier) diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 3b4c948..cedce31 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -91,7 +91,8 @@ func New( // Resolve 通过任意标识符解析资产 // 主路径:查注册表(精确匹配 ICCID 或 VirtualNo) // Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符) -func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) { +// includeUsageSummary 为 true 时额外填充当前世代流量汇总字段 +func (s *Service) Resolve(ctx context.Context, identifier string, includeUsageSummary bool) (*dto.AssetResolveResponse, error) { if s.assetIdentifierStore != nil { regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier) if regErr == nil && regRecord != nil { @@ -102,6 +103,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes resp, buildErr := s.buildDeviceResolveResponse(ctx, device) if buildErr == nil { resp.Identifier = identifier + if includeUsageSummary { + s.fillUsageSummary(ctx, resp, "device", device.ID) + } } return resp, buildErr } @@ -111,6 +115,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes resp, buildErr := s.buildCardResolveResponse(ctx, card) if buildErr == nil { resp.Identifier = identifier + if includeUsageSummary { + s.fillUsageSummary(ctx, resp, "iot_card", card.ID) + } } return resp, buildErr } @@ -123,6 +130,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes resp, buildErr := s.buildDeviceResolveResponse(ctx, device) if buildErr == nil { resp.Identifier = identifier + if includeUsageSummary { + s.fillUsageSummary(ctx, resp, "device", device.ID) + } } return resp, buildErr } @@ -135,6 +145,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes resp, buildErr := s.buildCardResolveResponse(ctx, &card) if buildErr == nil { resp.Identifier = identifier + if includeUsageSummary { + s.fillUsageSummary(ctx, resp, "iot_card", card.ID) + } } return resp, buildErr } @@ -336,6 +349,19 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes resp.EnableVirtualData = usage.EnableVirtualDataSnapshot } +// fillUsageSummary 填充当前世代流量汇总字段。 +// 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null)。 +func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) { + usages, err := s.packageUsageStore.GetCurrentGenerationUsagesForSummary(ctx, carrierType, carrierID) + if err != nil { + logger.GetAppLogger().Error("查询流量汇总失败", zap.String("carrierType", carrierType), zap.Uint("carrierID", carrierID), zap.Error(err)) + return + } + totalUsed, totalRemaining := computeUsageSummary(usages) + resp.TotalVirtualUsedMB = &totalUsed + resp.TotalVirtualRemainingMB = &totalRemaining +} + // fillShopName 填充店铺名称 func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) { if resp.ShopID == nil || *resp.ShopID == 0 { @@ -971,7 +997,7 @@ func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSi pageSize = 100 } - asset, err := s.Resolve(ctx, identifier) + asset, err := s.Resolve(ctx, identifier, false) if err != nil { return nil, err } diff --git a/internal/service/asset/usage_summary.go b/internal/service/asset/usage_summary.go new file mode 100644 index 0000000..b1fddb0 --- /dev/null +++ b/internal/service/asset/usage_summary.go @@ -0,0 +1,16 @@ +package asset + +import "github.com/break/junhong_cmp_fiber/internal/model" + +// computeUsageSummary 汇总当前世代套餐的虚已用量和虚剩余量。 +// 对每条记录调用 BuildTrafficMetrics() 获取虚流量字段,累加后计算剩余。 +func computeUsageSummary(usages []*model.PackageUsage) (totalUsedMB float64, totalRemainingMB float64) { + var totalVirtualTotal float64 + for _, u := range usages { + m := u.BuildTrafficMetrics() + totalUsedMB += m.VirtualUsedMB + totalVirtualTotal += float64(m.VirtualTotalMB) + } + totalRemainingMB = totalVirtualTotal - totalUsedMB + return +} diff --git a/internal/service/asset/usage_summary_test.go b/internal/service/asset/usage_summary_test.go new file mode 100644 index 0000000..dcacd6d --- /dev/null +++ b/internal/service/asset/usage_summary_test.go @@ -0,0 +1,81 @@ +package asset + +import ( + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +func TestComputeUsageSummary_EmptyList(t *testing.T) { + totalUsed, totalRemaining := computeUsageSummary(nil) + if totalUsed != 0.0 { + t.Errorf("期望 totalUsed=0.0,实际=%v", totalUsed) + } + if totalRemaining != 0.0 { + t.Errorf("期望 totalRemaining=0.0,实际=%v", totalRemaining) + } +} + +func TestComputeUsageSummary_SingleVirtualPackage(t *testing.T) { + // 启用虚流量:真实总量=100MB,虚拟总量=200MB,倍率=0.5 + // 真实已用=40MB → 虚拟已用=min(40*0.5, 100)=20MB + // 虚拟剩余=200-20=180MB + usage := &model.PackageUsage{ + DataLimitMB: 100, + DataUsageMB: 40, + VirtualTotalMBSnapshot: 200, + DisplayGainRatioSnapshot: 0.5, + EnableVirtualDataSnapshot: true, + } + totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage}) + if totalUsed != 20.0 { + t.Errorf("期望 totalUsed=20.0,实际=%v", totalUsed) + } + if totalRemaining != 180.0 { + t.Errorf("期望 totalRemaining=180.0,实际=%v", totalRemaining) + } +} + +func TestComputeUsageSummary_SingleNonVirtualPackage(t *testing.T) { + // 未启用虚流量:退化为真实值 + // 真实总量=100MB,真实已用=40MB → 虚拟已用=40MB,虚拟剩余=60MB + usage := &model.PackageUsage{ + DataLimitMB: 100, + DataUsageMB: 40, + EnableVirtualDataSnapshot: false, + } + totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage}) + if totalUsed != 40.0 { + t.Errorf("期望 totalUsed=40.0,实际=%v", totalUsed) + } + if totalRemaining != 60.0 { + t.Errorf("期望 totalRemaining=60.0,实际=%v", totalRemaining) + } +} + +func TestComputeUsageSummary_MultiplePackages(t *testing.T) { + // 套餐1(启用虚流量):VirtualUsed=20, VirtualTotal=200 + // 套餐2(未启用虚流量):VirtualUsed=10, VirtualTotal=50 + // 汇总:totalUsed=30, totalRemaining=(200+50)-30=220 + usages := []*model.PackageUsage{ + { + DataLimitMB: 100, + DataUsageMB: 40, + VirtualTotalMBSnapshot: 200, + DisplayGainRatioSnapshot: 0.5, + EnableVirtualDataSnapshot: true, + }, + { + DataLimitMB: 50, + DataUsageMB: 10, + EnableVirtualDataSnapshot: false, + }, + } + totalUsed, totalRemaining := computeUsageSummary(usages) + if totalUsed != 30.0 { + t.Errorf("期望 totalUsed=30.0,实际=%v", totalUsed) + } + if totalRemaining != 220.0 { + t.Errorf("期望 totalRemaining=220.0,实际=%v", totalRemaining) + } +} diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 13ca0fa..155a08e 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -128,7 +128,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli } skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) - assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier)) + assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier), false) if err != nil { return nil, err } @@ -1060,7 +1060,7 @@ func (s *Service) createAlipayForceRechargeOrder( func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.ClientOrderListRequest) ([]dto.ClientOrderListItem, int64, error) { skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) - assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier)) + assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier), false) if err != nil { return nil, 0, err } @@ -1231,7 +1231,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r if err == nil && device != nil { assetRealnamePolicy = device.RealnamePolicy // 设备的 RealNameStatus 需要通过 Resolve 获取(从当前卡派生) - resolved, resolveErr := s.assetService.Resolve(skipCtx, device.VirtualNo) + resolved, resolveErr := s.assetService.Resolve(skipCtx, device.VirtualNo, false) if resolveErr == nil && resolved != nil { assetRealnameStatus = resolved.RealNameStatus } diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index 28d233c..e82c349 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -301,6 +301,40 @@ func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType strin return usages, nil } +// GetCurrentGenerationUsagesForSummary 查询当前世代内所有已激活过的套餐记录(含加油包),用于流量汇总。 +// 当前世代定义:status∈{1,2,3,4} 中 generation 最大值所在的那一批记录。 +// 返回计算所需字段:DataUsageMB、DataLimitMB、VirtualTotalMBSnapshot、DisplayGainRatioSnapshot、EnableVirtualDataSnapshot。 +func (s *PackageUsageStore) GetCurrentGenerationUsagesForSummary(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) { + var usages []*model.PackageUsage + + // 子查询取当前世代编号 + subQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Select("MAX(generation)"). + Where("status IN ?", []int{ + constants.PackageUsageStatusActive, + constants.PackageUsageStatusDepleted, + constants.PackageUsageStatusExpired, + constants.PackageUsageStatusInvalidated, + }) + subQuery = applyPackageUsageCarrierFilter(subQuery, carrierType, carrierID) + + query := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Select("data_usage_mb, data_limit_mb, virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot"). + Where("status IN ?", []int{ + constants.PackageUsageStatusActive, + constants.PackageUsageStatusDepleted, + constants.PackageUsageStatusExpired, + constants.PackageUsageStatusInvalidated, + }). + Where("generation = (?)", subQuery) + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) + + if err := query.Find(&usages).Error; err != nil { + return nil, err + } + return usages, nil +} + func applyPackageUsageCarrierFilter(query *gorm.DB, carrierType string, carrierID uint) *gorm.DB { if carrierType == "iot_card" { return query.Where("iot_card_id = ?", carrierID)