package app import ( "context" "sort" "strconv" "strings" "time" "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" asset "github.com/break/junhong_cmp_fiber/internal/service/asset" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/response" "github.com/gofiber/fiber/v2" "go.uber.org/zap" "gorm.io/gorm" ) // ClientAssetHandler C 端资产信息处理器 // 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口 type ClientAssetHandler struct { assetService *asset.Service personalDeviceStore *postgres.PersonalCustomerDeviceStore assetWalletStore *postgres.AssetWalletStore packageStore *postgres.PackageStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore db *gorm.DB logger *zap.Logger } // NewClientAssetHandler 创建 C 端资产信息处理器 func NewClientAssetHandler( assetService *asset.Service, personalDeviceStore *postgres.PersonalCustomerDeviceStore, assetWalletStore *postgres.AssetWalletStore, packageStore *postgres.PackageStore, shopPackageAllocationStore *postgres.ShopPackageAllocationStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, db *gorm.DB, logger *zap.Logger, ) *ClientAssetHandler { return &ClientAssetHandler{ assetService: assetService, personalDeviceStore: personalDeviceStore, assetWalletStore: assetWalletStore, packageStore: packageStore, shopPackageAllocationStore: shopPackageAllocationStore, iotCardStore: iotCardStore, deviceStore: deviceStore, db: db, logger: logger, } } type resolvedAssetContext struct { CustomerID uint Identifier string Asset *dto.AssetResolveResponse Generation int WalletBalance int64 SkipPermissionCtx context.Context IsAgentChannel bool SellerShopID uint MainPackageActived bool } // resolveAssetFromIdentifier 统一执行资产解析与归属校验 // 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐 func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) { customerID, ok := middleware.GetCustomerID(c) if !ok || customerID == 0 { return nil, errors.New(errors.CodeUnauthorized) } identifier = strings.TrimSpace(identifier) if identifier == "" { identifier = strings.TrimSpace(c.Query("identifier")) } if identifier == "" { var req dto.AssetRefreshRequest if err := c.BodyParser(&req); err == nil { identifier = strings.TrimSpace(req.Identifier) } } if identifier == "" { return nil, errors.New(errors.CodeInvalidParam) } skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) if err != nil { return nil, err } owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) if ownErr != nil { return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") } if !owned { return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) if genErr != nil { return nil, genErr } walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) if walletErr != nil { return nil, walletErr } ctxInfo := &resolvedAssetContext{ CustomerID: customerID, Identifier: identifier, Asset: assetInfo, Generation: generation, WalletBalance: walletBalance, SkipPermissionCtx: skipPermissionCtx, } if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 { ctxInfo.IsAgentChannel = true ctxInfo.SellerShopID = *assetInfo.ShopID } return ctxInfo, nil } // GetAssetInfo B1 资产信息 // GET /api/c/v1/asset/info func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { var req dto.AssetInfoRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } resp := &dto.AssetInfoResponse{ AssetType: resolved.Asset.AssetType, AssetID: resolved.Asset.AssetID, Identifier: resolved.Identifier, VirtualNo: resolved.Asset.VirtualNo, Status: resolved.Asset.Status, RealNameStatus: resolved.Asset.RealNameStatus, CarrierName: resolved.Asset.CarrierName, Generation: strconv.Itoa(resolved.Generation), WalletBalance: resolved.WalletBalance, ActivatedAt: resolved.Asset.ActivatedAt, CurrentPackage: resolved.Asset.CurrentPackage, PackageTotalMB: resolved.Asset.PackageTotalMB, PackageUsedMB: resolved.Asset.PackageUsedMB, PackageRemainMB: resolved.Asset.PackageRemainMB, DeviceName: resolved.Asset.DeviceName, IMEI: resolved.Asset.IMEI, SN: resolved.Asset.SN, DeviceModel: resolved.Asset.DeviceModel, DeviceType: resolved.Asset.DeviceType, Manufacturer: resolved.Asset.Manufacturer, MaxSimSlots: resolved.Asset.MaxSimSlots, BoundCardCount: resolved.Asset.BoundCardCount, Cards: resolved.Asset.Cards, DeviceProtectStatus: resolved.Asset.DeviceProtectStatus, ICCID: resolved.Asset.ICCID, MSISDN: resolved.Asset.MSISDN, CarrierID: resolved.Asset.CarrierID, CarrierType: resolved.Asset.CarrierType, NetworkStatus: resolved.Asset.NetworkStatus, ActivationStatus: resolved.Asset.ActivationStatus, CardCategory: resolved.Asset.CardCategory, BoundDeviceID: resolved.Asset.BoundDeviceID, BoundDeviceNo: resolved.Asset.BoundDeviceNo, BoundDeviceName: resolved.Asset.BoundDeviceName, } return response.Success(c, resp) } // GetAvailablePackages B2 资产可购套餐列表 // GET /api/c/v1/asset/packages func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { var req dto.AssetPackageListRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 { return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐") } allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID) if err != nil { return err } resolved.MainPackageActived = hasActiveMainPackage(allUsages) listCtx := resolved.SkipPermissionCtx if resolved.IsAgentChannel { listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent) listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID) } pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{ Page: 1, PageSize: constants.MaxPageSize, OrderBy: "id DESC", }, map[string]any{ "series_id": *resolved.Asset.SeriesID, "status": constants.StatusEnabled, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败") } allocationMap := make(map[uint]*model.ShopPackageAllocation) if resolved.IsAgentChannel { packageIDs := collectPackageIDs(pkgs) allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages( resolved.SkipPermissionCtx, resolved.SellerShopID, packageIDs, ) if allocErr != nil { return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败") } for _, allocation := range allocations { allocationMap[allocation.PackageID] = allocation } } items := make([]dto.ClientPackageItem, 0, len(pkgs)) for _, pkg := range pkgs { item, ok := buildClientPackageItem(pkg, resolved, allocationMap) if !ok { continue } items = append(items, item) } if len(items) == 0 { return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐") } sort.Slice(items, func(i, j int) bool { return items[i].RetailPrice < items[j].RetailPrice }) return response.Success(c, &dto.AssetPackageListResponse{Packages: items}) } // GetPackageHistory B3 资产套餐历史 // GET /api/c/v1/asset/package-history func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { var req dto.AssetPackageHistoryRequest if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 { req.PageSize = constants.DefaultPageSize } if req.PageSize > constants.MaxPageSize { req.PageSize = constants.MaxPageSize } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { 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) if err != nil { return err } list := make([]dto.AssetPackageResponse, 0, len(usages)) for _, usage := range usages { pkg := packageMap[usage.PackageID] ratio := 1.0 pkgName := "" pkgType := "" if pkg != nil { ratio = safeVirtualRatio(pkg.VirtualRatio) 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), DataLimitMB: usage.DataLimitMB, VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio), DataUsageMB: usage.DataUsageMB, VirtualUsedMB: float64(usage.DataUsageMB) / ratio, VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio, VirtualRatio: ratio, ActivatedAt: nonZeroTimePtr(usage.ActivatedAt), ExpiresAt: nonZeroTimePtr(usage.ExpiresAt), MasterUsageID: usage.MasterUsageID, Priority: usage.Priority, CreatedAt: usage.CreatedAt, }) } return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize) } // RefreshAsset B4 资产刷新 // POST /api/c/v1/asset/refresh func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error { var req dto.AssetRefreshRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam) } resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) if err != nil { return err } if _, err := h.assetService.Refresh( resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID, ); err != nil { return err } resp := &dto.AssetRefreshResponse{ RefreshType: resolved.Asset.AssetType, Accepted: true, CooldownSeconds: 0, } if resolved.Asset.AssetType == constants.ResourceTypeDevice { resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second) } return response.Success(c, resp) } func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) if err != nil { return false, err } for _, record := range records { if record == nil { continue } if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { return true, nil } } return false, nil } func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { switch assetType { case "card": card, err := h.iotCardStore.GetByID(ctx, assetID) if err != nil { if err == gorm.ErrRecordNotFound { return 0, errors.New(errors.CodeAssetNotFound) } return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败") } return card.Generation, nil case "device": device, err := h.deviceStore.GetByID(ctx, assetID) if err != nil { if err == gorm.ErrRecordNotFound { return 0, errors.New(errors.CodeAssetNotFound) } return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败") } return device.Generation, nil default: return 0, errors.New(errors.CodeInvalidParam) } } func (h *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) { resourceType := constants.AssetWalletResourceTypeIotCard if assetType == constants.ResourceTypeDevice { resourceType = constants.AssetWalletResourceTypeDevice } wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID) if err != nil { if err == gorm.ErrRecordNotFound { return 0, nil } return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") } return wallet.Balance, nil } func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) { ids := make([]uint, 0, len(usages)) seen := make(map[uint]struct{}, len(usages)) for _, usage := range usages { if usage == nil { continue } if _, ok := seen[usage.PackageID]; ok { continue } seen[usage.PackageID] = struct{}{} ids = append(ids, usage.PackageID) } packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } result := make(map[uint]*model.Package, len(packages)) for _, pkg := range packages { result[pkg.ID] = pkg } return result, nil } func collectPackageIDs(pkgs []*model.Package) []uint { ids := make([]uint, 0, len(pkgs)) for _, pkg := range pkgs { if pkg == nil { continue } ids = append(ids, pkg.ID) } return ids } func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool { for _, usage := range usages { if usage == nil { continue } if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive { return true } } return false } func buildClientPackageItem( pkg *model.Package, resolved *resolvedAssetContext, allocationMap map[uint]*model.ShopPackageAllocation, ) (dto.ClientPackageItem, bool) { if pkg == nil || pkg.Status != constants.StatusEnabled { return dto.ClientPackageItem{}, false } isAddon := pkg.PackageType == constants.PackageTypeAddon if isAddon && !resolved.MainPackageActived { return dto.ClientPackageItem{}, false } retailPrice := pkg.SuggestedRetailPrice costPrice := pkg.CostPrice if resolved.IsAgentChannel { allocation, ok := allocationMap[pkg.ID] if !ok || allocation == nil { return dto.ClientPackageItem{}, false } if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled { return dto.ClientPackageItem{}, false } retailPrice = allocation.RetailPrice costPrice = allocation.CostPrice } else if pkg.ShelfStatus != constants.ShelfStatusOn { return dto.ClientPackageItem{}, false } if retailPrice < costPrice { return dto.ClientPackageItem{}, false } validityDays := pkg.DurationDays if validityDays <= 0 && pkg.DurationMonths > 0 { validityDays = pkg.DurationMonths * 30 } dataAllowance := pkg.VirtualDataMB if dataAllowance <= 0 { dataAllowance = pkg.RealDataMB } return dto.ClientPackageItem{ PackageID: pkg.ID, PackageName: pkg.PackageName, PackageType: pkg.PackageType, RetailPrice: retailPrice, CostPrice: costPrice, ValidityDays: validityDays, IsAddon: isAddon, DataAllowance: dataAllowance, DataUnit: "MB", Description: pkg.PackageCode, }, true } func nonZeroTimePtr(t time.Time) *time.Time { if t.IsZero() { return nil } return &t } func safeVirtualRatio(ratio float64) float64 { if ratio <= 0 { return 1.0 } return ratio } func packageStatusName(status int) string { switch status { case constants.PackageUsageStatusPending: return "待生效" case constants.PackageUsageStatusActive: return "生效中" case constants.PackageUsageStatusDepleted: return "已用完" case constants.PackageUsageStatusExpired: return "已过期" case constants.PackageUsageStatusInvalidated: return "已失效" default: return "未知" } }