diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 8df780c..14c3c8e 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -192,7 +192,7 @@ func initServices(s *stores, deps *Dependencies) *services { shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger) packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation) orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone) - assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) + assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) return &services{ diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 509ef36..cd3e30d 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -3,6 +3,7 @@ package admin import ( "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" @@ -162,6 +163,63 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error { return response.Success(c, result) } +// UpdatePackageExpiresAt 修改资产套餐过期时间 +// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/expires-at +func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error { + if err := ensureAssetPackageAdjuster(c); err != nil { + return err + } + + asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) + if err != nil { + return err + } + + var req dto.UpdateAssetPackageExpiresAtRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + expiresAt, err := parseAssetPackageTime(req.ExpiresAt) + if err != nil { + return errors.New(errors.CodeInvalidParam, "过期时间格式无效") + } + + result, err := h.assetService.UpdatePackageExpiresAt(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), packageUsageID, expiresAt) + if err != nil { + return err + } + + return response.Success(c, result) +} + +// UpdatePackageUsage 修改资产套餐已用量 +// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/used-data +func (h *AssetHandler) UpdatePackageUsage(c *fiber.Ctx) error { + if err := ensureAssetPackageAdjuster(c); err != nil { + return err + } + + asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c) + if err != nil { + return err + } + + var req dto.UpdateAssetPackageUsageRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if req.DataUsageMB == nil || *req.DataUsageMB < 0 { + return errors.New(errors.CodeInvalidParam) + } + + result, err := h.assetService.UpdatePackageUsage(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), packageUsageID, *req.DataUsageMB) + if err != nil { + return err + } + + return response.Success(c, result) +} + // Stop 停机(设备批量停机,单卡停机) // POST /api/admin/assets/:identifier/stop func (h *AssetHandler) Stop(c *fiber.Ctx) error { @@ -440,3 +498,57 @@ func resolveCallerAccountType(c *fiber.Ctx) string { } return "agent" } + +func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetResolveResponse, uint, error) { + asset, err := h.resolveByIdentifier(c) + if err != nil { + return nil, 0, err + } + + packageUsageID, parseErr := strconv.ParseUint(c.Params("package_usage_id"), 10, 64) + if parseErr != nil || packageUsageID == 0 { + return nil, 0, errors.New(errors.CodeInvalidParam) + } + + return asset, uint(packageUsageID), nil +} + +func ensureAssetPackageAdjuster(c *fiber.Ctx) error { + if middleware.GetUserIDFromContext(c.UserContext()) != 2 { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil +} + +func parseAssetPackageTime(value string) (time.Time, error) { + raw := strings.TrimSpace(value) + if raw == "" { + return time.Time{}, errors.New(errors.CodeInvalidParam) + } + + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t, nil + } + } + return time.Time{}, errors.New(errors.CodeInvalidParam) +} + +func assetIdentifierForAudit(asset *dto.AssetResolveResponse) string { + if asset == nil { + return "" + } + if asset.AssetType == "card" && strings.TrimSpace(asset.ICCID) != "" { + return asset.ICCID + } + if strings.TrimSpace(asset.VirtualNo) != "" { + return asset.VirtualNo + } + return asset.Identifier +} diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index c4316f7..b20cbb4 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -57,14 +57,14 @@ type AssetResolveResponse struct { SwitchMode string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动(设备类型时有效)"` LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间(设备类型时有效)"` // 卡专属字段(device类型时为零值) - CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"` - CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"` - CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` - MSISDN string `json:"msisdn,omitempty" description:"手机号"` - IMSI string `json:"imsi,omitempty" description:"IMSI"` - CardCategory string `json:"card_category,omitempty" description:"卡业务类型"` - Supplier string `json:"supplier,omitempty" description:"供应商"` - EnablePolling bool `json:"enable_polling" description:"是否参与轮询"` + CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"` + CarrierType string `json:"carrier_type,omitempty" description:"运营商类型"` + CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"` + MSISDN string `json:"msisdn,omitempty" description:"手机号"` + IMSI string `json:"imsi,omitempty" description:"IMSI"` + CardCategory string `json:"card_category,omitempty" description:"卡业务类型"` + Supplier string `json:"supplier,omitempty" description:"供应商"` + EnablePolling bool `json:"enable_polling" description:"是否参与轮询"` NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机(asset_type=card时有效)"` GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"` GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` @@ -172,6 +172,20 @@ type AssetPackagesRequest struct { Status *int `query:"status" validate:"omitempty,min=0,max=4" minimum:"0" maximum:"4" description:"套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)"` } +// UpdateAssetPackageExpiresAtRequest 修改资产套餐过期时间请求 +type UpdateAssetPackageExpiresAtRequest struct { + Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` + PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"` + ExpiresAt string `json:"expires_at" validate:"required" required:"true" description:"新的套餐过期时间,格式:YYYY-MM-DD HH:MM:SS 或 RFC3339"` +} + +// UpdateAssetPackageUsageRequest 修改资产套餐已用量请求 +type UpdateAssetPackageUsageRequest struct { + Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"` + PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"` + DataUsageMB *int64 `json:"data_usage_mb" validate:"required,min=0" required:"true" minimum:"0" description:"新的套餐真实已用量(MB)"` +} + // DeviceIDRequest 设备ID请求(路径参数) type DeviceIDRequest struct { DeviceID uint `path:"device_id" description:"设备ID" required:"true"` diff --git a/internal/routes/asset.go b/internal/routes/asset.go index edff827..13c23da 100644 --- a/internal/routes/asset.go +++ b/internal/routes/asset.go @@ -56,6 +56,24 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle Auth: true, }) + Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/expires-at", handler.UpdatePackageExpiresAt, RouteSpec{ + Summary: "修改资产套餐过期时间", + Description: "修改指定资产下某条套餐使用记录的过期时间。仅账号ID为2的账号可调用。", + Tags: []string{"资产管理"}, + Input: new(dto.UpdateAssetPackageExpiresAtRequest), + Output: new(dto.AssetPackageResponse), + Auth: true, + }) + + Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/used-data", handler.UpdatePackageUsage, RouteSpec{ + Summary: "修改资产套餐已用量", + Description: "修改指定资产下某条套餐使用记录的真实已用量(MB)。仅账号ID为2的账号可调用。", + Tags: []string{"资产管理"}, + Input: new(dto.UpdateAssetPackageUsageRequest), + Output: new(dto.AssetPackageResponse), + Auth: true, + }) + Register(assets, doc, groupPath, "POST", "/:identifier/stop", handler.Stop, RouteSpec{ Summary: "停机", Description: "设备批量停机设备下所有已实名卡;单卡手动停机。", diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 9e62e69..3b4c948 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -13,6 +13,7 @@ import ( "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" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "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" @@ -45,6 +46,7 @@ type Service struct { iotCardService IotCardRefresher gatewayClient *gateway.Client assetIdentifierStore *postgres.AssetIdentifierStore + assetAuditService assetAuditSvc.OperationLogger } // New 创建资产服务实例 @@ -64,6 +66,7 @@ func New( orderStore *postgres.OrderStore, orderItemStore *postgres.OrderItemStore, exchangeOrderStore *postgres.ExchangeOrderStore, + assetAuditService assetAuditSvc.OperationLogger, ) *Service { return &Service{ db: db, @@ -81,6 +84,7 @@ func New( iotCardService: iotCardService, gatewayClient: gatewayClient, assetIdentifierStore: assetIdentifierStore, + assetAuditService: assetAuditService, } } @@ -834,49 +838,102 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询当前生效套餐失败") } - pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) - metrics := usage.BuildTrafficMetrics() - pkgName := usage.PackageName - pkgType := "" - expiryBase := "" - if pkgErr == nil && pkg != nil { - if pkgName == "" { - pkgName = pkg.PackageName - } - pkgType = pkg.PackageType - expiryBase = pkg.ExpiryBase + return s.buildAssetPackageResponse(ctx, usage, callerAccountType), nil +} + +// UpdatePackageExpiresAt 修改资产套餐过期时间。 +func (s *Service) UpdatePackageExpiresAt(ctx context.Context, assetType string, assetID uint, assetIdentifier string, packageUsageID uint, expiresAt time.Time) (*dto.AssetPackageResponse, error) { + carrierType, err := normalizeAssetCarrierType(assetType) + if err != nil { + return nil, err } - var paidAmount *int64 - if callerAccountType == constants.OwnerTypePlatform { - paidAmount = usage.PaidAmount + + before, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) + if err != nil { + return nil, wrapPackageUsageNotFound(err) } - return &dto.AssetPackageResponse{ - PackageUsageID: usage.ID, - PackageID: usage.PackageID, - PackageName: pkgName, - PackageType: pkgType, - ExpiryBase: expiryBase, - OrderID: usage.OrderID, - OrderNo: usage.OrderNo, - RefundID: usage.RefundID, - RefundNo: usage.RefundNo, - 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, - PaidAmount: paidAmount, - RetailAmount: usage.RetailAmount, - CreatedAt: usage.CreatedAt, - }, nil + + beforeData := packageUsageExpiresAtAuditData(before) + rows, err := s.packageUsageStore.UpdateExpiresAtForCarrier(ctx, packageUsageID, carrierType, assetID, expiresAt) + if err != nil { + appErr := errors.Wrap(errors.CodeDatabaseError, err, "修改套餐过期时间失败") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "expires_at": expiresAt, + }, appErr) + return nil, appErr + } + if rows == 0 { + appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "expires_at": expiresAt, + }, appErr) + return nil, appErr + } + + updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) + if err != nil { + appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "expires_at": expiresAt, + }, appErr) + return nil, appErr + } + + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageExpiresAt, "修改资产套餐过期时间", constants.AssetAuditResultSuccess, assetType, assetID, assetIdentifier, beforeData, packageUsageExpiresAtAuditData(updated), nil) + return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil +} + +// UpdatePackageUsage 修改资产套餐已用量。 +func (s *Service) UpdatePackageUsage(ctx context.Context, assetType string, assetID uint, assetIdentifier string, packageUsageID uint, dataUsageMB int64) (*dto.AssetPackageResponse, error) { + if dataUsageMB < 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + + carrierType, err := normalizeAssetCarrierType(assetType) + if err != nil { + return nil, err + } + + before, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) + if err != nil { + return nil, wrapPackageUsageNotFound(err) + } + + beforeData := packageUsageTrafficAuditData(before) + nextStatus := statusForManualDataUsage(before, dataUsageMB) + rows, err := s.packageUsageStore.UpdateDataUsageForCarrier(ctx, packageUsageID, carrierType, assetID, dataUsageMB, nextStatus) + if err != nil { + appErr := errors.Wrap(errors.CodeDatabaseError, err, "修改套餐已用量失败") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "data_usage_mb": dataUsageMB, + }, appErr) + return nil, appErr + } + if rows == 0 { + appErr := errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "data_usage_mb": dataUsageMB, + }, appErr) + return nil, appErr + } + + updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID) + if err != nil { + appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量失败", constants.AssetAuditResultFailed, assetType, assetID, assetIdentifier, beforeData, map[string]any{ + "package_usage_id": packageUsageID, + "data_usage_mb": dataUsageMB, + }, appErr) + return nil, appErr + } + + s.logPackageAdjustmentAudit(ctx, constants.AssetAuditOpAssetPackageUsage, "修改资产套餐已用量", constants.AssetAuditResultSuccess, assetType, assetID, assetIdentifier, beforeData, packageUsageTrafficAuditData(updated), nil) + return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil } // getDeviceProtectStatus 查询设备保护期状态 @@ -973,6 +1030,153 @@ func (s *Service) resolveAssetGeneration(ctx context.Context, assetType string, return 1 } +func (s *Service) buildAssetPackageResponse(ctx context.Context, usage *model.PackageUsage, callerAccountType string) *dto.AssetPackageResponse { + if usage == nil { + return nil + } + + pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) + metrics := usage.BuildTrafficMetrics() + pkgName := usage.PackageName + pkgType := "" + expiryBase := "" + if pkgErr == nil && pkg != nil { + if pkgName == "" { + pkgName = pkg.PackageName + } + pkgType = pkg.PackageType + expiryBase = pkg.ExpiryBase + } + var paidAmount *int64 + if callerAccountType == constants.OwnerTypePlatform { + paidAmount = usage.PaidAmount + } + + return &dto.AssetPackageResponse{ + PackageUsageID: usage.ID, + PackageID: usage.PackageID, + PackageName: pkgName, + PackageType: pkgType, + ExpiryBase: expiryBase, + OrderID: usage.OrderID, + OrderNo: usage.OrderNo, + RefundID: usage.RefundID, + RefundNo: usage.RefundNo, + 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, + PaidAmount: paidAmount, + RetailAmount: usage.RetailAmount, + CreatedAt: usage.CreatedAt, + } +} + +func normalizeAssetCarrierType(assetType string) (string, error) { + switch assetType { + case "card", constants.AssetTypeIotCard: + return constants.AssetTypeIotCard, nil + case constants.AssetTypeDevice: + return constants.AssetTypeDevice, nil + default: + return "", errors.New(errors.CodeInvalidParam, "不支持的资产类型,仅支持 card 或 device") + } +} + +func wrapPackageUsageNotFound(err error) error { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") +} + +func statusForManualDataUsage(usage *model.PackageUsage, dataUsageMB int64) *int { + if usage == nil { + return nil + } + + threshold := usage.EffectiveThresholdMB() + if threshold <= 0 { + return nil + } + switch usage.Status { + case constants.PackageUsageStatusActive: + if dataUsageMB >= threshold { + status := constants.PackageUsageStatusDepleted + return &status + } + case constants.PackageUsageStatusDepleted: + if dataUsageMB < threshold { + status := constants.PackageUsageStatusActive + return &status + } + } + return nil +} + +func packageUsageExpiresAtAuditData(usage *model.PackageUsage) map[string]any { + if usage == nil { + return nil + } + return map[string]any{ + "package_usage_id": usage.ID, + "expires_at": usage.ExpiresAt, + "status": usage.Status, + "status_name": packageStatusName(usage.Status), + } +} + +func packageUsageTrafficAuditData(usage *model.PackageUsage) map[string]any { + if usage == nil { + return nil + } + metrics := usage.BuildTrafficMetrics() + return map[string]any{ + "package_usage_id": usage.ID, + "data_usage_mb": usage.DataUsageMB, + "virtual_used_mb": metrics.VirtualUsedMB, + "real_total_mb": metrics.RealTotalMB, + "virtual_total_mb": metrics.VirtualTotalMB, + "status": usage.Status, + "status_name": packageStatusName(usage.Status), + "enable_virtual_data": usage.EnableVirtualDataSnapshot, + } +} + +func (s *Service) logPackageAdjustmentAudit(ctx context.Context, operationType, operationDesc, resultStatus, assetType string, assetID uint, assetIdentifier string, beforeData, afterData map[string]any, err error) { + if s == nil || s.assetAuditService == nil { + return + } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + wrappedBefore, wrappedAfter := assetAuditSvc.WrapOperationContent(beforeData, afterData, map[string]any{ + "asset_type": assetAuditSvc.NormalizeAssetType(assetType), + "asset_id": assetID, + "asset_identifier": assetIdentifier, + }) + s.assetAuditService.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, assetAuditSvc.BuildLogParams{ + Operator: assetAuditSvc.OperatorFromContext(ctx), + AssetType: assetType, + AssetID: assetID, + AssetIdentifier: assetIdentifier, + OperationType: operationType, + OperationDesc: operationDesc, + BeforeData: wrappedBefore, + AfterData: wrappedAfter, + ResultStatus: resultStatus, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + })) +} + // tracePreviousGenerations 通过换货链逆向追溯前代资产的订单,最多追溯10代 func (s *Service) tracePreviousGenerations(ctx context.Context, assetType string, assetID uint) ([]*dto.PreviousGenerationOrders, bool) { const maxDepth = 10 diff --git a/internal/service/asset_audit/operation_content.go b/internal/service/asset_audit/operation_content.go index cb90577..4a25cc7 100644 --- a/internal/service/asset_audit/operation_content.go +++ b/internal/service/asset_audit/operation_content.go @@ -339,6 +339,24 @@ func getOperationFieldDesc(operationType string) map[string]string { return map[string]string{ "realname_policy": "实名策略(none/before_order/after_order)", } + case "asset_package_expires_at": + return map[string]string{ + "package_usage_id": "套餐使用记录ID", + "expires_at": "套餐过期时间", + "status": "套餐状态", + "status_name": "套餐状态名称", + } + case "asset_package_usage": + return map[string]string{ + "package_usage_id": "套餐使用记录ID", + "data_usage_mb": "套餐真实已用量(MB)", + "virtual_used_mb": "套餐展示已用量(MB)", + "real_total_mb": "套餐真实总量(MB)", + "virtual_total_mb": "套餐业务停机阈值(MB)", + "status": "套餐状态", + "status_name": "套餐状态名称", + "enable_virtual_data": "是否启用虚流量", + } case "card_realname_status": return map[string]string{ "real_name_status": "实名状态(0未实名/1已实名)", diff --git a/internal/store/postgres/package_usage_store.go b/internal/store/postgres/package_usage_store.go index 14dd22b..d7cc09e 100644 --- a/internal/store/postgres/package_usage_store.go +++ b/internal/store/postgres/package_usage_store.go @@ -42,6 +42,17 @@ func (s *PackageUsageStore) GetByID(ctx context.Context, id uint) (*model.Packag return &usage, nil } +// GetByIDForCarrier 根据载体查询套餐使用记录。 +func (s *PackageUsageStore) GetByIDForCarrier(ctx context.Context, id uint, carrierType string, carrierID uint) (*model.PackageUsage, error) { + var usage model.PackageUsage + query := s.db.WithContext(ctx).Where("id = ?", id) + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) + if err := query.First(&usage).Error; err != nil { + return nil, err + } + return &usage, nil +} + // Update 更新套餐使用记录 func (s *PackageUsageStore) Update(ctx context.Context, usage *model.PackageUsage) error { return s.db.WithContext(ctx).Save(usage).Error @@ -140,6 +151,37 @@ func (s *PackageUsageStore) UpdateDataUsage(ctx context.Context, id uint, increm Update("data_usage_mb", gorm.Expr("data_usage_mb + ?", incrementMB)).Error } +// UpdateExpiresAtForCarrier 按载体条件更新套餐过期时间。 +func (s *PackageUsageStore) UpdateExpiresAtForCarrier(ctx context.Context, id uint, carrierType string, carrierID uint, expiresAt time.Time) (int64, error) { + query := s.db.WithContext(ctx). + Model(&model.PackageUsage{}). + Where("id = ?", id) + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) + + result := query.Updates(map[string]any{ + "expires_at": expiresAt, + }) + return result.RowsAffected, result.Error +} + +// UpdateDataUsageForCarrier 按载体条件更新套餐已用量。 +func (s *PackageUsageStore) UpdateDataUsageForCarrier(ctx context.Context, id uint, carrierType string, carrierID uint, dataUsageMB int64, status *int) (int64, error) { + query := s.db.WithContext(ctx). + Model(&model.PackageUsage{}). + Where("id = ?", id) + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) + + updates := map[string]any{ + "data_usage_mb": dataUsageMB, + } + if status != nil { + updates["status"] = *status + } + + result := query.Updates(updates) + return result.RowsAffected, result.Error +} + // GetPackagesForReset 查询需要重置的套餐(WHERE next_reset_at <= NOW) func (s *PackageUsageStore) GetPackagesForReset(ctx context.Context, limit int) ([]*model.PackageUsage, error) { var usages []*model.PackageUsage @@ -239,11 +281,7 @@ func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType strin var usages []*model.PackageUsage query := s.db.WithContext(ctx).Order("priority ASC, created_at DESC") - if carrierType == "iot_card" { - query = query.Where("iot_card_id = ?", carrierID) - } else if carrierType == "device" { - query = query.Where("device_id = ?", carrierID) - } + query = applyPackageUsageCarrierFilter(query, carrierType, carrierID) if status != nil { query = query.Where("status = ?", *status) } @@ -253,3 +291,13 @@ func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType strin } return usages, nil } + +func applyPackageUsageCarrierFilter(query *gorm.DB, carrierType string, carrierID uint) *gorm.DB { + if carrierType == "iot_card" { + return query.Where("iot_card_id = ?", carrierID) + } + if carrierType == "device" { + return query.Where("device_id = ?", carrierID) + } + return query +} diff --git a/pkg/constants/asset_audit.go b/pkg/constants/asset_audit.go index 1bc9cb2..4964ace 100644 --- a/pkg/constants/asset_audit.go +++ b/pkg/constants/asset_audit.go @@ -18,36 +18,38 @@ const ( // 资产审计日志操作类型常量 const ( - AssetAuditOpCardAllocate = "card_allocate" // 单卡分配 - AssetAuditOpCardRecall = "card_recall" // 单卡回收 - AssetAuditOpCardSeriesBinding = "card_series_binding" // 卡系列绑定 - AssetAuditOpCardPollingStatus = "card_polling_status" // 卡轮询状态更新 - AssetAuditOpCardRealnamePolicy = "card_realname_policy" // 卡实名策略更新 - AssetAuditOpCardRealnameStatus = "card_realname_status" // 卡实名状态更新 - AssetAuditOpCardDelete = "card_delete" // 删除卡 - AssetAuditOpCardBatchDelete = "card_batch_delete" // 批量删除卡 - AssetAuditOpCardManualStop = "card_manual_stop" // 手动停卡 - AssetAuditOpCardManualStart = "card_manual_start" // 手动复机 - AssetAuditOpCardAutoStop = "card_auto_stop" // 自动停卡 - AssetAuditOpCardAutoStart = "card_auto_start" // 自动复机 - AssetAuditOpDeviceDelete = "device_delete" // 删除设备 - AssetAuditOpDeviceAllocate = "device_allocate" // 设备分配 - AssetAuditOpDeviceRecall = "device_recall" // 设备回收 - AssetAuditOpDeviceSeriesBinding = "device_series_binding" // 设备系列绑定 - AssetAuditOpDeviceBindCard = "device_bind_card" // 设备绑卡 - AssetAuditOpDeviceUnbindCard = "device_unbind_card" // 设备解绑卡 - AssetAuditOpDeviceStop = "device_stop" // 设备停机 - AssetAuditOpDeviceStart = "device_start" // 设备复机 - AssetAuditOpDeviceSpeedLimit = "device_speed_limit" // 设备限速 - AssetAuditOpDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置 - AssetAuditOpDeviceSwitchCard = "device_switch_card" // 设备切卡 - AssetAuditOpDeviceSwitchMode = "device_switch_mode" // 设备切卡模式 - AssetAuditOpDeviceReboot = "device_reboot" // 设备重启 - AssetAuditOpDeviceReset = "device_reset" // 设备恢复出厂 - AssetAuditOpAssetDeactivate = "asset_deactivate" // 资产停用 - AssetAuditOpAssetPollingStatus = "asset_polling_status" // 统一入口轮询状态更新 - AssetAuditOpAssetRealnamePolicy = "asset_realname_policy" // 统一入口实名策略更新 - AssetAuditOpDeviceImportTaskCreate = "device_import_task_create" // 设备导入任务创建 + AssetAuditOpCardAllocate = "card_allocate" // 单卡分配 + AssetAuditOpCardRecall = "card_recall" // 单卡回收 + AssetAuditOpCardSeriesBinding = "card_series_binding" // 卡系列绑定 + AssetAuditOpCardPollingStatus = "card_polling_status" // 卡轮询状态更新 + AssetAuditOpCardRealnamePolicy = "card_realname_policy" // 卡实名策略更新 + AssetAuditOpCardRealnameStatus = "card_realname_status" // 卡实名状态更新 + AssetAuditOpCardDelete = "card_delete" // 删除卡 + AssetAuditOpCardBatchDelete = "card_batch_delete" // 批量删除卡 + AssetAuditOpCardManualStop = "card_manual_stop" // 手动停卡 + AssetAuditOpCardManualStart = "card_manual_start" // 手动复机 + AssetAuditOpCardAutoStop = "card_auto_stop" // 自动停卡 + AssetAuditOpCardAutoStart = "card_auto_start" // 自动复机 + AssetAuditOpDeviceDelete = "device_delete" // 删除设备 + AssetAuditOpDeviceAllocate = "device_allocate" // 设备分配 + AssetAuditOpDeviceRecall = "device_recall" // 设备回收 + AssetAuditOpDeviceSeriesBinding = "device_series_binding" // 设备系列绑定 + AssetAuditOpDeviceBindCard = "device_bind_card" // 设备绑卡 + AssetAuditOpDeviceUnbindCard = "device_unbind_card" // 设备解绑卡 + AssetAuditOpDeviceStop = "device_stop" // 设备停机 + AssetAuditOpDeviceStart = "device_start" // 设备复机 + AssetAuditOpDeviceSpeedLimit = "device_speed_limit" // 设备限速 + AssetAuditOpDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置 + AssetAuditOpDeviceSwitchCard = "device_switch_card" // 设备切卡 + AssetAuditOpDeviceSwitchMode = "device_switch_mode" // 设备切卡模式 + AssetAuditOpDeviceReboot = "device_reboot" // 设备重启 + AssetAuditOpDeviceReset = "device_reset" // 设备恢复出厂 + AssetAuditOpAssetDeactivate = "asset_deactivate" // 资产停用 + AssetAuditOpAssetPollingStatus = "asset_polling_status" // 统一入口轮询状态更新 + AssetAuditOpAssetRealnamePolicy = "asset_realname_policy" // 统一入口实名策略更新 + AssetAuditOpAssetPackageExpiresAt = "asset_package_expires_at" // 资产套餐过期时间更新 + AssetAuditOpAssetPackageUsage = "asset_package_usage" // 资产套餐已用量更新 + AssetAuditOpDeviceImportTaskCreate = "device_import_task_create" // 设备导入任务创建 AssetAuditOpIotCardImportTaskCreate = "iot_card_import_task_create" // 卡导入任务创建 )