Files
junhong_cmp_fiber/internal/handler/app/client_asset.go
huang 5065d925ad
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
fix: 修正套餐激活和时间字段nullable问题
核心变更:
1. Model层时间字段改为*time.Time并设为nullable
   - PackageUsage.ActivatedAt/ExpiresAt
   - PersonalCustomerDevice/ICCID/Phone.LastUsedAt/VerifiedAt

2. 数据库迁移:
   - activated_at/expires_at列移除NOT NULL约束
   - 清洗零值记录(status=0且activated_at<'2000-01-01')

3. 新增ActivateSpecificPackage方法:精准激活指定套餐,
   修复HandlePackageQueueActivation从"查找过期包"改为直接激活payload指定套餐

4. 新增孤儿套餐恢复扫描:Worker启动或每次套餐检查时,
   自动发现并恢复无status=1主套餐的孤儿载体

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 11:14:39 +08:00

686 lines
20 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"
"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
}
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,
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,
NetworkStatusName: constants.GetNetworkStatusName(resolved.Asset.NetworkStatus),
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, "当前无可购买套餐")
}
packagesResult, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID, 1, 50)
if err != nil {
return err
}
resolved.MainPackageActived = hasActiveMainPackage(packagesResult.Items)
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: 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) 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 "未知"
}
}
// 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.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
}