Files
junhong_cmp_fiber/internal/handler/app/client_asset.go
邹进 ce2977ce22 fix: C 端设备局域网地址为空时默认 192.168.1.1
网关常不返回设备 lan_ip,H5 点后台管理时提示设备未开机;为空时回落到设备默认网关地址。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:21:38 +09:00

914 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"sort"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"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"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"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"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
var clientAssetValidator = validator.New()
// ClientAssetHandler C 端资产信息处理器
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
type ClientAssetHandler struct {
assetService *asset.Service
customerBinding *customerBinding.Service
assetWalletStore *postgres.AssetWalletStore
packageStore *postgres.PackageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
logger *zap.Logger
observationSeries cardObservationApp.BestEffortSeriesDispatcher
paymentMethodPolicy ClientPaymentMethodPolicy
forceRechargeChecker ClientForceRechargeChecker
expiryReminder ClientExpiryReminder
}
// ClientExpiryReminder 在 H5 进入资产时判定并发出套餐临期提醒。
type ClientExpiryReminder interface {
Remind(ctx context.Context, customerID uint, candidate dto.ExpiringAssetItem) *dto.AssetExpiryReminder
}
// SetExpiryReminder 注入进入资产时的套餐临期提醒用例。
func (h *ClientAssetHandler) SetExpiryReminder(reminder ClientExpiryReminder) {
h.expiryReminder = reminder
}
// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。
type ClientPaymentMethodPolicy interface {
AllowedMethods(ctx context.Context, assetType string) ([]string, error)
AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error)
EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error
EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error
}
// ClientForceRechargeChecker 提供 C 端资产当前强充资格的统一判定。
type ClientForceRechargeChecker interface {
GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*rechargeSvc.ForceRechargeRequirement, error)
}
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
func (h *ClientAssetHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) {
h.paymentMethodPolicy = policy
}
// SetForceRechargeChecker 注入复用现有一次性佣金状态语义的强充判定服务。
func (h *ClientAssetHandler) SetForceRechargeChecker(checker ClientForceRechargeChecker) {
h.forceRechargeChecker = checker
}
// NewClientAssetHandler 创建 C 端资产信息处理器
func NewClientAssetHandler(
assetService *asset.Service,
binding *customerBinding.Service,
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,
customerBinding: binding,
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
HasAddonMainPackage 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, false)
if err != nil {
return nil, err
}
owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID)
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
}
currentPackageID, err := h.getCurrentPackageID(resolved.SkipPermissionCtx, resolved.Asset.CurrentPackageUsageID)
if err != nil {
return err
}
renewalPrice, err := h.getRenewalPrice(resolved.SkipPermissionCtx, currentPackageID, resolved.SellerShopID)
if err != nil {
return err
}
if h.paymentMethodPolicy == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
if h.forceRechargeChecker == nil {
return errors.New(errors.CodeNoPaymentConfig)
}
resourceType := resolved.Asset.AssetType
if resourceType == "card" {
resourceType = constants.ResourceTypeIotCard
}
forceRecharge, err := h.forceRechargeChecker.GetRechargeCheck(resolved.SkipPermissionCtx, resourceType, resolved.Asset.AssetID)
if err != nil {
return err
}
allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
if forceRecharge.NeedForceRecharge {
allowedPaymentMethods, err = h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType)
if err != nil {
return err
}
}
phone, _ := middleware.GetCustomerPhone(c)
resp := &dto.AssetInfoResponse{
PackageExpiryEstimate: resolved.Asset.PackageExpiryEstimate,
BoundPhone: phone,
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
Identifier: resolved.Identifier,
VirtualNo: resolved.Asset.VirtualNo,
Status: resolved.Asset.Status,
StatusName: resolved.Asset.StatusName,
RealNameStatus: resolved.Asset.RealNameStatus,
RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus),
RealnamePolicy: resolved.Asset.RealnamePolicy,
EffectiveRealnamePolicy: resolved.Asset.RealnamePolicy,
RealnameRequired: resolved.Asset.RealnamePolicy != constants.RealnamePolicyNone,
CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance,
AllowedPaymentMethods: allowedPaymentMethods,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
CurrentPackageID: currentPackageID,
RenewalPrice: renewalPrice,
CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID,
CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt,
CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt,
RealTotalMB: resolved.Asset.RealTotalMB,
RealUsedMB: resolved.Asset.RealUsedMB,
VirtualTotalMB: resolved.Asset.VirtualTotalMB,
VirtualUsedMB: resolved.Asset.VirtualUsedMB,
ReductionPct: resolved.Asset.ReductionPct,
EnableVirtualData: resolved.Asset.EnableVirtualData,
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,
NetworkStatusName: constants.GetNetworkStatusName(resolved.Asset.NetworkStatus),
GatewayExtend: resolved.Asset.GatewayExtend,
ActivationStatus: resolved.Asset.ActivationStatus,
ActivationStatusName: constants.GetActivationStatusName(resolved.Asset.ActivationStatus),
CardCategory: resolved.Asset.CardCategory,
BoundDeviceID: resolved.Asset.BoundDeviceID,
BoundDeviceNo: resolved.Asset.BoundDeviceNo,
BoundDeviceName: resolved.Asset.BoundDeviceName,
}
if h.expiryReminder != nil {
resp.ExpiryReminder = h.expiryReminder.Remind(c.UserContext(), resolved.CustomerID, expiringCandidateOf(resolved.Asset))
}
// 调用服务层获取设备实时 Gateway 数据(per D-11)
// Gateway 失败时 DeviceRealtime 仅含 gateway_msg,不阻断主流程(per D-06)
if resp.AssetType == "device" {
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
c.UserContext(), "device", resp.AssetID,
)
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
}
}
h.dispatchAssetReadObservations(c.UserContext(), resolved.Asset)
return response.Success(c, resp)
}
func (h *ClientAssetHandler) getCurrentPackageID(ctx context.Context, usageID *uint) (uint, error) {
if usageID == nil || *usageID == 0 {
return 0, nil
}
var usage model.PackageUsage
if err := h.db.WithContext(ctx).Select("package_id").First(&usage, *usageID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐引用失败")
}
return usage.PackageID, nil
}
func (h *ClientAssetHandler) getRenewalPrice(ctx context.Context, packageID, sellerShopID uint) (*int64, error) {
if packageID == 0 {
return nil, nil
}
pkg, err := h.packageStore.GetByID(ctx, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询续费套餐失败")
}
if sellerShopID == 0 {
price := packageprice.PackageEffectiveRetailPrice(pkg)
return &price, nil
}
allocation, err := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询续费套餐价格失败")
}
price := packageprice.AllocationEffectiveRetailPrice(allocation)
return &price, nil
}
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
if h.observationSeries == nil || asset == nil {
return
}
cardIDs := make([]uint, 0, len(asset.Cards)+1)
if asset.AssetType == "card" {
cardIDs = append(cardIDs, asset.AssetID)
} else {
for _, card := range asset.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
dispatchCardReadSeries(ctx, h.observationSeries, constants.CardObservationSceneClientAssetRead, cardIDs)
}
func dispatchCardReadSeries(ctx context.Context, dispatcher cardObservationApp.BestEffortSeriesDispatcher, scene string, cardIDs []uint) {
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{
constants.CardObservationSyncTypeRealname,
constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
} {
dispatcher.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: resourceID, SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// 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)
}
if err := clientAssetValidator.Struct(&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, "当前无可购买套餐")
}
if _, err := packagepkg.FindAttachableMainPackageForAddon(
h.db.WithContext(resolved.SkipPermissionCtx),
resolved.Asset.AssetType,
resolved.Asset.AssetID,
time.Now(),
); err == nil {
resolved.HasAddonMainPackage = true
} else if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
}
listCtx := resolved.SkipPermissionCtx
if resolved.IsAgentChannel {
listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent)
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
}
filters := map[string]any{
"series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled,
"shelf_status": constants.ShelfStatusOn,
}
if req.PackageType != nil {
filters["package_type"] = *req.PackageType
}
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
Page: 1,
PageSize: constants.MaxPageSize,
OrderBy: "id DESC",
}, filters)
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
}
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
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items))
if err != nil {
return err
}
items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items))
for _, node := range history.Items {
item := buildClientPackageHistoryNode(node, packageMap)
h.fillClientSilenceViews(c.UserContext(), item, node)
items = append(items, item)
}
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
}
// fillClientSilenceViews 按历史树逐节点填写沉默期展示信息(总开关关闭时不返回)。
func (h *ClientAssetHandler) fillClientSilenceViews(ctx context.Context, item *dto.ClientAssetPackageHistoryNode, node *assetquery.PackageHistoryNode) {
if item == nil || node == nil || h.assetService == nil {
return
}
item.Silence = h.assetService.SilenceView(ctx, node.Usage)
for index, child := range node.Children {
if index < len(item.Children) {
h.fillClientSilenceViews(ctx, item.Children[index], child)
}
}
}
// 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) 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 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
}
if pkg.IsGift {
return dto.ClientPackageItem{}, false
}
isAddon := pkg.PackageType == constants.PackageTypeAddon
if isAddon && !resolved.HasAddonMainPackage {
return dto.ClientPackageItem{}, false
}
retailPrice := packageprice.PackageEffectiveRetailPrice(pkg)
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 = packageprice.AllocationEffectiveRetailPrice(allocation)
costPrice = allocation.CostPrice
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
return dto.ClientPackageItem{}, false
}
if retailPrice < costPrice {
return dto.ClientPackageItem{}, false
}
validityDays := packagepkg.CalculateValidityDays(pkg.CalendarType, time.Now(), pkg.DurationMonths, pkg.DurationDays)
dataAllowance := pkg.RealDataMB
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 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 "未知"
}
}
// clientDeviceLANIP 网关未返回局域网网关地址(空或仅空白)时使用设备默认网关地址,保证 C 端可进入设备后台。
func clientDeviceLANIP(lanIP *string) *string {
if lanIP != nil && strings.TrimSpace(*lanIP) != "" {
return lanIP
}
defaultIP := constants.DefaultDeviceLANIP
return &defaultIP
}
// mapDeviceGatewayInfoToClientInfo 将 B 端 DeviceGatewayInfo 映射为 C 端 DeviceRealtimeInfo
// B 端和 C 端结构独立,通过映射函数转换(per D-08)
func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealtimeInfo {
if g == nil {
return nil
}
info := &dto.DeviceRealtimeInfo{}
if g.OnlineStatus != nil {
v := int64(*g.OnlineStatus)
info.OnlineStatus = &v
}
if g.BatteryLevel != nil {
v := int64(*g.BatteryLevel)
info.BatteryLevel = &v
}
if g.Status != nil {
v := int64(*g.Status)
info.Status = &v
}
info.RunTime = g.RunTime
info.ConnectTime = g.ConnectTime
info.LastOnlineTime = g.LastOnlineTime
info.LastUpdateTime = g.LastUpdateTime
if g.Rsrp != nil {
v := int64(*g.Rsrp)
info.Rsrp = &v
}
if g.Rsrq != nil {
v := int64(*g.Rsrq)
info.Rsrq = &v
}
info.Rssi = g.Rssi
if g.Sinr != nil {
v := int64(*g.Sinr)
info.Sinr = &v
}
info.SignalQuality = g.SignalQuality
info.SignalBadReason = g.SignalBadReason
info.SSID = g.SSID
info.WifiEnabled = g.WifiEnabled
info.WifiPassword = g.WifiPassword
info.IPAddress = g.IPAddress
info.WANIP = g.WANIP
info.LANIP = clientDeviceLANIP(g.LANIP)
info.MACAddress = g.MACAddress
info.DailyUsage = g.DailyUsage
info.DLStats = g.DLStats
info.ULStats = g.ULStats
if g.LimitSpeed != nil {
v := int64(*g.LimitSpeed)
info.LimitSpeed = &v
}
info.CurrentIccid = g.CurrentIccid
if g.MaxClients != nil {
v := int64(*g.MaxClients)
info.MaxClients = &v
}
if g.ClientNumber != nil {
v := int64(*g.ClientNumber)
info.ClientNumber = &v
}
info.SoftwareVersion = g.SoftwareVersion
if g.SwitchMode != nil {
// SwitchMode 在 B 端为字符串("0"/"1"),C 端为整型(0/1),需解析转换
if n, convErr := strconv.Atoi(*g.SwitchMode); convErr == nil {
info.SwitchMode = &n
}
}
if g.SyncInterval != nil {
v := int64(*g.SyncInterval)
info.SyncInterval = &v
}
info.DeviceID = g.DeviceID
info.DeviceName = g.DeviceName
info.DeviceType = g.DeviceType
info.Imei = g.IMEI
info.Imsi = g.IMSI
info.GatewayMsg = g.GatewayMsg
return info
}
// expiringCandidateOf 把资产解析结果转换为临期提醒候选;资产标识与通知中心过滤口径一致(卡 ICCID;设备虚拟号,空则 IMEI)。
func expiringCandidateOf(asset *dto.AssetResolveResponse) dto.ExpiringAssetItem {
candidate := dto.ExpiringAssetItem{
AssetID: asset.AssetID, ShopID: asset.ShopID, PackageExpiryEstimate: asset.PackageExpiryEstimate,
}
if asset.CurrentPackageUsageID != nil {
candidate.PackageUsageID = *asset.CurrentPackageUsageID
}
if asset.AssetType == "device" {
candidate.AssetType = constants.AssetTypeDevice
candidate.Identifier = asset.VirtualNo
if candidate.Identifier == "" {
candidate.Identifier = asset.IMEI
}
return candidate
}
candidate.AssetType = constants.AssetTypeIotCard
candidate.Identifier = asset.ICCID
return candidate
}