feat: 资产套餐历史增加主子层级查询

This commit is contained in:
2026-09-07 17:17:15 +08:00
parent c7c2b17d78
commit 696120ab38
18 changed files with 1030 additions and 236 deletions

View File

@@ -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 资产刷新

View File

@@ -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 资产解析请求

View File

@@ -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:"每页顶层关系组数量"`
}
// ========================================

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

View File

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

View File

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

View File

@@ -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 默认 1pageSize 默认 50pageSize 最大 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 中的 carrierTypecard→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) {