feat: 资产套餐历史增加主子层级查询
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
@@ -462,7 +463,7 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
||||
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
|
||||
}
|
||||
|
||||
// GetPackageHistory B3 资产套餐历史
|
||||
// GetPackageHistory B3 资产套餐历史。
|
||||
// GET /api/c/v1/asset/package-history
|
||||
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||
var req dto.AssetPackageHistoryRequest
|
||||
@@ -485,73 +486,93 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
|
||||
Where("generation = ?", resolved.Generation)
|
||||
if resolved.Asset.AssetType == "card" {
|
||||
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
|
||||
} else {
|
||||
query = query.Where("device_id = ?", resolved.Asset.AssetID)
|
||||
}
|
||||
if req.Status != nil {
|
||||
query = query.Where("status = ?", *req.Status)
|
||||
}
|
||||
if req.PackageType != nil {
|
||||
query = query.Where("package_id IN (?)",
|
||||
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
|
||||
}
|
||||
|
||||
var usages []*model.PackageUsage
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
|
||||
}
|
||||
|
||||
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
|
||||
history, err := assetquery.NewPackageHistoryQuery(h.db).List(resolved.SkipPermissionCtx, assetquery.PackageHistoryInput{
|
||||
AssetType: resolved.Asset.AssetType,
|
||||
AssetID: resolved.Asset.AssetID,
|
||||
Generation: &resolved.Generation,
|
||||
Status: req.Status,
|
||||
PackageType: req.PackageType,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
list := make([]dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
pkg := packageMap[usage.PackageID]
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
pkgName := usage.PackageName
|
||||
pkgType := ""
|
||||
if pkg != nil {
|
||||
if pkgName == "" {
|
||||
pkgName = pkg.PackageName
|
||||
}
|
||||
pkgType = pkg.PackageType
|
||||
}
|
||||
|
||||
list = append(list, dto.AssetPackageResponse{
|
||||
PackageUsageID: usage.ID,
|
||||
PackageID: usage.PackageID,
|
||||
PackageName: pkgName,
|
||||
PackageType: pkgType,
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
EnableVirtualData: usage.EnableVirtualDataSnapshot,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
Priority: usage.Priority,
|
||||
CreatedAt: usage.CreatedAt,
|
||||
})
|
||||
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||
items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items))
|
||||
for _, node := range history.Items {
|
||||
items = append(items, buildClientPackageHistoryNode(node, packageMap))
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, items, history.Total, req.Page, req.PageSize)
|
||||
}
|
||||
|
||||
func collectPackageHistoryUsages(items []*assetquery.PackageHistoryNode) []*model.PackageUsage {
|
||||
usages := make([]*model.PackageUsage, 0)
|
||||
var collect func(*assetquery.PackageHistoryNode)
|
||||
collect = func(node *assetquery.PackageHistoryNode) {
|
||||
if node == nil || node.Usage == nil {
|
||||
return
|
||||
}
|
||||
usages = append(usages, node.Usage)
|
||||
for _, child := range node.Children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
collect(item)
|
||||
}
|
||||
return usages
|
||||
}
|
||||
|
||||
func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package) *dto.ClientAssetPackageHistoryNode {
|
||||
usage := node.Usage
|
||||
pkg := packageMap[usage.PackageID]
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
packageName := usage.PackageName
|
||||
packageType := ""
|
||||
if pkg != nil {
|
||||
if packageName == "" {
|
||||
packageName = pkg.PackageName
|
||||
}
|
||||
packageType = pkg.PackageType
|
||||
}
|
||||
|
||||
item := &dto.ClientAssetPackageHistoryNode{
|
||||
PackageUsageID: usage.ID,
|
||||
PackageID: usage.PackageID,
|
||||
PackageName: packageName,
|
||||
PackageType: packageType,
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
EnableVirtualData: usage.EnableVirtualDataSnapshot,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
Priority: usage.Priority,
|
||||
CreatedAt: usage.CreatedAt,
|
||||
Children: make([]*dto.ClientAssetPackageHistoryNode, 0, len(node.Children)),
|
||||
}
|
||||
if node.RelationshipStatus != "" {
|
||||
item.RelationshipStatus = node.RelationshipStatus
|
||||
item.RelationshipStatusName = "关联主套餐缺失"
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
item.Children = append(item.Children, buildClientPackageHistoryNode(child, packageMap))
|
||||
}
|
||||
item.ExpandByDefault = len(item.Children) > 0
|
||||
return item
|
||||
}
|
||||
|
||||
// RefreshAsset B4 资产刷新
|
||||
|
||||
@@ -160,12 +160,45 @@ type AssetPackageResponse struct {
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// AssetPackagesResult 套餐列表分页结果
|
||||
// AssetPackageHistoryNode 后台资产套餐历史层级节点。
|
||||
type AssetPackageHistoryNode struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型:formal/addon"`
|
||||
ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准"`
|
||||
OrderID uint `json:"order_id" description:"关联订单ID(无订单分配时为0)"`
|
||||
OrderNo string `json:"order_no,omitempty" description:"订单号快照"`
|
||||
RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照"`
|
||||
RefundNo string `json:"refund_no,omitempty" description:"退款单号快照"`
|
||||
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:"状态名称"`
|
||||
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:"展示增幅比例"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
|
||||
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID;普通主项为null"`
|
||||
Priority int `json:"priority" description:"优先级"`
|
||||
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见"`
|
||||
RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分)"`
|
||||
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
|
||||
Children []*AssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
|
||||
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
|
||||
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"`
|
||||
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
|
||||
}
|
||||
|
||||
// AssetPackagesResult 后台资产套餐历史分页结果。
|
||||
type AssetPackagesResult struct {
|
||||
Total int64 `json:"total" description:"总条数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页条数"`
|
||||
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
|
||||
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页顶层关系组数量"`
|
||||
Items []*AssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
|
||||
}
|
||||
|
||||
// AssetResolveRequest 资产解析请求
|
||||
|
||||
@@ -183,12 +183,39 @@ type AssetPackageHistoryRequest struct {
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
}
|
||||
|
||||
// AssetPackageHistoryResponse B3 资产套餐历史响应
|
||||
// ClientAssetPackageHistoryNode H5 资产套餐历史层级节点。
|
||||
type ClientAssetPackageHistoryNode struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
OrderID uint `json:"order_id" description:"历史兼容字段;本接口不填充真实订单ID,零值仍输出为0"`
|
||||
PackageType string `json:"package_type" description:"套餐类型:formal/addon"`
|
||||
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:"状态名称"`
|
||||
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:"展示增幅比例"`
|
||||
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
|
||||
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID;普通主项为null"`
|
||||
Priority int `json:"priority" description:"优先级"`
|
||||
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
|
||||
Children []*ClientAssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
|
||||
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
|
||||
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态:master_missing"`
|
||||
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
|
||||
}
|
||||
|
||||
// AssetPackageHistoryResponse B3 资产套餐历史层级响应。
|
||||
type AssetPackageHistoryResponse struct {
|
||||
List []AssetPackageResponse `json:"items" description:"套餐历史列表"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
Page int `json:"page" description:"页码"`
|
||||
PageSize int `json:"size" description:"每页数量"`
|
||||
List []*ClientAssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
|
||||
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
|
||||
Page int `json:"page" description:"页码"`
|
||||
PageSize int `json:"size" description:"每页顶层关系组数量"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
304
internal/query/asset/package_history.go
Normal file
304
internal/query/asset/package_history.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const relationshipStatusMasterMissing = "master_missing"
|
||||
|
||||
// PackageHistoryQuery 读取资产范围内的套餐使用关系。
|
||||
type PackageHistoryQuery struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// PackageHistoryInput 定义已完成入口授权后的套餐历史读取范围。
|
||||
type PackageHistoryInput struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Generation *int
|
||||
Status *int
|
||||
PackageType *string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// PackageHistoryResult 保存筛选后的顶层关系组总数和分页后的关系组。
|
||||
type PackageHistoryResult struct {
|
||||
Total int64
|
||||
Items []*PackageHistoryNode
|
||||
}
|
||||
|
||||
// PackageHistoryNode 保存一个套餐使用记录及其可展示关联子项。
|
||||
type PackageHistoryNode struct {
|
||||
Usage *model.PackageUsage
|
||||
Children []*PackageHistoryNode
|
||||
RelationshipStatus string
|
||||
}
|
||||
|
||||
type packageUsagePresence struct {
|
||||
ID uint
|
||||
IotCardID uint
|
||||
DeviceID uint
|
||||
Generation int
|
||||
DeletedAt gorm.DeletedAt
|
||||
}
|
||||
|
||||
// NewPackageHistoryQuery 创建套餐历史关系查询。
|
||||
func NewPackageHistoryQuery(db *gorm.DB) *PackageHistoryQuery {
|
||||
return &PackageHistoryQuery{db: db}
|
||||
}
|
||||
|
||||
// List 在既有资产和世代范围内读取完整关系,再按整组应用筛选、排序和分页。
|
||||
func (q *PackageHistoryQuery) List(ctx context.Context, input PackageHistoryInput) (*PackageHistoryResult, error) {
|
||||
query, err := q.baseUsageQuery(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var usages []*model.PackageUsage
|
||||
if err := query.Find(&usages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
|
||||
}
|
||||
|
||||
usageByID := make(map[uint]*model.PackageUsage, len(usages))
|
||||
for _, usage := range usages {
|
||||
usageByID[usage.ID] = usage
|
||||
}
|
||||
|
||||
missingMasterIDs := unresolvedMasterIDs(usages, usageByID)
|
||||
presentMasters, err := q.lookupMasterUsagePresence(ctx, missingMasterIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]*PackageHistoryNode, 0, len(usages))
|
||||
nodes := make(map[uint]*PackageHistoryNode, len(usages))
|
||||
for _, usage := range usages {
|
||||
nodes[usage.ID] = &PackageHistoryNode{Usage: usage, Children: make([]*PackageHistoryNode, 0)}
|
||||
}
|
||||
for _, usage := range usages {
|
||||
node := nodes[usage.ID]
|
||||
if usage.MasterUsageID == nil {
|
||||
items = append(items, node)
|
||||
continue
|
||||
}
|
||||
|
||||
master, inRange := nodes[*usage.MasterUsageID]
|
||||
if inRange {
|
||||
master.Children = append(master.Children, node)
|
||||
continue
|
||||
}
|
||||
if _, exists := presentMasters[*usage.MasterUsageID]; exists {
|
||||
return nil, errors.New(errors.CodeDatabaseError, "读取套餐历史关联失败")
|
||||
}
|
||||
node.RelationshipStatus = relationshipStatusMasterMissing
|
||||
items = append(items, node)
|
||||
}
|
||||
|
||||
matchingPackageIDs, err := q.matchingPackageIDs(ctx, input.PackageType, usages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = filterPackageHistoryGroups(items, input.Status, matchingPackageIDs)
|
||||
sortPackageHistoryGroups(items)
|
||||
total := int64(len(items))
|
||||
|
||||
return &PackageHistoryResult{
|
||||
Total: total,
|
||||
Items: paginatePackageHistoryGroups(items, input.Page, input.PageSize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *PackageHistoryQuery) baseUsageQuery(ctx context.Context, input PackageHistoryInput) (*gorm.DB, error) {
|
||||
query := q.db.WithContext(ctx).Model(&model.PackageUsage{})
|
||||
switch input.AssetType {
|
||||
case "card":
|
||||
query = query.Where("iot_card_id = ?", input.AssetID)
|
||||
case "device":
|
||||
query = query.Where("device_id = ?", input.AssetID)
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型非法")
|
||||
}
|
||||
if input.Generation != nil {
|
||||
query = query.Where("generation = ?", *input.Generation)
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (q *PackageHistoryQuery) matchingPackageIDs(ctx context.Context, packageType *string, usages []*model.PackageUsage) (map[uint]struct{}, error) {
|
||||
if packageType == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
usagePackageIDs := collectPackageHistoryUsagePackageIDs(usages)
|
||||
result := make(map[uint]struct{})
|
||||
if len(usagePackageIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var ids []uint
|
||||
if err := q.db.WithContext(ctx).Model(&model.Package{}).
|
||||
Where("id IN ? AND package_type = ?", usagePackageIDs, *packageType).
|
||||
Pluck("id", &ids).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐类型资格失败")
|
||||
}
|
||||
for _, id := range ids {
|
||||
result[id] = struct{}{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectPackageHistoryUsagePackageIDs(usages []*model.PackageUsage) []uint {
|
||||
ids := make([]uint, 0, len(usages))
|
||||
seen := make(map[uint]struct{}, len(usages))
|
||||
for _, usage := range usages {
|
||||
if usage == nil || usage.PackageID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[usage.PackageID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[usage.PackageID] = struct{}{}
|
||||
ids = append(ids, usage.PackageID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func filterPackageHistoryGroups(items []*PackageHistoryNode, status *int, packageIDs map[uint]struct{}) []*PackageHistoryNode {
|
||||
filtered := make([]*PackageHistoryNode, 0, len(items))
|
||||
for _, item := range items {
|
||||
if matchesPackageHistoryUsage(item.Usage, status, packageIDs) {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
for _, child := range item.Children {
|
||||
if matchesPackageHistoryUsage(child.Usage, status, packageIDs) {
|
||||
filtered = append(filtered, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func matchesPackageHistoryUsage(usage *model.PackageUsage, status *int, packageIDs map[uint]struct{}) bool {
|
||||
if status != nil && usage.Status != *status {
|
||||
return false
|
||||
}
|
||||
if packageIDs == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := packageIDs[usage.PackageID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func sortPackageHistoryGroups(items []*PackageHistoryNode) {
|
||||
for _, item := range items {
|
||||
sort.Slice(item.Children, func(i, j int) bool {
|
||||
return packageHistoryChildLess(item.Children[i].Usage, item.Children[j].Usage)
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := items[i].Usage
|
||||
right := items[j].Usage
|
||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||
return left.ID > right.ID
|
||||
}
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func packageHistoryChildLess(left, right *model.PackageUsage) bool {
|
||||
leftBucket := packageHistoryChildBucket(left)
|
||||
rightBucket := packageHistoryChildBucket(right)
|
||||
if leftBucket != rightBucket {
|
||||
return leftBucket < rightBucket
|
||||
}
|
||||
|
||||
leftTime := left.CreatedAt
|
||||
rightTime := right.CreatedAt
|
||||
if leftBucket == 0 {
|
||||
leftTime = *left.ActivatedAt
|
||||
rightTime = *right.ActivatedAt
|
||||
}
|
||||
if leftTime.Equal(rightTime) {
|
||||
return left.ID < right.ID
|
||||
}
|
||||
return leftTime.Before(rightTime)
|
||||
}
|
||||
|
||||
func packageHistoryChildBucket(usage *model.PackageUsage) int {
|
||||
if usage.Status == constants.PackageUsageStatusPending {
|
||||
return 2
|
||||
}
|
||||
if usage.ActivatedAt != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func paginatePackageHistoryGroups(items []*PackageHistoryNode, page, pageSize int) []*PackageHistoryNode {
|
||||
if len(items) == 0 {
|
||||
return make([]*PackageHistoryNode, 0)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 1
|
||||
}
|
||||
if page > (len(items)-1)/pageSize+1 {
|
||||
return make([]*PackageHistoryNode, 0)
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
return items[start:end]
|
||||
}
|
||||
|
||||
func unresolvedMasterIDs(usages []*model.PackageUsage, usageByID map[uint]*model.PackageUsage) []uint {
|
||||
ids := make([]uint, 0)
|
||||
seen := make(map[uint]struct{})
|
||||
for _, usage := range usages {
|
||||
if usage.MasterUsageID == nil {
|
||||
continue
|
||||
}
|
||||
masterID := *usage.MasterUsageID
|
||||
if _, found := usageByID[masterID]; found {
|
||||
continue
|
||||
}
|
||||
if _, alreadySeen := seen[masterID]; alreadySeen {
|
||||
continue
|
||||
}
|
||||
seen[masterID] = struct{}{}
|
||||
ids = append(ids, masterID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (q *PackageHistoryQuery) lookupMasterUsagePresence(ctx context.Context, ids []uint) (map[uint]packageUsagePresence, error) {
|
||||
found := make(map[uint]packageUsagePresence, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return found, nil
|
||||
}
|
||||
|
||||
var records []packageUsagePresence
|
||||
if err := q.db.WithContext(ctx).Unscoped().Model(&model.PackageUsage{}).
|
||||
Select("id, iot_card_id, device_id, generation, deleted_at").
|
||||
Where("id IN ?", ids).
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "核对套餐主记录失败")
|
||||
}
|
||||
for _, record := range records {
|
||||
found[record.ID] = record
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
@@ -40,8 +40,8 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
|
||||
})
|
||||
|
||||
Register(assets, doc, groupPath, "GET", "/:identifier/packages", handler.Packages, RouteSpec{
|
||||
Summary: "资产套餐列表",
|
||||
Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页与 status 状态筛选。",
|
||||
Summary: "资产套餐历史",
|
||||
Description: "查询该资产全部世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额,total 为筛选后的顶层项数量;支持 status 按同一使用记录筛选。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。",
|
||||
Tags: []string{"资产管理"},
|
||||
Input: new(dto.AssetPackagesRequest),
|
||||
Output: new(dto.AssetPackagesResult),
|
||||
|
||||
@@ -156,11 +156,12 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
|
||||
})
|
||||
|
||||
Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{
|
||||
Summary: "资产套餐历史",
|
||||
Tags: []string{"个人客户 - 资产"},
|
||||
Auth: true,
|
||||
Input: &dto.AssetPackageHistoryRequest{},
|
||||
Output: &dto.AssetPackageHistoryResponse{},
|
||||
Summary: "资产套餐历史",
|
||||
Description: "查询客户已绑定资产当前世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额,total 为筛选后的顶层项数量;status 与 package_type 必须由同一使用记录联合命中,命中任一成员即返回完整关系组。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。",
|
||||
Tags: []string{"个人客户 - 资产"},
|
||||
Auth: true,
|
||||
Input: &dto.AssetPackageHistoryRequest{},
|
||||
Output: &dto.AssetPackageHistoryResponse{},
|
||||
})
|
||||
|
||||
Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{
|
||||
|
||||
@@ -6,7 +6,6 @@ package asset
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -760,11 +760,9 @@ func parseGatewayTime(raw gateway.FlexString) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// GetPackages 获取资产的所有套餐列表(支持分页和状态筛选)
|
||||
// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回
|
||||
// page 默认 1,pageSize 默认 50,pageSize 最大 100
|
||||
// GetPackages 获取资产套餐历史层级(支持顶层关系组分页和状态筛选)。
|
||||
// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回。
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int, callerAccountType string) (*dto.AssetPackagesResult, error) {
|
||||
// 分页参数边界处理
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -778,109 +776,122 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐状态非法")
|
||||
}
|
||||
|
||||
// assetType 对应 Store 中的 carrierType:card→iot_card, device→device
|
||||
carrierType := assetType
|
||||
if assetType == "card" {
|
||||
carrierType = "iot_card"
|
||||
}
|
||||
|
||||
usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id, status)
|
||||
history, err := assetquery.NewPackageHistoryQuery(s.db).List(ctx, assetquery.PackageHistoryInput{
|
||||
AssetType: assetType,
|
||||
AssetID: id,
|
||||
Status: status,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 收集所有 PackageID 并批量查询
|
||||
pkgIDSet := make(map[uint]struct{}, len(usages))
|
||||
for _, u := range usages {
|
||||
pkgIDSet[u.PackageID] = struct{}{}
|
||||
}
|
||||
pkgIDs := make([]uint, 0, len(pkgIDSet))
|
||||
for id := range pkgIDSet {
|
||||
pkgIDs = append(pkgIDs, id)
|
||||
}
|
||||
packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs)
|
||||
packageIDs := collectPackageHistoryPackageIDs(history.Items)
|
||||
packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, packageIDs)
|
||||
if pkgErr != nil {
|
||||
logger.GetAppLogger().Warn("批量查询套餐信息失败,套餐名称可能缺失",
|
||||
zap.Uints("package_ids", pkgIDs),
|
||||
zap.Uints("package_ids", packageIDs),
|
||||
zap.Error(pkgErr))
|
||||
}
|
||||
pkgMap := make(map[uint]*model.Package, len(packages))
|
||||
for _, p := range packages {
|
||||
pkgMap[p.ID] = p
|
||||
packageMap := make(map[uint]*model.Package, len(packages))
|
||||
for _, pkg := range packages {
|
||||
packageMap[pkg.ID] = pkg
|
||||
}
|
||||
|
||||
all := make([]*dto.AssetPackageResponse, 0, len(usages))
|
||||
for _, u := range usages {
|
||||
pkg := pkgMap[u.PackageID]
|
||||
metrics := u.BuildTrafficMetrics()
|
||||
pkgName := u.PackageName
|
||||
pkgType := ""
|
||||
expiryBase := ""
|
||||
if pkg != nil {
|
||||
if pkgName == "" {
|
||||
pkgName = pkg.PackageName
|
||||
}
|
||||
pkgType = pkg.PackageType
|
||||
expiryBase = pkg.ExpiryBase
|
||||
}
|
||||
var paidAmount *int64
|
||||
if callerAccountType == constants.OwnerTypePlatform {
|
||||
paidAmount = u.PaidAmount
|
||||
}
|
||||
item := &dto.AssetPackageResponse{
|
||||
PackageUsageID: u.ID,
|
||||
PackageID: u.PackageID,
|
||||
PackageName: pkgName,
|
||||
PackageType: pkgType,
|
||||
ExpiryBase: expiryBase,
|
||||
OrderID: u.OrderID,
|
||||
OrderNo: u.OrderNo,
|
||||
RefundID: u.RefundID,
|
||||
RefundNo: u.RefundNo,
|
||||
UsageType: u.UsageType,
|
||||
Status: u.Status,
|
||||
StatusName: packageStatusName(u.Status),
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
EnableVirtualData: u.EnableVirtualDataSnapshot,
|
||||
ActivatedAt: u.ActivatedAt,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
MasterUsageID: u.MasterUsageID,
|
||||
Priority: u.Priority,
|
||||
PaidAmount: paidAmount,
|
||||
RetailAmount: u.RetailAmount,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
all = append(all, item)
|
||||
items := make([]*dto.AssetPackageHistoryNode, 0, len(history.Items))
|
||||
for _, node := range history.Items {
|
||||
items = append(items, buildAssetPackageHistoryNode(node, packageMap, callerAccountType))
|
||||
}
|
||||
|
||||
// 按 created_at DESC 排序
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].CreatedAt.After(all[j].CreatedAt)
|
||||
})
|
||||
|
||||
total := int64(len(all))
|
||||
offset := (page - 1) * pageSize
|
||||
end := offset + pageSize
|
||||
if offset >= len(all) {
|
||||
offset = len(all)
|
||||
}
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
items := all[offset:end]
|
||||
|
||||
return &dto.AssetPackagesResult{
|
||||
Total: total,
|
||||
Total: history.Total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func collectPackageHistoryPackageIDs(items []*assetquery.PackageHistoryNode) []uint {
|
||||
ids := make([]uint, 0)
|
||||
seen := make(map[uint]struct{})
|
||||
var collect func(*assetquery.PackageHistoryNode)
|
||||
collect = func(node *assetquery.PackageHistoryNode) {
|
||||
if node == nil || node.Usage == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := seen[node.Usage.PackageID]; !exists {
|
||||
seen[node.Usage.PackageID] = struct{}{}
|
||||
ids = append(ids, node.Usage.PackageID)
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
collect(item)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func buildAssetPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package, callerAccountType string) *dto.AssetPackageHistoryNode {
|
||||
usage := node.Usage
|
||||
pkg := packageMap[usage.PackageID]
|
||||
metrics := usage.BuildTrafficMetrics()
|
||||
packageName := usage.PackageName
|
||||
packageType := ""
|
||||
expiryBase := ""
|
||||
if pkg != nil {
|
||||
if packageName == "" {
|
||||
packageName = pkg.PackageName
|
||||
}
|
||||
packageType = pkg.PackageType
|
||||
expiryBase = pkg.ExpiryBase
|
||||
}
|
||||
var paidAmount *int64
|
||||
if callerAccountType == constants.OwnerTypePlatform {
|
||||
paidAmount = usage.PaidAmount
|
||||
}
|
||||
|
||||
item := &dto.AssetPackageHistoryNode{
|
||||
PackageUsageID: usage.ID,
|
||||
PackageID: usage.PackageID,
|
||||
PackageName: packageName,
|
||||
PackageType: packageType,
|
||||
ExpiryBase: expiryBase,
|
||||
OrderID: usage.OrderID,
|
||||
OrderNo: usage.OrderNo,
|
||||
RefundID: usage.RefundID,
|
||||
RefundNo: usage.RefundNo,
|
||||
UsageType: usage.UsageType,
|
||||
Status: usage.Status,
|
||||
StatusName: packageStatusName(usage.Status),
|
||||
RealTotalMB: metrics.RealTotalMB,
|
||||
RealUsedMB: metrics.RealUsedMB,
|
||||
VirtualTotalMB: metrics.VirtualTotalMB,
|
||||
VirtualUsedMB: metrics.VirtualUsedMB,
|
||||
ReductionPct: metrics.ReductionPct,
|
||||
EnableVirtualData: usage.EnableVirtualDataSnapshot,
|
||||
ActivatedAt: usage.ActivatedAt,
|
||||
ExpiresAt: usage.ExpiresAt,
|
||||
MasterUsageID: usage.MasterUsageID,
|
||||
Priority: usage.Priority,
|
||||
PaidAmount: paidAmount,
|
||||
RetailAmount: usage.RetailAmount,
|
||||
CreatedAt: usage.CreatedAt,
|
||||
Children: make([]*dto.AssetPackageHistoryNode, 0, len(node.Children)),
|
||||
}
|
||||
if node.RelationshipStatus != "" {
|
||||
item.RelationshipStatus = node.RelationshipStatus
|
||||
item.RelationshipStatusName = "关联主套餐缺失"
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
item.Children = append(item.Children, buildAssetPackageHistoryNode(child, packageMap, callerAccountType))
|
||||
}
|
||||
item.ExpandByDefault = len(item.Children) > 0
|
||||
return item
|
||||
}
|
||||
|
||||
// GetCurrentPackage 获取资产当前生效的主套餐
|
||||
// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回
|
||||
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint, callerAccountType string) (*dto.AssetPackageResponse, error) {
|
||||
|
||||
Reference in New Issue
Block a user