Files
junhong_cmp_fiber/internal/handler/app/client_asset.go
break ba435dd6a6
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
关于绑定的问题
2026-06-22 12:08:41 +09:00

670 lines
21 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"
"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"
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"
"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
customerBinding *customerBinding.Service
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,
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)
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
}
phone, _ := middleware.GetCustomerPhone(c)
resp := &dto.AssetInfoResponse{
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),
CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
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,
}
// 调用服务层获取设备实时 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)
}
}
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, "当前无可购买套餐")
}
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)
}
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]
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,
})
}
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) 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 "未知"
}
}
// 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 = 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
}