Files
junhong_cmp_fiber/internal/service/asset/service.go
huang 2b3a9cb33f
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m19s
feat: 新增套餐使用记录实付金额快照字段 paid_amount
- 新增迁移 000129:tb_package_usage 添加 paid_amount BIGINT 字段,存量数据通过 JOIN tb_order 回填
- PackageUsage Model 新增 PaidAmount *int64 字段
- 4 个写入点(order service 主套餐/加油包、auto_purchase 主套餐/加油包)赋值 order.ActualPaidAmount
- AssetPackageResponse DTO 新增 paid_amount 字段
- GetCurrentPackage / GetPackages 填充 paid_amount,接口直接返回购买价格无需 JOIN 订单表
2026-04-18 10:19:21 +08:00

1089 lines
34 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 asset 提供统一的资产查询与操作服务
// 资产包含两种类型IoT卡(card)和设备(device)
// 支持资产解析、实时状态查询、网关刷新、套餐查询等功能
package asset
import (
"context"
stderrors "errors"
"sort"
"time"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
// IotCardRefresher 用于调用 RefreshCardDataFromGateway避免循环依赖
type IotCardRefresher interface {
RefreshCardDataFromGateway(ctx context.Context, iccid string) error
}
// Service 资产查询与操作服务
type Service struct {
db *gorm.DB
deviceStore *postgres.DeviceStore
iotCardStore *postgres.IotCardStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
shopStore *postgres.ShopStore
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
exchangeOrderStore *postgres.ExchangeOrderStore
redis *redis.Client
iotCardService IotCardRefresher
gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore
}
// New 创建资产服务实例
func New(
db *gorm.DB,
deviceStore *postgres.DeviceStore,
iotCardStore *postgres.IotCardStore,
packageUsageStore *postgres.PackageUsageStore,
packageStore *postgres.PackageStore,
packageSeriesStore *postgres.PackageSeriesStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
shopStore *postgres.ShopStore,
redisClient *redis.Client,
iotCardService IotCardRefresher,
gatewayClient *gateway.Client,
assetIdentifierStore *postgres.AssetIdentifierStore,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
exchangeOrderStore *postgres.ExchangeOrderStore,
) *Service {
return &Service{
db: db,
deviceStore: deviceStore,
iotCardStore: iotCardStore,
packageUsageStore: packageUsageStore,
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
deviceSimBindingStore: deviceSimBindingStore,
shopStore: shopStore,
orderStore: orderStore,
orderItemStore: orderItemStore,
exchangeOrderStore: exchangeOrderStore,
redis: redisClient,
iotCardService: iotCardService,
gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore,
}
}
// Resolve 通过任意标识符解析资产
// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo
// Fallback原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) {
if s.assetIdentifierStore != nil {
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
if regErr == nil && regRecord != nil {
switch regRecord.AssetType {
case model.AssetTypeDevice:
device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
if devErr == nil && device != nil {
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
case model.AssetTypeIotCard:
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
if cardErr == nil && card != nil {
resp, buildErr := s.buildCardResolveResponse(ctx, card)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
}
}
}
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil && device != nil {
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
var card model.IotCard
query := s.db.WithContext(ctx).
Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
resp, buildErr := s.buildCardResolveResponse(ctx, &card)
if buildErr == nil {
resp.Identifier = identifier
}
return resp, buildErr
}
return nil, errors.New(errors.CodeNotFound, "未找到匹配的资产")
}
// buildDeviceResolveResponse 构建设备类型的资产解析响应
func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.Device) (*dto.AssetResolveResponse, error) {
resp := &dto.AssetResolveResponse{
AssetType: "device",
AssetID: device.ID,
VirtualNo: device.VirtualNo,
Status: device.Status,
StatusName: constants.GetIotCardStatusName(device.Status),
BatchNo: device.BatchNo,
ShopID: device.ShopID,
SeriesID: device.SeriesID,
ActivatedAt: device.ActivatedAt,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
DeviceName: device.DeviceName,
IMEI: device.IMEI,
SN: device.SN,
DeviceModel: device.DeviceModel,
DeviceType: device.DeviceType,
MaxSimSlots: device.MaxSimSlots,
Manufacturer: device.Manufacturer,
OnlineStatus: device.OnlineStatus,
LastOnlineTime: device.LastOnlineTime,
SoftwareVersion: device.SoftwareVersion,
SwitchMode: device.SwitchMode,
LastGatewaySyncAt: device.LastGatewaySyncAt,
EnablePolling: device.EnablePolling,
RealnamePolicy: device.RealnamePolicy,
}
// 查绑定卡
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID)
if err == nil && len(bindings) > 0 {
resp.BoundCardCount = len(bindings)
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
isCurrentMap := make(map[uint]bool, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
isCurrentMap[b.IotCardID] = b.IsCurrent
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
logger.GetAppLogger().Warn("查询设备绑定卡信息失败,结果可能不完整",
zap.Uints("card_ids", cardIDs),
zap.Error(err))
}
for _, c := range cards {
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
CardID: c.ID,
ICCID: c.ICCID,
MSISDN: c.MSISDN,
NetworkStatus: c.NetworkStatus,
RealNameStatus: c.RealNameStatus,
RealNameAt: c.FirstRealnameAt,
SlotPosition: slotMap[c.ID],
IsCurrent: isCurrentMap[c.ID],
CarrierID: c.CarrierID,
CarrierType: c.CarrierType,
CarrierName: c.CarrierName,
})
}
// RealNameStatus 以当前使用卡为准;无当前卡则视为未实名
for _, c := range cards {
if isCurrentMap[c.ID] {
resp.RealNameStatus = c.RealNameStatus
break
}
}
// RealNameAt 取所有绑定卡中最早的实名时间
for _, c := range cards {
if c.FirstRealnameAt == nil {
continue
}
if resp.RealNameAt == nil || c.FirstRealnameAt.Before(*resp.RealNameAt) {
t := *c.FirstRealnameAt
resp.RealNameAt = &t
}
}
}
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "device", device.ID)
// 查 shop 名称
s.fillShopName(ctx, resp)
// 查套餐系列名称
s.fillSeriesName(ctx, resp)
// 查 Redis 保护期
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID)
return resp, nil
}
// buildCardResolveResponse 构建卡类型的资产解析响应
func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) {
resp := &dto.AssetResolveResponse{
AssetType: "card",
AssetID: card.ID,
VirtualNo: card.VirtualNo,
Status: card.Status,
StatusName: constants.GetIotCardStatusName(card.Status),
BatchNo: card.BatchNo,
ShopID: card.ShopID,
SeriesID: card.SeriesID,
ActivatedAt: card.ActivatedAt,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
RealNameStatus: card.RealNameStatus,
RealNameAt: card.FirstRealnameAt,
RealnamePolicy: card.RealnamePolicy,
NetworkStatus: card.NetworkStatus,
ICCID: card.ICCID,
CarrierID: card.CarrierID,
CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
MSISDN: card.MSISDN,
IMSI: card.IMSI,
CardCategory: card.CardCategory,
Supplier: card.Supplier,
ActivationStatus: card.ActivationStatus,
EnablePolling: card.EnablePolling,
}
// 查绑定设备
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err == nil && binding != nil {
resp.BoundDeviceID = &binding.DeviceID
device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID)
if devErr == nil && device != nil {
resp.BoundDeviceNo = device.VirtualNo
resp.BoundDeviceName = device.DeviceName
}
}
// 查当前主套餐
s.fillPackageInfo(ctx, resp, "iot_card", card.ID)
// 查 shop 名称
s.fillShopName(ctx, resp)
// 查套餐系列名称
s.fillSeriesName(ctx, resp)
return resp, nil
}
// fillPackageInfo 填充当前主套餐信息到响应中
func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, carrierID)
if err != nil || usage == nil {
return
}
pkg, err := s.packageStore.GetByID(ctx, usage.PackageID)
if err != nil || pkg == nil {
return
}
resp.CurrentPackage = pkg.PackageName
ratio := safeVirtualRatio(pkg.VirtualRatio)
resp.PackageTotalMB = int64(float64(usage.DataLimitMB) / ratio)
resp.PackageUsedMB = float64(usage.DataUsageMB) / ratio
resp.PackageRemainMB = float64(usage.DataLimitMB-usage.DataUsageMB) / ratio
}
// fillShopName 填充店铺名称
func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) {
if resp.ShopID == nil || *resp.ShopID == 0 {
return
}
shop, err := s.shopStore.GetByID(ctx, *resp.ShopID)
if err == nil && shop != nil {
resp.ShopName = shop.ShopName
}
}
// fillSeriesName 填充套餐系列名称
func (s *Service) fillSeriesName(ctx context.Context, resp *dto.AssetResolveResponse) {
if resp.SeriesID == nil || *resp.SeriesID == 0 {
return
}
series, err := s.packageSeriesStore.GetByID(ctx, *resp.SeriesID)
if err == nil && series != nil {
resp.SeriesName = series.SeriesName
}
}
// GetRealtimeStatus 获取资产实时状态只读DB/Redis
func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
resp := &dto.AssetRealtimeStatusResponse{
AssetType: assetType,
AssetID: id,
}
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
}
resp.NetworkStatus = card.NetworkStatus
resp.RealNameStatus = card.RealNameStatus
resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB
resp.LastSyncTime = card.LastSyncTime
case "device":
// 查绑定卡状态列表
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
if err == nil && len(bindings) > 0 {
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
isCurrentMap := make(map[uint]bool, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
isCurrentMap[b.IotCardID] = b.IsCurrent
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
logger.GetAppLogger().Warn("查询设备绑定卡信息失败,结果可能不完整",
zap.Uints("card_ids", cardIDs),
zap.Error(err))
}
for _, c := range cards {
resp.Cards = append(resp.Cards, dto.BoundCardInfo{
CardID: c.ID,
ICCID: c.ICCID,
MSISDN: c.MSISDN,
NetworkStatus: c.NetworkStatus,
RealNameStatus: c.RealNameStatus,
SlotPosition: slotMap[c.ID],
IsCurrent: isCurrentMap[c.ID],
CarrierID: c.CarrierID,
CarrierType: c.CarrierType,
CarrierName: c.CarrierName,
})
}
}
resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)
// 实时查询 Gateway 设备状态不缓存per D-05
// Gateway 失败不阻断主流程DeviceRealtime 始终非 nil失败时仅填 GatewayMsgper D-06
resp.DeviceRealtime = s.fetchDeviceGatewayInfo(ctx, id)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
}
return resp, nil
}
// fetchDeviceGatewayInfo 查询设备 Gateway 实时状态,始终返回非 nil 对象
// Gateway 成功时填充全量字段,失败时仅填充 GatewayMsg 供前端展示错误原因
func (s *Service) fetchDeviceGatewayInfo(ctx context.Context, deviceID uint) *dto.DeviceGatewayInfo {
if s.gatewayClient == nil {
msg := "Gateway 客户端未配置"
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
msg := "查询设备信息失败: " + err.Error()
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
// IMEI 优先,无则用 SN与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
cardNo := device.IMEI
if cardNo == "" {
cardNo = device.SN
}
if cardNo == "" {
msg := "设备缺少 IMEI 和 SN无法查询 Gateway 实时状态"
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
CardNo: cardNo,
})
if syncErr != nil {
msg := syncErr.Error()
logger.GetAppLogger().Warn("fetchDeviceGatewayInfo: sync-info 调用失败",
zap.Uint("device_id", deviceID),
zap.Error(syncErr))
return &dto.DeviceGatewayInfo{GatewayMsg: &msg}
}
// 实时状态成功后异步回写 DB保持 Resolve 接口的缓存与 Gateway 同步
// 使用 context.Background() 避免请求结束后 ctx 取消导致写入失败
go s.updateDeviceFromSyncInfo(context.Background(), deviceID, syncResp)
return mapSyncRespToDeviceGatewayInfo(syncResp)
}
// mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO
// 使用指针字段,未赋值的字段保持 nilomitempty
func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo {
info := &dto.DeviceGatewayInfo{
OnlineStatus: flexIntPtr(r.OnlineStatus),
RunTime: strPtr(r.RunTime),
ConnectTime: strPtr(r.ConnectTime),
LastOnlineTime: strPtr(r.LastOnlineTime),
LastUpdateTime: strPtr(r.LastUpdateTime),
Rsrp: flexIntPtr(r.Rsrp),
Rsrq: flexIntPtr(r.Rsrq),
Rssi: strPtr(r.RSSI),
Sinr: flexIntPtr(r.Sinr),
SSID: strPtr(r.SSID),
WifiEnabled: flexBoolPtr(r.WifiEnabled),
WifiPassword: strPtr(r.WifiPassword),
IPAddress: strPtr(r.IPAddress),
WANIP: strPtr(r.WANIP),
LANIP: strPtr(r.LANIP),
MACAddress: strPtr(r.MacAddress),
DailyUsage: strPtr(r.DailyUsage),
DLStats: strPtr(r.DLStats),
ULStats: strPtr(r.ULStats),
LimitSpeed: flexIntPtr(r.LimitSpeed),
CurrentIccid: strPtr(r.CurrentIccid),
MaxClients: flexIntPtr(r.MaxClients),
ClientNumber: flexIntPtr(r.ClientNumber),
SoftwareVersion: strPtr(r.SoftwareVersion),
SwitchMode: strPtr(r.SwitchMode),
SyncInterval: flexIntPtr(r.SyncInterval),
DeviceID: strPtr(r.DeviceID),
DeviceName: strPtr(r.DeviceName),
DeviceType: strPtr(r.DeviceType),
IMEI: strPtr(r.IMEI),
IMSI: strPtr(r.IMSI),
Status: flexIntPtr(r.Status),
}
if r.BatteryLevel != nil {
n := int(*r.BatteryLevel)
info.BatteryLevel = &n
}
return info
}
// strPtr 将空字符串转为 nil非空字符串返回指针
// 避免 JSON 响应中出现大量空字符串字段omitempty 对空字符串无效)
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func flexBoolPtr(fb gateway.FlexBool) *bool {
b := bool(fb)
return &b
}
func flexIntPtr(fi gateway.FlexInt) *int {
n := int(fi)
return &n
}
// Refresh 刷新资产数据(调网关同步)
func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.AssetRealtimeStatusResponse, error) {
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
}
if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败")
}
return s.GetRealtimeStatus(ctx, "card", id)
case "device":
// 检查冷却期
cooldownKey := constants.RedisDeviceRefreshCooldownKey(id)
if s.redis.Exists(ctx, cooldownKey).Val() > 0 {
return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁请30秒后再试")
}
// 查所有绑定卡,逐一刷新
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败")
}
for _, b := range bindings {
card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID)
if cardErr != nil {
logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败",
zap.Uint("device_id", id),
zap.Uint("card_id", b.IotCardID),
zap.Error(cardErr))
continue
}
if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil {
logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败",
zap.Uint("device_id", id),
zap.String("iccid", card.ICCID),
zap.Error(refreshErr))
}
}
// 设置冷却 Key
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
// 刷新设备自身信息在线状态、当前卡、固件版本等失败不阻断刷新流程per D-15
if s.gatewayClient != nil {
device, devErr := s.deviceStore.GetByID(ctx, id)
if devErr == nil {
// IMEI 优先,无则用 SNper D-3 注释说明)
cardNo := device.IMEI
if cardNo == "" {
cardNo = device.SN
}
if cardNo != "" {
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
CardNo: cardNo,
}); syncErr == nil {
s.updateDeviceFromSyncInfo(ctx, id, syncResp)
} else {
logger.GetAppLogger().Warn("sync-info 调用失败",
zap.Uint("device_id", id),
zap.Error(syncErr))
}
}
}
}
return s.GetRealtimeStatus(ctx, "device", id)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device")
}
}
// updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识
// 失败时只记录日志,不返回错误(非关键步骤)
func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) {
now := time.Now()
// 解析 LastOnlineTimeISO 8601 字符串 → *time.Time
var lastOnlineTime *time.Time
if resp.LastOnlineTime != "" {
if t, err := time.Parse(time.RFC3339, resp.LastOnlineTime); err == nil {
lastOnlineTime = &t
}
}
// 更新设备 5 个存储字段
updates := map[string]any{
"online_status": int(resp.OnlineStatus),
"software_version": resp.SoftwareVersion,
"switch_mode": resp.SwitchMode,
"last_gateway_sync_at": now,
}
if lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
if err := s.db.WithContext(ctx).
Model(&model.Device{}).
Where("id = ?", deviceID).
Updates(updates).Error; err != nil {
logger.GetAppLogger().Warn("sync-info 更新设备字段失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return
}
// 更新 is_current 标识事务原子操作per D-14 决策)
if err := s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid); err != nil {
logger.GetAppLogger().Warn("sync-info 更新 is_current 失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
}
}
// GetPackages 获取资产的所有套餐列表(支持分页)
// page 默认 1pageSize 默认 50pageSize 最大 100
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error) {
// 分页参数边界处理
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 50
}
if pageSize > 100 {
pageSize = 100
}
// assetType 对应 Store 中的 carrierTypecard→iot_card, device→device
carrierType := assetType
if assetType == "card" {
carrierType = "iot_card"
}
usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, 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)
if pkgErr != nil {
logger.GetAppLogger().Warn("批量查询套餐信息失败,套餐名称可能缺失",
zap.Uints("package_ids", pkgIDs),
zap.Error(pkgErr))
}
pkgMap := make(map[uint]*model.Package, len(packages))
for _, p := range packages {
pkgMap[p.ID] = p
}
all := make([]*dto.AssetPackageResponse, 0, len(usages))
for _, u := range usages {
pkg := pkgMap[u.PackageID]
ratio := 1.0
pkgName := ""
pkgType := ""
enableVirtualData := false
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
enableVirtualData = pkg.EnableVirtualData
}
item := &dto.AssetPackageResponse{
PackageUsageID: u.ID,
PackageID: u.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
DataLimitMB: u.DataLimitMB,
VirtualLimitMB: int64(float64(u.DataLimitMB) / ratio),
DataUsageMB: u.DataUsageMB,
VirtualUsedMB: float64(u.DataUsageMB) / ratio,
VirtualRemainMB: float64(u.DataLimitMB-u.DataUsageMB) / ratio,
VirtualRatio: ratio,
EnableVirtualData: enableVirtualData,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
PaidAmount: u.PaidAmount,
CreatedAt: u.CreatedAt,
}
all = append(all, item)
}
// 按 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,
Page: page,
PageSize: pageSize,
Items: items,
}, nil
}
// GetCurrentPackage 获取资产当前生效的主套餐
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint) (*dto.AssetPackageResponse, error) {
carrierType := assetType
if assetType == "card" {
carrierType = "iot_card"
}
usage, err := s.packageUsageStore.GetActiveMainPackage(ctx, carrierType, id)
if err != nil {
// 记录不存在表示当前无生效套餐,属于正常业务状态,返回 nil 而非错误
// 其他 DB 错误才作为服务端错误处理
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前生效套餐失败")
}
pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID)
ratio := 1.0
pkgName := ""
pkgType := ""
enableVirtualData := false
if pkgErr == nil && pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
enableVirtualData = pkg.EnableVirtualData
}
return &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,
EnableVirtualData: enableVirtualData,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
PaidAmount: usage.PaidAmount,
CreatedAt: usage.CreatedAt,
}, nil
}
// getDeviceProtectStatus 查询设备保护期状态
func (s *Service) getDeviceProtectStatus(ctx context.Context, deviceID uint) string {
stopKey := constants.RedisDeviceProtectKey(deviceID, "stop")
startKey := constants.RedisDeviceProtectKey(deviceID, "start")
if s.redis.Exists(ctx, stopKey).Val() > 0 {
return "stop"
}
if s.redis.Exists(ctx, startKey).Val() > 0 {
return "start"
}
return "none"
}
// nonZeroTimePtr 将非零时间转为指针,零值返回 nil避免序列化出历史时区偏移 +08:05
func nonZeroTimePtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
// safeVirtualRatio 安全获取虚流量比例,避免除零
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
// GetOrders 查询资产历史订单,支持换货链跨代追溯
// page/pageSize 仅对本代订单生效前代订单每代最多返回20条
// 最多追溯10代超出后 truncated=true
func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSize int, includePrevious bool) (*dto.AssetOrdersResponse, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
asset, err := s.Resolve(ctx, identifier)
if err != nil {
return nil, err
}
currentGeneration := s.resolveAssetGeneration(ctx, asset.AssetType, asset.AssetID)
orders, total, err := s.orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询本代订单失败")
}
orderItems := s.fetchOrderItemsMap(ctx, orders)
currentItems := make([]*dto.AssetOrderItem, 0, len(orders))
for _, o := range orders {
currentItems = append(currentItems, buildAssetOrderItem(o, orderItems[o.ID]))
}
resp := &dto.AssetOrdersResponse{
CurrentGeneration: &dto.GenerationOrders{
Generation: currentGeneration,
Identifier: identifier,
AssetType: asset.AssetType,
Total: total,
Page: page,
PageSize: pageSize,
Items: currentItems,
},
PreviousGenerations: nil,
Truncated: false,
}
if !includePrevious {
return resp, nil
}
prevGens, truncated := s.tracePreviousGenerations(ctx, asset.AssetType, asset.AssetID)
resp.PreviousGenerations = prevGens
resp.Truncated = truncated
return resp, nil
}
func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, assetID uint) int {
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err == nil && card != nil {
return card.Generation
}
case "device":
device, err := s.deviceStore.GetByID(ctx, assetID)
if err == nil && device != nil {
return device.Generation
}
}
return 1
}
// tracePreviousGenerations 通过换货链逆向追溯前代资产的订单最多追溯10代
func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) {
const maxDepth = 10
const prevPageSize = 20
prevGens := make([]*dto.PreviousGenerationOrders, 0)
currentType := assetType
currentID := assetID
truncated := false
for i := 0; i < maxDepth; i++ {
// 转换为 ExchangeOrder 中使用的资产类型card→iot_card
storeType := currentType
if storeType == "card" {
storeType = "iot_card"
}
exchOrder, err := s.exchangeOrderStore.FindByNewAssetID(ctx, storeType, currentID)
if err != nil || exchOrder == nil {
break
}
oldOrders, oldTotal, err := s.orderStore.ListByAssetIdentifier(ctx, exchOrder.OldAssetIdentifier, 1, prevPageSize)
if err != nil {
logger.GetAppLogger().Warn("查询前代订单失败",
zap.String("old_identifier", exchOrder.OldAssetIdentifier),
zap.Error(err))
oldOrders = nil
oldTotal = 0
}
oldItems := s.fetchOrderItemsMap(ctx, oldOrders)
orderItems := make([]*dto.AssetOrderItem, 0, len(oldOrders))
for _, o := range oldOrders {
orderItems = append(orderItems, buildAssetOrderItem(o, oldItems[o.ID]))
}
oldAssetType := exchOrder.OldAssetType
if oldAssetType == "iot_card" {
oldAssetType = "card"
}
oldGeneration := s.resolveAssetGeneration(ctx, oldAssetType, exchOrder.OldAssetID)
prevGens = append(prevGens, &dto.PreviousGenerationOrders{
Generation: oldGeneration,
Identifier: exchOrder.OldAssetIdentifier,
AssetType: oldAssetType,
ExchangeNo: exchOrder.ExchangeNo,
ExchangedAt: exchOrder.UpdatedAt,
Total: oldTotal,
Items: orderItems,
})
currentType = oldAssetType
currentID = exchOrder.OldAssetID
if i == maxDepth-1 {
truncated = true
}
}
return prevGens, truncated
}
// fetchOrderItemsMap 批量查询订单明细并按 orderID 分组
func (s *Service) fetchOrderItemsMap(ctx context.Context, orders []*model.Order) map[uint][]*model.OrderItem {
if len(orders) == 0 {
return nil
}
orderIDs := make([]uint, 0, len(orders))
for _, o := range orders {
orderIDs = append(orderIDs, o.ID)
}
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
if err != nil {
logger.GetAppLogger().Warn("批量查询订单明细失败", zap.Error(err))
return nil
}
result := make(map[uint][]*model.OrderItem, len(orders))
for _, item := range allItems {
result[item.OrderID] = append(result[item.OrderID], item)
}
return result
}
func buildAssetOrderItem(o *model.Order, items []*model.OrderItem) *dto.AssetOrderItem {
details := make([]*dto.AssetOrderItemDetail, 0, len(items))
for _, it := range items {
details = append(details, &dto.AssetOrderItemDetail{
PackageName: it.PackageName,
Quantity: it.Quantity,
UnitPrice: it.UnitPrice,
Amount: it.Amount,
})
}
return &dto.AssetOrderItem{
OrderNo: o.OrderNo,
OrderType: o.OrderType,
PaymentStatus: o.PaymentStatus,
PaymentStatusText: paymentStatusText(o.PaymentStatus),
TotalAmount: o.TotalAmount,
PaymentMethod: o.PaymentMethod,
PaidAt: o.PaidAt,
Generation: o.Generation,
Items: details,
CreatedAt: o.CreatedAt,
}
}
func paymentStatusText(status int) string {
switch status {
case 1:
return "待支付"
case 2:
return "已支付"
case 3:
return "已取消"
case 4:
return "已退款"
default:
return "未知"
}
}
// GetEffectiveRealnamePolicy 获取资产的有效实名认证策略
// 设备卡使用设备策略,单独卡使用卡策略
func GetEffectiveRealnamePolicy(card *model.IotCard, device *model.Device) string {
if device != nil {
return device.RealnamePolicy
}
if card != nil {
return card.RealnamePolicy
}
return constants.RealnamePolicyNone
}
// HasValidRechargeOrPaidOrder 检查资产在当前世代是否有有效充值或已支付订单
// 用于 after_order 策略的生效条件判断
func (s *Service) HasValidRechargeOrPaidOrder(ctx context.Context, assetType string, assetID uint) (bool, error) {
generation := s.resolveAssetGeneration(ctx, assetType, assetID)
if assetType == "card" {
var rechargeCount int64
err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}).
Where("iot_card_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid).
Count(&rechargeCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡充值订单失败")
}
if rechargeCount > 0 {
return true, nil
}
var orderCount int64
err = s.db.WithContext(ctx).Model(&model.Order{}).
Where("iot_card_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid).
Count(&orderCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡订单失败")
}
if orderCount > 0 {
return true, nil
}
} else if assetType == "device" {
var rechargeCount int64
err := s.db.WithContext(ctx).Model(&model.RechargeOrder{}).
Where("device_id = ? AND generation = ? AND status = ?", assetID, generation, model.RechargeOrderStatusPaid).
Count(&rechargeCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备充值订单失败")
}
if rechargeCount > 0 {
return true, nil
}
var orderCount int64
err = s.db.WithContext(ctx).Model(&model.Order{}).
Where("device_id = ? AND generation = ? AND payment_status = ?", assetID, generation, model.PaymentStatusPaid).
Count(&orderCount).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备订单失败")
}
if orderCount > 0 {
return true, nil
}
}
return false, nil
}
// packageStatusName 套餐状态码转中文名称
func packageStatusName(status int) string {
switch status {
case 0:
return "待生效"
case 1:
return "生效中"
case 2:
return "已用完"
case 3:
return "已过期"
case 4:
return "已失效"
default:
return "未知"
}
}