This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 设备绑定的卡信息
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
internal/service/asset/usage_summary.go
Normal file
16
internal/service/asset/usage_summary.go
Normal file
@@ -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
|
||||
}
|
||||
81
internal/service/asset/usage_summary_test.go
Normal file
81
internal/service/asset/usage_summary_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user