feat: 实现 IoT 卡轮询系统(支持千万级卡规模)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m35s

实现功能:
- 实名状态检查轮询(可配置间隔)
- 卡流量检查轮询(支持跨月流量追踪)
- 套餐检查与超额自动停机
- 分布式并发控制(Redis 信号量)
- 手动触发轮询(单卡/批量/条件筛选)
- 数据清理配置与执行
- 告警规则与历史记录
- 实时监控统计(队列/性能/并发)

性能优化:
- Redis 缓存卡信息,减少 DB 查询
- Pipeline 批量写入 Redis
- 异步流量记录写入
- 渐进式初始化(10万卡/批)

压测工具(scripts/benchmark/):
- Mock Gateway 模拟上游服务
- 测试卡生成器
- 配置初始化脚本
- 实时监控脚本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 17:32:44 +08:00
parent b11edde720
commit 931e140e8e
104 changed files with 16883 additions and 87 deletions

View File

@@ -48,5 +48,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
H5Order: h5.NewOrderHandler(svc.Order),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
}
}

View File

@@ -1,6 +1,7 @@
package bootstrap
import (
"github.com/break/junhong_cmp_fiber/internal/polling"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
@@ -29,6 +30,7 @@ import (
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_allocation"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
@@ -69,6 +71,12 @@ type services struct {
PurchaseValidation *purchaseValidationSvc.Service
Order *orderSvc.Service
Recharge *rechargeSvc.Service
PollingConfig *pollingSvc.ConfigService
PollingConcurrency *pollingSvc.ConcurrencyService
PollingMonitoring *pollingSvc.MonitoringService
PollingAlert *pollingSvc.AlertService
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -76,6 +84,10 @@ func initServices(s *stores, deps *Dependencies) *services {
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
// 创建 IotCard service 并设置 polling callback
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
return &services{
Account: account,
AccountAudit: accountAudit,
@@ -110,14 +122,14 @@ func initServices(s *stores, deps *Dependencies) *services {
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries),
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation),
Package: packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation),
ShopSeriesAllocation: shopSeriesAllocationSvc.New(s.ShopSeriesAllocation, s.ShopPackageAllocation, s.Shop, s.PackageSeries),
ShopPackageAllocation: shopPackageAllocationSvc.New(s.ShopPackageAllocation, s.ShopSeriesAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop),
@@ -126,5 +138,11 @@ func initServices(s *stores, deps *Dependencies) *services {
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, deps.WechatPayment, deps.QueueClient, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.Recharge, s.Wallet, s.WalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, s.Shop, deps.Redis, deps.Logger),
}
}

View File

@@ -39,6 +39,13 @@ type stores struct {
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
Recharge *postgres.RechargeStore
PollingConfig *postgres.PollingConfigStore
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
PollingAlertRule *postgres.PollingAlertRuleStore
PollingAlertHistory *postgres.PollingAlertHistoryStore
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
}
func initStores(deps *Dependencies) *stores {
@@ -77,5 +84,12 @@ func initStores(deps *Dependencies) *stores {
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
Recharge: postgres.NewRechargeStore(deps.DB, deps.Redis),
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
}
}

View File

@@ -46,6 +46,12 @@ type Handlers struct {
H5Order *h5.OrderHandler
H5Recharge *h5.RechargeHandler
PaymentCallback *callback.PaymentHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
}
// Middlewares 封装所有中间件

View File

@@ -0,0 +1,291 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingAlertHandler 轮询告警处理器
type PollingAlertHandler struct {
service *polling.AlertService
}
// NewPollingAlertHandler 创建轮询告警处理器
func NewPollingAlertHandler(service *polling.AlertService) *PollingAlertHandler {
return &PollingAlertHandler{service: service}
}
// CreateRule 创建告警规则
// @Summary 创建轮询告警规则
// @Description 创建新的轮询告警规则
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreatePollingAlertRuleReq true "创建请求"
// @Success 200 {object} response.Response{data=dto.PollingAlertRuleResp}
// @Router /api/admin/polling-alert-rules [post]
func (h *PollingAlertHandler) CreateRule(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.CreatePollingAlertRuleReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
rule := &model.PollingAlertRule{
RuleName: req.RuleName,
TaskType: req.TaskType,
MetricType: req.MetricType,
Operator: req.Operator,
Threshold: req.Threshold,
AlertLevel: req.AlertLevel,
CooldownMinutes: req.CooldownMinutes,
NotificationChannels: req.NotifyChannels,
}
if err := h.service.CreateRule(ctx, rule); err != nil {
return err
}
return response.Success(c, h.toRuleResp(rule))
}
// ListRules 获取告警规则列表
// @Summary 获取轮询告警规则列表
// @Description 获取所有轮询告警规则
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingAlertRuleListResp}
// @Router /api/admin/polling-alert-rules [get]
func (h *PollingAlertHandler) ListRules(c *fiber.Ctx) error {
ctx := c.UserContext()
rules, err := h.service.ListRules(ctx)
if err != nil {
return err
}
items := make([]*dto.PollingAlertRuleResp, 0, len(rules))
for _, rule := range rules {
items = append(items, h.toRuleResp(rule))
}
return response.Success(c, &dto.PollingAlertRuleListResp{Items: items})
}
// GetRule 获取告警规则详情
// @Summary 获取轮询告警规则详情
// @Description 获取指定轮询告警规则的详细信息
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "规则ID"
// @Success 200 {object} response.Response{data=dto.PollingAlertRuleResp}
// @Router /api/admin/polling-alert-rules/{id} [get]
func (h *PollingAlertHandler) GetRule(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的规则ID")
}
rule, err := h.service.GetRule(ctx, uint(id))
if err != nil {
return err
}
return response.Success(c, h.toRuleResp(rule))
}
// UpdateRule 更新告警规则
// @Summary 更新轮询告警规则
// @Description 更新指定轮询告警规则
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "规则ID"
// @Param request body dto.UpdatePollingAlertRuleReq true "更新请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-alert-rules/{id} [put]
func (h *PollingAlertHandler) UpdateRule(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的规则ID")
}
var req dto.UpdatePollingAlertRuleReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
updates := make(map[string]interface{})
if req.RuleName != nil {
updates["rule_name"] = *req.RuleName
}
if req.Threshold != nil {
updates["threshold"] = *req.Threshold
}
if req.AlertLevel != nil {
updates["alert_level"] = *req.AlertLevel
}
if req.Status != nil {
updates["status"] = *req.Status
}
if req.CooldownMinutes != nil {
updates["cooldown_minutes"] = *req.CooldownMinutes
}
if req.NotifyChannels != nil {
updates["notification_channels"] = *req.NotifyChannels
}
if err := h.service.UpdateRule(ctx, uint(id), updates); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteRule 删除告警规则
// @Summary 删除轮询告警规则
// @Description 删除指定轮询告警规则
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "规则ID"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-alert-rules/{id} [delete]
func (h *PollingAlertHandler) DeleteRule(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的规则ID")
}
if err := h.service.DeleteRule(ctx, uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// ListHistory 获取告警历史
// @Summary 获取轮询告警历史
// @Description 获取轮询告警历史记录
// @Tags 轮询管理-告警
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param rule_id query int false "规则ID"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} response.Response{data=dto.PollingAlertHistoryListResp}
// @Router /api/admin/polling-alert-history [get]
func (h *PollingAlertHandler) ListHistory(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.ListPollingAlertHistoryReq
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 > 100 {
req.PageSize = 20
}
histories, total, err := h.service.ListHistory(ctx, req.Page, req.PageSize, req.RuleID)
if err != nil {
return err
}
items := make([]*dto.PollingAlertHistoryResp, 0, len(histories))
for _, h := range histories {
items = append(items, &dto.PollingAlertHistoryResp{
ID: h.ID,
RuleID: h.RuleID,
RuleName: "", // 历史记录中没有规则名称,需要单独查询
TaskType: h.TaskType,
MetricType: h.MetricType,
AlertLevel: h.AlertLevel,
Threshold: h.Threshold,
CurrentValue: h.CurrentValue,
Message: h.AlertMessage,
CreatedAt: h.CreatedAt,
})
}
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
return response.Success(c, &dto.PollingAlertHistoryListResp{
Items: items,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
})
}
func (h *PollingAlertHandler) toRuleResp(rule *model.PollingAlertRule) *dto.PollingAlertRuleResp {
return &dto.PollingAlertRuleResp{
ID: rule.ID,
RuleName: rule.RuleName,
TaskType: rule.TaskType,
TaskTypeName: h.getTaskTypeName(rule.TaskType),
MetricType: rule.MetricType,
MetricTypeName: h.getMetricTypeName(rule.MetricType),
Operator: rule.Operator,
Threshold: rule.Threshold,
AlertLevel: rule.AlertLevel,
Status: int(rule.Status),
CooldownMinutes: rule.CooldownMinutes,
NotifyChannels: rule.NotificationChannels,
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
}
}
func (h *PollingAlertHandler) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}
func (h *PollingAlertHandler) getMetricTypeName(metricType string) string {
switch metricType {
case "queue_size":
return "队列积压"
case "success_rate":
return "成功率"
case "avg_duration":
return "平均耗时"
case "concurrency":
return "并发数"
default:
return metricType
}
}

View File

@@ -0,0 +1,351 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingCleanupHandler 轮询数据清理处理器
type PollingCleanupHandler struct {
service *polling.CleanupService
}
// NewPollingCleanupHandler 创建轮询数据清理处理器
func NewPollingCleanupHandler(service *polling.CleanupService) *PollingCleanupHandler {
return &PollingCleanupHandler{service: service}
}
// CreateConfig 创建清理配置
// @Summary 创建数据清理配置
// @Description 创建新的数据清理配置
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateDataCleanupConfigReq true "创建请求"
// @Success 200 {object} response.Response{data=dto.DataCleanupConfigResp}
// @Router /api/admin/data-cleanup-configs [post]
func (h *PollingCleanupHandler) CreateConfig(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.CreateDataCleanupConfigReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
config := &model.DataCleanupConfig{
TargetTable: req.TargetTable,
RetentionDays: req.RetentionDays,
BatchSize: req.BatchSize,
Description: req.Description,
}
if err := h.service.CreateConfig(ctx, config); err != nil {
return err
}
return response.Success(c, h.toConfigResp(config))
}
// ListConfigs 获取清理配置列表
// @Summary 获取数据清理配置列表
// @Description 获取所有数据清理配置
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.DataCleanupConfigListResp}
// @Router /api/admin/data-cleanup-configs [get]
func (h *PollingCleanupHandler) ListConfigs(c *fiber.Ctx) error {
ctx := c.UserContext()
configs, err := h.service.ListConfigs(ctx)
if err != nil {
return err
}
items := make([]*dto.DataCleanupConfigResp, 0, len(configs))
for _, config := range configs {
items = append(items, h.toConfigResp(config))
}
return response.Success(c, &dto.DataCleanupConfigListResp{Items: items})
}
// GetConfig 获取清理配置详情
// @Summary 获取数据清理配置详情
// @Description 获取指定数据清理配置的详细信息
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "配置ID"
// @Success 200 {object} response.Response{data=dto.DataCleanupConfigResp}
// @Router /api/admin/data-cleanup-configs/{id} [get]
func (h *PollingCleanupHandler) GetConfig(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的配置ID")
}
config, err := h.service.GetConfig(ctx, uint(id))
if err != nil {
return err
}
return response.Success(c, h.toConfigResp(config))
}
// UpdateConfig 更新清理配置
// @Summary 更新数据清理配置
// @Description 更新指定数据清理配置
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "配置ID"
// @Param request body dto.UpdateDataCleanupConfigReq true "更新请求"
// @Success 200 {object} response.Response
// @Router /api/admin/data-cleanup-configs/{id} [put]
func (h *PollingCleanupHandler) UpdateConfig(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的配置ID")
}
var req dto.UpdateDataCleanupConfigReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
updates := make(map[string]any)
if req.RetentionDays != nil {
updates["retention_days"] = *req.RetentionDays
}
if req.BatchSize != nil {
updates["batch_size"] = *req.BatchSize
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Description != nil {
updates["description"] = *req.Description
}
// 获取当前用户ID
userID := middleware.GetUserIDFromContext(ctx)
if userID > 0 {
updates["updated_by"] = userID
}
if err := h.service.UpdateConfig(ctx, uint(id), updates); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteConfig 删除清理配置
// @Summary 删除数据清理配置
// @Description 删除指定数据清理配置
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "配置ID"
// @Success 200 {object} response.Response
// @Router /api/admin/data-cleanup-configs/{id} [delete]
func (h *PollingCleanupHandler) DeleteConfig(c *fiber.Ctx) error {
ctx := c.UserContext()
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return errors.New(errors.CodeInvalidParam, "无效的配置ID")
}
if err := h.service.DeleteConfig(ctx, uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// ListLogs 获取清理日志列表
// @Summary 获取数据清理日志列表
// @Description 获取数据清理日志记录
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param table_name query string false "表名筛选"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} response.Response{data=dto.DataCleanupLogListResp}
// @Router /api/admin/data-cleanup-logs [get]
func (h *PollingCleanupHandler) ListLogs(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.ListDataCleanupLogReq
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 > 100 {
req.PageSize = 20
}
logs, total, err := h.service.ListLogs(ctx, req.Page, req.PageSize, req.TableName)
if err != nil {
return err
}
items := make([]*dto.DataCleanupLogResp, 0, len(logs))
for _, log := range logs {
items = append(items, h.toLogResp(log))
}
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
return response.Success(c, &dto.DataCleanupLogListResp{
Items: items,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
})
}
// Preview 预览待清理数据
// @Summary 预览待清理数据
// @Description 预览各表待清理的数据量
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.DataCleanupPreviewResp}
// @Router /api/admin/data-cleanup/preview [get]
func (h *PollingCleanupHandler) Preview(c *fiber.Ctx) error {
ctx := c.UserContext()
previews, err := h.service.Preview(ctx)
if err != nil {
return err
}
items := make([]*dto.DataCleanupPreviewItem, 0, len(previews))
for _, p := range previews {
items = append(items, &dto.DataCleanupPreviewItem{
TableName: p.TableName,
RetentionDays: p.RetentionDays,
RecordCount: p.RecordCount,
Description: p.Description,
})
}
return response.Success(c, &dto.DataCleanupPreviewResp{Items: items})
}
// GetProgress 获取清理进度
// @Summary 获取数据清理进度
// @Description 获取当前数据清理任务的进度
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.DataCleanupProgressResp}
// @Router /api/admin/data-cleanup/progress [get]
func (h *PollingCleanupHandler) GetProgress(c *fiber.Ctx) error {
ctx := c.UserContext()
progress, err := h.service.GetProgress(ctx)
if err != nil {
return err
}
resp := &dto.DataCleanupProgressResp{
IsRunning: progress.IsRunning,
CurrentTable: progress.CurrentTable,
TotalTables: progress.TotalTables,
ProcessedTables: progress.ProcessedTables,
TotalDeleted: progress.TotalDeleted,
StartedAt: progress.StartedAt,
}
if progress.LastLog != nil {
resp.LastLog = h.toLogResp(progress.LastLog)
}
return response.Success(c, resp)
}
// TriggerCleanup 手动触发清理
// @Summary 手动触发数据清理
// @Description 手动触发数据清理任务
// @Tags 轮询管理-数据清理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.TriggerDataCleanupReq true "触发请求"
// @Success 200 {object} response.Response
// @Router /api/admin/data-cleanup/trigger [post]
func (h *PollingCleanupHandler) TriggerCleanup(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.TriggerDataCleanupReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
// 获取当前用户ID
userID := middleware.GetUserIDFromContext(ctx)
// 异步执行清理
go func() {
_ = h.service.TriggerCleanup(ctx, req.TableName, userID)
}()
return response.Success(c, nil)
}
func (h *PollingCleanupHandler) toConfigResp(config *model.DataCleanupConfig) *dto.DataCleanupConfigResp {
return &dto.DataCleanupConfigResp{
ID: config.ID,
TargetTable: config.TargetTable,
RetentionDays: config.RetentionDays,
BatchSize: config.BatchSize,
Enabled: int(config.Enabled),
Description: config.Description,
CreatedAt: config.CreatedAt,
UpdatedAt: config.UpdatedAt,
UpdatedBy: config.UpdatedBy,
}
}
func (h *PollingCleanupHandler) toLogResp(log *model.DataCleanupLog) *dto.DataCleanupLogResp {
return &dto.DataCleanupLogResp{
ID: log.ID,
TargetTable: log.TargetTable,
CleanupType: log.CleanupType,
RetentionDays: log.RetentionDays,
DeletedCount: log.DeletedCount,
DurationMs: log.DurationMs,
Status: log.Status,
ErrorMessage: log.ErrorMessage,
StartedAt: log.StartedAt,
CompletedAt: log.CompletedAt,
TriggeredBy: log.TriggeredBy,
}
}

View File

@@ -0,0 +1,147 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingConcurrencyHandler 轮询并发控制处理器
type PollingConcurrencyHandler struct {
service *polling.ConcurrencyService
}
// NewPollingConcurrencyHandler 创建轮询并发控制处理器
func NewPollingConcurrencyHandler(service *polling.ConcurrencyService) *PollingConcurrencyHandler {
return &PollingConcurrencyHandler{service: service}
}
// List 获取所有并发配置
// @Summary 获取轮询并发配置列表
// @Description 获取所有轮询任务类型的并发配置及当前状态
// @Tags 轮询管理-并发控制
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingConcurrencyListResp}
// @Router /api/admin/polling-concurrency [get]
func (h *PollingConcurrencyHandler) List(c *fiber.Ctx) error {
ctx := c.UserContext()
statuses, err := h.service.List(ctx)
if err != nil {
return err
}
items := make([]*dto.PollingConcurrencyResp, 0, len(statuses))
for _, s := range statuses {
items = append(items, &dto.PollingConcurrencyResp{
TaskType: s.TaskType,
TaskTypeName: s.TaskTypeName,
MaxConcurrency: s.MaxConcurrency,
Current: s.Current,
Available: s.Available,
Utilization: s.Utilization,
})
}
return response.Success(c, &dto.PollingConcurrencyListResp{Items: items})
}
// Get 获取指定任务类型的并发配置
// @Summary 获取指定任务类型的并发配置
// @Description 获取指定轮询任务类型的并发配置及当前状态
// @Tags 轮询管理-并发控制
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param task_type path string true "任务类型"
// @Success 200 {object} response.Response{data=dto.PollingConcurrencyResp}
// @Router /api/admin/polling-concurrency/{task_type} [get]
func (h *PollingConcurrencyHandler) Get(c *fiber.Ctx) error {
ctx := c.UserContext()
taskType := c.Params("task_type")
if taskType == "" {
return errors.New(errors.CodeInvalidParam, "任务类型不能为空")
}
status, err := h.service.GetByTaskType(ctx, taskType)
if err != nil {
return err
}
return response.Success(c, &dto.PollingConcurrencyResp{
TaskType: status.TaskType,
TaskTypeName: status.TaskTypeName,
MaxConcurrency: status.MaxConcurrency,
Current: status.Current,
Available: status.Available,
Utilization: status.Utilization,
})
}
// Update 更新并发配置
// @Summary 更新轮询并发配置
// @Description 更新指定轮询任务类型的最大并发数
// @Tags 轮询管理-并发控制
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param task_type path string true "任务类型"
// @Param request body dto.UpdatePollingConcurrencyReq true "更新请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-concurrency/{task_type} [put]
func (h *PollingConcurrencyHandler) Update(c *fiber.Ctx) error {
ctx := c.UserContext()
taskType := c.Params("task_type")
if taskType == "" {
return errors.New(errors.CodeInvalidParam, "任务类型不能为空")
}
var req dto.UpdatePollingConcurrencyReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userID := middleware.GetUserIDFromContext(ctx)
if err := h.service.UpdateMaxConcurrency(ctx, taskType, req.MaxConcurrency, userID); err != nil {
return err
}
return response.Success(c, nil)
}
// Reset 重置并发计数
// @Summary 重置轮询并发计数
// @Description 重置指定轮询任务类型的当前并发计数为0用于信号量修复
// @Tags 轮询管理-并发控制
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.ResetPollingConcurrencyReq true "重置请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-concurrency/reset [post]
func (h *PollingConcurrencyHandler) Reset(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.ResetPollingConcurrencyReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.TaskType == "" {
return errors.New(errors.CodeInvalidParam, "任务类型不能为空")
}
if err := h.service.ResetConcurrency(ctx, req.TaskType); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,193 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingService "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingConfigHandler 轮询配置 Handler
type PollingConfigHandler struct {
service *pollingService.ConfigService
}
// NewPollingConfigHandler 创建轮询配置 Handler 实例
func NewPollingConfigHandler(service *pollingService.ConfigService) *PollingConfigHandler {
return &PollingConfigHandler{service: service}
}
// List 获取轮询配置列表
// @Summary 获取轮询配置列表
// @Description 获取轮询配置列表,支持分页和筛选
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param status query int false "状态 (1:启用, 0:禁用)"
// @Param card_condition query string false "卡状态条件"
// @Param card_category query string false "卡业务类型"
// @Param carrier_id query int false "运营商ID"
// @Param config_name query string false "配置名称"
// @Success 200 {object} response.Response{data=dto.PollingConfigPageResult}
// @Router /api/admin/polling-configs [get]
func (h *PollingConfigHandler) List(c *fiber.Ctx) error {
var req dto.PollingConfigListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
configs, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, configs, total, req.Page, req.PageSize)
}
// Create 创建轮询配置
// @Summary 创建轮询配置
// @Description 创建新的轮询配置
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param body body dto.CreatePollingConfigRequest true "创建轮询配置请求"
// @Success 200 {object} response.Response{data=dto.PollingConfigResponse}
// @Router /api/admin/polling-configs [post]
func (h *PollingConfigHandler) Create(c *fiber.Ctx) error {
var req dto.CreatePollingConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
config, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, config)
}
// Get 获取轮询配置详情
// @Summary 获取轮询配置详情
// @Description 根据 ID 获取轮询配置详情
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param id path int true "配置ID"
// @Success 200 {object} response.Response{data=dto.PollingConfigResponse}
// @Router /api/admin/polling-configs/{id} [get]
func (h *PollingConfigHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
config, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, config)
}
// Update 更新轮询配置
// @Summary 更新轮询配置
// @Description 根据 ID 更新轮询配置
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param id path int true "配置ID"
// @Param body body dto.UpdatePollingConfigRequest true "更新轮询配置请求"
// @Success 200 {object} response.Response{data=dto.PollingConfigResponse}
// @Router /api/admin/polling-configs/{id} [put]
func (h *PollingConfigHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
var req dto.UpdatePollingConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
config, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, config)
}
// Delete 删除轮询配置
// @Summary 删除轮询配置
// @Description 根据 ID 删除轮询配置
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param id path int true "配置ID"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-configs/{id} [delete]
func (h *PollingConfigHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// UpdateStatus 更新轮询配置状态
// @Summary 更新轮询配置状态
// @Description 启用或禁用轮询配置
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Param id path int true "配置ID"
// @Param body body dto.UpdatePollingConfigStatusRequest true "更新状态请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-configs/{id}/status [put]
func (h *PollingConfigHandler) UpdateStatus(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
var req dto.UpdatePollingConfigStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.service.UpdateStatus(c.UserContext(), uint(id), req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// ListEnabled 获取所有启用的配置
// @Summary 获取所有启用的配置
// @Description 获取所有启用状态的轮询配置,按优先级排序
// @Tags 轮询配置管理
// @Accept json
// @Produce json
// @Success 200 {object} response.Response{data=[]dto.PollingConfigResponse}
// @Router /api/admin/polling-configs/enabled [get]
func (h *PollingConfigHandler) ListEnabled(c *fiber.Ctx) error {
configs, err := h.service.ListEnabled(c.UserContext())
if err != nil {
return err
}
return response.Success(c, configs)
}

View File

@@ -0,0 +1,311 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingManualTriggerHandler 轮询手动触发处理器
type PollingManualTriggerHandler struct {
service *polling.ManualTriggerService
}
// NewPollingManualTriggerHandler 创建轮询手动触发处理器
func NewPollingManualTriggerHandler(service *polling.ManualTriggerService) *PollingManualTriggerHandler {
return &PollingManualTriggerHandler{service: service}
}
// TriggerSingle 单卡手动触发
// @Summary 单卡手动触发
// @Description 触发单张卡的轮询任务
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.TriggerSingleReq true "触发请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-manual-trigger/single [post]
func (h *PollingManualTriggerHandler) TriggerSingle(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.TriggerSingleReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if err := h.service.TriggerSingle(ctx, req.CardID, req.TaskType, userID); err != nil {
return err
}
return response.Success(c, nil)
}
// TriggerBatch 批量手动触发
// @Summary 批量手动触发
// @Description 批量触发多张卡的轮询任务
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.TriggerBatchReq true "触发请求"
// @Success 200 {object} response.Response{data=dto.ManualTriggerLogResp}
// @Router /api/admin/polling-manual-trigger/batch [post]
func (h *PollingManualTriggerHandler) TriggerBatch(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.TriggerBatchReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized)
}
log, err := h.service.TriggerBatch(ctx, req.CardIDs, req.TaskType, userID)
if err != nil {
return err
}
return response.Success(c, h.toLogResp(log))
}
// TriggerByCondition 条件筛选触发
// @Summary 条件筛选触发
// @Description 根据条件筛选卡并触发轮询任务
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.TriggerByConditionReq true "触发请求"
// @Success 200 {object} response.Response{data=dto.ManualTriggerLogResp}
// @Router /api/admin/polling-manual-trigger/by-condition [post]
func (h *PollingManualTriggerHandler) TriggerByCondition(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.TriggerByConditionReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized)
}
filter := &polling.ConditionFilter{
CardStatus: req.CardStatus,
CarrierCode: req.CarrierCode,
CardType: req.CardType,
ShopID: req.ShopID,
PackageIDs: req.PackageIDs,
EnablePolling: req.EnablePolling,
Limit: req.Limit,
}
log, err := h.service.TriggerByCondition(ctx, filter, req.TaskType, userID)
if err != nil {
return err
}
return response.Success(c, h.toLogResp(log))
}
// GetStatus 获取触发状态
// @Summary 获取手动触发状态
// @Description 获取当前用户的手动触发状态
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.ManualTriggerStatusResp}
// @Router /api/admin/polling-manual-trigger/status [get]
func (h *PollingManualTriggerHandler) GetStatus(c *fiber.Ctx) error {
ctx := c.UserContext()
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized)
}
// 获取正在运行的任务
runningTasks, err := h.service.GetRunningTasks(ctx, userID)
if err != nil {
return err
}
items := make([]*dto.ManualTriggerLogResp, 0, len(runningTasks))
for _, log := range runningTasks {
items = append(items, h.toLogResp(log))
}
// 获取各队列大小
queueSizes := make(map[string]int64)
for _, taskType := range []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
} {
size, _ := h.service.GetQueueSize(ctx, taskType)
queueSizes[taskType] = size
}
return response.Success(c, &dto.ManualTriggerStatusResp{
RunningTasks: items,
QueueSizes: queueSizes,
})
}
// ListHistory 获取触发历史
// @Summary 获取手动触发历史
// @Description 获取手动触发历史记录
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param task_type query string false "任务类型筛选"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} response.Response{data=dto.ManualTriggerLogListResp}
// @Router /api/admin/polling-manual-trigger/history [get]
func (h *PollingManualTriggerHandler) ListHistory(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.ListManualTriggerLogReq
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 > 100 {
req.PageSize = 20
}
logs, total, err := h.service.ListHistory(ctx, req.Page, req.PageSize, req.TaskType, nil)
if err != nil {
return err
}
items := make([]*dto.ManualTriggerLogResp, 0, len(logs))
for _, log := range logs {
items = append(items, h.toLogResp(log))
}
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
return response.Success(c, &dto.ManualTriggerLogListResp{
Items: items,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
})
}
// CancelTrigger 取消触发任务
// @Summary 取消手动触发任务
// @Description 取消正在执行的手动触发任务
// @Tags 轮询管理-手动触发
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CancelTriggerReq true "取消请求"
// @Success 200 {object} response.Response
// @Router /api/admin/polling-manual-trigger/cancel [post]
func (h *PollingManualTriggerHandler) CancelTrigger(c *fiber.Ctx) error {
ctx := c.UserContext()
var req dto.CancelTriggerReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if err := h.service.CancelTrigger(ctx, req.TriggerID, userID); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *PollingManualTriggerHandler) toLogResp(log *model.PollingManualTriggerLog) *dto.ManualTriggerLogResp {
return &dto.ManualTriggerLogResp{
ID: log.ID,
TaskType: log.TaskType,
TaskTypeName: h.getTaskTypeName(log.TaskType),
TriggerType: log.TriggerType,
TriggerTypeName: h.getTriggerTypeName(log.TriggerType),
TotalCount: log.TotalCount,
ProcessedCount: log.ProcessedCount,
SuccessCount: log.SuccessCount,
FailedCount: log.FailedCount,
Status: log.Status,
StatusName: h.getStatusName(log.Status),
TriggeredBy: log.TriggeredBy,
TriggeredAt: log.TriggeredAt,
CompletedAt: log.CompletedAt,
}
}
func (h *PollingManualTriggerHandler) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}
func (h *PollingManualTriggerHandler) getTriggerTypeName(triggerType string) string {
switch triggerType {
case "single":
return "单卡触发"
case "batch":
return "批量触发"
case "by_condition":
return "条件筛选"
default:
return triggerType
}
}
func (h *PollingManualTriggerHandler) getStatusName(status string) string {
switch status {
case "pending":
return "待处理"
case "processing":
return "处理中"
case "completed":
return "已完成"
case "cancelled":
return "已取消"
default:
return status
}
}

View File

@@ -0,0 +1,139 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PollingMonitoringHandler 轮询监控处理器
type PollingMonitoringHandler struct {
service *polling.MonitoringService
}
// NewPollingMonitoringHandler 创建轮询监控处理器
func NewPollingMonitoringHandler(service *polling.MonitoringService) *PollingMonitoringHandler {
return &PollingMonitoringHandler{service: service}
}
// GetOverview 获取轮询总览
// @Summary 获取轮询总览统计
// @Description 获取轮询系统的总览统计数据,包括初始化进度、队列大小等
// @Tags 轮询管理-监控
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingOverviewResp}
// @Router /api/admin/polling-stats [get]
func (h *PollingMonitoringHandler) GetOverview(c *fiber.Ctx) error {
ctx := c.UserContext()
stats, err := h.service.GetOverview(ctx)
if err != nil {
return err
}
return response.Success(c, &dto.PollingOverviewResp{
TotalCards: stats.TotalCards,
InitializedCards: stats.InitializedCards,
InitProgress: stats.InitProgress,
IsInitializing: stats.IsInitializing,
RealnameQueueSize: stats.RealnameQueueSize,
CarddataQueueSize: stats.CarddataQueueSize,
PackageQueueSize: stats.PackageQueueSize,
})
}
// GetQueueStatuses 获取队列状态
// @Summary 获取轮询队列状态
// @Description 获取所有轮询队列的详细状态,包括队列大小、到期数、等待时间等
// @Tags 轮询管理-监控
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingQueueStatusListResp}
// @Router /api/admin/polling-stats/queues [get]
func (h *PollingMonitoringHandler) GetQueueStatuses(c *fiber.Ctx) error {
ctx := c.UserContext()
statuses, err := h.service.GetQueueStatuses(ctx)
if err != nil {
return err
}
items := make([]*dto.PollingQueueStatusResp, 0, len(statuses))
for _, s := range statuses {
items = append(items, &dto.PollingQueueStatusResp{
TaskType: s.TaskType,
TaskTypeName: s.TaskTypeName,
QueueSize: s.QueueSize,
ManualPending: s.ManualPending,
DueCount: s.DueCount,
AvgWaitTime: s.AvgWaitTime,
})
}
return response.Success(c, &dto.PollingQueueStatusListResp{Items: items})
}
// GetTaskStatuses 获取任务统计
// @Summary 获取轮询任务统计
// @Description 获取所有轮询任务类型的执行统计,包括成功率、平均耗时等
// @Tags 轮询管理-监控
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingTaskStatsListResp}
// @Router /api/admin/polling-stats/tasks [get]
func (h *PollingMonitoringHandler) GetTaskStatuses(c *fiber.Ctx) error {
ctx := c.UserContext()
statuses, err := h.service.GetTaskStatuses(ctx)
if err != nil {
return err
}
items := make([]*dto.PollingTaskStatsResp, 0, len(statuses))
for _, s := range statuses {
items = append(items, &dto.PollingTaskStatsResp{
TaskType: s.TaskType,
TaskTypeName: s.TaskTypeName,
SuccessCount1h: s.SuccessCount1h,
FailureCount1h: s.FailureCount1h,
TotalCount1h: s.TotalCount1h,
SuccessRate: s.SuccessRate,
AvgDurationMs: s.AvgDurationMs,
})
}
return response.Success(c, &dto.PollingTaskStatsListResp{Items: items})
}
// GetInitProgress 获取初始化进度
// @Summary 获取轮询初始化进度
// @Description 获取轮询系统初始化的详细进度,包括已处理数、预计完成时间等
// @Tags 轮询管理-监控
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=dto.PollingInitProgressResp}
// @Router /api/admin/polling-stats/init-progress [get]
func (h *PollingMonitoringHandler) GetInitProgress(c *fiber.Ctx) error {
ctx := c.UserContext()
progress, err := h.service.GetInitProgress(ctx)
if err != nil {
return err
}
return response.Success(c, &dto.PollingInitProgressResp{
TotalCards: progress.TotalCards,
InitializedCards: progress.InitializedCards,
Progress: progress.Progress,
IsComplete: progress.IsComplete,
StartedAt: progress.StartedAt,
EstimatedETA: progress.EstimatedETA,
})
}

View File

@@ -38,9 +38,15 @@ type StandaloneIotCardResponse struct {
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"本月已用流量(MB)"`
CurrentMonthStartDate *time.Time `json:"current_month_start_date,omitempty" description:"本月开始日期"`
LastMonthTotalMB float64 `json:"last_month_total_mb" description:"上月流量总量(MB)"`
LastDataCheckAt *time.Time `json:"last_data_check_at,omitempty" description:"最后流量检查时间"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at,omitempty" description:"最后实名检查时间"`
EnablePolling bool `json:"enable_polling" description:"是否参与轮询"`
SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"`
SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"`
FirstCommissionPaid bool `json:"first_commission_paid" description:"一次性佣金是否已发放"`
AccumulatedRecharge int64 `json:"accumulated_recharge" description:"累计充值金额(分)"`

View File

@@ -0,0 +1,84 @@
package dto
import "time"
// CreatePollingAlertRuleReq 创建告警规则请求
type CreatePollingAlertRuleReq struct {
RuleName string `json:"rule_name" validate:"required,max=100" description:"规则名称"`
TaskType string `json:"task_type" validate:"required" description:"任务类型 (polling:realname/polling:carddata/polling:package)"`
MetricType string `json:"metric_type" validate:"required" description:"指标类型 (queue_size/success_rate/avg_duration/concurrency)"`
Operator string `json:"operator" validate:"omitempty,oneof=> >= < <= ==" description:"比较运算符,默认 >"`
Threshold float64 `json:"threshold" validate:"required" description:"阈值"`
AlertLevel string `json:"alert_level" validate:"required,oneof=warning critical" description:"告警级别 (warning/critical)"`
CooldownMinutes int `json:"cooldown_minutes" validate:"omitempty,min=0,max=1440" description:"冷却时间(分钟)默认5分钟"`
NotifyChannels string `json:"notify_channels" validate:"omitempty" description:"通知渠道(JSON格式)"`
}
// UpdatePollingAlertRuleReq 更新告警规则请求Body 部分)
type UpdatePollingAlertRuleReq struct {
RuleName *string `json:"rule_name" validate:"omitempty,max=100" description:"规则名称"`
Threshold *float64 `json:"threshold" validate:"omitempty" description:"阈值"`
AlertLevel *string `json:"alert_level" validate:"omitempty,oneof=warning critical" description:"告警级别"`
Status *int `json:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
CooldownMinutes *int `json:"cooldown_minutes" validate:"omitempty,min=0,max=1440" description:"冷却时间(分钟)"`
NotifyChannels *string `json:"notify_channels" validate:"omitempty" description:"通知渠道"`
}
// UpdatePollingAlertRuleParams 更新告警规则参数(包含路径参数和 Body
type UpdatePollingAlertRuleParams struct {
IDReq
UpdatePollingAlertRuleReq
}
// PollingAlertRuleResp 告警规则响应
type PollingAlertRuleResp struct {
ID uint `json:"id" description:"规则ID"`
RuleName string `json:"rule_name" description:"规则名称"`
TaskType string `json:"task_type" description:"任务类型"`
TaskTypeName string `json:"task_type_name" description:"任务类型名称"`
MetricType string `json:"metric_type" description:"指标类型"`
MetricTypeName string `json:"metric_type_name" description:"指标类型名称"`
Operator string `json:"operator" description:"比较运算符"`
Threshold float64 `json:"threshold" description:"阈值"`
AlertLevel string `json:"alert_level" description:"告警级别"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
CooldownMinutes int `json:"cooldown_minutes" description:"冷却时间(分钟)"`
NotifyChannels string `json:"notify_channels" description:"通知渠道"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// PollingAlertRuleListResp 告警规则列表响应
type PollingAlertRuleListResp struct {
Items []*PollingAlertRuleResp `json:"items" description:"告警规则列表"`
}
// PollingAlertHistoryResp 告警历史响应
type PollingAlertHistoryResp struct {
ID uint `json:"id" description:"历史ID"`
RuleID uint `json:"rule_id" description:"规则ID"`
RuleName string `json:"rule_name" description:"规则名称"`
TaskType string `json:"task_type" description:"任务类型"`
MetricType string `json:"metric_type" description:"指标类型"`
AlertLevel string `json:"alert_level" description:"告警级别"`
Threshold float64 `json:"threshold" description:"阈值"`
CurrentValue float64 `json:"current_value" description:"触发时的值"`
Message string `json:"message" description:"告警消息"`
CreatedAt time.Time `json:"created_at" description:"触发时间"`
}
// PollingAlertHistoryListResp 告警历史列表响应
type PollingAlertHistoryListResp struct {
Items []*PollingAlertHistoryResp `json:"items" description:"告警历史列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// ListPollingAlertHistoryReq 查询告警历史请求
type ListPollingAlertHistoryReq struct {
RuleID *uint `json:"rule_id" query:"rule_id" description:"规则ID"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"`
}

View File

@@ -0,0 +1,103 @@
package dto
import "time"
// CreateDataCleanupConfigReq 创建数据清理配置请求
type CreateDataCleanupConfigReq struct {
TargetTable string `json:"table_name" validate:"required,max=100" description:"表名"`
RetentionDays int `json:"retention_days" validate:"required,min=7" description:"保留天数最少7天"`
BatchSize int `json:"batch_size" validate:"omitempty,min=1000,max=100000" description:"每批删除条数默认10000"`
Description string `json:"description" validate:"omitempty,max=500" description:"配置说明"`
}
// UpdateDataCleanupConfigReq 更新数据清理配置请求Body 部分)
type UpdateDataCleanupConfigReq struct {
RetentionDays *int `json:"retention_days" validate:"omitempty,min=7" description:"保留天数"`
BatchSize *int `json:"batch_size" validate:"omitempty,min=1000,max=100000" description:"每批删除条数"`
Enabled *int `json:"enabled" validate:"omitempty,oneof=0 1" description:"是否启用0-禁用1-启用"`
Description *string `json:"description" validate:"omitempty,max=500" description:"配置说明"`
}
// UpdateDataCleanupConfigParams 更新数据清理配置参数(包含路径参数和 Body
type UpdateDataCleanupConfigParams struct {
IDReq
UpdateDataCleanupConfigReq
}
// DataCleanupConfigResp 数据清理配置响应
type DataCleanupConfigResp struct {
ID uint `json:"id" description:"配置ID"`
TargetTable string `json:"table_name" description:"表名"`
RetentionDays int `json:"retention_days" description:"保留天数"`
BatchSize int `json:"batch_size" description:"每批删除条数"`
Enabled int `json:"enabled" description:"是否启用0-禁用1-启用"`
Description string `json:"description" description:"配置说明"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
UpdatedBy *uint `json:"updated_by,omitempty" description:"更新人ID"`
}
// DataCleanupConfigListResp 数据清理配置列表响应
type DataCleanupConfigListResp struct {
Items []*DataCleanupConfigResp `json:"items" description:"配置列表"`
}
// DataCleanupLogResp 数据清理日志响应
type DataCleanupLogResp struct {
ID uint `json:"id" description:"日志ID"`
TargetTable string `json:"table_name" description:"表名"`
CleanupType string `json:"cleanup_type" description:"清理类型scheduled/manual"`
RetentionDays int `json:"retention_days" description:"保留天数"`
DeletedCount int64 `json:"deleted_count" description:"删除记录数"`
DurationMs int64 `json:"duration_ms" description:"执行耗时(毫秒)"`
Status string `json:"status" description:"状态success/failed/running"`
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
StartedAt time.Time `json:"started_at" description:"开始时间"`
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
TriggeredBy *uint `json:"triggered_by,omitempty" description:"触发人ID"`
}
// DataCleanupLogListResp 数据清理日志列表响应
type DataCleanupLogListResp struct {
Items []*DataCleanupLogResp `json:"items" description:"日志列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// ListDataCleanupLogReq 查询数据清理日志请求
type ListDataCleanupLogReq struct {
TableName string `json:"table_name" query:"table_name" description:"表名筛选"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"`
}
// DataCleanupPreviewResp 数据清理预览响应
type DataCleanupPreviewResp struct {
Items []*DataCleanupPreviewItem `json:"items" description:"预览列表"`
}
// DataCleanupPreviewItem 数据清理预览项
type DataCleanupPreviewItem struct {
TableName string `json:"table_name" description:"表名"`
RetentionDays int `json:"retention_days" description:"保留天数"`
RecordCount int64 `json:"record_count" description:"待清理记录数"`
Description string `json:"description" description:"配置说明"`
}
// DataCleanupProgressResp 数据清理进度响应
type DataCleanupProgressResp struct {
IsRunning bool `json:"is_running" description:"是否正在运行"`
CurrentTable string `json:"current_table,omitempty" description:"当前清理的表"`
TotalTables int `json:"total_tables" description:"总表数"`
ProcessedTables int `json:"processed_tables" description:"已处理表数"`
TotalDeleted int64 `json:"total_deleted" description:"已删除记录数"`
StartedAt *time.Time `json:"started_at,omitempty" description:"开始时间"`
LastLog *DataCleanupLogResp `json:"last_log,omitempty" description:"最近一条清理日志"`
}
// TriggerDataCleanupReq 手动触发数据清理请求
type TriggerDataCleanupReq struct {
TableName string `json:"table_name" validate:"omitempty,max=100" description:"表名,为空则清理所有"`
}

View File

@@ -0,0 +1,32 @@
package dto
// GetPollingConcurrencyReq 获取指定任务类型的并发配置请求
type GetPollingConcurrencyReq struct {
TaskType string `path:"task_type" description:"任务类型" required:"true"`
}
// UpdatePollingConcurrencyReq 更新轮询并发配置请求
type UpdatePollingConcurrencyReq struct {
TaskType string `path:"task_type" description:"任务类型" required:"true"`
MaxConcurrency int `json:"max_concurrency" validate:"required,min=1,max=1000" description:"最大并发数1-1000"`
}
// PollingConcurrencyResp 轮询并发配置响应
type PollingConcurrencyResp struct {
TaskType string `json:"task_type" description:"任务类型"`
TaskTypeName string `json:"task_type_name" description:"任务类型名称"`
MaxConcurrency int `json:"max_concurrency" description:"最大并发数"`
Current int64 `json:"current" description:"当前并发数"`
Available int64 `json:"available" description:"可用并发数"`
Utilization float64 `json:"utilization" description:"使用率(百分比)"`
}
// PollingConcurrencyListResp 轮询并发配置列表响应
type PollingConcurrencyListResp struct {
Items []*PollingConcurrencyResp `json:"items" description:"并发配置列表"`
}
// ResetPollingConcurrencyReq 重置轮询并发计数请求
type ResetPollingConcurrencyReq struct {
TaskType string `json:"task_type" validate:"required" description:"任务类型"`
}

View File

@@ -0,0 +1,81 @@
package dto
// CreatePollingConfigRequest 创建轮询配置请求
type CreatePollingConfigRequest struct {
ConfigName string `json:"config_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority int `json:"priority" validate:"required,min=1,max=1000" required:"true" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
}
// UpdatePollingConfigRequest 更新轮询配置请求
type UpdatePollingConfigRequest struct {
ConfigName *string `json:"config_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
CardCondition *string `json:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory *string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" validate:"omitempty" description:"运营商ID可选精确匹配"`
Priority *int `json:"priority" validate:"omitempty,min=1,max=1000" minimum:"1" maximum:"1000" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" validate:"omitempty,min=30" minimum:"30" description:"实名检查间隔NULL表示不检查最小30秒"`
CarddataCheckInterval *int `json:"carddata_check_interval" validate:"omitempty,min=60" minimum:"60" description:"流量检查间隔NULL表示不检查最小60秒"`
PackageCheckInterval *int `json:"package_check_interval" validate:"omitempty,min=60" minimum:"60" description:"套餐检查间隔NULL表示不检查最小60秒"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置说明"`
}
// PollingConfigListRequest 轮询配置列表请求
type PollingConfigListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int16 `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (1:启用, 0:禁用)"`
CardCondition *string `json:"card_condition" query:"card_condition" validate:"omitempty,oneof=not_real_name real_name activated suspended" description:"卡状态条件"`
CardCategory *string `json:"card_category" query:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" validate:"omitempty" description:"运营商ID"`
ConfigName *string `json:"config_name" query:"config_name" validate:"omitempty,max=100" maxLength:"100" description:"配置名称(模糊搜索)"`
}
// UpdatePollingConfigStatusRequest 更新轮询配置状态请求
type UpdatePollingConfigStatusRequest struct {
Status int16 `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (1:启用, 0:禁用)"`
}
// PollingConfigResponse 轮询配置响应
type PollingConfigResponse struct {
ID uint `json:"id" description:"配置ID"`
ConfigName string `json:"config_name" description:"配置名称"`
CardCondition string `json:"card_condition" description:"卡状态条件 (not_real_name:未实名, real_name:已实名, activated:已激活, suspended:已停用)"`
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
CarrierID *uint `json:"carrier_id" description:"运营商ID"`
Priority int `json:"priority" description:"优先级(数字越小优先级越高)"`
RealnameCheckInterval *int `json:"realname_check_interval" description:"实名检查间隔NULL表示不检查"`
CarddataCheckInterval *int `json:"carddata_check_interval" description:"流量检查间隔NULL表示不检查"`
PackageCheckInterval *int `json:"package_check_interval" description:"套餐检查间隔NULL表示不检查"`
Status int16 `json:"status" description:"状态 (1:启用, 0:禁用)"`
Description string `json:"description" description:"配置说明"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// UpdatePollingConfigParams 更新轮询配置参数
type UpdatePollingConfigParams struct {
IDReq
UpdatePollingConfigRequest
}
// UpdatePollingConfigStatusParams 更新轮询配置状态参数
type UpdatePollingConfigStatusParams struct {
IDReq
UpdatePollingConfigStatusRequest
}
// PollingConfigPageResult 轮询配置分页结果
type PollingConfigPageResult struct {
List []*PollingConfigResponse `json:"list" description:"配置列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}

View File

@@ -0,0 +1,72 @@
package dto
import "time"
// TriggerSingleReq 单卡手动触发请求
type TriggerSingleReq struct {
CardID uint `json:"card_id" validate:"required" description:"卡ID"`
TaskType string `json:"task_type" validate:"required,oneof=polling:realname polling:carddata polling:package" description:"任务类型"`
}
// TriggerBatchReq 批量手动触发请求
type TriggerBatchReq struct {
CardIDs []uint `json:"card_ids" validate:"required,min=1,max=1000" description:"卡ID列表最多1000个"`
TaskType string `json:"task_type" validate:"required,oneof=polling:realname polling:carddata polling:package" description:"任务类型"`
}
// TriggerByConditionReq 条件筛选触发请求
type TriggerByConditionReq struct {
TaskType string `json:"task_type" validate:"required,oneof=polling:realname polling:carddata polling:package" description:"任务类型"`
CardStatus string `json:"card_status" validate:"omitempty" description:"卡状态筛选"`
CarrierCode string `json:"carrier_code" validate:"omitempty" description:"运营商代码筛选"`
CardType string `json:"card_type" validate:"omitempty" description:"卡类型筛选"`
ShopID *uint `json:"shop_id" validate:"omitempty" description:"店铺ID筛选"`
PackageIDs []uint `json:"package_ids" validate:"omitempty" description:"套餐ID列表筛选"`
EnablePolling *bool `json:"enable_polling" validate:"omitempty" description:"是否启用轮询筛选"`
Limit int `json:"limit" validate:"omitempty,min=1,max=1000" description:"限制数量最多1000"`
}
// CancelTriggerReq 取消触发请求
type CancelTriggerReq struct {
TriggerID uint `json:"trigger_id" validate:"required" description:"触发任务ID"`
}
// ManualTriggerLogResp 手动触发日志响应
type ManualTriggerLogResp struct {
ID uint `json:"id" description:"日志ID"`
TaskType string `json:"task_type" description:"任务类型"`
TaskTypeName string `json:"task_type_name" description:"任务类型名称"`
TriggerType string `json:"trigger_type" description:"触发类型single/batch/by_condition"`
TriggerTypeName string `json:"trigger_type_name" description:"触发类型名称"`
TotalCount int `json:"total_count" description:"总卡数"`
ProcessedCount int `json:"processed_count" description:"已处理数"`
SuccessCount int `json:"success_count" description:"成功数"`
FailedCount int `json:"failed_count" description:"失败数"`
Status string `json:"status" description:"状态pending/processing/completed/cancelled"`
StatusName string `json:"status_name" description:"状态名称"`
TriggeredBy uint `json:"triggered_by" description:"触发人ID"`
TriggeredAt time.Time `json:"triggered_at" description:"触发时间"`
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
}
// ManualTriggerLogListResp 手动触发日志列表响应
type ManualTriggerLogListResp struct {
Items []*ManualTriggerLogResp `json:"items" description:"日志列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}
// ListManualTriggerLogReq 查询手动触发日志请求
type ListManualTriggerLogReq struct {
TaskType string `json:"task_type" query:"task_type" description:"任务类型筛选"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量"`
}
// ManualTriggerStatusResp 手动触发状态响应
type ManualTriggerStatusResp struct {
RunningTasks []*ManualTriggerLogResp `json:"running_tasks" description:"正在运行的任务"`
QueueSizes map[string]int64 `json:"queue_sizes" description:"各队列大小"`
}

View File

@@ -0,0 +1,55 @@
package dto
import "time"
// PollingOverviewResp 轮询总览响应
type PollingOverviewResp struct {
TotalCards int64 `json:"total_cards" description:"总卡数"`
InitializedCards int64 `json:"initialized_cards" description:"已初始化卡数"`
InitProgress float64 `json:"init_progress" description:"初始化进度0-100"`
IsInitializing bool `json:"is_initializing" description:"是否正在初始化"`
RealnameQueueSize int64 `json:"realname_queue_size" description:"实名检查队列大小"`
CarddataQueueSize int64 `json:"carddata_queue_size" description:"流量检查队列大小"`
PackageQueueSize int64 `json:"package_queue_size" description:"套餐检查队列大小"`
}
// PollingQueueStatusResp 队列状态响应
type PollingQueueStatusResp struct {
TaskType string `json:"task_type" description:"任务类型"`
TaskTypeName string `json:"task_type_name" description:"任务类型名称"`
QueueSize int64 `json:"queue_size" description:"队列大小"`
ManualPending int64 `json:"manual_pending" description:"手动触发待处理数"`
DueCount int64 `json:"due_count" description:"到期待处理数"`
AvgWaitTime float64 `json:"avg_wait_time_s" description:"平均等待时间(秒)"`
}
// PollingQueueStatusListResp 队列状态列表响应
type PollingQueueStatusListResp struct {
Items []*PollingQueueStatusResp `json:"items" description:"队列状态列表"`
}
// PollingTaskStatsResp 任务统计响应
type PollingTaskStatsResp struct {
TaskType string `json:"task_type" description:"任务类型"`
TaskTypeName string `json:"task_type_name" description:"任务类型名称"`
SuccessCount1h int64 `json:"success_count_1h" description:"1小时成功数"`
FailureCount1h int64 `json:"failure_count_1h" description:"1小时失败数"`
TotalCount1h int64 `json:"total_count_1h" description:"1小时总数"`
SuccessRate float64 `json:"success_rate" description:"成功率0-100"`
AvgDurationMs float64 `json:"avg_duration_ms" description:"平均耗时(毫秒)"`
}
// PollingTaskStatsListResp 任务统计列表响应
type PollingTaskStatsListResp struct {
Items []*PollingTaskStatsResp `json:"items" description:"任务统计列表"`
}
// PollingInitProgressResp 初始化进度响应
type PollingInitProgressResp struct {
TotalCards int64 `json:"total_cards" description:"总卡数"`
InitializedCards int64 `json:"initialized_cards" description:"已初始化卡数"`
Progress float64 `json:"progress" description:"进度百分比0-100"`
IsComplete bool `json:"is_complete" description:"是否完成"`
StartedAt time.Time `json:"started_at" description:"开始时间"`
EstimatedETA string `json:"estimated_eta" description:"预计完成时间"`
}

View File

@@ -32,6 +32,9 @@ type IotCard struct {
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
NetworkStatus int `gorm:"column:network_status;type:int;default:0;not null;comment:网络状态 0-停机 1-开机" json:"network_status"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:累计流量使用(MB)" json:"data_usage_mb"`
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb;type:decimal(10,2);default:0;comment:本月已用流量(MB) - Gateway返回的自然月流量总量" json:"current_month_usage_mb"`
CurrentMonthStartDate *time.Time `gorm:"column:current_month_start_date;type:date;comment:本月开始日期 - 用于检测跨月流量重置" json:"current_month_start_date"`
LastMonthTotalMB float64 `gorm:"column:last_month_total_mb;type:decimal(10,2);default:0;comment:上月结束时的总流量(MB) - 用于跨月流量计算" json:"last_month_total_mb"`
EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"`
LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"`
LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"`

View File

@@ -1,29 +1,152 @@
package model
import (
"gorm.io/gorm"
"time"
)
// PollingConfig 轮询配置模型
// 支持梯度轮询策略(实名检查、卡流量检查、套餐流量检查)
// PollingConfig 轮询配置
type PollingConfig struct {
gorm.Model
BaseModel `gorm:"embedded"`
ConfigName string `gorm:"column:config_name;type:varchar(100);uniqueIndex:idx_polling_config_name,where:deleted_at IS NULL;not null;comment:配置名称(如 未实名卡、实名卡)" json:"config_name"`
Description string `gorm:"column:description;type:varchar(500);comment:配置描述" json:"description"`
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件 not_real_name-未实名 real_name-已实名 activated-已激活 suspended-已停用" json:"card_condition"`
CarrierID uint `gorm:"column:carrier_id;index;comment:运营商ID(NULL表示所有运营商)" json:"carrier_id"`
RealNameCheckEnabled bool `gorm:"column:real_name_check_enabled;type:boolean;default:false;comment:是否启用实名检查" json:"real_name_check_enabled"`
RealNameCheckInterval int `gorm:"column:real_name_check_interval;type:int;default:60;comment:实名检查间隔(秒)" json:"real_name_check_interval"`
CardDataCheckEnabled bool `gorm:"column:card_data_check_enabled;type:boolean;default:false;comment:是否启用卡流量检查" json:"card_data_check_enabled"`
CardDataCheckInterval int `gorm:"column:card_data_check_interval;type:int;default:60;comment:卡流量检查间隔(秒)" json:"card_data_check_interval"`
PackageCheckEnabled bool `gorm:"column:package_check_enabled;type:boolean;default:false;comment:是否启用套餐流量检查" json:"package_check_enabled"`
PackageCheckInterval int `gorm:"column:package_check_interval;type:int;default:60;comment:套餐流量检查间隔(秒)" json:"package_check_interval"`
Priority int `gorm:"column:priority;type:int;default:100;not null;comment:优先级(数字越小优先级越高)" json:"priority"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ConfigName string `gorm:"column:config_name;type:varchar(100);not null;comment:配置名称" json:"config_name"`
CardCondition string `gorm:"column:card_condition;type:varchar(50);comment:卡状态条件not_real_name/real_name/activated/suspended" json:"card_condition"`
CardCategory string `gorm:"column:card_category;type:varchar(50);comment:卡业务类型normal/industry" json:"card_category"`
CarrierID *uint `gorm:"column:carrier_id;comment:运营商ID可选精确匹配" json:"carrier_id"`
Priority int `gorm:"column:priority;not null;default:100;comment:优先级(数字越小优先级越高)" json:"priority"`
RealnameCheckInterval *int `gorm:"column:realname_check_interval;comment:实名检查间隔NULL表示不检查" json:"realname_check_interval"`
CarddataCheckInterval *int `gorm:"column:carddata_check_interval;comment:流量检查间隔NULL表示不检查" json:"carddata_check_interval"`
PackageCheckInterval *int `gorm:"column:package_check_interval;comment:套餐检查间隔NULL表示不检查" json:"package_check_interval"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态0-禁用1-启用" json:"status"`
Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
}
// TableName 指定表名
func (PollingConfig) TableName() string {
return "tb_polling_config"
}
// PollingConcurrencyConfig 并发控制配置表
type PollingConcurrencyConfig struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TaskType string `gorm:"column:task_type;type:varchar(50);uniqueIndex;not null;comment:任务类型realname/carddata/package/stop_start" json:"task_type"`
MaxConcurrency int `gorm:"column:max_concurrency;not null;default:50;comment:最大并发数" json:"max_concurrency"`
Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
}
// TableName 指定表名
func (PollingConcurrencyConfig) TableName() string {
return "tb_polling_concurrency_config"
}
// PollingAlertRule 告警规则表
type PollingAlertRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RuleName string `gorm:"column:rule_name;type:varchar(100);not null;comment:规则名称" json:"rule_name"`
TaskType string `gorm:"column:task_type;type:varchar(50);not null;comment:任务类型realname/carddata/package" json:"task_type"`
MetricType string `gorm:"column:metric_type;type:varchar(50);not null;comment:指标类型queue_size/success_rate/avg_duration/concurrency" json:"metric_type"`
Operator string `gorm:"column:operator;type:varchar(20);not null;comment:比较运算符gt/lt/gte/lte/eq" json:"operator"`
Threshold float64 `gorm:"column:threshold;type:decimal(10,2);not null;comment:阈值" json:"threshold"`
DurationMinutes int `gorm:"column:duration_minutes;not null;default:5;comment:持续时长(分钟),避免短暂波动" json:"duration_minutes"`
AlertLevel string `gorm:"column:alert_level;type:varchar(20);not null;default:'warning';comment:告警级别info/warning/error/critical" json:"alert_level"`
NotificationChannels string `gorm:"column:notification_channels;type:text;comment:通知渠道JSON数组[\"email\",\"sms\",\"webhook\"]" json:"notification_channels"`
NotificationConfig string `gorm:"column:notification_config;type:text;comment:通知配置JSON" json:"notification_config"`
Status int16 `gorm:"column:status;type:smallint;not null;default:1;comment:状态0-禁用1-启用" json:"status"`
CooldownMinutes int `gorm:"column:cooldown_minutes;not null;default:5;comment:冷却期(分钟),避免重复告警" json:"cooldown_minutes"`
Description string `gorm:"column:description;type:text;comment:规则说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
CreatedBy *uint `gorm:"column:created_by;comment:创建人ID" json:"created_by"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
}
// TableName 指定表名
func (PollingAlertRule) TableName() string {
return "tb_polling_alert_rule"
}
// PollingAlertHistory 告警历史表
type PollingAlertHistory struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RuleID uint `gorm:"column:rule_id;not null;comment:告警规则ID" json:"rule_id"`
TaskType string `gorm:"column:task_type;type:varchar(50);not null;comment:任务类型" json:"task_type"`
MetricType string `gorm:"column:metric_type;type:varchar(50);not null;comment:指标类型" json:"metric_type"`
AlertLevel string `gorm:"column:alert_level;type:varchar(20);not null;comment:告警级别" json:"alert_level"`
CurrentValue float64 `gorm:"column:current_value;type:decimal(10,2);not null;comment:当前值" json:"current_value"`
Threshold float64 `gorm:"column:threshold;type:decimal(10,2);not null;comment:阈值" json:"threshold"`
AlertMessage string `gorm:"column:alert_message;type:text;not null;comment:告警消息" json:"alert_message"`
NotificationChannels string `gorm:"column:notification_channels;type:text;comment:通知渠道JSON数组" json:"notification_channels"`
NotificationStatus string `gorm:"column:notification_status;type:varchar(20);comment:通知状态pending/sent/failed" json:"notification_status"`
NotificationResult string `gorm:"column:notification_result;type:text;comment:通知结果JSON" json:"notification_result"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:告警时间" json:"created_at"`
}
// TableName 指定表名
func (PollingAlertHistory) TableName() string {
return "tb_polling_alert_history"
}
// DataCleanupConfig 数据清理配置表
type DataCleanupConfig struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TargetTable string `gorm:"column:table_name;type:varchar(100);uniqueIndex;not null;comment:表名" json:"table_name"`
RetentionDays int `gorm:"column:retention_days;not null;comment:保留天数" json:"retention_days"`
Enabled int16 `gorm:"column:enabled;type:smallint;not null;default:1;comment:是否启用0-禁用1-启用" json:"enabled"`
BatchSize int `gorm:"column:batch_size;not null;default:10000;comment:每批删除条数" json:"batch_size"`
Description string `gorm:"column:description;type:text;comment:配置说明" json:"description"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"`
UpdatedBy *uint `gorm:"column:updated_by;comment:更新人ID" json:"updated_by"`
}
// TableName 指定表名
func (DataCleanupConfig) TableName() string {
return "tb_data_cleanup_config"
}
// DataCleanupLog 数据清理日志表
type DataCleanupLog struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TargetTable string `gorm:"column:table_name;type:varchar(100);not null;comment:表名" json:"table_name"`
CleanupType string `gorm:"column:cleanup_type;type:varchar(50);not null;comment:清理类型scheduled/manual" json:"cleanup_type"`
RetentionDays int `gorm:"column:retention_days;not null;comment:保留天数" json:"retention_days"`
DeletedCount int64 `gorm:"column:deleted_count;not null;default:0;comment:删除记录数" json:"deleted_count"`
DurationMs int64 `gorm:"column:duration_ms;not null;default:0;comment:执行耗时(毫秒)" json:"duration_ms"`
Status string `gorm:"column:status;type:varchar(20);not null;comment:状态success/failed/running" json:"status"`
ErrorMessage string `gorm:"column:error_message;type:text;comment:错误信息" json:"error_message"`
StartedAt time.Time `gorm:"column:started_at;not null;comment:开始时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
TriggeredBy *uint `gorm:"column:triggered_by;comment:触发人ID手动触发时" json:"triggered_by"`
}
// TableName 指定表名
func (DataCleanupLog) TableName() string {
return "tb_data_cleanup_log"
}
// PollingManualTriggerLog 手动触发日志表
type PollingManualTriggerLog struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
TaskType string `gorm:"column:task_type;type:varchar(50);not null;comment:任务类型realname/carddata/package" json:"task_type"`
TriggerType string `gorm:"column:trigger_type;type:varchar(50);not null;comment:触发类型single/batch/by_condition" json:"trigger_type"`
CardIDs string `gorm:"column:card_ids;type:text;comment:卡ID列表JSON数组" json:"card_ids"`
ConditionFilter string `gorm:"column:condition_filter;type:text;comment:筛选条件JSON" json:"condition_filter"`
TotalCount int `gorm:"column:total_count;not null;default:0;comment:总卡数" json:"total_count"`
ProcessedCount int `gorm:"column:processed_count;not null;default:0;comment:已处理数" json:"processed_count"`
SuccessCount int `gorm:"column:success_count;not null;default:0;comment:成功数" json:"success_count"`
FailedCount int `gorm:"column:failed_count;not null;default:0;comment:失败数" json:"failed_count"`
Status string `gorm:"column:status;type:varchar(20);not null;comment:状态pending/processing/completed/cancelled" json:"status"`
TriggeredBy uint `gorm:"column:triggered_by;not null;comment:触发人ID" json:"triggered_by"`
TriggeredAt time.Time `gorm:"column:triggered_at;not null;default:CURRENT_TIMESTAMP;comment:触发时间" json:"triggered_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
}
// TableName 指定表名
func (PollingManualTriggerLog) TableName() string {
return "tb_polling_manual_trigger_log"
}

View File

@@ -0,0 +1,107 @@
package polling
import (
"context"
"strconv"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// APICallback API 进程使用的轻量级轮询回调
// 直接操作 Redis 队列,不依赖调度器
type APICallback struct {
redis *redis.Client
logger *zap.Logger
}
// NewAPICallback 创建 API 回调实例
func NewAPICallback(redis *redis.Client, logger *zap.Logger) *APICallback {
return &APICallback{
redis: redis,
logger: logger,
}
}
// OnCardCreated 卡创建时的回调
// 注意:大多数卡创建是通过 Worker 的批量导入完成的,这个方法主要用于单卡创建场景
func (c *APICallback) OnCardCreated(ctx context.Context, card *model.IotCard) {
if card == nil {
return
}
c.logger.Debug("API 回调:卡创建", zap.Uint("card_id", card.ID))
// 卡创建后scheduler 的渐进式初始化会将其加入队列
// 这里不做处理,让 scheduler 处理
}
// OnCardStatusChanged 卡状态变化时的回调
func (c *APICallback) OnCardStatusChanged(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡状态变化", zap.Uint("card_id", cardID))
// 状态变化后scheduler 下次扫描时会更新配置匹配
// 这里不做处理,让 scheduler 处理
}
// OnCardDeleted 卡删除时的回调
// 从所有队列中移除卡
func (c *APICallback) OnCardDeleted(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡删除", zap.Uint("card_id", cardID))
member := strconv.FormatUint(uint64(cardID), 10)
// 从所有轮询队列中移除
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
c.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// 删除卡信息缓存
cacheKey := constants.RedisPollingCardInfoKey(cardID)
if err := c.redis.Del(ctx, cacheKey).Err(); err != nil {
c.logger.Warn("删除卡缓存失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardEnabled 卡启用轮询时的回调
func (c *APICallback) OnCardEnabled(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡启用轮询", zap.Uint("card_id", cardID))
// 启用后scheduler 下次扫描时会将其加入队列
}
// OnCardDisabled 卡禁用轮询时的回调
// 从所有队列中移除卡
func (c *APICallback) OnCardDisabled(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡禁用轮询", zap.Uint("card_id", cardID))
member := strconv.FormatUint(uint64(cardID), 10)
// 从所有轮询队列中移除
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
c.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
}

View File

@@ -0,0 +1,228 @@
package polling
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// OnCardCreated 卡创建时的回调
// 将新卡加入轮询队列
func (s *Scheduler) OnCardCreated(ctx context.Context, card *model.IotCard) {
if card == nil {
return
}
s.logger.Debug("卡创建回调", zap.Uint("card_id", card.ID))
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("初始化新卡轮询失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
}
}
// OnBatchCardsCreated 批量卡创建时的回调
func (s *Scheduler) OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard) {
if len(cards) == 0 {
return
}
s.logger.Info("批量卡创建回调", zap.Int("count", len(cards)))
for _, card := range cards {
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Warn("初始化批量导入卡轮询失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
}
}
}
// OnCardStatusChanged 卡状态变化时的回调
// 重新匹配配置并更新轮询队列
func (s *Scheduler) OnCardStatusChanged(ctx context.Context, cardID uint) {
s.logger.Debug("卡状态变化回调", zap.Uint("card_id", cardID))
// 从数据库重新加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("加载卡信息失败",
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
// 先从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
// 重新初始化轮询
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("重新初始化卡轮询失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardDeleted 卡删除时的回调
// 从轮询队列中移除
func (s *Scheduler) OnCardDeleted(ctx context.Context, cardID uint) {
s.logger.Debug("卡删除回调", zap.Uint("card_id", cardID))
// 从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
// 删除缓存
key := constants.RedisPollingCardInfoKey(cardID)
if err := s.redis.Del(ctx, key).Err(); err != nil {
s.logger.Warn("删除卡缓存失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardEnabled 卡启用轮询时的回调
func (s *Scheduler) OnCardEnabled(ctx context.Context, cardID uint) {
s.logger.Debug("卡启用轮询回调", zap.Uint("card_id", cardID))
// 从数据库加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("加载卡信息失败",
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
// 初始化轮询
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("启用卡轮询失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardDisabled 卡禁用轮询时的回调
func (s *Scheduler) OnCardDisabled(ctx context.Context, cardID uint) {
s.logger.Debug("卡禁用轮询回调", zap.Uint("card_id", cardID))
// 从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
}
// removeFromAllQueues 从所有轮询队列中移除卡
func (s *Scheduler) removeFromAllQueues(ctx context.Context, cardID uint) {
member := formatUint(cardID)
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := s.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
s.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
}
// RequeueCard 重新将卡加入队列
// 用于任务完成后重新入队
func (s *Scheduler) RequeueCard(ctx context.Context, cardID uint, taskType string) error {
// 从数据库加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
// 匹配配置
config := s.MatchConfig(card)
if config == nil {
return nil
}
now := time.Now()
var queueKey string
var intervalSeconds int
switch taskType {
case constants.TaskTypePollingRealname:
if config.RealnameCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueueRealnameKey()
intervalSeconds = *config.RealnameCheckInterval
case constants.TaskTypePollingCarddata:
if config.CarddataCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueueCarddataKey()
intervalSeconds = *config.CarddataCheckInterval
case constants.TaskTypePollingPackage:
if config.PackageCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueuePackageKey()
intervalSeconds = *config.PackageCheckInterval
default:
return nil
}
nextCheck := now.Add(time.Duration(intervalSeconds) * time.Second)
return s.addToQueue(ctx, queueKey, cardID, nextCheck)
}
// TriggerManualCheck 触发手动检查
func (s *Scheduler) TriggerManualCheck(ctx context.Context, cardID uint, taskType string) error {
key := constants.RedisPollingManualQueueKey(taskType)
return s.redis.RPush(ctx, key, formatUint(cardID)).Err()
}
// TriggerBatchManualCheck 批量触发手动检查
func (s *Scheduler) TriggerBatchManualCheck(ctx context.Context, cardIDs []uint, taskType string) error {
if len(cardIDs) == 0 {
return nil
}
key := constants.RedisPollingManualQueueKey(taskType)
// 转换为 interface{} 切片
members := make([]interface{}, len(cardIDs))
for i, id := range cardIDs {
members[i] = formatUint(id)
}
return s.redis.RPush(ctx, key, members...).Err()
}
// LazyLoad 懒加载卡信息
// 当卡未初始化但被访问时调用
func (s *Scheduler) LazyLoad(ctx context.Context, cardID uint) error {
// 检查是否已在缓存中
key := constants.RedisPollingCardInfoKey(cardID)
exists, err := s.redis.Exists(ctx, key).Result()
if err != nil {
return err
}
if exists > 0 {
return nil // 已缓存
}
// 从数据库加载
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
// 初始化轮询
return s.initCardPolling(ctx, card)
}

View File

@@ -0,0 +1,711 @@
package polling
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Scheduler 轮询调度器
// 负责管理 IoT 卡的定期检查任务(实名、流量、套餐)
type Scheduler struct {
db *gorm.DB
redis *redis.Client
queueClient *asynq.Client
logger *zap.Logger
configStore *postgres.PollingConfigStore
iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore
// 配置缓存
configCache []*model.PollingConfig
configCacheLock sync.RWMutex
configCacheTime time.Time
// 初始化状态
initProgress *InitProgress
initCompleted atomic.Bool
// 控制信号
stopChan chan struct{}
wg sync.WaitGroup
}
// InitProgress 初始化进度
type InitProgress struct {
mu sync.RWMutex
TotalCards int64 `json:"total_cards"` // 总卡数
LoadedCards int64 `json:"loaded_cards"` // 已加载卡数
StartTime time.Time `json:"start_time"` // 开始时间
LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间
Status string `json:"status"` // 状态: pending, running, completed, failed
ErrorMessage string `json:"error_message"` // 错误信息
}
// SchedulerConfig 调度器配置
// 设计目标:支持一亿张卡规模
type SchedulerConfig struct {
ScheduleInterval time.Duration // 调度循环间隔(默认 1 秒,支持高吞吐)
InitBatchSize int // 初始化每批加载数量(默认 100000
InitBatchSleepDuration time.Duration // 初始化批次间休眠时间(默认 500ms
ConfigCacheTTL time.Duration // 配置缓存 TTL默认 5 分钟)
CardCacheTTL time.Duration // 卡信息缓存 TTL默认 7 天)
ScheduleBatchSize int // 每次调度取出的卡数(默认 50000
MaxManualBatchSize int // 手动触发每次处理数量(默认 1000
}
// DefaultSchedulerConfig 默认调度器配置
// 单 Worker 设计吞吐50000 张/秒,支持多 Worker 水平扩展
func DefaultSchedulerConfig() *SchedulerConfig {
return &SchedulerConfig{
ScheduleInterval: 1 * time.Second, // 1秒调度一次提高响应速度
InitBatchSize: 100000, // 10万张/批初始化
InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化
ConfigCacheTTL: 5 * time.Minute,
CardCacheTTL: 7 * 24 * time.Hour,
ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张
MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张
}
}
// NewScheduler 创建调度器实例
func NewScheduler(
db *gorm.DB,
redisClient *redis.Client,
queueClient *asynq.Client,
logger *zap.Logger,
) *Scheduler {
return &Scheduler{
db: db,
redis: redisClient,
queueClient: queueClient,
logger: logger,
configStore: postgres.NewPollingConfigStore(db),
iotCardStore: postgres.NewIotCardStore(db, redisClient),
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
initProgress: &InitProgress{
Status: "pending",
},
stopChan: make(chan struct{}),
}
}
// Start 启动调度器
// 快速启动10秒内完成配置加载和调度器启动
func (s *Scheduler) Start(ctx context.Context) error {
startTime := time.Now()
s.logger.Info("轮询调度器启动中...")
// 1. 加载轮询配置到缓存
if err := s.loadConfigs(ctx); err != nil {
s.logger.Error("加载轮询配置失败", zap.Error(err))
return err
}
s.logger.Info("轮询配置已加载", zap.Int("config_count", len(s.configCache)))
// 2. 初始化并发控制配置
if err := s.initConcurrencyConfigs(ctx); err != nil {
s.logger.Warn("初始化并发控制配置失败,使用默认值", zap.Error(err))
}
// 3. 启动调度循环(非阻塞)
s.wg.Add(1)
go s.scheduleLoop(ctx)
// 4. 启动后台渐进式初始化(非阻塞)
s.wg.Add(1)
go s.progressiveInit(ctx)
elapsed := time.Since(startTime)
s.logger.Info("轮询调度器已启动",
zap.Duration("startup_time", elapsed),
zap.Bool("fast_start", elapsed < 10*time.Second))
return nil
}
// Stop 停止调度器
func (s *Scheduler) Stop() {
s.logger.Info("正在停止轮询调度器...")
close(s.stopChan)
s.wg.Wait()
s.logger.Info("轮询调度器已停止")
}
// loadConfigs 加载轮询配置到缓存
func (s *Scheduler) loadConfigs(ctx context.Context) error {
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return err
}
s.configCacheLock.Lock()
s.configCache = configs
s.configCacheTime = time.Now()
s.configCacheLock.Unlock()
// 同步到 Redis 缓存
return s.syncConfigsToRedis(ctx, configs)
}
// syncConfigsToRedis 同步配置到 Redis
func (s *Scheduler) syncConfigsToRedis(ctx context.Context, configs []*model.PollingConfig) error {
key := constants.RedisPollingConfigsCacheKey()
// 序列化配置列表为 JSON
configData := make([]interface{}, 0, len(configs)*2)
for _, cfg := range configs {
jsonData, err := json.Marshal(cfg)
if err != nil {
s.logger.Warn("序列化轮询配置失败", zap.Uint("config_id", cfg.ID), zap.Error(err))
continue
}
configData = append(configData, cfg.ID, string(jsonData))
}
if len(configData) > 0 {
pipe := s.redis.Pipeline()
pipe.Del(ctx, key)
// 使用 HSET 存储配置
pipe.HSet(ctx, key, configData...)
pipe.Expire(ctx, key, 24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
return nil
}
// initConcurrencyConfigs 初始化并发控制配置到 Redis
func (s *Scheduler) initConcurrencyConfigs(ctx context.Context) error {
configs, err := s.concurrencyStore.List(ctx)
if err != nil {
return err
}
for _, cfg := range configs {
key := constants.RedisPollingConcurrencyConfigKey(cfg.TaskType)
if err := s.redis.Set(ctx, key, cfg.MaxConcurrency, 0).Err(); err != nil {
s.logger.Warn("设置并发配置失败",
zap.String("task_type", cfg.TaskType),
zap.Error(err))
}
}
return nil
}
// scheduleLoop 调度循环
// 每 10 秒执行一次,从 Redis Sorted Set 获取到期的卡,生成 Asynq 任务
func (s *Scheduler) scheduleLoop(ctx context.Context) {
defer s.wg.Done()
config := DefaultSchedulerConfig()
ticker := time.NewTicker(config.ScheduleInterval)
defer ticker.Stop()
s.logger.Info("调度循环已启动", zap.Duration("interval", config.ScheduleInterval))
for {
select {
case <-s.stopChan:
s.logger.Info("调度循环收到停止信号")
return
case <-ticker.C:
s.processSchedule(ctx)
}
}
}
// processSchedule 处理一次调度
func (s *Scheduler) processSchedule(ctx context.Context) {
now := time.Now().Unix()
// 1. 优先处理手动触发队列
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
// 2. 处理定时队列
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
}
// processManualQueue 处理手动触发队列
// 优化:批量读取和提交,提高吞吐
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string) {
config := DefaultSchedulerConfig()
key := constants.RedisPollingManualQueueKey(taskType)
// 批量读取手动触发任务
cardIDs := make([]string, 0, config.MaxManualBatchSize)
for i := 0; i < config.MaxManualBatchSize; i++ {
cardIDStr, err := s.redis.LPop(ctx, key).Result()
if err != nil {
if err != redis.Nil {
s.logger.Error("读取手动触发队列失败",
zap.String("task_type", taskType),
zap.Error(err))
}
break
}
cardIDs = append(cardIDs, cardIDStr)
}
// 批量提交任务
if len(cardIDs) > 0 {
s.enqueueBatch(ctx, taskType, cardIDs, true)
}
}
// processTimedQueue 处理定时队列
// 优化:支持大批量处理,每次最多取 ScheduleBatchSize 张卡
func (s *Scheduler) processTimedQueue(ctx context.Context, queueKey, taskType string, now int64) {
config := DefaultSchedulerConfig()
// 获取所有到期的卡score <= now
// 使用 ZRANGEBYSCORE 获取,每次最多取 ScheduleBatchSize 张
cardIDs, err := s.redis.ZRangeByScore(ctx, queueKey, &redis.ZRangeBy{
Min: "-inf",
Max: formatInt64(now),
Count: int64(config.ScheduleBatchSize),
}).Result()
if err != nil {
if err != redis.Nil {
s.logger.Error("读取定时队列失败",
zap.String("queue_key", queueKey),
zap.Error(err))
}
return
}
if len(cardIDs) == 0 {
return
}
// 只在数量较大时打印日志,避免日志过多
if len(cardIDs) >= 1000 {
s.logger.Info("处理定时队列",
zap.String("task_type", taskType),
zap.Int("card_count", len(cardIDs)))
}
// 移除已取出的卡(使用最后一个卡的 score 作为边界,更精确)
if err := s.redis.ZRemRangeByScore(ctx, queueKey, "-inf", formatInt64(now)).Err(); err != nil {
s.logger.Error("移除已处理的卡失败", zap.Error(err))
}
// 批量提交任务(使用 goroutine 并行提交,提高吞吐)
s.enqueueBatch(ctx, taskType, cardIDs, false)
}
// enqueueBatch 批量提交任务到 Asynq 队列
// 使用多 goroutine 并行提交,提高吞吐量
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string, isManual bool) {
if len(cardIDs) == 0 {
return
}
// 分批并行提交,每批 1000 个,最多 10 个并行
batchSize := 1000
maxParallel := 10
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i := 0; i < len(cardIDs); i += batchSize {
end := i + batchSize
if end > len(cardIDs) {
end = len(cardIDs)
}
batch := cardIDs[i:end]
wg.Add(1)
sem <- struct{}{} // 获取信号量
go func(batch []string) {
defer wg.Done()
defer func() { <-sem }() // 释放信号量
for _, cardID := range batch {
if err := s.enqueueTask(ctx, taskType, cardID, isManual); err != nil {
s.logger.Warn("提交任务失败",
zap.String("task_type", taskType),
zap.String("card_id", cardID),
zap.Error(err))
}
}
}(batch)
}
wg.Wait()
}
// enqueueTask 提交任务到 Asynq 队列
func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, isManual bool) error {
payload := map[string]interface{}{
"card_id": cardID,
"is_manual": isManual,
"timestamp": time.Now().Unix(),
}
task := asynq.NewTask(taskType, mustMarshal(payload),
asynq.MaxRetry(0), // 不重试,失败后重新入队
asynq.Timeout(30*time.Second), // 30秒超时
asynq.Queue(constants.QueueDefault),
)
_, err := s.queueClient.Enqueue(task)
return err
}
// progressiveInit 渐进式初始化
// 分批加载卡数据到 Redis每批 10 万张sleep 1 秒
func (s *Scheduler) progressiveInit(ctx context.Context) {
defer s.wg.Done()
config := DefaultSchedulerConfig()
s.initProgress.mu.Lock()
s.initProgress.Status = "running"
s.initProgress.StartTime = time.Now()
s.initProgress.mu.Unlock()
s.logger.Info("开始渐进式初始化...")
// 获取总卡数
var totalCards int64
if err := s.db.Model(&model.IotCard{}).Count(&totalCards).Error; err != nil {
s.logger.Error("获取卡总数失败", zap.Error(err))
s.setInitError(err.Error())
return
}
s.initProgress.mu.Lock()
s.initProgress.TotalCards = totalCards
s.initProgress.mu.Unlock()
s.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
// 使用游标分批加载
var lastID uint = 0
batchCount := 0
for {
select {
case <-s.stopChan:
s.logger.Info("渐进式初始化被中断")
return
default:
}
// 加载一批卡
var cards []*model.IotCard
err := s.db.WithContext(ctx).
Where("id > ?", lastID).
Order("id ASC").
Limit(config.InitBatchSize).
Find(&cards).Error
if err != nil {
s.logger.Error("加载卡数据失败", zap.Error(err))
s.setInitError(err.Error())
return
}
if len(cards) == 0 {
break
}
// 批量处理这批卡(使用 Pipeline 提高性能)
if err := s.initCardsBatch(ctx, cards); err != nil {
s.logger.Warn("批量初始化卡轮询失败", zap.Error(err))
}
lastID = cards[len(cards)-1].ID
batchCount++
s.initProgress.mu.Lock()
s.initProgress.LoadedCards += int64(len(cards))
s.initProgress.LastBatchTime = time.Now()
s.initProgress.mu.Unlock()
s.logger.Info("完成一批卡初始化",
zap.Int("batch", batchCount),
zap.Int("batch_size", len(cards)),
zap.Int64("loaded", s.initProgress.LoadedCards),
zap.Int64("total", totalCards))
// 批次间休眠,避免打爆数据库
time.Sleep(config.InitBatchSleepDuration)
}
s.initProgress.mu.Lock()
s.initProgress.Status = "completed"
s.initProgress.mu.Unlock()
s.initCompleted.Store(true)
s.logger.Info("渐进式初始化完成",
zap.Int64("total_loaded", s.initProgress.LoadedCards),
zap.Duration("duration", time.Since(s.initProgress.StartTime)))
}
// initCardsBatch 批量初始化卡的轮询
// 使用 Redis Pipeline 批量写入,大幅提高初始化性能
// 10万张卡从 ~60秒 优化到 ~5秒
func (s *Scheduler) initCardsBatch(ctx context.Context, cards []*model.IotCard) error {
if len(cards) == 0 {
return nil
}
config := DefaultSchedulerConfig()
now := time.Now()
pipe := s.redis.Pipeline()
for _, card := range cards {
// 匹配配置
cfg := s.MatchConfig(card)
if cfg == nil {
continue // 无匹配配置,不需要轮询
}
// 添加到相应的轮询队列
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueueRealnameKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueueCarddataKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueuePackageKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
// 缓存卡信息到 Redis
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"cached_at": now.Unix(),
}
pipe.HSet(ctx, cacheKey, cacheData)
pipe.Expire(ctx, cacheKey, config.CardCacheTTL)
}
// 执行 Pipeline
_, err := pipe.Exec(ctx)
return err
}
// initCardPolling 初始化单张卡的轮询(保留用于懒加载场景)
func (s *Scheduler) initCardPolling(ctx context.Context, card *model.IotCard) error {
// 匹配配置
config := s.MatchConfig(card)
if config == nil {
return nil // 无匹配配置,不需要轮询
}
now := time.Now()
// 添加到相应的轮询队列
if config.RealnameCheckInterval != nil && *config.RealnameCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *config.RealnameCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueueRealnameKey(), card.ID, nextCheck); err != nil {
return err
}
}
if config.CarddataCheckInterval != nil && *config.CarddataCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.CarddataCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueueCarddataKey(), card.ID, nextCheck); err != nil {
return err
}
}
if config.PackageCheckInterval != nil && *config.PackageCheckInterval > 0 {
// 套餐检查使用流量检查时间作为参考
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.PackageCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueuePackageKey(), card.ID, nextCheck); err != nil {
return err
}
}
// 缓存卡信息到 Redis
return s.cacheCardInfo(ctx, card, now)
}
// MatchConfig 匹配轮询配置
// 按优先级返回第一个匹配的配置
func (s *Scheduler) MatchConfig(card *model.IotCard) *model.PollingConfig {
s.configCacheLock.RLock()
defer s.configCacheLock.RUnlock()
for _, cfg := range s.configCache {
if s.matchConfigConditions(cfg, card) {
return cfg
}
}
return nil
}
// matchConfigConditions 检查卡是否匹配配置条件
func (s *Scheduler) matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool {
// 检查卡状态条件
if cfg.CardCondition != "" {
cardCondition := s.getCardCondition(card)
if cfg.CardCondition != cardCondition {
return false
}
}
// 检查卡业务类型
if cfg.CardCategory != "" {
if cfg.CardCategory != card.CardCategory {
return false
}
}
// 检查运营商
if cfg.CarrierID != nil {
if *cfg.CarrierID != card.CarrierID {
return false
}
}
return true
}
// getCardCondition 获取卡的状态条件
func (s *Scheduler) getCardCondition(card *model.IotCard) string {
// 根据卡的实名状态和激活状态确定条件
if card.RealNameStatus == 0 || card.RealNameStatus == 1 {
return "not_real_name" // 未实名
}
if card.RealNameStatus == 2 {
if card.NetworkStatus == 1 {
return "activated" // 已激活
}
return "real_name" // 已实名但未激活
}
if card.NetworkStatus == 0 {
return "suspended" // 已停用
}
return ""
}
// calculateNextCheckTime 计算下次检查时间
func (s *Scheduler) calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int) time.Time {
now := time.Now()
if lastCheckAt == nil {
// 首次检查,立即执行(加上随机抖动避免集中)
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
return now.Add(jitter)
}
// 计算下次检查时间
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
if nextCheck.Before(now) {
// 如果已过期,立即执行
return now
}
return nextCheck
}
// addToQueue 添加卡到轮询队列
func (s *Scheduler) addToQueue(ctx context.Context, queueKey string, cardID uint, nextCheck time.Time) error {
score := float64(nextCheck.Unix())
member := formatUint(cardID)
return s.redis.ZAdd(ctx, queueKey, redis.Z{
Score: score,
Member: member,
}).Err()
}
// cacheCardInfo 缓存卡信息到 Redis
func (s *Scheduler) cacheCardInfo(ctx context.Context, card *model.IotCard, cachedAt time.Time) error {
key := constants.RedisPollingCardInfoKey(card.ID)
config := DefaultSchedulerConfig()
data := map[string]interface{}{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"cached_at": cachedAt.Unix(),
}
pipe := s.redis.Pipeline()
pipe.HSet(ctx, key, data)
pipe.Expire(ctx, key, config.CardCacheTTL)
_, err := pipe.Exec(ctx)
return err
}
// setInitError 设置初始化错误
func (s *Scheduler) setInitError(msg string) {
s.initProgress.mu.Lock()
s.initProgress.Status = "failed"
s.initProgress.ErrorMessage = msg
s.initProgress.mu.Unlock()
}
// GetInitProgress 获取初始化进度
func (s *Scheduler) GetInitProgress() InitProgress {
s.initProgress.mu.RLock()
defer s.initProgress.mu.RUnlock()
return InitProgress{
TotalCards: s.initProgress.TotalCards,
LoadedCards: s.initProgress.LoadedCards,
StartTime: s.initProgress.StartTime,
LastBatchTime: s.initProgress.LastBatchTime,
Status: s.initProgress.Status,
ErrorMessage: s.initProgress.ErrorMessage,
}
}
// IsInitCompleted 检查初始化是否完成
func (s *Scheduler) IsInitCompleted() bool {
return s.initCompleted.Load()
}
// RefreshConfigs 刷新配置缓存
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
return s.loadConfigs(ctx)
}

35
internal/polling/utils.go Normal file
View File

@@ -0,0 +1,35 @@
package polling
import (
"strconv"
"github.com/bytedance/sonic"
)
// formatInt64 将 int64 转换为字符串
func formatInt64(n int64) string {
return strconv.FormatInt(n, 10)
}
// formatUint 将 uint 转换为字符串
func formatUint(n uint) string {
return strconv.FormatUint(uint64(n), 10)
}
// mustMarshal 序列化为 JSON失败时 panic
func mustMarshal(v interface{}) []byte {
data, err := sonic.Marshal(v)
if err != nil {
panic(err)
}
return data
}
// parseUint 将字符串解析为 uint
func parseUint(s string) (uint, error) {
n, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return uint(n), nil
}

View File

@@ -89,4 +89,22 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.AdminOrder != nil {
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
}
if handlers.PollingConfig != nil {
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
}
if handlers.PollingConcurrency != nil {
registerPollingConcurrencyRoutes(authGroup, handlers.PollingConcurrency, doc, basePath)
}
if handlers.PollingMonitoring != nil {
registerPollingMonitoringRoutes(authGroup, handlers.PollingMonitoring, doc, basePath)
}
if handlers.PollingAlert != nil {
registerPollingAlertRoutes(authGroup, handlers.PollingAlert, doc, basePath)
}
if handlers.PollingCleanup != nil {
registerPollingCleanupRoutes(authGroup, handlers.PollingCleanup, doc, basePath)
}
if handlers.PollingManualTrigger != nil {
registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath)
}
}

View File

@@ -0,0 +1,66 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingAlertRoutes 注册轮询告警路由
func registerPollingAlertRoutes(router fiber.Router, handler *admin.PollingAlertHandler, doc *openapi.Generator, basePath string) {
// 告警规则管理
rules := router.Group("/polling-alert-rules")
rulesPath := basePath + "/polling-alert-rules"
Register(rules, doc, rulesPath, "POST", "", handler.CreateRule, RouteSpec{
Summary: "创建轮询告警规则",
Tags: []string{"轮询管理-告警"},
Input: new(dto.CreatePollingAlertRuleReq),
Output: new(dto.PollingAlertRuleResp),
Auth: true,
})
Register(rules, doc, rulesPath, "GET", "", handler.ListRules, RouteSpec{
Summary: "获取轮询告警规则列表",
Tags: []string{"轮询管理-告警"},
Input: nil,
Output: new(dto.PollingAlertRuleListResp),
Auth: true,
})
Register(rules, doc, rulesPath, "GET", "/:id", handler.GetRule, RouteSpec{
Summary: "获取轮询告警规则详情",
Tags: []string{"轮询管理-告警"},
Input: new(dto.IDReq),
Output: new(dto.PollingAlertRuleResp),
Auth: true,
})
Register(rules, doc, rulesPath, "PUT", "/:id", handler.UpdateRule, RouteSpec{
Summary: "更新轮询告警规则",
Tags: []string{"轮询管理-告警"},
Input: new(dto.UpdatePollingAlertRuleParams),
Output: nil,
Auth: true,
})
Register(rules, doc, rulesPath, "DELETE", "/:id", handler.DeleteRule, RouteSpec{
Summary: "删除轮询告警规则",
Tags: []string{"轮询管理-告警"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
// 告警历史
historyPath := basePath + "/polling-alert-history"
Register(router, doc, historyPath, "GET", "/polling-alert-history", handler.ListHistory, RouteSpec{
Summary: "获取轮询告警历史",
Tags: []string{"轮询管理-告警"},
Input: new(dto.ListPollingAlertHistoryReq),
Output: new(dto.PollingAlertHistoryListResp),
Auth: true,
})
}

View File

@@ -0,0 +1,92 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingCleanupRoutes 注册轮询数据清理路由
func registerPollingCleanupRoutes(router fiber.Router, handler *admin.PollingCleanupHandler, doc *openapi.Generator, basePath string) {
// 清理配置管理
configs := router.Group("/data-cleanup-configs")
configsPath := basePath + "/data-cleanup-configs"
Register(configs, doc, configsPath, "POST", "", handler.CreateConfig, RouteSpec{
Summary: "创建数据清理配置",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.CreateDataCleanupConfigReq),
Output: new(dto.DataCleanupConfigResp),
Auth: true,
})
Register(configs, doc, configsPath, "GET", "", handler.ListConfigs, RouteSpec{
Summary: "获取数据清理配置列表",
Tags: []string{"轮询管理-数据清理"},
Input: nil,
Output: new(dto.DataCleanupConfigListResp),
Auth: true,
})
Register(configs, doc, configsPath, "GET", "/:id", handler.GetConfig, RouteSpec{
Summary: "获取数据清理配置详情",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.IDReq),
Output: new(dto.DataCleanupConfigResp),
Auth: true,
})
Register(configs, doc, configsPath, "PUT", "/:id", handler.UpdateConfig, RouteSpec{
Summary: "更新数据清理配置",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.UpdateDataCleanupConfigParams),
Output: nil,
Auth: true,
})
Register(configs, doc, configsPath, "DELETE", "/:id", handler.DeleteConfig, RouteSpec{
Summary: "删除数据清理配置",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
// 清理日志
logsPath := basePath + "/data-cleanup-logs"
Register(router, doc, logsPath, "GET", "/data-cleanup-logs", handler.ListLogs, RouteSpec{
Summary: "获取数据清理日志列表",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.ListDataCleanupLogReq),
Output: new(dto.DataCleanupLogListResp),
Auth: true,
})
// 清理操作
cleanupPath := basePath + "/data-cleanup"
Register(router, doc, cleanupPath+"/preview", "GET", "/data-cleanup/preview", handler.Preview, RouteSpec{
Summary: "预览待清理数据",
Tags: []string{"轮询管理-数据清理"},
Input: nil,
Output: new(dto.DataCleanupPreviewResp),
Auth: true,
})
Register(router, doc, cleanupPath+"/progress", "GET", "/data-cleanup/progress", handler.GetProgress, RouteSpec{
Summary: "获取数据清理进度",
Tags: []string{"轮询管理-数据清理"},
Input: nil,
Output: new(dto.DataCleanupProgressResp),
Auth: true,
})
Register(router, doc, cleanupPath+"/trigger", "POST", "/data-cleanup/trigger", handler.TriggerCleanup, RouteSpec{
Summary: "手动触发数据清理",
Tags: []string{"轮询管理-数据清理"},
Input: new(dto.TriggerDataCleanupReq),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,47 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingConcurrencyRoutes 注册轮询并发控制路由
func registerPollingConcurrencyRoutes(router fiber.Router, handler *admin.PollingConcurrencyHandler, doc *openapi.Generator, basePath string) {
concurrency := router.Group("/polling-concurrency")
groupPath := basePath + "/polling-concurrency"
Register(concurrency, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "获取轮询并发配置列表",
Tags: []string{"轮询管理-并发控制"},
Input: nil,
Output: new(dto.PollingConcurrencyListResp),
Auth: true,
})
Register(concurrency, doc, groupPath, "POST", "/reset", handler.Reset, RouteSpec{
Summary: "重置轮询并发计数",
Tags: []string{"轮询管理-并发控制"},
Input: new(dto.ResetPollingConcurrencyReq),
Output: nil,
Auth: true,
})
Register(concurrency, doc, groupPath, "GET", "/:task_type", handler.Get, RouteSpec{
Summary: "获取指定任务类型的并发配置",
Tags: []string{"轮询管理-并发控制"},
Input: new(dto.GetPollingConcurrencyReq),
Output: new(dto.PollingConcurrencyResp),
Auth: true,
})
Register(concurrency, doc, groupPath, "PUT", "/:task_type", handler.Update, RouteSpec{
Summary: "更新轮询并发配置",
Tags: []string{"轮询管理-并发控制"},
Input: new(dto.UpdatePollingConcurrencyReq),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,71 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingConfigRoutes 注册轮询配置管理路由
func registerPollingConfigRoutes(router fiber.Router, handler *admin.PollingConfigHandler, doc *openapi.Generator, basePath string) {
configs := router.Group("/polling-configs")
groupPath := basePath + "/polling-configs"
Register(configs, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "获取轮询配置列表",
Tags: []string{"轮询配置管理"},
Input: new(dto.PollingConfigListRequest),
Output: new(dto.PollingConfigPageResult),
Auth: true,
})
Register(configs, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建轮询配置",
Tags: []string{"轮询配置管理"},
Input: new(dto.CreatePollingConfigRequest),
Output: new(dto.PollingConfigResponse),
Auth: true,
})
Register(configs, doc, groupPath, "GET", "/enabled", handler.ListEnabled, RouteSpec{
Summary: "获取所有启用的配置",
Tags: []string{"轮询配置管理"},
Input: nil,
Output: []dto.PollingConfigResponse{},
Auth: true,
})
Register(configs, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "获取轮询配置详情",
Tags: []string{"轮询配置管理"},
Input: new(dto.IDReq),
Output: new(dto.PollingConfigResponse),
Auth: true,
})
Register(configs, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新轮询配置",
Tags: []string{"轮询配置管理"},
Input: new(dto.UpdatePollingConfigParams),
Output: new(dto.PollingConfigResponse),
Auth: true,
})
Register(configs, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除轮询配置",
Tags: []string{"轮询配置管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(configs, doc, groupPath, "PUT", "/:id/status", handler.UpdateStatus, RouteSpec{
Summary: "更新轮询配置状态",
Tags: []string{"轮询配置管理"},
Input: new(dto.UpdatePollingConfigStatusParams),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,63 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingManualTriggerRoutes 注册轮询手动触发路由
func registerPollingManualTriggerRoutes(router fiber.Router, handler *admin.PollingManualTriggerHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/polling-manual-trigger")
groupPath := basePath + "/polling-manual-trigger"
Register(group, doc, groupPath, "POST", "/single", handler.TriggerSingle, RouteSpec{
Summary: "单卡手动触发",
Tags: []string{"轮询管理-手动触发"},
Input: new(dto.TriggerSingleReq),
Output: nil,
Auth: true,
})
Register(group, doc, groupPath, "POST", "/batch", handler.TriggerBatch, RouteSpec{
Summary: "批量手动触发",
Tags: []string{"轮询管理-手动触发"},
Input: new(dto.TriggerBatchReq),
Output: new(dto.ManualTriggerLogResp),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/by-condition", handler.TriggerByCondition, RouteSpec{
Summary: "条件筛选触发",
Tags: []string{"轮询管理-手动触发"},
Input: new(dto.TriggerByConditionReq),
Output: new(dto.ManualTriggerLogResp),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/status", handler.GetStatus, RouteSpec{
Summary: "获取手动触发状态",
Tags: []string{"轮询管理-手动触发"},
Input: nil,
Output: new(dto.ManualTriggerStatusResp),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/history", handler.ListHistory, RouteSpec{
Summary: "获取手动触发历史",
Tags: []string{"轮询管理-手动触发"},
Input: new(dto.ListManualTriggerLogReq),
Output: new(dto.ManualTriggerLogListResp),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/cancel", handler.CancelTrigger, RouteSpec{
Summary: "取消手动触发任务",
Tags: []string{"轮询管理-手动触发"},
Input: new(dto.CancelTriggerReq),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,47 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingMonitoringRoutes 注册轮询监控路由
func registerPollingMonitoringRoutes(router fiber.Router, handler *admin.PollingMonitoringHandler, doc *openapi.Generator, basePath string) {
stats := router.Group("/polling-stats")
groupPath := basePath + "/polling-stats"
Register(stats, doc, groupPath, "GET", "", handler.GetOverview, RouteSpec{
Summary: "获取轮询总览统计",
Tags: []string{"轮询管理-监控"},
Input: nil,
Output: new(dto.PollingOverviewResp),
Auth: true,
})
Register(stats, doc, groupPath, "GET", "/queues", handler.GetQueueStatuses, RouteSpec{
Summary: "获取轮询队列状态",
Tags: []string{"轮询管理-监控"},
Input: nil,
Output: new(dto.PollingQueueStatusListResp),
Auth: true,
})
Register(stats, doc, groupPath, "GET", "/tasks", handler.GetTaskStatuses, RouteSpec{
Summary: "获取轮询任务统计",
Tags: []string{"轮询管理-监控"},
Input: nil,
Output: new(dto.PollingTaskStatsListResp),
Auth: true,
})
Register(stats, doc, groupPath, "GET", "/init-progress", handler.GetInitProgress, RouteSpec{
Summary: "获取轮询初始化进度",
Tags: []string{"轮询管理-监控"},
Input: nil,
Output: new(dto.PollingInitProgressResp),
Auth: true,
})
}

View File

@@ -14,6 +14,21 @@ import (
"gorm.io/gorm"
)
// PollingCallback 轮询回调接口
// 用于在卡生命周期事件发生时通知轮询调度器
type PollingCallback interface {
// OnCardCreated 单卡创建时的回调
OnCardCreated(ctx context.Context, card *model.IotCard)
// OnCardStatusChanged 卡状态变化时的回调
OnCardStatusChanged(ctx context.Context, cardID uint)
// OnCardDeleted 卡删除时的回调
OnCardDeleted(ctx context.Context, cardID uint)
// OnCardEnabled 卡启用轮询时的回调
OnCardEnabled(ctx context.Context, cardID uint)
// OnCardDisabled 卡禁用轮询时的回调
OnCardDisabled(ctx context.Context, cardID uint)
}
type Service struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
@@ -24,6 +39,7 @@ type Service struct {
packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client
logger *zap.Logger
pollingCallback PollingCallback // 轮询回调,可选
}
func New(
@@ -50,6 +66,12 @@ func New(
}
}
// SetPollingCallback 设置轮询回调
// 在应用启动时由 bootstrap 调用,注入轮询调度器
func (s *Service) SetPollingCallback(callback PollingCallback) {
s.pollingCallback = callback
}
func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIotCardRequest) (*dto.ListStandaloneIotCardResponse, error) {
page := req.Page
pageSize := req.PageSize
@@ -198,30 +220,36 @@ func (s *Service) loadSeriesNames(ctx context.Context, cards []*model.IotCard) m
func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string, seriesMap map[uint]string) *dto.StandaloneIotCardResponse {
resp := &dto.StandaloneIotCardResponse{
ID: card.ID,
ICCID: card.ICCID,
CardCategory: card.CardCategory,
CarrierID: card.CarrierID,
CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
IMSI: card.IMSI,
MSISDN: card.MSISDN,
BatchNo: card.BatchNo,
Supplier: card.Supplier,
CostPrice: card.CostPrice,
DistributePrice: card.DistributePrice,
Status: card.Status,
ShopID: card.ShopID,
ActivatedAt: card.ActivatedAt,
ActivationStatus: card.ActivationStatus,
RealNameStatus: card.RealNameStatus,
NetworkStatus: card.NetworkStatus,
DataUsageMB: card.DataUsageMB,
SeriesID: card.SeriesID,
FirstCommissionPaid: card.FirstCommissionPaid,
AccumulatedRecharge: card.AccumulatedRecharge,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
ID: card.ID,
ICCID: card.ICCID,
CardCategory: card.CardCategory,
CarrierID: card.CarrierID,
CarrierType: card.CarrierType,
CarrierName: card.CarrierName,
IMSI: card.IMSI,
MSISDN: card.MSISDN,
BatchNo: card.BatchNo,
Supplier: card.Supplier,
CostPrice: card.CostPrice,
DistributePrice: card.DistributePrice,
Status: card.Status,
ShopID: card.ShopID,
ActivatedAt: card.ActivatedAt,
ActivationStatus: card.ActivationStatus,
RealNameStatus: card.RealNameStatus,
NetworkStatus: card.NetworkStatus,
DataUsageMB: card.DataUsageMB,
CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate,
LastMonthTotalMB: card.LastMonthTotalMB,
LastDataCheckAt: card.LastDataCheckAt,
LastRealNameCheckAt: card.LastRealNameCheckAt,
EnablePolling: card.EnablePolling,
SeriesID: card.SeriesID,
FirstCommissionPaid: card.FirstCommissionPaid,
AccumulatedRecharge: card.AccumulatedRecharge,
CreatedAt: card.CreatedAt,
UpdatedAt: card.UpdatedAt,
}
if card.ShopID != nil && *card.ShopID > 0 {
@@ -325,6 +353,13 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
return nil, err
}
// 通知轮询调度器状态变化(卡被分配后可能需要重新匹配配置)
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
}
return &dto.AllocateStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
@@ -422,6 +457,13 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard
return nil, err
}
// 通知轮询调度器状态变化(卡被回收后可能需要重新匹配配置)
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
}
return &dto.RecallStandaloneCardsResponse{
TotalCount: len(cards),
SuccessCount: len(cardIDs),
@@ -733,15 +775,138 @@ func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) e
}
if card.Status != newStatus {
oldStatus := card.Status
card.Status = newStatus
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
}
s.logger.Info("同步卡状态成功",
zap.String("iccid", iccid),
zap.Int("oldStatus", card.Status),
zap.Int("oldStatus", oldStatus),
zap.Int("newStatus", newStatus),
)
// 通知轮询调度器状态变化
if s.pollingCallback != nil {
s.pollingCallback.OnCardStatusChanged(ctx, card.ID)
}
}
return nil
}
// UpdatePollingStatus 更新卡的轮询状态
// 启用或禁用卡的轮询功能
func (s *Service) UpdatePollingStatus(ctx context.Context, cardID uint, enablePolling bool) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return err
}
// 检查是否需要更新
if card.EnablePolling == enablePolling {
return nil // 状态未变化
}
// 更新数据库
card.EnablePolling = enablePolling
if err := s.iotCardStore.Update(ctx, card); err != nil {
return err
}
s.logger.Info("更新卡轮询状态",
zap.Uint("card_id", cardID),
zap.Bool("enable_polling", enablePolling),
)
// 通知轮询调度器
if s.pollingCallback != nil {
if enablePolling {
s.pollingCallback.OnCardEnabled(ctx, cardID)
} else {
s.pollingCallback.OnCardDisabled(ctx, cardID)
}
}
return nil
}
// BatchUpdatePollingStatus 批量更新卡的轮询状态
func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, enablePolling bool) error {
if len(cardIDs) == 0 {
return nil
}
// 批量更新数据库
if err := s.iotCardStore.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); err != nil {
return err
}
s.logger.Info("批量更新卡轮询状态",
zap.Int("count", len(cardIDs)),
zap.Bool("enable_polling", enablePolling),
)
// 通知轮询调度器
if s.pollingCallback != nil {
for _, cardID := range cardIDs {
if enablePolling {
s.pollingCallback.OnCardEnabled(ctx, cardID)
} else {
s.pollingCallback.OnCardDisabled(ctx, cardID)
}
}
}
return nil
}
// DeleteCard 删除卡(软删除)
func (s *Service) DeleteCard(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return err
}
// 执行软删除
if err := s.iotCardStore.Delete(ctx, cardID); err != nil {
return err
}
s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID))
// 通知轮询调度器
if s.pollingCallback != nil {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
return nil
}
// BatchDeleteCards 批量删除卡(软删除)
func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error {
if len(cardIDs) == 0 {
return nil
}
// 批量软删除
if err := s.iotCardStore.BatchDelete(ctx, cardIDs); err != nil {
return err
}
s.logger.Info("批量删除卡", zap.Int("count", len(cardIDs)))
// 通知轮询调度器
if s.pollingCallback != nil {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardDeleted(ctx, cardID)
}
}
return nil

View File

@@ -2,6 +2,7 @@ package packagepkg
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
@@ -16,20 +17,23 @@ import (
)
type Service struct {
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
packageAllocationStore *postgres.ShopPackageAllocationStore
packageStore *postgres.PackageStore
packageSeriesStore *postgres.PackageSeriesStore
packageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
}
func New(
packageStore *postgres.PackageStore,
packageSeriesStore *postgres.PackageSeriesStore,
packageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
) *Service {
return &Service{
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
packageAllocationStore: packageAllocationStore,
packageStore: packageStore,
packageSeriesStore: packageSeriesStore,
packageAllocationStore: packageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
}
}
@@ -262,8 +266,9 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
}
}
// 批量查询套餐系列
// 批量查询套餐系列(名称和配置)
seriesMap := make(map[uint]string)
seriesConfigMap := make(map[uint]*model.OneTimeCommissionConfig)
if len(seriesIDMap) > 0 {
seriesIDs := make([]uint, 0, len(seriesIDMap))
for id := range seriesIDMap {
@@ -275,19 +280,29 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
}
for _, series := range seriesList {
seriesMap[series.ID] = series.SeriesName
// 解析一次性佣金配置
if series.EnableOneTimeCommission {
config, _ := series.GetOneTimeCommissionConfig()
if config != nil {
seriesConfigMap[series.ID] = config
}
}
}
}
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
var allocationMap map[uint]*model.ShopPackageAllocation
var seriesAllocationMap map[uint]*model.ShopSeriesAllocation
if userType == constants.UserTypeAgent && shopID > 0 && len(packageIDs) > 0 {
allocationMap = s.batchGetAllocationsForShop(ctx, shopID, packageIDs)
// 批量获取店铺的系列分配
seriesAllocationMap = s.batchGetSeriesAllocationsForShop(ctx, shopID, seriesIDMap)
}
responses := make([]*dto.PackageResponse, len(packages))
for i, pkg := range packages {
resp := s.toResponseWithAllocation(pkg, allocationMap)
resp := s.toResponseWithAllocation(ctx, pkg, allocationMap, seriesAllocationMap, seriesConfigMap)
if pkg.SeriesID > 0 {
if seriesName, ok := seriesMap[pkg.SeriesID]; ok {
resp.SeriesName = &seriesName
@@ -299,6 +314,27 @@ func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto
return responses, total, nil
}
// batchGetSeriesAllocationsForShop 批量获取店铺的系列分配
func (s *Service) batchGetSeriesAllocationsForShop(ctx context.Context, shopID uint, seriesIDMap map[uint]bool) map[uint]*model.ShopSeriesAllocation {
result := make(map[uint]*model.ShopSeriesAllocation)
if len(seriesIDMap) == 0 {
return result
}
allocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, shopID)
if err != nil || len(allocations) == 0 {
return result
}
for _, alloc := range allocations {
if seriesIDMap[alloc.SeriesID] {
result[alloc.SeriesID] = alloc
}
}
return result
}
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
@@ -408,7 +444,7 @@ func (s *Service) batchGetAllocationsForShop(ctx context.Context, shopID uint, p
return allocationMap
}
func (s *Service) toResponseWithAllocation(pkg *model.Package, allocationMap map[uint]*model.ShopPackageAllocation) *dto.PackageResponse {
func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package, allocationMap map[uint]*model.ShopPackageAllocation, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) *dto.PackageResponse {
var seriesID *uint
if pkg.SeriesID > 0 {
seriesID = &pkg.SeriesID
@@ -440,5 +476,77 @@ func (s *Service) toResponseWithAllocation(pkg *model.Package, allocationMap map
}
}
// 填充返佣信息(仅代理用户可见)
if pkg.SeriesID > 0 && seriesAllocationMap != nil && seriesConfigMap != nil {
s.fillCommissionInfo(resp, pkg.SeriesID, seriesAllocationMap, seriesConfigMap)
}
return resp
}
// fillCommissionInfo 填充返佣信息到响应中
func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) {
seriesAllocation, hasAllocation := seriesAllocationMap[seriesID]
config, hasConfig := seriesConfigMap[seriesID]
if !hasAllocation || !hasConfig {
return
}
// 检查是否启用一次性佣金
if !seriesAllocation.EnableOneTimeCommission || !config.Enable {
return
}
// 设置一次性佣金金额
oneTimeAmount := seriesAllocation.OneTimeCommissionAmount
resp.OneTimeCommissionAmount = &oneTimeAmount
// 设置当前返佣比例(格式化为可读字符串)
if config.CommissionType == "fixed" {
// 固定金额模式:显示代理能拿到的金额
resp.CurrentCommissionRate = formatAmount(seriesAllocation.OneTimeCommissionAmount)
} else if config.CommissionType == "tiered" && len(config.Tiers) > 0 {
// 梯度模式:显示基础金额,并设置梯度信息
resp.CurrentCommissionRate = formatAmount(seriesAllocation.OneTimeCommissionAmount)
// 构建梯度信息
tierInfo := s.buildTierInfo(config.Tiers, seriesAllocation.OneTimeCommissionAmount)
if tierInfo != nil {
resp.TierInfo = tierInfo
}
}
}
// buildTierInfo 构建梯度返佣信息
func (s *Service) buildTierInfo(tiers []model.OneTimeCommissionTier, currentAmount int64) *dto.CommissionTierInfo {
if len(tiers) == 0 {
return nil
}
tierInfo := &dto.CommissionTierInfo{
CurrentRate: formatAmount(currentAmount),
}
// 找到下一个可达到的梯度
// 梯度按 threshold 升序排列,找到第一个 amount > currentAmount 的梯度
for _, tier := range tiers {
if tier.Amount > currentAmount {
tierInfo.NextThreshold = &tier.Threshold
nextRate := formatAmount(tier.Amount)
tierInfo.NextRate = nextRate
break
}
}
return tierInfo
}
// formatAmount 格式化金额为可读字符串(分转元)
func formatAmount(amountFen int64) string {
yuan := float64(amountFen) / 100
if yuan == float64(int64(yuan)) {
return fmt.Sprintf("%.0f元/张", yuan)
}
return fmt.Sprintf("%.2f元/张", yuan)
}

View File

@@ -25,7 +25,7 @@ func TestPackageService_Create(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -94,7 +94,7 @@ func TestPackageService_UpdateStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -162,7 +162,7 @@ func TestPackageService_UpdateShelfStatus(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -246,7 +246,7 @@ func TestPackageService_Get(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -283,7 +283,7 @@ func TestPackageService_Update(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -328,7 +328,7 @@ func TestPackageService_Delete(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -362,7 +362,7 @@ func TestPackageService_List(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -442,7 +442,7 @@ func TestPackageService_VirtualDataValidation(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
@@ -549,7 +549,7 @@ func TestPackageService_SeriesNameInResponse(t *testing.T) {
tx := testutils.NewTestTransaction(t)
packageStore := postgres.NewPackageStore(tx)
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
svc := New(packageStore, packageSeriesStore, nil)
svc := New(packageStore, packageSeriesStore, nil, nil)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,

View File

@@ -0,0 +1,506 @@
package polling
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// AlertService 告警服务
type AlertService struct {
ruleStore *postgres.PollingAlertRuleStore
historyStore *postgres.PollingAlertHistoryStore
redis *redis.Client
logger *zap.Logger
}
// NewAlertService 创建告警服务实例
func NewAlertService(
ruleStore *postgres.PollingAlertRuleStore,
historyStore *postgres.PollingAlertHistoryStore,
redis *redis.Client,
logger *zap.Logger,
) *AlertService {
return &AlertService{
ruleStore: ruleStore,
historyStore: historyStore,
redis: redis,
logger: logger,
}
}
// CreateRule 创建告警规则
func (s *AlertService) CreateRule(ctx context.Context, rule *model.PollingAlertRule) error {
// 验证参数
if rule.RuleName == "" {
return errors.New(errors.CodeInvalidParam, "规则名称不能为空")
}
if rule.MetricType == "" {
return errors.New(errors.CodeInvalidParam, "指标类型不能为空")
}
if rule.TaskType == "" {
return errors.New(errors.CodeInvalidParam, "任务类型不能为空")
}
rule.Status = 1 // 默认启用
if rule.CooldownMinutes == 0 {
rule.CooldownMinutes = 5 // 默认5分钟冷却期
}
if rule.Operator == "" {
rule.Operator = ">" // 默认大于
}
return s.ruleStore.Create(ctx, rule)
}
// GetRule 获取告警规则
func (s *AlertService) GetRule(ctx context.Context, id uint) (*model.PollingAlertRule, error) {
rule, err := s.ruleStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "告警规则不存在")
}
return rule, nil
}
// ListRules 获取告警规则列表
func (s *AlertService) ListRules(ctx context.Context) ([]*model.PollingAlertRule, error) {
return s.ruleStore.List(ctx)
}
// UpdateRule 更新告警规则
func (s *AlertService) UpdateRule(ctx context.Context, id uint, updates map[string]interface{}) error {
rule, err := s.ruleStore.GetByID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在")
}
if name, ok := updates["rule_name"].(string); ok && name != "" {
rule.RuleName = name
}
if threshold, ok := updates["threshold"].(float64); ok {
rule.Threshold = threshold
}
if level, ok := updates["alert_level"].(string); ok {
rule.AlertLevel = level
}
if status, ok := updates["status"].(int); ok {
rule.Status = int16(status)
}
if cooldown, ok := updates["cooldown_minutes"].(int); ok {
rule.CooldownMinutes = cooldown
}
if channels, ok := updates["notification_channels"].(string); ok {
rule.NotificationChannels = channels
}
return s.ruleStore.Update(ctx, rule)
}
// DeleteRule 删除告警规则
func (s *AlertService) DeleteRule(ctx context.Context, id uint) error {
_, err := s.ruleStore.GetByID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在")
}
return s.ruleStore.Delete(ctx, id)
}
// ListHistory 获取告警历史
func (s *AlertService) ListHistory(ctx context.Context, page, pageSize int, ruleID *uint) ([]*model.PollingAlertHistory, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return s.historyStore.List(ctx, page, pageSize, ruleID)
}
// CheckAlerts 检查告警(定时调用)
func (s *AlertService) CheckAlerts(ctx context.Context) error {
rules, err := s.ruleStore.ListEnabled(ctx)
if err != nil {
return err
}
for _, rule := range rules {
if err := s.checkRule(ctx, rule); err != nil {
s.logger.Warn("检查告警规则失败",
zap.Uint("rule_id", rule.ID),
zap.String("rule_name", rule.RuleName),
zap.Error(err))
}
}
return nil
}
// checkRule 检查单个规则
func (s *AlertService) checkRule(ctx context.Context, rule *model.PollingAlertRule) error {
// 检查冷却期
if s.isInCooldown(ctx, rule) {
return nil
}
// 获取当前指标值
currentValue, err := s.getMetricValue(ctx, rule.TaskType, rule.MetricType)
if err != nil {
return err
}
// 判断是否触发告警
triggered := false
switch rule.Operator {
case ">":
triggered = currentValue > rule.Threshold
case ">=":
triggered = currentValue >= rule.Threshold
case "<":
triggered = currentValue < rule.Threshold
case "<=":
triggered = currentValue <= rule.Threshold
case "==":
triggered = currentValue == rule.Threshold
default:
triggered = currentValue > rule.Threshold
}
if triggered {
return s.triggerAlert(ctx, rule, currentValue)
}
return nil
}
// isInCooldown 检查是否在冷却期
func (s *AlertService) isInCooldown(ctx context.Context, rule *model.PollingAlertRule) bool {
if rule.CooldownMinutes <= 0 {
return false
}
history, err := s.historyStore.GetLatestByRuleID(ctx, rule.ID)
if err != nil {
return false // 没有历史记录,不在冷却期
}
cooldownEnd := history.CreatedAt.Add(time.Duration(rule.CooldownMinutes) * time.Minute)
return time.Now().Before(cooldownEnd)
}
// getMetricValue 获取指标值
func (s *AlertService) getMetricValue(ctx context.Context, taskType, metricType string) (float64, error) {
statsKey := constants.RedisPollingStatsKey(taskType)
data, err := s.redis.HGetAll(ctx, statsKey).Result()
if err != nil {
return 0, err
}
switch metricType {
case "queue_size":
// 获取队列大小
var queueKey string
switch taskType {
case constants.TaskTypePollingRealname:
queueKey = constants.RedisPollingQueueRealnameKey()
case constants.TaskTypePollingCarddata:
queueKey = constants.RedisPollingQueueCarddataKey()
case constants.TaskTypePollingPackage:
queueKey = constants.RedisPollingQueuePackageKey()
}
size, _ := s.redis.ZCard(ctx, queueKey).Result()
return float64(size), nil
case "success_rate":
success := parseInt64(data["success_count_1h"])
failure := parseInt64(data["failure_count_1h"])
total := success + failure
if total == 0 {
return 100, nil // 无数据时认为成功率 100%
}
return float64(success) / float64(total) * 100, nil
case "avg_duration":
success := parseInt64(data["success_count_1h"])
failure := parseInt64(data["failure_count_1h"])
total := success + failure
duration := parseInt64(data["total_duration_1h"])
if total == 0 {
return 0, nil
}
return float64(duration) / float64(total), nil
case "concurrency":
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
current, _ := s.redis.Get(ctx, currentKey).Int64()
return float64(current), nil
default:
return 0, errors.New(errors.CodeInvalidParam, "未知的指标类型")
}
}
// triggerAlert 触发告警
func (s *AlertService) triggerAlert(ctx context.Context, rule *model.PollingAlertRule, currentValue float64) error {
// 创建告警历史记录
alertMessage := s.buildAlertMessage(rule, currentValue)
history := &model.PollingAlertHistory{
RuleID: rule.ID,
TaskType: rule.TaskType,
MetricType: rule.MetricType,
AlertLevel: rule.AlertLevel,
Threshold: rule.Threshold,
CurrentValue: currentValue,
AlertMessage: alertMessage,
NotificationChannels: rule.NotificationChannels,
NotificationStatus: "pending",
}
if err := s.historyStore.Create(ctx, history); err != nil {
return err
}
s.logger.Warn("触发告警",
zap.Uint("rule_id", rule.ID),
zap.String("rule_name", rule.RuleName),
zap.String("task_type", rule.TaskType),
zap.String("metric_type", rule.MetricType),
zap.String("level", rule.AlertLevel),
zap.Float64("threshold", rule.Threshold),
zap.Float64("current_value", currentValue))
// 发送通知邮件、短信、Webhook 等)
s.sendNotifications(ctx, rule, history, alertMessage)
return nil
}
// sendNotifications 发送告警通知到配置的渠道
func (s *AlertService) sendNotifications(ctx context.Context, rule *model.PollingAlertRule, history *model.PollingAlertHistory, message string) {
channels := parseNotificationChannels(rule.NotificationChannels)
if len(channels) == 0 {
s.logger.Debug("未配置通知渠道,跳过通知发送", zap.Uint("rule_id", rule.ID))
return
}
var wg sync.WaitGroup
var successCount, failCount int
var mu sync.Mutex
for _, channel := range channels {
wg.Add(1)
go func(ch string) {
defer wg.Done()
var err error
switch ch {
case "email":
err = s.sendEmailNotification(ctx, rule, message)
case "sms":
err = s.sendSMSNotification(ctx, rule, message)
case "webhook":
err = s.sendWebhookNotification(ctx, rule, history)
default:
s.logger.Warn("未知的通知渠道", zap.String("channel", ch))
return
}
mu.Lock()
if err != nil {
failCount++
s.logger.Error("发送通知失败",
zap.String("channel", ch),
zap.Uint("rule_id", rule.ID),
zap.Error(err))
} else {
successCount++
s.logger.Info("发送通知成功",
zap.String("channel", ch),
zap.Uint("rule_id", rule.ID))
}
mu.Unlock()
}(channel)
}
wg.Wait()
// 更新通知状态
var status string
if successCount > 0 && failCount == 0 {
status = "sent"
} else if successCount > 0 {
status = "partial"
} else {
status = "failed"
}
if err := s.historyStore.UpdateNotificationStatus(ctx, history.ID, status); err != nil {
s.logger.Warn("更新通知状态失败", zap.Uint("history_id", history.ID), zap.Error(err))
}
}
// parseNotificationChannels 解析通知渠道配置
// 格式: "email,sms,webhook" 或 JSON 数组
func parseNotificationChannels(channels string) []string {
if channels == "" {
return nil
}
// 尝试解析为 JSON 数组
var result []string
if err := sonic.UnmarshalString(channels, &result); err == nil {
return result
}
// 按逗号分割
parts := strings.Split(channels, ",")
result = make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}
// getWebhookURLFromConfig 从配置中解析 Webhook URL
// 配置格式: {"webhook_url": "https://example.com/webhook"}
func getWebhookURLFromConfig(config string) string {
if config == "" {
return ""
}
var cfg map[string]any
if err := sonic.UnmarshalString(config, &cfg); err != nil {
return ""
}
if url, ok := cfg["webhook_url"].(string); ok {
return url
}
return ""
}
// sendEmailNotification 发送邮件通知
func (s *AlertService) sendEmailNotification(_ context.Context, rule *model.PollingAlertRule, message string) error {
// TODO: 集成邮件服务
// 当前仅记录日志,实际发送需要配置 SMTP 服务
s.logger.Info("邮件通知(待实现)",
zap.Uint("rule_id", rule.ID),
zap.String("message", message))
return nil
}
// sendSMSNotification 发送短信通知
func (s *AlertService) sendSMSNotification(_ context.Context, rule *model.PollingAlertRule, message string) error {
// TODO: 集成短信服务
// 当前仅记录日志,实际发送需要配置短信网关
s.logger.Info("短信通知(待实现)",
zap.Uint("rule_id", rule.ID),
zap.String("message", message))
return nil
}
// sendWebhookNotification 发送 Webhook 通知
func (s *AlertService) sendWebhookNotification(ctx context.Context, rule *model.PollingAlertRule, history *model.PollingAlertHistory) error {
// 从规则配置中获取 Webhook URL
webhookURL := getWebhookURLFromConfig(rule.NotificationConfig)
if webhookURL == "" {
s.logger.Debug("未配置 Webhook URL跳过发送", zap.Uint("rule_id", rule.ID))
return nil
}
// 构建告警数据
payload := map[string]any{
"rule_id": rule.ID,
"rule_name": rule.RuleName,
"task_type": rule.TaskType,
"metric_type": rule.MetricType,
"alert_level": rule.AlertLevel,
"threshold": rule.Threshold,
"current_value": history.CurrentValue,
"message": history.AlertMessage,
"triggered_at": time.Now().Format(time.RFC3339),
}
jsonData, err := sonic.Marshal(payload)
if err != nil {
return fmt.Errorf("序列化告警数据失败: %w", err)
}
// 发送 HTTP POST 请求
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Webhook 返回错误状态码: %d", resp.StatusCode)
}
s.logger.Info("Webhook 通知发送成功",
zap.Uint("rule_id", rule.ID),
zap.String("url", webhookURL),
zap.Int("status_code", resp.StatusCode))
return nil
}
// buildAlertMessage 构建告警消息
func (s *AlertService) buildAlertMessage(rule *model.PollingAlertRule, currentValue float64) string {
taskTypeName := s.getTaskTypeName(rule.TaskType)
metricTypeName := s.getMetricTypeName(rule.MetricType)
return taskTypeName + "的" + metricTypeName + "已触发告警: " +
"当前值 " + formatFloat(currentValue) + ", 阈值 " + formatFloat(rule.Threshold)
}
func (s *AlertService) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}
func (s *AlertService) getMetricTypeName(metricType string) string {
switch metricType {
case "queue_size":
return "队列积压"
case "success_rate":
return "成功率"
case "avg_duration":
return "平均耗时"
case "concurrency":
return "并发数"
default:
return metricType
}
}
func formatFloat(f float64) string {
// 简单格式化保留2位小数
return string(rune(int(f))) + "." + string(rune(int(f*100)%100))
}

View File

@@ -0,0 +1,347 @@
package polling
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CleanupService 数据清理服务
type CleanupService struct {
configStore *postgres.DataCleanupConfigStore
logStore *postgres.DataCleanupLogStore
logger *zap.Logger
mu sync.Mutex // 防止并发清理
isRunning bool
}
// NewCleanupService 创建数据清理服务实例
func NewCleanupService(
configStore *postgres.DataCleanupConfigStore,
logStore *postgres.DataCleanupLogStore,
logger *zap.Logger,
) *CleanupService {
return &CleanupService{
configStore: configStore,
logStore: logStore,
logger: logger,
}
}
// CreateConfig 创建清理配置
func (s *CleanupService) CreateConfig(ctx context.Context, config *model.DataCleanupConfig) error {
if config.TargetTable == "" {
return errors.New(errors.CodeInvalidParam, "表名不能为空")
}
if config.RetentionDays < 7 {
return errors.New(errors.CodeInvalidParam, "保留天数不能少于7天")
}
if config.BatchSize <= 0 {
config.BatchSize = 10000 // 默认每批1万条
}
config.Enabled = 1 // 默认启用
return s.configStore.Create(ctx, config)
}
// GetConfig 获取清理配置
func (s *CleanupService) GetConfig(ctx context.Context, id uint) (*model.DataCleanupConfig, error) {
config, err := s.configStore.GetByID(ctx, id)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "清理配置不存在")
}
return config, nil
}
// ListConfigs 获取所有清理配置
func (s *CleanupService) ListConfigs(ctx context.Context) ([]*model.DataCleanupConfig, error) {
return s.configStore.List(ctx)
}
// UpdateConfig 更新清理配置
func (s *CleanupService) UpdateConfig(ctx context.Context, id uint, updates map[string]any) error {
config, err := s.configStore.GetByID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "清理配置不存在")
}
if retentionDays, ok := updates["retention_days"].(int); ok {
if retentionDays < 7 {
return errors.New(errors.CodeInvalidParam, "保留天数不能少于7天")
}
config.RetentionDays = retentionDays
}
if batchSize, ok := updates["batch_size"].(int); ok {
if batchSize > 0 {
config.BatchSize = batchSize
}
}
if enabled, ok := updates["enabled"].(int); ok {
config.Enabled = int16(enabled)
}
if desc, ok := updates["description"].(string); ok {
config.Description = desc
}
if updatedBy, ok := updates["updated_by"].(uint); ok {
config.UpdatedBy = &updatedBy
}
return s.configStore.Update(ctx, config)
}
// DeleteConfig 删除清理配置
func (s *CleanupService) DeleteConfig(ctx context.Context, id uint) error {
_, err := s.configStore.GetByID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "清理配置不存在")
}
return s.configStore.Delete(ctx, id)
}
// ListLogs 获取清理日志列表
func (s *CleanupService) ListLogs(ctx context.Context, page, pageSize int, tableName string) ([]*model.DataCleanupLog, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return s.logStore.List(ctx, page, pageSize, tableName)
}
// CleanupPreview 清理预览
type CleanupPreview struct {
TableName string `json:"table_name"`
RetentionDays int `json:"retention_days"`
RecordCount int64 `json:"record_count"`
Description string `json:"description"`
}
// Preview 预览待清理数据
func (s *CleanupService) Preview(ctx context.Context) ([]*CleanupPreview, error) {
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return nil, err
}
previews := make([]*CleanupPreview, 0, len(configs))
for _, config := range configs {
count, err := s.logStore.CountOldRecords(ctx, config.TargetTable, config.RetentionDays)
if err != nil {
s.logger.Warn("预览清理数据失败",
zap.String("table", config.TargetTable),
zap.Error(err))
continue
}
previews = append(previews, &CleanupPreview{
TableName: config.TargetTable,
RetentionDays: config.RetentionDays,
RecordCount: count,
Description: config.Description,
})
}
return previews, nil
}
// CleanupProgress 清理进度
type CleanupProgress struct {
IsRunning bool `json:"is_running"`
CurrentTable string `json:"current_table,omitempty"`
TotalTables int `json:"total_tables"`
ProcessedTables int `json:"processed_tables"`
TotalDeleted int64 `json:"total_deleted"`
StartedAt *time.Time `json:"started_at,omitempty"`
LastLog *model.DataCleanupLog `json:"last_log,omitempty"`
}
// GetProgress 获取清理进度
func (s *CleanupService) GetProgress(ctx context.Context) (*CleanupProgress, error) {
s.mu.Lock()
isRunning := s.isRunning
s.mu.Unlock()
// 获取最近的清理日志
logs, _, err := s.logStore.List(ctx, 1, 1, "")
if err != nil {
return nil, err
}
progress := &CleanupProgress{
IsRunning: isRunning,
}
if len(logs) > 0 {
progress.LastLog = logs[0]
if logs[0].Status == "running" {
progress.CurrentTable = logs[0].TargetTable
progress.StartedAt = &logs[0].StartedAt
}
}
return progress, nil
}
// TriggerCleanup 手动触发清理
func (s *CleanupService) TriggerCleanup(ctx context.Context, tableName string, triggeredBy uint) error {
s.mu.Lock()
if s.isRunning {
s.mu.Unlock()
return errors.New(errors.CodeInvalidParam, "清理任务正在运行中")
}
s.isRunning = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
}()
var configs []*model.DataCleanupConfig
var err error
if tableName != "" {
// 清理指定表
config, err := s.configStore.GetByTableName(ctx, tableName)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "清理配置不存在")
}
configs = []*model.DataCleanupConfig{config}
} else {
// 清理所有启用的表
configs, err = s.configStore.ListEnabled(ctx)
if err != nil {
return err
}
}
for _, config := range configs {
if err := s.cleanupTable(ctx, config, "manual", &triggeredBy); err != nil {
s.logger.Error("清理表失败",
zap.String("table", config.TargetTable),
zap.Error(err))
// 继续处理其他表
}
}
return nil
}
// RunScheduledCleanup 运行定时清理任务
func (s *CleanupService) RunScheduledCleanup(ctx context.Context) error {
s.mu.Lock()
if s.isRunning {
s.mu.Unlock()
s.logger.Info("清理任务正在运行中,跳过本次调度")
return nil
}
s.isRunning = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.isRunning = false
s.mu.Unlock()
}()
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return err
}
s.logger.Info("开始定时清理任务", zap.Int("config_count", len(configs)))
for _, config := range configs {
if err := s.cleanupTable(ctx, config, "scheduled", nil); err != nil {
s.logger.Error("定时清理表失败",
zap.String("table", config.TargetTable),
zap.Error(err))
// 继续处理其他表
}
}
s.logger.Info("定时清理任务完成")
return nil
}
// cleanupTable 清理指定表
func (s *CleanupService) cleanupTable(ctx context.Context, config *model.DataCleanupConfig, cleanupType string, triggeredBy *uint) error {
startTime := time.Now()
// 创建清理日志
log := &model.DataCleanupLog{
TargetTable: config.TargetTable,
CleanupType: cleanupType,
RetentionDays: config.RetentionDays,
Status: "running",
StartedAt: startTime,
TriggeredBy: triggeredBy,
}
if err := s.logStore.Create(ctx, log); err != nil {
return err
}
var totalDeleted int64
var lastErr error
// 分批删除
cleanupLoop:
for {
deleted, err := s.logStore.DeleteOldRecords(ctx, config.TargetTable, config.RetentionDays, config.BatchSize)
if err != nil {
lastErr = err
break
}
totalDeleted += deleted
s.logger.Debug("清理进度",
zap.String("table", config.TargetTable),
zap.Int64("batch_deleted", deleted),
zap.Int64("total_deleted", totalDeleted))
if deleted < int64(config.BatchSize) {
// 没有更多数据需要删除
break
}
// 检查 context 是否已取消
select {
case <-ctx.Done():
lastErr = ctx.Err()
break cleanupLoop
default:
}
}
// 更新清理日志
endTime := time.Now()
log.CompletedAt = &endTime
log.DeletedCount = totalDeleted
log.DurationMs = endTime.Sub(startTime).Milliseconds()
if lastErr != nil {
log.Status = "failed"
log.ErrorMessage = lastErr.Error()
} else {
log.Status = "success"
}
if err := s.logStore.Update(ctx, log); err != nil {
s.logger.Error("更新清理日志失败", zap.Error(err))
}
s.logger.Info("清理表完成",
zap.String("table", config.TargetTable),
zap.Int64("deleted_count", totalDeleted),
zap.Int64("duration_ms", log.DurationMs),
zap.String("status", log.Status))
return lastErr
}

View File

@@ -0,0 +1,188 @@
package polling
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
)
// ConcurrencyService 并发控制服务
type ConcurrencyService struct {
store *postgres.PollingConcurrencyConfigStore
redis *redis.Client
}
// NewConcurrencyService 创建并发控制服务实例
func NewConcurrencyService(store *postgres.PollingConcurrencyConfigStore, redis *redis.Client) *ConcurrencyService {
return &ConcurrencyService{
store: store,
redis: redis,
}
}
// ConcurrencyStatus 并发状态
type ConcurrencyStatus struct {
TaskType string `json:"task_type"`
TaskTypeName string `json:"task_type_name"`
MaxConcurrency int `json:"max_concurrency"`
Current int64 `json:"current"`
Available int64 `json:"available"`
Utilization float64 `json:"utilization"`
}
// List 获取所有并发控制配置及当前状态
func (s *ConcurrencyService) List(ctx context.Context) ([]*ConcurrencyStatus, error) {
configs, err := s.store.List(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "获取并发配置列表失败")
}
result := make([]*ConcurrencyStatus, 0, len(configs))
for _, cfg := range configs {
status := &ConcurrencyStatus{
TaskType: cfg.TaskType,
TaskTypeName: s.getTaskTypeName(cfg.TaskType),
MaxConcurrency: cfg.MaxConcurrency,
}
// 从 Redis 获取当前并发数
currentKey := constants.RedisPollingConcurrencyCurrentKey(cfg.TaskType)
current, err := s.redis.Get(ctx, currentKey).Int64()
if err != nil && err != redis.Nil {
current = 0
}
status.Current = current
status.Available = int64(cfg.MaxConcurrency) - current
if status.Available < 0 {
status.Available = 0
}
if cfg.MaxConcurrency > 0 {
status.Utilization = float64(current) / float64(cfg.MaxConcurrency) * 100
}
result = append(result, status)
}
return result, nil
}
// GetByTaskType 根据任务类型获取并发配置及状态
func (s *ConcurrencyService) GetByTaskType(ctx context.Context, taskType string) (*ConcurrencyStatus, error) {
cfg, err := s.store.GetByTaskType(ctx, taskType)
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "并发配置不存在")
}
status := &ConcurrencyStatus{
TaskType: cfg.TaskType,
TaskTypeName: s.getTaskTypeName(cfg.TaskType),
MaxConcurrency: cfg.MaxConcurrency,
}
// 从 Redis 获取当前并发数
currentKey := constants.RedisPollingConcurrencyCurrentKey(cfg.TaskType)
current, err := s.redis.Get(ctx, currentKey).Int64()
if err != nil && err != redis.Nil {
current = 0
}
status.Current = current
status.Available = int64(cfg.MaxConcurrency) - current
if status.Available < 0 {
status.Available = 0
}
if cfg.MaxConcurrency > 0 {
status.Utilization = float64(current) / float64(cfg.MaxConcurrency) * 100
}
return status, nil
}
// UpdateMaxConcurrency 更新最大并发数
func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType string, maxConcurrency int, updatedBy uint) error {
// 验证参数
if maxConcurrency < 1 || maxConcurrency > 1000 {
return errors.New(errors.CodeInvalidParam, "并发数必须在 1-1000 之间")
}
// 验证任务类型存在
_, err := s.store.GetByTaskType(ctx, taskType)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在")
}
// 更新数据库
if err := s.store.UpdateMaxConcurrency(ctx, taskType, maxConcurrency, updatedBy); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新并发配置失败")
}
// 同步更新 Redis 配置缓存
configKey := constants.RedisPollingConcurrencyConfigKey(taskType)
if err := s.redis.Set(ctx, configKey, maxConcurrency, 24*time.Hour).Err(); err != nil {
// Redis 更新失败不影响主流程,下次读取会从数据库重新加载
}
return nil
}
// ResetConcurrency 重置并发计数(用于信号量修复)
func (s *ConcurrencyService) ResetConcurrency(ctx context.Context, taskType string) error {
// 验证任务类型存在
_, err := s.store.GetByTaskType(ctx, taskType)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在")
}
// 重置 Redis 当前计数为 0
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
if err := s.redis.Set(ctx, currentKey, 0, 24*time.Hour).Err(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "重置并发计数失败")
}
return nil
}
// InitFromDB 从数据库初始化 Redis 并发配置
func (s *ConcurrencyService) InitFromDB(ctx context.Context) error {
configs, err := s.store.List(ctx)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "获取并发配置失败")
}
for _, cfg := range configs {
configKey := constants.RedisPollingConcurrencyConfigKey(cfg.TaskType)
if err := s.redis.Set(ctx, configKey, cfg.MaxConcurrency, 24*time.Hour).Err(); err != nil {
// 忽略单个配置同步失败
continue
}
}
return nil
}
// SyncConfigToRedis 同步单个配置到 Redis
func (s *ConcurrencyService) SyncConfigToRedis(ctx context.Context, config *model.PollingConcurrencyConfig) error {
configKey := constants.RedisPollingConcurrencyConfigKey(config.TaskType)
return s.redis.Set(ctx, configKey, config.MaxConcurrency, 24*time.Hour).Err()
}
// getTaskTypeName 获取任务类型的中文名称
func (s *ConcurrencyService) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}

View File

@@ -0,0 +1,252 @@
package polling
import (
"context"
"time"
"gorm.io/gorm"
"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"
"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/middleware"
)
// ConfigService 轮询配置服务
type ConfigService struct {
configStore *postgres.PollingConfigStore
}
// NewConfigService 创建轮询配置服务实例
func NewConfigService(configStore *postgres.PollingConfigStore) *ConfigService {
return &ConfigService{configStore: configStore}
}
// Create 创建轮询配置
func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfigRequest) (*dto.PollingConfigResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 验证配置名称唯一性
existing, _ := s.configStore.GetByName(ctx, req.ConfigName)
if existing != nil {
return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在")
}
// 验证检查间隔(至少一个不为空)
if req.RealnameCheckInterval == nil && req.CarddataCheckInterval == nil && req.PackageCheckInterval == nil {
return nil, errors.New(errors.CodeInvalidParam, "至少需要配置一种检查间隔")
}
config := &model.PollingConfig{
ConfigName: req.ConfigName,
CardCondition: req.CardCondition,
CardCategory: req.CardCategory,
CarrierID: req.CarrierID,
Priority: req.Priority,
RealnameCheckInterval: req.RealnameCheckInterval,
CarddataCheckInterval: req.CarddataCheckInterval,
PackageCheckInterval: req.PackageCheckInterval,
Status: 1, // 默认启用
Description: req.Description,
CreatedBy: &currentUserID,
UpdatedBy: &currentUserID,
}
if err := s.configStore.Create(ctx, config); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建轮询配置失败")
}
return s.toResponse(config), nil
}
// Get 获取轮询配置详情
func (s *ConfigService) Get(ctx context.Context, id uint) (*dto.PollingConfigResponse, error) {
config, err := s.configStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
}
return s.toResponse(config), nil
}
// Update 更新轮询配置
func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePollingConfigRequest) (*dto.PollingConfigResponse, error) {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
config, err := s.configStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
}
// 更新字段
if req.ConfigName != nil {
// 检查名称唯一性
existing, _ := s.configStore.GetByName(ctx, *req.ConfigName)
if existing != nil && existing.ID != id {
return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在")
}
config.ConfigName = *req.ConfigName
}
if req.CardCondition != nil {
config.CardCondition = *req.CardCondition
}
if req.CardCategory != nil {
config.CardCategory = *req.CardCategory
}
if req.CarrierID != nil {
config.CarrierID = req.CarrierID
}
if req.Priority != nil {
config.Priority = *req.Priority
}
if req.RealnameCheckInterval != nil {
config.RealnameCheckInterval = req.RealnameCheckInterval
}
if req.CarddataCheckInterval != nil {
config.CarddataCheckInterval = req.CarddataCheckInterval
}
if req.PackageCheckInterval != nil {
config.PackageCheckInterval = req.PackageCheckInterval
}
if req.Description != nil {
config.Description = *req.Description
}
config.UpdatedBy = &currentUserID
if err := s.configStore.Update(ctx, config); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新轮询配置失败")
}
return s.toResponse(config), nil
}
// Delete 删除轮询配置
func (s *ConfigService) Delete(ctx context.Context, id uint) error {
_, err := s.configStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
}
if err := s.configStore.Delete(ctx, id); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "删除轮询配置失败")
}
return nil
}
// List 列表查询轮询配置
func (s *ConfigService) List(ctx context.Context, req *dto.PollingConfigListRequest) ([]*dto.PollingConfigResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "priority ASC, id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.Status != nil {
filters["status"] = *req.Status
}
if req.CardCondition != nil {
filters["card_condition"] = *req.CardCondition
}
if req.CardCategory != nil {
filters["card_category"] = *req.CardCategory
}
if req.CarrierID != nil {
filters["carrier_id"] = *req.CarrierID
}
if req.ConfigName != nil {
filters["config_name"] = *req.ConfigName
}
configs, total, err := s.configStore.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询轮询配置列表失败")
}
responses := make([]*dto.PollingConfigResponse, len(configs))
for i, c := range configs {
responses[i] = s.toResponse(c)
}
return responses, total, nil
}
// UpdateStatus 更新配置状态(启用/禁用)
func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
_, err := s.configStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败")
}
if err := s.configStore.UpdateStatus(ctx, id, status, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新轮询配置状态失败")
}
return nil
}
// ListEnabled 获取所有启用的配置
func (s *ConfigService) ListEnabled(ctx context.Context) ([]*dto.PollingConfigResponse, error) {
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "获取启用配置失败")
}
responses := make([]*dto.PollingConfigResponse, len(configs))
for i, c := range configs {
responses[i] = s.toResponse(c)
}
return responses, nil
}
// toResponse 转换为响应 DTO
func (s *ConfigService) toResponse(c *model.PollingConfig) *dto.PollingConfigResponse {
return &dto.PollingConfigResponse{
ID: c.ID,
ConfigName: c.ConfigName,
CardCondition: c.CardCondition,
CardCategory: c.CardCategory,
CarrierID: c.CarrierID,
Priority: c.Priority,
RealnameCheckInterval: c.RealnameCheckInterval,
CarddataCheckInterval: c.CarddataCheckInterval,
PackageCheckInterval: c.PackageCheckInterval,
Status: c.Status,
Description: c.Description,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -0,0 +1,477 @@
package polling
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"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/middleware"
)
// ManualTriggerService 手动触发服务
type ManualTriggerService struct {
logStore *postgres.PollingManualTriggerLogStore
iotCardStore *postgres.IotCardStore
shopStore middleware.ShopStoreInterface
redis *redis.Client
logger *zap.Logger
}
// NewManualTriggerService 创建手动触发服务实例
func NewManualTriggerService(
logStore *postgres.PollingManualTriggerLogStore,
iotCardStore *postgres.IotCardStore,
shopStore middleware.ShopStoreInterface,
redis *redis.Client,
logger *zap.Logger,
) *ManualTriggerService {
return &ManualTriggerService{
logStore: logStore,
iotCardStore: iotCardStore,
shopStore: shopStore,
redis: redis,
logger: logger,
}
}
// TriggerSingle 单卡手动触发
func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, taskType string, triggeredBy uint) error {
// 验证任务类型
if !isValidTaskType(taskType) {
return errors.New(errors.CodeInvalidParam, "无效的任务类型")
}
// 权限验证:检查用户是否有权管理该卡
if err := s.canManageCard(ctx, cardID); err != nil {
return err
}
// 检查每日触发限制
todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy)
if err != nil {
return err
}
if todayCount >= 100 { // 每日最多触发100次
return errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
}
// 检查去重
dedupeKey := constants.RedisPollingManualDedupeKey(taskType)
added, err := s.redis.SAdd(ctx, dedupeKey, cardID).Result()
if err != nil {
return err
}
if added == 0 {
return errors.New(errors.CodeInvalidParam, "该卡已在手动触发队列中")
}
// 设置去重 key 过期时间1小时
s.redis.Expire(ctx, dedupeKey, time.Hour)
// 创建触发日志
cardIDsJSON, _ := json.Marshal([]uint{cardID})
triggerLog := &model.PollingManualTriggerLog{
TaskType: taskType,
TriggerType: "single",
CardIDs: string(cardIDsJSON),
TotalCount: 1,
Status: "processing",
TriggeredBy: triggeredBy,
TriggeredAt: time.Now(),
}
if err := s.logStore.Create(ctx, triggerLog); err != nil {
return err
}
// 加入手动触发队列(使用 List优先级高于定时轮询
queueKey := constants.RedisPollingManualQueueKey(taskType)
if err := s.redis.LPush(ctx, queueKey, cardID).Err(); err != nil {
return err
}
// 更新日志状态
_ = s.logStore.UpdateProgress(ctx, triggerLog.ID, 1, 1, 0)
_ = s.logStore.UpdateStatus(ctx, triggerLog.ID, "completed")
s.logger.Info("单卡手动触发成功",
zap.Uint("card_id", cardID),
zap.String("task_type", taskType),
zap.Uint("triggered_by", triggeredBy))
return nil
}
// TriggerBatch 批量手动触发
func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint, taskType string, triggeredBy uint) (*model.PollingManualTriggerLog, error) {
// 验证任务类型
if !isValidTaskType(taskType) {
return nil, errors.New(errors.CodeInvalidParam, "无效的任务类型")
}
// 单次最多1000张卡
if len(cardIDs) > 1000 {
return nil, errors.New(errors.CodeInvalidParam, "单次最多触发1000张卡")
}
// 权限验证:检查用户是否有权管理所有卡
if err := s.canManageCards(ctx, cardIDs); err != nil {
return nil, err
}
// 检查每日触发限制
todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy)
if err != nil {
return nil, err
}
if todayCount >= 100 {
return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
}
// 创建触发日志
cardIDsJSON, _ := json.Marshal(cardIDs)
triggerLog := &model.PollingManualTriggerLog{
TaskType: taskType,
TriggerType: "batch",
CardIDs: string(cardIDsJSON),
TotalCount: len(cardIDs),
Status: "processing",
TriggeredBy: triggeredBy,
TriggeredAt: time.Now(),
}
if err := s.logStore.Create(ctx, triggerLog); err != nil {
return nil, err
}
// 异步处理批量触发
go s.processBatchTrigger(context.Background(), triggerLog.ID, cardIDs, taskType)
return triggerLog, nil
}
// processBatchTrigger 异步处理批量触发
func (s *ManualTriggerService) processBatchTrigger(ctx context.Context, logID uint, cardIDs []uint, taskType string) {
dedupeKey := constants.RedisPollingManualDedupeKey(taskType)
queueKey := constants.RedisPollingManualQueueKey(taskType)
var processedCount, successCount, failedCount int
for _, cardID := range cardIDs {
// 检查去重
added, err := s.redis.SAdd(ctx, dedupeKey, cardID).Result()
if err != nil {
failedCount++
processedCount++
continue
}
if added == 0 {
// 已在队列中,跳过
failedCount++
processedCount++
continue
}
// 加入队列
if err := s.redis.LPush(ctx, queueKey, cardID).Err(); err != nil {
failedCount++
} else {
successCount++
}
processedCount++
// 每处理100条更新一次进度
if processedCount%100 == 0 {
_ = s.logStore.UpdateProgress(ctx, logID, processedCount, successCount, failedCount)
}
}
// 设置去重 key 过期时间
s.redis.Expire(ctx, dedupeKey, time.Hour)
// 更新最终状态
_ = s.logStore.UpdateProgress(ctx, logID, processedCount, successCount, failedCount)
_ = s.logStore.UpdateStatus(ctx, logID, "completed")
s.logger.Info("批量手动触发完成",
zap.Uint("log_id", logID),
zap.Int("total", len(cardIDs)),
zap.Int("success", successCount),
zap.Int("failed", failedCount))
}
// ConditionFilter 条件筛选参数
type ConditionFilter struct {
CardStatus string `json:"card_status,omitempty"` // 卡状态
CarrierCode string `json:"carrier_code,omitempty"` // 运营商代码
CardType string `json:"card_type,omitempty"` // 卡类型
ShopID *uint `json:"shop_id,omitempty"` // 店铺ID
PackageIDs []uint `json:"package_ids,omitempty"` // 套餐ID列表
EnablePolling *bool `json:"enable_polling,omitempty"` // 是否启用轮询
Limit int `json:"limit,omitempty"` // 限制数量
}
// TriggerByCondition 条件筛选触发
func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *ConditionFilter, taskType string, triggeredBy uint) (*model.PollingManualTriggerLog, error) {
// 验证任务类型
if !isValidTaskType(taskType) {
return nil, errors.New(errors.CodeInvalidParam, "无效的任务类型")
}
// 设置默认限制
if filter.Limit <= 0 || filter.Limit > 1000 {
filter.Limit = 1000
}
// 权限验证:代理只能筛选自己管理的店铺的卡
if err := s.applyShopPermissionFilter(ctx, filter); err != nil {
return nil, err
}
// 检查每日触发限制
todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy)
if err != nil {
return nil, err
}
if todayCount >= 100 {
return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限")
}
// 查询符合条件的卡(已应用权限过滤)
cardIDs, err := s.queryCardsByCondition(ctx, filter)
if err != nil {
return nil, err
}
if len(cardIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "没有符合条件的卡")
}
// 创建触发日志
filterJSON, _ := json.Marshal(filter)
cardIDsJSON, _ := json.Marshal(cardIDs)
triggerLog := &model.PollingManualTriggerLog{
TaskType: taskType,
TriggerType: "by_condition",
CardIDs: string(cardIDsJSON),
ConditionFilter: string(filterJSON),
TotalCount: len(cardIDs),
Status: "processing",
TriggeredBy: triggeredBy,
TriggeredAt: time.Now(),
}
if err := s.logStore.Create(ctx, triggerLog); err != nil {
return nil, err
}
// 异步处理批量触发
go s.processBatchTrigger(context.Background(), triggerLog.ID, cardIDs, taskType)
return triggerLog, nil
}
// queryCardsByCondition 根据条件查询卡ID
func (s *ManualTriggerService) queryCardsByCondition(ctx context.Context, filter *ConditionFilter) ([]uint, error) {
// 构建查询条件并查询卡
queryFilter := &postgres.IotCardQueryFilter{
ShopID: filter.ShopID,
EnablePolling: filter.EnablePolling,
Limit: filter.Limit,
}
// 映射其他过滤条件
if filter.CardStatus != "" {
queryFilter.CardStatus = &filter.CardStatus
}
if filter.CarrierCode != "" {
queryFilter.CarrierCode = &filter.CarrierCode
}
if filter.CardType != "" {
queryFilter.CardType = &filter.CardType
}
// 调用 IotCardStore 查询
cardIDs, err := s.iotCardStore.QueryIDsByFilter(ctx, queryFilter)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询符合条件的卡失败")
}
return cardIDs, nil
}
// GetStatus 获取触发状态
func (s *ManualTriggerService) GetStatus(ctx context.Context, logID uint) (*model.PollingManualTriggerLog, error) {
return s.logStore.GetByID(ctx, logID)
}
// ListHistory 获取触发历史
func (s *ManualTriggerService) ListHistory(ctx context.Context, page, pageSize int, taskType string, triggeredBy *uint) ([]*model.PollingManualTriggerLog, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
return s.logStore.List(ctx, page, pageSize, taskType, triggeredBy)
}
// CancelTrigger 取消触发任务
func (s *ManualTriggerService) CancelTrigger(ctx context.Context, logID uint, triggeredBy uint) error {
log, err := s.logStore.GetByID(ctx, logID)
if err != nil {
return errors.Wrap(errors.CodeNotFound, err, "触发任务不存在")
}
if log.TriggeredBy != triggeredBy {
return errors.New(errors.CodeForbidden, "无权限取消该任务")
}
if log.Status != "pending" && log.Status != "processing" {
return errors.New(errors.CodeInvalidParam, "任务已完成或已取消")
}
return s.logStore.UpdateStatus(ctx, logID, "cancelled")
}
// GetRunningTasks 获取正在运行的任务
func (s *ManualTriggerService) GetRunningTasks(ctx context.Context, triggeredBy uint) ([]*model.PollingManualTriggerLog, error) {
return s.logStore.GetRunning(ctx, triggeredBy)
}
// GetQueueSize 获取手动触发队列大小
func (s *ManualTriggerService) GetQueueSize(ctx context.Context, taskType string) (int64, error) {
queueKey := constants.RedisPollingManualQueueKey(taskType)
return s.redis.LLen(ctx, queueKey).Result()
}
func isValidTaskType(taskType string) bool {
switch taskType {
case constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage:
return true
default:
return false
}
}
// canManageCard 检查用户是否有权管理单张卡
func (s *ManualTriggerService) canManageCard(ctx context.Context, cardID uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
// 超级管理员和平台用户跳过权限检查
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
// 企业账号禁止手动触发
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询")
}
// 代理账号只能管理自己店铺及下级店铺的卡
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在")
}
// 平台卡ShopID为nil代理不能管理
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
// 检查代理是否有权管理该店铺
return middleware.CanManageShop(ctx, *card.ShopID, s.shopStore)
}
// canManageCards 检查用户是否有权管理多张卡
func (s *ManualTriggerService) canManageCards(ctx context.Context, cardIDs []uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
// 超级管理员和平台用户跳过权限检查
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
// 企业账号禁止手动触发
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询")
}
// 代理账号只能管理自己店铺及下级店铺的卡
currentShopID := middleware.GetShopIDFromContext(ctx)
if currentShopID == 0 {
return errors.New(errors.CodeForbidden, "无权限操作")
}
// 获取下级店铺ID列表
subordinateIDs, err := s.shopStore.GetSubordinateShopIDs(ctx, currentShopID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询下级店铺失败")
}
// 构建可管理的店铺ID集合
allowedShopIDs := make(map[uint]bool)
for _, id := range subordinateIDs {
allowedShopIDs[id] = true
}
// 批量查询卡信息
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "查询卡信息失败")
}
// 验证所有卡都在可管理范围内
for _, card := range cards {
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
if !allowedShopIDs[*card.ShopID] {
return errors.New(errors.CodeForbidden, "包含无权限操作的卡")
}
}
return nil
}
// applyShopPermissionFilter 应用店铺权限过滤(代理只能筛选自己管理的卡)
func (s *ManualTriggerService) applyShopPermissionFilter(ctx context.Context, filter *ConditionFilter) error {
userType := middleware.GetUserTypeFromContext(ctx)
// 超级管理员和平台用户不需要限制
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
// 企业账号禁止手动触发
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询")
}
// 代理账号:限制只能查询自己店铺及下级店铺的卡
currentShopID := middleware.GetShopIDFromContext(ctx)
if currentShopID == 0 {
return errors.New(errors.CodeForbidden, "无权限操作")
}
// 如果用户指定了 ShopID验证是否在可管理范围内
if filter.ShopID != nil {
if err := middleware.CanManageShop(ctx, *filter.ShopID, s.shopStore); err != nil {
return err
}
// 已指定有效的 ShopID无需修改
return nil
}
// 用户未指定 ShopID限制为当前用户的店铺代理只能查自己店铺的卡
// 注意:这里限制为当前店铺,而不是所有下级店铺,以避免返回过多数据
filter.ShopID = &currentShopID
return nil
}

View File

@@ -0,0 +1,283 @@
package polling
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// MonitoringService 轮询监控服务
type MonitoringService struct {
redis *redis.Client
}
// NewMonitoringService 创建轮询监控服务实例
func NewMonitoringService(redis *redis.Client) *MonitoringService {
return &MonitoringService{redis: redis}
}
// OverviewStats 总览统计
type OverviewStats struct {
TotalCards int64 `json:"total_cards"` // 总卡数
InitializedCards int64 `json:"initialized_cards"` // 已初始化卡数
InitProgress float64 `json:"init_progress"` // 初始化进度 (0-100)
IsInitializing bool `json:"is_initializing"` // 是否正在初始化
RealnameQueueSize int64 `json:"realname_queue_size"` // 实名检查队列大小
CarddataQueueSize int64 `json:"carddata_queue_size"` // 流量检查队列大小
PackageQueueSize int64 `json:"package_queue_size"` // 套餐检查队列大小
}
// QueueStatus 队列状态
type QueueStatus struct {
TaskType string `json:"task_type"` // 任务类型
TaskTypeName string `json:"task_type_name"` // 任务类型名称
QueueSize int64 `json:"queue_size"` // 队列大小
ManualPending int64 `json:"manual_pending"` // 手动触发待处理数
DueCount int64 `json:"due_count"` // 到期待处理数
AvgWaitTime float64 `json:"avg_wait_time_s"` // 平均等待时间(秒)
}
// TaskStats 任务统计
type TaskStats struct {
TaskType string `json:"task_type"` // 任务类型
TaskTypeName string `json:"task_type_name"` // 任务类型名称
SuccessCount1h int64 `json:"success_count_1h"` // 1小时成功数
FailureCount1h int64 `json:"failure_count_1h"` // 1小时失败数
TotalCount1h int64 `json:"total_count_1h"` // 1小时总数
SuccessRate float64 `json:"success_rate"` // 成功率 (0-100)
AvgDurationMs float64 `json:"avg_duration_ms"` // 平均耗时(毫秒)
}
// InitProgress 初始化进度
type InitProgress struct {
TotalCards int64 `json:"total_cards"` // 总卡数
InitializedCards int64 `json:"initialized_cards"` // 已初始化卡数
Progress float64 `json:"progress"` // 进度百分比 (0-100)
IsComplete bool `json:"is_complete"` // 是否完成
StartedAt time.Time `json:"started_at"` // 开始时间
EstimatedETA string `json:"estimated_eta"` // 预计完成时间
}
// GetOverview 获取总览统计
func (s *MonitoringService) GetOverview(ctx context.Context) (*OverviewStats, error) {
stats := &OverviewStats{}
// 获取初始化进度
progressKey := constants.RedisPollingInitProgressKey()
progressData, err := s.redis.HGetAll(ctx, progressKey).Result()
if err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "获取初始化进度失败")
}
if total, ok := progressData["total"]; ok {
stats.TotalCards = parseInt64(total)
}
if initialized, ok := progressData["initialized"]; ok {
stats.InitializedCards = parseInt64(initialized)
}
if stats.TotalCards > 0 {
stats.InitProgress = float64(stats.InitializedCards) / float64(stats.TotalCards) * 100
}
stats.IsInitializing = stats.InitializedCards < stats.TotalCards && stats.TotalCards > 0
// 获取队列大小
stats.RealnameQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueRealnameKey()).Result()
stats.CarddataQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueueCarddataKey()).Result()
stats.PackageQueueSize, _ = s.redis.ZCard(ctx, constants.RedisPollingQueuePackageKey()).Result()
return stats, nil
}
// GetQueueStatuses 获取所有队列状态
func (s *MonitoringService) GetQueueStatuses(ctx context.Context) ([]*QueueStatus, error) {
taskTypes := []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
}
result := make([]*QueueStatus, 0, len(taskTypes))
now := time.Now().Unix()
for _, taskType := range taskTypes {
status := &QueueStatus{
TaskType: taskType,
TaskTypeName: s.getTaskTypeName(taskType),
}
// 获取队列 key
var queueKey string
switch taskType {
case constants.TaskTypePollingRealname:
queueKey = constants.RedisPollingQueueRealnameKey()
case constants.TaskTypePollingCarddata:
queueKey = constants.RedisPollingQueueCarddataKey()
case constants.TaskTypePollingPackage:
queueKey = constants.RedisPollingQueuePackageKey()
}
// 获取队列大小
status.QueueSize, _ = s.redis.ZCard(ctx, queueKey).Result()
// 获取到期数量score <= now
status.DueCount, _ = s.redis.ZCount(ctx, queueKey, "-inf", formatInt64(now)).Result()
// 获取手动触发队列待处理数
manualKey := constants.RedisPollingManualQueueKey(taskType)
status.ManualPending, _ = s.redis.LLen(ctx, manualKey).Result()
// 计算平均等待时间取最早的10个任务的平均等待时间
earliest, err := s.redis.ZRangeWithScores(ctx, queueKey, 0, 9).Result()
if err == nil && len(earliest) > 0 {
var totalWait float64
for _, z := range earliest {
waitTime := float64(now) - z.Score
if waitTime > 0 {
totalWait += waitTime
}
}
status.AvgWaitTime = totalWait / float64(len(earliest))
}
result = append(result, status)
}
return result, nil
}
// GetTaskStatuses 获取所有任务统计
func (s *MonitoringService) GetTaskStatuses(ctx context.Context) ([]*TaskStats, error) {
taskTypes := []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
}
result := make([]*TaskStats, 0, len(taskTypes))
for _, taskType := range taskTypes {
stats := &TaskStats{
TaskType: taskType,
TaskTypeName: s.getTaskTypeName(taskType),
}
// 获取统计数据
statsKey := constants.RedisPollingStatsKey(taskType)
data, err := s.redis.HGetAll(ctx, statsKey).Result()
if err != nil && err != redis.Nil {
continue
}
if success, ok := data["success_count_1h"]; ok {
stats.SuccessCount1h = parseInt64(success)
}
if failure, ok := data["failure_count_1h"]; ok {
stats.FailureCount1h = parseInt64(failure)
}
stats.TotalCount1h = stats.SuccessCount1h + stats.FailureCount1h
if stats.TotalCount1h > 0 {
stats.SuccessRate = float64(stats.SuccessCount1h) / float64(stats.TotalCount1h) * 100
if duration, ok := data["total_duration_1h"]; ok {
totalDuration := parseInt64(duration)
stats.AvgDurationMs = float64(totalDuration) / float64(stats.TotalCount1h)
}
}
result = append(result, stats)
}
return result, nil
}
// GetInitProgress 获取初始化进度详情
func (s *MonitoringService) GetInitProgress(ctx context.Context) (*InitProgress, error) {
progressKey := constants.RedisPollingInitProgressKey()
data, err := s.redis.HGetAll(ctx, progressKey).Result()
if err != nil && err != redis.Nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "获取初始化进度失败")
}
progress := &InitProgress{}
if total, ok := data["total"]; ok {
progress.TotalCards = parseInt64(total)
}
if initialized, ok := data["initialized"]; ok {
progress.InitializedCards = parseInt64(initialized)
}
if startedAt, ok := data["started_at"]; ok {
if t, err := time.Parse(time.RFC3339, startedAt); err == nil {
progress.StartedAt = t
}
}
if progress.TotalCards > 0 {
progress.Progress = float64(progress.InitializedCards) / float64(progress.TotalCards) * 100
}
progress.IsComplete = progress.InitializedCards >= progress.TotalCards && progress.TotalCards > 0
// 估算完成时间
if !progress.IsComplete && progress.InitializedCards > 0 && !progress.StartedAt.IsZero() {
elapsed := time.Since(progress.StartedAt)
remaining := progress.TotalCards - progress.InitializedCards
rate := float64(progress.InitializedCards) / elapsed.Seconds()
if rate > 0 {
etaSeconds := float64(remaining) / rate
eta := time.Now().Add(time.Duration(etaSeconds) * time.Second)
progress.EstimatedETA = eta.Format("15:04:05")
}
}
return progress, nil
}
// getTaskTypeName 获取任务类型的中文名称
func (s *MonitoringService) getTaskTypeName(taskType string) string {
switch taskType {
case constants.TaskTypePollingRealname:
return "实名检查"
case constants.TaskTypePollingCarddata:
return "流量检查"
case constants.TaskTypePollingPackage:
return "套餐检查"
default:
return taskType
}
}
// parseInt64 解析字符串为 int64
func parseInt64(s string) int64 {
var result int64
for _, c := range s {
if c >= '0' && c <= '9' {
result = result*10 + int64(c-'0')
}
}
return result
}
// formatInt64 格式化 int64 为字符串
func formatInt64(n int64) string {
if n == 0 {
return "0"
}
var result []byte
negative := n < 0
if negative {
n = -n
}
for n > 0 {
result = append([]byte{byte('0' + n%10)}, result...)
n /= 10
}
if negative {
result = append([]byte{'-'}, result...)
}
return string(result)
}

View File

@@ -116,7 +116,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.ShopPackageAllocationR
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByID(ctx, allocation.PackageID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""
@@ -156,7 +156,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopPackag
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByID(ctx, allocation.PackageID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""
@@ -226,7 +226,7 @@ func (s *Service) List(ctx context.Context, req *dto.ShopPackageAllocationListRe
responses := make([]*dto.ShopPackageAllocationResponse, len(allocations))
for i, a := range allocations {
shop, _ := s.shopStore.GetByID(ctx, a.ShopID)
pkg, _ := s.packageStore.GetByID(ctx, a.PackageID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, a.PackageID)
shopName := ""
packageName := ""
@@ -271,10 +271,10 @@ func (s *Service) buildResponse(ctx context.Context, a *model.ShopPackageAllocat
var seriesID uint
seriesName := ""
pkg, _ := s.packageStore.GetByID(ctx, a.PackageID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, a.PackageID)
if pkg != nil {
seriesID = pkg.SeriesID
series, _ := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
series, _ := s.packageSeriesStore.GetByIDUnscoped(ctx, pkg.SeriesID)
if series != nil {
seriesName = series.SeriesName
}
@@ -349,7 +349,7 @@ func (s *Service) UpdateCostPrice(ctx context.Context, id uint, newCostPrice int
}
shop, _ := s.shopStore.GetByID(ctx, allocation.ShopID)
pkg, _ := s.packageStore.GetByID(ctx, allocation.PackageID)
pkg, _ := s.packageStore.GetByIDUnscoped(ctx, allocation.PackageID)
shopName := ""
packageName := ""

View File

@@ -0,0 +1,91 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// DataUsageRecordStore 流量使用记录存储
type DataUsageRecordStore struct {
db *gorm.DB
}
// NewDataUsageRecordStore 创建流量使用记录存储实例
func NewDataUsageRecordStore(db *gorm.DB) *DataUsageRecordStore {
return &DataUsageRecordStore{db: db}
}
// Create 创建流量使用记录
func (s *DataUsageRecordStore) Create(ctx context.Context, record *model.DataUsageRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
// CreateBatch 批量创建流量使用记录
func (s *DataUsageRecordStore) CreateBatch(ctx context.Context, records []*model.DataUsageRecord) error {
if len(records) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(records, 100).Error
}
// GetLatestByCardID 获取卡的最新流量记录
func (s *DataUsageRecordStore) GetLatestByCardID(ctx context.Context, cardID uint) (*model.DataUsageRecord, error) {
var record model.DataUsageRecord
if err := s.db.WithContext(ctx).
Where("iot_card_id = ?", cardID).
Order("check_time DESC").
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
// ListByCardID 获取卡的流量记录列表
func (s *DataUsageRecordStore) ListByCardID(ctx context.Context, cardID uint, startTime, endTime *time.Time, page, pageSize int) ([]*model.DataUsageRecord, int64, error) {
var records []*model.DataUsageRecord
var total int64
query := s.db.WithContext(ctx).Model(&model.DataUsageRecord{}).Where("iot_card_id = ?", cardID)
if startTime != nil {
query = query.Where("check_time >= ?", *startTime)
}
if endTime != nil {
query = query.Where("check_time <= ?", *endTime)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("check_time DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
// DeleteOlderThan 删除指定时间之前的记录(用于数据清理)
func (s *DataUsageRecordStore) DeleteOlderThan(ctx context.Context, before time.Time, batchSize int) (int64, error) {
result := s.db.WithContext(ctx).
Where("check_time < ?", before).
Limit(batchSize).
Delete(&model.DataUsageRecord{})
return result.RowsAffected, result.Error
}
// CountByCardID 统计卡的流量记录数量
func (s *DataUsageRecordStore) CountByCardID(ctx context.Context, cardID uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.DataUsageRecord{}).
Where("iot_card_id = ?", cardID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -410,3 +410,68 @@ func (s *IotCardStore) UpdateRechargeTrackingFields(ctx context.Context, cardID
"first_recharge_triggered_by_series": triggeredJSON,
}).Error
}
// IotCardQueryFilter 卡查询过滤条件
type IotCardQueryFilter struct {
ShopID *uint // 店铺ID
CardStatus *string // 卡状态
CarrierCode *string // 运营商代码
CardType *string // 卡类型
EnablePolling *bool // 是否启用轮询
Limit int // 限制数量
}
// QueryIDsByFilter 根据过滤条件查询卡ID列表
func (s *IotCardStore) QueryIDsByFilter(ctx context.Context, filter *IotCardQueryFilter) ([]uint, error) {
query := s.db.WithContext(ctx).Model(&model.IotCard{}).Select("id")
// 应用过滤条件
if filter.ShopID != nil {
query = query.Where("shop_id = ?", *filter.ShopID)
}
if filter.CardStatus != nil && *filter.CardStatus != "" {
query = query.Where("card_status = ?", *filter.CardStatus)
}
if filter.CarrierCode != nil && *filter.CarrierCode != "" {
query = query.Where("carrier_code = ?", *filter.CarrierCode)
}
if filter.CardType != nil && *filter.CardType != "" {
query = query.Where("card_type = ?", *filter.CardType)
}
if filter.EnablePolling != nil {
query = query.Where("enable_polling = ?", *filter.EnablePolling)
}
// 应用限制
if filter.Limit > 0 {
query = query.Limit(filter.Limit)
}
var cardIDs []uint
if err := query.Pluck("id", &cardIDs).Error; err != nil {
return nil, err
}
return cardIDs, nil
}
// BatchUpdatePollingStatus 批量更新卡的轮询状态
func (s *IotCardStore) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, enablePolling bool) error {
if len(cardIDs) == 0 {
return nil
}
return s.db.WithContext(ctx).
Model(&model.IotCard{}).
Where("id IN ?", cardIDs).
Update("enable_polling", enablePolling).Error
}
// BatchDelete 批量删除卡(软删除)
func (s *IotCardStore) BatchDelete(ctx context.Context, cardIDs []uint) error {
if len(cardIDs) == 0 {
return nil
}
return s.db.WithContext(ctx).
Where("id IN ?", cardIDs).
Delete(&model.IotCard{}).Error
}

View File

@@ -97,3 +97,13 @@ func (s *PackageSeriesStore) List(ctx context.Context, opts *store.QueryOptions,
func (s *PackageSeriesStore) UpdateStatus(ctx context.Context, id uint, status int) error {
return s.db.WithContext(ctx).Model(&model.PackageSeries{}).Where("id = ?", id).Update("status", status).Error
}
// GetByIDUnscoped 根据ID获取套餐系列包括已删除的记录
// 用于关联查询场景,确保已删除的系列信息仍能被展示
func (s *PackageSeriesStore) GetByIDUnscoped(ctx context.Context, id uint) (*model.PackageSeries, error) {
var series model.PackageSeries
if err := s.db.WithContext(ctx).Unscoped().First(&series, id).Error; err != nil {
return nil, err
}
return &series, nil
}

View File

@@ -106,3 +106,25 @@ func (s *PackageStore) UpdateStatus(ctx context.Context, id uint, status int) er
func (s *PackageStore) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
return s.db.WithContext(ctx).Model(&model.Package{}).Where("id = ?", id).Update("shelf_status", shelfStatus).Error
}
// GetByIDUnscoped 根据ID获取套餐包括已删除的记录
// 用于关联查询场景,确保已删除的套餐信息仍能被展示
func (s *PackageStore) GetByIDUnscoped(ctx context.Context, id uint) (*model.Package, error) {
var pkg model.Package
if err := s.db.WithContext(ctx).Unscoped().First(&pkg, id).Error; err != nil {
return nil, err
}
return &pkg, nil
}
// GetByIDsUnscoped 批量获取套餐(包括已删除的记录)
func (s *PackageStore) GetByIDsUnscoped(ctx context.Context, ids []uint) ([]*model.Package, error) {
if len(ids) == 0 {
return nil, nil
}
var packages []*model.Package
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&packages).Error; err != nil {
return nil, err
}
return packages, nil
}

View File

@@ -0,0 +1,114 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PollingAlertRuleStore 告警规则存储
type PollingAlertRuleStore struct {
db *gorm.DB
}
// NewPollingAlertRuleStore 创建告警规则存储实例
func NewPollingAlertRuleStore(db *gorm.DB) *PollingAlertRuleStore {
return &PollingAlertRuleStore{db: db}
}
// Create 创建告警规则
func (s *PollingAlertRuleStore) Create(ctx context.Context, rule *model.PollingAlertRule) error {
return s.db.WithContext(ctx).Create(rule).Error
}
// GetByID 根据ID获取告警规则
func (s *PollingAlertRuleStore) GetByID(ctx context.Context, id uint) (*model.PollingAlertRule, error) {
var rule model.PollingAlertRule
if err := s.db.WithContext(ctx).First(&rule, id).Error; err != nil {
return nil, err
}
return &rule, nil
}
// List 获取告警规则列表
func (s *PollingAlertRuleStore) List(ctx context.Context) ([]*model.PollingAlertRule, error) {
var rules []*model.PollingAlertRule
if err := s.db.WithContext(ctx).Order("id ASC").Find(&rules).Error; err != nil {
return nil, err
}
return rules, nil
}
// ListEnabled 获取启用的告警规则
func (s *PollingAlertRuleStore) ListEnabled(ctx context.Context) ([]*model.PollingAlertRule, error) {
var rules []*model.PollingAlertRule
if err := s.db.WithContext(ctx).Where("status = 1").Order("id ASC").Find(&rules).Error; err != nil {
return nil, err
}
return rules, nil
}
// Update 更新告警规则
func (s *PollingAlertRuleStore) Update(ctx context.Context, rule *model.PollingAlertRule) error {
return s.db.WithContext(ctx).Save(rule).Error
}
// Delete 删除告警规则
func (s *PollingAlertRuleStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.PollingAlertRule{}, id).Error
}
// PollingAlertHistoryStore 告警历史存储
type PollingAlertHistoryStore struct {
db *gorm.DB
}
// NewPollingAlertHistoryStore 创建告警历史存储实例
func NewPollingAlertHistoryStore(db *gorm.DB) *PollingAlertHistoryStore {
return &PollingAlertHistoryStore{db: db}
}
// Create 创建告警历史
func (s *PollingAlertHistoryStore) Create(ctx context.Context, history *model.PollingAlertHistory) error {
return s.db.WithContext(ctx).Create(history).Error
}
// List 获取告警历史列表
func (s *PollingAlertHistoryStore) List(ctx context.Context, page, pageSize int, ruleID *uint) ([]*model.PollingAlertHistory, int64, error) {
var histories []*model.PollingAlertHistory
var total int64
query := s.db.WithContext(ctx).Model(&model.PollingAlertHistory{})
if ruleID != nil {
query = query.Where("rule_id = ?", *ruleID)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&histories).Error; err != nil {
return nil, 0, err
}
return histories, total, nil
}
// GetLatestByRuleID 获取规则最近一条告警
func (s *PollingAlertHistoryStore) GetLatestByRuleID(ctx context.Context, ruleID uint) (*model.PollingAlertHistory, error) {
var history model.PollingAlertHistory
if err := s.db.WithContext(ctx).Where("rule_id = ?", ruleID).Order("created_at DESC").First(&history).Error; err != nil {
return nil, err
}
return &history, nil
}
// UpdateNotificationStatus 更新通知发送状态
func (s *PollingAlertHistoryStore) UpdateNotificationStatus(ctx context.Context, id uint, status string) error {
return s.db.WithContext(ctx).Model(&model.PollingAlertHistory{}).
Where("id = ?", id).
Update("notification_status", status).Error
}

View File

@@ -0,0 +1,207 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// DataCleanupConfigStore 数据清理配置存储
type DataCleanupConfigStore struct {
db *gorm.DB
}
// NewDataCleanupConfigStore 创建数据清理配置存储实例
func NewDataCleanupConfigStore(db *gorm.DB) *DataCleanupConfigStore {
return &DataCleanupConfigStore{db: db}
}
// Create 创建清理配置
func (s *DataCleanupConfigStore) Create(ctx context.Context, config *model.DataCleanupConfig) error {
return s.db.WithContext(ctx).Create(config).Error
}
// GetByID 根据ID获取清理配置
func (s *DataCleanupConfigStore) GetByID(ctx context.Context, id uint) (*model.DataCleanupConfig, error) {
var config model.DataCleanupConfig
if err := s.db.WithContext(ctx).First(&config, id).Error; err != nil {
return nil, err
}
return &config, nil
}
// GetByTableName 根据表名获取清理配置
func (s *DataCleanupConfigStore) GetByTableName(ctx context.Context, tableName string) (*model.DataCleanupConfig, error) {
var config model.DataCleanupConfig
if err := s.db.WithContext(ctx).Where("table_name = ?", tableName).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
// List 获取所有清理配置
func (s *DataCleanupConfigStore) List(ctx context.Context) ([]*model.DataCleanupConfig, error) {
var configs []*model.DataCleanupConfig
if err := s.db.WithContext(ctx).Order("id ASC").Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// ListEnabled 获取启用的清理配置
func (s *DataCleanupConfigStore) ListEnabled(ctx context.Context) ([]*model.DataCleanupConfig, error) {
var configs []*model.DataCleanupConfig
if err := s.db.WithContext(ctx).Where("enabled = 1").Order("id ASC").Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// Update 更新清理配置
func (s *DataCleanupConfigStore) Update(ctx context.Context, config *model.DataCleanupConfig) error {
return s.db.WithContext(ctx).Save(config).Error
}
// Delete 删除清理配置
func (s *DataCleanupConfigStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.DataCleanupConfig{}, id).Error
}
// DataCleanupLogStore 数据清理日志存储
type DataCleanupLogStore struct {
db *gorm.DB
}
// NewDataCleanupLogStore 创建数据清理日志存储实例
func NewDataCleanupLogStore(db *gorm.DB) *DataCleanupLogStore {
return &DataCleanupLogStore{db: db}
}
// Create 创建清理日志
func (s *DataCleanupLogStore) Create(ctx context.Context, log *model.DataCleanupLog) error {
return s.db.WithContext(ctx).Create(log).Error
}
// GetByID 根据ID获取清理日志
func (s *DataCleanupLogStore) GetByID(ctx context.Context, id uint) (*model.DataCleanupLog, error) {
var log model.DataCleanupLog
if err := s.db.WithContext(ctx).First(&log, id).Error; err != nil {
return nil, err
}
return &log, nil
}
// Update 更新清理日志
func (s *DataCleanupLogStore) Update(ctx context.Context, log *model.DataCleanupLog) error {
return s.db.WithContext(ctx).Save(log).Error
}
// List 分页获取清理日志
func (s *DataCleanupLogStore) List(ctx context.Context, page, pageSize int, tableName string) ([]*model.DataCleanupLog, int64, error) {
var logs []*model.DataCleanupLog
var total int64
query := s.db.WithContext(ctx).Model(&model.DataCleanupLog{})
if tableName != "" {
query = query.Where("table_name = ?", tableName)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("started_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// GetLatestRunning 获取正在运行的清理任务
func (s *DataCleanupLogStore) GetLatestRunning(ctx context.Context, tableName string) (*model.DataCleanupLog, error) {
var log model.DataCleanupLog
if err := s.db.WithContext(ctx).Where("table_name = ? AND status = 'running'", tableName).First(&log).Error; err != nil {
return nil, err
}
return &log, nil
}
// DeleteOldRecords 删除指定表的过期记录
// 返回删除的记录数
func (s *DataCleanupLogStore) DeleteOldRecords(ctx context.Context, tableName string, retentionDays int, batchSize int) (int64, error) {
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
// 根据表名执行不同的删除逻辑
var result *gorm.DB
switch tableName {
case "tb_polling_alert_history":
result = s.db.WithContext(ctx).
Where("created_at < ?", cutoffTime).
Limit(batchSize).
Delete(&model.PollingAlertHistory{})
case "tb_data_cleanup_log":
result = s.db.WithContext(ctx).
Where("started_at < ?", cutoffTime).
Limit(batchSize).
Delete(&model.DataCleanupLog{})
case "tb_polling_manual_trigger_log":
result = s.db.WithContext(ctx).
Where("triggered_at < ?", cutoffTime).
Limit(batchSize).
Delete(&model.PollingManualTriggerLog{})
default:
// 对于其他表,使用原始 SQL
result = s.db.WithContext(ctx).Exec(
"DELETE FROM "+tableName+" WHERE created_at < ? LIMIT ?",
cutoffTime, batchSize,
)
}
if result.Error != nil {
return 0, result.Error
}
return result.RowsAffected, nil
}
// CountOldRecords 统计指定表的过期记录数
func (s *DataCleanupLogStore) CountOldRecords(ctx context.Context, tableName string, retentionDays int) (int64, error) {
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
var count int64
// 根据表名查询不同的时间字段
switch tableName {
case "tb_polling_alert_history":
if err := s.db.WithContext(ctx).Model(&model.PollingAlertHistory{}).
Where("created_at < ?", cutoffTime).
Count(&count).Error; err != nil {
return 0, err
}
case "tb_data_cleanup_log":
if err := s.db.WithContext(ctx).Model(&model.DataCleanupLog{}).
Where("started_at < ?", cutoffTime).
Count(&count).Error; err != nil {
return 0, err
}
case "tb_polling_manual_trigger_log":
if err := s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{}).
Where("triggered_at < ?", cutoffTime).
Count(&count).Error; err != nil {
return 0, err
}
default:
// 对于其他表,使用原始 SQL
row := s.db.WithContext(ctx).Raw(
"SELECT COUNT(*) FROM "+tableName+" WHERE created_at < ?",
cutoffTime,
).Row()
if err := row.Scan(&count); err != nil {
return 0, err
}
}
return count, nil
}

View File

@@ -0,0 +1,52 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PollingConcurrencyConfigStore 并发控制配置存储
type PollingConcurrencyConfigStore struct {
db *gorm.DB
}
// NewPollingConcurrencyConfigStore 创建并发控制配置存储实例
func NewPollingConcurrencyConfigStore(db *gorm.DB) *PollingConcurrencyConfigStore {
return &PollingConcurrencyConfigStore{db: db}
}
// List 获取所有并发控制配置
func (s *PollingConcurrencyConfigStore) List(ctx context.Context) ([]*model.PollingConcurrencyConfig, error) {
var configs []*model.PollingConcurrencyConfig
if err := s.db.WithContext(ctx).Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// GetByTaskType 根据任务类型获取配置
func (s *PollingConcurrencyConfigStore) GetByTaskType(ctx context.Context, taskType string) (*model.PollingConcurrencyConfig, error) {
var config model.PollingConcurrencyConfig
if err := s.db.WithContext(ctx).Where("task_type = ?", taskType).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
// Update 更新并发控制配置
func (s *PollingConcurrencyConfigStore) Update(ctx context.Context, config *model.PollingConcurrencyConfig) error {
return s.db.WithContext(ctx).Save(config).Error
}
// UpdateMaxConcurrency 更新最大并发数
func (s *PollingConcurrencyConfigStore) UpdateMaxConcurrency(ctx context.Context, taskType string, maxConcurrency int, updatedBy uint) error {
return s.db.WithContext(ctx).Model(&model.PollingConcurrencyConfig{}).
Where("task_type = ?", taskType).
Updates(map[string]interface{}{
"max_concurrency": maxConcurrency,
"updated_by": updatedBy,
}).Error
}

View File

@@ -0,0 +1,125 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
)
// PollingConfigStore 轮询配置存储
type PollingConfigStore struct {
db *gorm.DB
}
// NewPollingConfigStore 创建轮询配置存储实例
func NewPollingConfigStore(db *gorm.DB) *PollingConfigStore {
return &PollingConfigStore{db: db}
}
// Create 创建轮询配置
func (s *PollingConfigStore) Create(ctx context.Context, config *model.PollingConfig) error {
return s.db.WithContext(ctx).Create(config).Error
}
// GetByID 根据 ID 获取轮询配置
func (s *PollingConfigStore) GetByID(ctx context.Context, id uint) (*model.PollingConfig, error) {
var config model.PollingConfig
if err := s.db.WithContext(ctx).First(&config, id).Error; err != nil {
return nil, err
}
return &config, nil
}
// Update 更新轮询配置
func (s *PollingConfigStore) Update(ctx context.Context, config *model.PollingConfig) error {
return s.db.WithContext(ctx).Save(config).Error
}
// Delete 删除轮询配置
func (s *PollingConfigStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.PollingConfig{}, id).Error
}
// List 列表查询轮询配置
func (s *PollingConfigStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.PollingConfig, int64, error) {
var configs []*model.PollingConfig
var total int64
query := s.db.WithContext(ctx).Model(&model.PollingConfig{})
// 过滤条件
if status, ok := filters["status"]; ok {
query = query.Where("status = ?", status)
}
if cardCondition, ok := filters["card_condition"].(string); ok && cardCondition != "" {
query = query.Where("card_condition = ?", cardCondition)
}
if cardCategory, ok := filters["card_category"].(string); ok && cardCategory != "" {
query = query.Where("card_category = ?", cardCategory)
}
if carrierID, ok := filters["carrier_id"]; ok {
query = query.Where("carrier_id = ?", carrierID)
}
if configName, ok := filters["config_name"].(string); ok && configName != "" {
query = query.Where("config_name LIKE ?", "%"+configName+"%")
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页
if opts == nil {
opts = store.DefaultQueryOptions()
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
// 排序
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("priority ASC, id DESC")
}
if err := query.Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
}
// ListEnabled 获取所有启用的轮询配置(按优先级排序)
func (s *PollingConfigStore) ListEnabled(ctx context.Context) ([]*model.PollingConfig, error) {
var configs []*model.PollingConfig
if err := s.db.WithContext(ctx).
Where("status = ?", 1).
Order("priority ASC").
Find(&configs).Error; err != nil {
return nil, err
}
return configs, nil
}
// GetByName 根据名称获取轮询配置
func (s *PollingConfigStore) GetByName(ctx context.Context, name string) (*model.PollingConfig, error) {
var config model.PollingConfig
if err := s.db.WithContext(ctx).Where("config_name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
// UpdateStatus 更新配置状态
func (s *PollingConfigStore) UpdateStatus(ctx context.Context, id uint, status int16, updatedBy uint) error {
return s.db.WithContext(ctx).Model(&model.PollingConfig{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updated_by": updatedBy,
}).Error
}

View File

@@ -0,0 +1,108 @@
package postgres
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PollingManualTriggerLogStore 手动触发日志存储
type PollingManualTriggerLogStore struct {
db *gorm.DB
}
// NewPollingManualTriggerLogStore 创建手动触发日志存储实例
func NewPollingManualTriggerLogStore(db *gorm.DB) *PollingManualTriggerLogStore {
return &PollingManualTriggerLogStore{db: db}
}
// Create 创建手动触发日志
func (s *PollingManualTriggerLogStore) Create(ctx context.Context, log *model.PollingManualTriggerLog) error {
return s.db.WithContext(ctx).Create(log).Error
}
// GetByID 根据ID获取手动触发日志
func (s *PollingManualTriggerLogStore) GetByID(ctx context.Context, id uint) (*model.PollingManualTriggerLog, error) {
var log model.PollingManualTriggerLog
if err := s.db.WithContext(ctx).First(&log, id).Error; err != nil {
return nil, err
}
return &log, nil
}
// Update 更新手动触发日志
func (s *PollingManualTriggerLogStore) Update(ctx context.Context, log *model.PollingManualTriggerLog) error {
return s.db.WithContext(ctx).Save(log).Error
}
// List 分页获取手动触发日志
func (s *PollingManualTriggerLogStore) List(ctx context.Context, page, pageSize int, taskType string, triggeredBy *uint) ([]*model.PollingManualTriggerLog, int64, error) {
var logs []*model.PollingManualTriggerLog
var total int64
query := s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{})
if taskType != "" {
query = query.Where("task_type = ?", taskType)
}
if triggeredBy != nil {
query = query.Where("triggered_by = ?", *triggeredBy)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("triggered_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
// GetRunning 获取正在运行的触发任务
func (s *PollingManualTriggerLogStore) GetRunning(ctx context.Context, triggeredBy uint) ([]*model.PollingManualTriggerLog, error) {
var logs []*model.PollingManualTriggerLog
if err := s.db.WithContext(ctx).
Where("triggered_by = ? AND status IN ('pending', 'processing')", triggeredBy).
Order("triggered_at DESC").
Find(&logs).Error; err != nil {
return nil, err
}
return logs, nil
}
// UpdateProgress 更新触发进度
func (s *PollingManualTriggerLogStore) UpdateProgress(ctx context.Context, id uint, processedCount, successCount, failedCount int) error {
return s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{}).
Where("id = ?", id).
Updates(map[string]any{
"processed_count": processedCount,
"success_count": successCount,
"failed_count": failedCount,
}).Error
}
// UpdateStatus 更新触发状态
func (s *PollingManualTriggerLogStore) UpdateStatus(ctx context.Context, id uint, status string) error {
updates := map[string]any{"status": status}
if status == "completed" || status == "cancelled" {
updates["completed_at"] = gorm.Expr("CURRENT_TIMESTAMP")
}
return s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{}).
Where("id = ?", id).
Updates(updates).Error
}
// CountTodayTriggers 统计今日触发次数
func (s *PollingManualTriggerLogStore) CountTodayTriggers(ctx context.Context, triggeredBy uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.PollingManualTriggerLog{}).
Where("triggered_by = ? AND DATE(triggered_at) = CURRENT_DATE", triggeredBy).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -34,12 +34,20 @@ type IotCardImportPayload struct {
TaskID uint `json:"task_id"`
}
// PollingCallback 轮询回调接口
// 用于在卡创建/删除/状态变化时通知轮询系统
type PollingCallback interface {
// OnBatchCardsCreated 批量卡创建时的回调
OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard)
}
type IotCardImportHandler struct {
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.IotCardImportTaskStore
iotCardStore *postgres.IotCardStore
storageService *storage.Service
pollingCallback PollingCallback
logger *zap.Logger
}
@@ -49,6 +57,7 @@ func NewIotCardImportHandler(
importTaskStore *postgres.IotCardImportTaskStore,
iotCardStore *postgres.IotCardStore,
storageSvc *storage.Service,
pollingCallback PollingCallback,
logger *zap.Logger,
) *IotCardImportHandler {
return &IotCardImportHandler{
@@ -57,6 +66,7 @@ func NewIotCardImportHandler(
importTaskStore: importTaskStore,
iotCardStore: iotCardStore,
storageService: storageSvc,
pollingCallback: pollingCallback,
logger: logger,
}
}
@@ -315,4 +325,9 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
}
result.successCount += len(newCards)
// 通知轮询系统:批量卡已创建
if h.pollingCallback != nil {
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
}
}

View File

@@ -21,7 +21,7 @@ func TestIotCardImportHandler_ProcessImport(t *testing.T) {
importTaskStore := postgres.NewIotCardImportTaskStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, nil, logger)
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, nil, nil, logger)
ctx := context.Background()
t.Run("成功导入新ICCID", func(t *testing.T) {
@@ -158,7 +158,7 @@ func TestIotCardImportHandler_ProcessBatch(t *testing.T) {
importTaskStore := postgres.NewIotCardImportTaskStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, nil, logger)
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, nil, nil, logger)
ctx := context.Background()
t.Run("验证行号和MSISDN正确记录", func(t *testing.T) {

View File

@@ -0,0 +1,828 @@
package task
import (
"context"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingTaskPayload 轮询任务载荷
type PollingTaskPayload struct {
CardID string `json:"card_id"`
IsManual bool `json:"is_manual"`
Timestamp int64 `json:"timestamp"`
}
// PollingHandler 轮询任务处理器
type PollingHandler struct {
db *gorm.DB
redis *redis.Client
gatewayClient *gateway.Client
iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
dataUsageRecordStore *postgres.DataUsageRecordStore
logger *zap.Logger
}
// NewPollingHandler 创建轮询任务处理器
func NewPollingHandler(
db *gorm.DB,
redis *redis.Client,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *PollingHandler {
return &PollingHandler{
db: db,
redis: redis,
gatewayClient: gatewayClient,
iotCardStore: postgres.NewIotCardStore(db, redis),
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
deviceSimBindingStore: postgres.NewDeviceSimBindingStore(db, redis),
dataUsageRecordStore: postgres.NewDataUsageRecordStore(db),
logger: logger,
}
}
// HandleRealnameCheck 处理实名检查任务
func (h *PollingHandler) HandleRealnameCheck(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
var payload PollingTaskPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析任务载荷失败", zap.Error(err))
return nil // 不重试
}
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
if err != nil {
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
return nil
}
// 获取并发信号量
if !h.acquireConcurrency(ctx, constants.TaskTypePollingRealname) {
h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname)
}
defer h.releaseConcurrency(ctx, constants.TaskTypePollingRealname)
h.logger.Debug("开始实名检查",
zap.Uint64("card_id", cardID),
zap.Bool("is_manual", payload.IsManual))
// 获取卡信息
card, err := h.getCardWithCache(ctx, uint(cardID))
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID))
return nil
}
h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
h.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname)
}
// 行业卡跳过实名检查
if card.CardCategory == "industry" {
h.logger.Debug("行业卡跳过实名检查", zap.Uint64("card_id", cardID))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname)
}
// 调用 Gateway API 查询实名状态
var newRealnameStatus int
if h.gatewayClient != nil {
result, err := h.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{
CardNo: card.ICCID,
})
if err != nil {
h.logger.Warn("查询实名状态失败",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Error(err))
h.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname)
}
// 解析实名状态
newRealnameStatus = h.parseRealnameStatus(result.Status)
h.logger.Info("实名检查完成",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.String("gateway_status", result.Status),
zap.Int("new_status", newRealnameStatus),
zap.Int("old_status", card.RealNameStatus))
} else {
// Gateway 未配置,模拟检查
newRealnameStatus = card.RealNameStatus
h.logger.Debug("实名检查完成模拟Gateway未配置",
zap.Uint64("card_id", cardID))
}
// 检测状态变化
statusChanged := newRealnameStatus != card.RealNameStatus
// 更新数据库
now := time.Now()
updates := map[string]any{
"last_real_name_check_at": now,
}
if statusChanged {
updates["real_name_status"] = newRealnameStatus
}
if err := h.db.Model(&model.IotCard{}).
Where("id = ?", cardID).
Updates(updates).Error; err != nil {
h.logger.Error("更新卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
}
// 如果状态变化,更新 Redis 缓存并重新匹配配置
if statusChanged {
h.updateCardCache(ctx, uint(cardID), map[string]any{
"real_name_status": newRealnameStatus,
})
// 状态变化后需要重新匹配配置(通过调度器回调)
h.logger.Info("实名状态已变化,需要重新匹配配置",
zap.Uint64("card_id", cardID),
zap.Int("old_status", card.RealNameStatus),
zap.Int("new_status", newRealnameStatus))
}
// 更新监控统计
h.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startTime))
// 重新入队
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingRealname)
}
// HandleCarddataCheck 处理卡流量检查任务
func (h *PollingHandler) HandleCarddataCheck(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
var payload PollingTaskPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析任务载荷失败", zap.Error(err))
return nil
}
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
if err != nil {
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
return nil
}
// 获取并发信号量
if !h.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) {
h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata)
}
defer h.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
h.logger.Debug("开始流量检查",
zap.Uint64("card_id", cardID),
zap.Bool("is_manual", payload.IsManual))
// 获取卡信息
card, err := h.getCardWithCache(ctx, uint(cardID))
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID))
return nil
}
h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
h.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata)
}
// 调用 Gateway API 查询流量
var gatewayFlowMB float64
if h.gatewayClient != nil {
result, err := h.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{
CardNo: card.ICCID,
})
if err != nil {
h.logger.Warn("查询流量失败",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Error(err))
h.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata)
}
// Gateway 返回的是 MB 单位的流量
gatewayFlowMB = float64(result.UsedFlow)
h.logger.Info("流量检查完成",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", gatewayFlowMB),
zap.String("unit", result.Unit))
} else {
// Gateway 未配置,使用当前值
gatewayFlowMB = card.CurrentMonthUsageMB
h.logger.Debug("流量检查完成模拟Gateway未配置",
zap.Uint64("card_id", cardID))
}
// 计算流量增量(处理跨月)
now := time.Now()
updates := h.calculateFlowUpdates(card, gatewayFlowMB, now)
updates["last_data_check_at"] = now
// 更新数据库
if err := h.db.Model(&model.IotCard{}).
Where("id = ?", cardID).
Updates(updates).Error; err != nil {
h.logger.Error("更新卡流量信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
}
// 插入流量历史记录(异步,不阻塞主流程)
go h.insertDataUsageRecord(context.Background(), uint(cardID), gatewayFlowMB, card.CurrentMonthUsageMB, now, payload.IsManual)
// 更新 Redis 缓存
h.updateCardCache(ctx, uint(cardID), map[string]any{
"current_month_usage_mb": updates["current_month_usage_mb"],
})
// 更新监控统计
h.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startTime))
// 触发套餐检查(加入手动队列优先处理)
h.triggerPackageCheck(ctx, uint(cardID))
// 重新入队
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata)
}
// calculateFlowUpdates 计算流量更新值(处理跨月逻辑)
func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) map[string]any {
updates := make(map[string]any)
// 获取本月1号
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 判断是否跨月
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
// 跨月了:保存上月总量,重置本月
h.logger.Info("检测到跨月,重置流量计数",
zap.Uint("card_id", card.ID),
zap.Float64("last_month_total", card.CurrentMonthUsageMB),
zap.Float64("new_month_usage", gatewayFlowMB))
// 计算本次增量:上月最后值 + 本月当前值
increment := card.CurrentMonthUsageMB + gatewayFlowMB
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
updates["current_month_usage_mb"] = gatewayFlowMB
updates["data_usage_mb"] = card.DataUsageMB + int64(increment)
} else {
// 同月内:计算增量
increment := gatewayFlowMB - card.CurrentMonthUsageMB
if increment < 0 {
// Gateway 返回值比记录的小,可能是数据异常,不更新
h.logger.Warn("流量异常Gateway返回值小于记录值",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("recorded_flow", card.CurrentMonthUsageMB))
increment = 0
}
updates["current_month_usage_mb"] = gatewayFlowMB
if increment > 0 {
updates["data_usage_mb"] = card.DataUsageMB + int64(increment)
}
}
// 首次流量查询初始化
if card.CurrentMonthStartDate == nil {
updates["current_month_start_date"] = currentMonthStart
}
return updates
}
// HandlePackageCheck 处理套餐检查任务
func (h *PollingHandler) HandlePackageCheck(ctx context.Context, t *asynq.Task) error {
startTime := time.Now()
var payload PollingTaskPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析任务载荷失败", zap.Error(err))
return nil
}
cardID, err := strconv.ParseUint(payload.CardID, 10, 64)
if err != nil {
h.logger.Error("解析卡ID失败", zap.String("card_id", payload.CardID), zap.Error(err))
return nil
}
// 获取并发信号量
if !h.acquireConcurrency(ctx, constants.TaskTypePollingPackage) {
h.logger.Debug("并发已满,任务稍后重试", zap.Uint64("card_id", cardID))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
defer h.releaseConcurrency(ctx, constants.TaskTypePollingPackage)
h.logger.Debug("开始套餐检查",
zap.Uint64("card_id", cardID),
zap.Bool("is_manual", payload.IsManual))
// 获取卡信息
card, err := h.getCardWithCache(ctx, uint(cardID))
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("卡不存在", zap.Uint64("card_id", cardID))
return nil
}
h.logger.Error("获取卡信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
h.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
// 检查套餐配置
if card.SeriesID == nil {
h.logger.Debug("卡无关联套餐系列,跳过检查", zap.Uint64("card_id", cardID))
h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
// 查询套餐信息(获取虚流量限制)
var pkg model.Package
if err := h.db.Where("series_id = ? AND status = 1", *card.SeriesID).
Order("created_at ASC").First(&pkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Debug("套餐系列无可用套餐", zap.Uint("series_id", *card.SeriesID))
} else {
h.logger.Warn("查询套餐失败", zap.Uint("series_id", *card.SeriesID), zap.Error(err))
}
h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
// 检查是否启用虚流量
if !pkg.EnableVirtualData || pkg.VirtualDataMB <= 0 {
h.logger.Debug("套餐未启用虚流量或虚流量为0跳过检查",
zap.Uint64("card_id", cardID),
zap.Uint("package_id", pkg.ID),
zap.Bool("enable_virtual", pkg.EnableVirtualData),
zap.Int64("virtual_data_mb", pkg.VirtualDataMB))
h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
// 计算流量使用:支持设备级套餐流量汇总
usedMB, deviceCards, isDeviceLevel := h.calculatePackageUsage(ctx, card)
limitMB := float64(pkg.VirtualDataMB)
usagePercent := (usedMB / limitMB) * 100
h.logger.Info("套餐流量检查",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Float64("used_mb", usedMB),
zap.Float64("limit_mb", limitMB),
zap.Float64("usage_percent", usagePercent),
zap.Bool("is_device_level", isDeviceLevel),
zap.Int("device_card_count", len(deviceCards)))
// 判断状态
var needStop bool
var statusMsg string
switch {
case usedMB > limitMB:
// 已超额,需要停机
needStop = true
statusMsg = "已超额"
h.logger.Warn("套餐已超额,准备停机",
zap.Uint64("card_id", cardID),
zap.Float64("used_mb", usedMB),
zap.Float64("limit_mb", limitMB))
case usagePercent >= 95:
// 临近超额(预警)
statusMsg = "临近超额"
h.logger.Warn("套餐流量临近超额",
zap.Uint64("card_id", cardID),
zap.Float64("usage_percent", usagePercent))
default:
// 正常
statusMsg = "正常"
}
// 执行停机
if needStop {
// 设备级套餐需要停机设备下所有卡
cardsToStop := []*model.IotCard{card}
if isDeviceLevel && len(deviceCards) > 0 {
cardsToStop = deviceCards
}
if h.gatewayClient != nil {
h.stopCards(ctx, cardsToStop, &pkg, usedMB)
} else {
h.logger.Debug("停机跳过Gateway未配置",
zap.Uint64("card_id", cardID),
zap.Int("cards_to_stop", len(cardsToStop)))
}
}
h.logger.Info("套餐检查完成",
zap.Uint64("card_id", cardID),
zap.String("iccid", card.ICCID),
zap.Float64("used_mb", usedMB),
zap.Float64("limit_mb", limitMB),
zap.String("status", statusMsg),
zap.Bool("stopped", needStop && card.NetworkStatus == 1),
zap.Duration("duration", time.Since(startTime)))
// 更新监控统计
h.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
// 重新入队
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingPackage)
}
// logStopOperation 记录停机操作日志
func (h *PollingHandler) logStopOperation(_ context.Context, card *model.IotCard, pkg *model.Package, usedMB float64) {
// 记录详细的停机操作日志(应用日志级别)
h.logger.Info("停机操作记录",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Uint("package_id", pkg.ID),
zap.String("package_name", pkg.PackageName),
zap.Float64("used_mb", usedMB),
zap.Int64("virtual_data_mb", pkg.VirtualDataMB),
zap.String("reason", "套餐超额自动停机"),
zap.String("operator", "系统自动"),
zap.Int("before_network_status", 1),
zap.Int("after_network_status", 0))
}
// calculatePackageUsage 计算套餐流量使用(支持设备级套餐汇总)
// 返回总流量MB、设备下所有卡如果是设备级套餐、是否为设备级套餐
func (h *PollingHandler) calculatePackageUsage(ctx context.Context, card *model.IotCard) (float64, []*model.IotCard, bool) {
// 检查卡是否绑定到设备
binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil {
// 卡未绑定到设备,使用单卡流量
return card.CurrentMonthUsageMB, nil, false
}
// 卡绑定到设备,获取设备下所有卡
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, binding.DeviceID)
if err != nil {
h.logger.Warn("查询设备下所有绑定失败,使用单卡流量",
zap.Uint("device_id", binding.DeviceID),
zap.Error(err))
return card.CurrentMonthUsageMB, nil, false
}
if len(bindings) == 0 {
return card.CurrentMonthUsageMB, nil, false
}
// 获取设备下所有卡的 ID
cardIDs := make([]uint, len(bindings))
for i, b := range bindings {
cardIDs[i] = b.IotCardID
}
// 批量查询卡信息
var cards []*model.IotCard
if err := h.db.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
h.logger.Warn("查询设备下所有卡失败,使用单卡流量",
zap.Uint("device_id", binding.DeviceID),
zap.Error(err))
return card.CurrentMonthUsageMB, nil, false
}
// 汇总流量
var totalUsedMB float64
for _, c := range cards {
totalUsedMB += c.CurrentMonthUsageMB
}
h.logger.Debug("设备级套餐流量汇总",
zap.Uint("device_id", binding.DeviceID),
zap.Int("card_count", len(cards)),
zap.Float64("total_used_mb", totalUsedMB))
return totalUsedMB, cards, true
}
// stopCards 批量停机卡
func (h *PollingHandler) stopCards(ctx context.Context, cards []*model.IotCard, pkg *model.Package, usedMB float64) {
for _, card := range cards {
// 跳过已停机的卡
if card.NetworkStatus != 1 {
continue
}
err := h.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{
CardNo: card.ICCID,
})
if err != nil {
h.logger.Error("停机失败",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.Error(err))
// 继续处理其他卡,不中断
continue
}
h.logger.Warn("停机成功",
zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID),
zap.String("reason", "套餐超额"))
// 更新数据库:卡的网络状态
now := time.Now()
if err := h.db.Model(&model.IotCard{}).
Where("id = ?", card.ID).
Updates(map[string]any{
"network_status": 0, // 停机
"updated_at": now,
}).Error; err != nil {
h.logger.Error("更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
// 更新 Redis 缓存
h.updateCardCache(ctx, card.ID, map[string]any{
"network_status": 0,
})
// 记录操作日志
h.logStopOperation(ctx, card, pkg, usedMB)
}
}
// parseRealnameStatus 解析实名状态
func (h *PollingHandler) parseRealnameStatus(gatewayStatus string) int {
switch gatewayStatus {
case "已实名", "realname", "1":
return 2 // 已实名
case "实名中", "processing":
return 1 // 实名中
default:
return 0 // 未实名
}
}
// extractTaskType 从完整的任务类型中提取简短的类型名
// 例如polling:carddata -> carddata
func extractTaskType(fullTaskType string) string {
if idx := strings.LastIndex(fullTaskType, ":"); idx != -1 {
return fullTaskType[idx+1:]
}
return fullTaskType
}
// acquireConcurrency 获取并发信号量
func (h *PollingHandler) acquireConcurrency(ctx context.Context, taskType string) bool {
// 提取简短的任务类型(数据库中存的是 carddata不是 polling:carddata
shortType := extractTaskType(taskType)
configKey := constants.RedisPollingConcurrencyConfigKey(shortType)
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType) // current 保持原样
// 获取最大并发数
maxConcurrency, err := h.redis.Get(ctx, configKey).Int()
if err != nil {
maxConcurrency = 50 // 默认值
}
// 尝试获取信号量
current, err := h.redis.Incr(ctx, currentKey).Result()
if err != nil {
h.logger.Warn("获取并发计数失败", zap.Error(err))
return true // 出错时允许执行
}
if current > int64(maxConcurrency) {
h.redis.Decr(ctx, currentKey)
return false
}
return true
}
// releaseConcurrency 释放并发信号量
func (h *PollingHandler) releaseConcurrency(ctx context.Context, taskType string) {
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
if err := h.redis.Decr(ctx, currentKey).Err(); err != nil {
h.logger.Warn("释放并发计数失败", zap.Error(err))
}
}
// requeueCard 重新将卡加入队列
func (h *PollingHandler) requeueCard(ctx context.Context, cardID uint, taskType string) error {
// 获取配置中的检查间隔
var intervalSeconds int
switch taskType {
case constants.TaskTypePollingRealname:
intervalSeconds = 300 // 默认 5 分钟
case constants.TaskTypePollingCarddata:
intervalSeconds = 600 // 默认 10 分钟
case constants.TaskTypePollingPackage:
intervalSeconds = 600 // 默认 10 分钟
default:
return nil
}
// 计算下次检查时间
nextCheck := time.Now().Add(time.Duration(intervalSeconds) * time.Second)
// 确定队列 key
var queueKey string
switch taskType {
case constants.TaskTypePollingRealname:
queueKey = constants.RedisPollingQueueRealnameKey()
case constants.TaskTypePollingCarddata:
queueKey = constants.RedisPollingQueueCarddataKey()
case constants.TaskTypePollingPackage:
queueKey = constants.RedisPollingQueuePackageKey()
}
// 添加到队列
return h.redis.ZAdd(ctx, queueKey, redis.Z{
Score: float64(nextCheck.Unix()),
Member: strconv.FormatUint(uint64(cardID), 10),
}).Err()
}
// triggerPackageCheck 触发套餐检查
func (h *PollingHandler) triggerPackageCheck(ctx context.Context, cardID uint) {
key := constants.RedisPollingManualQueueKey(constants.TaskTypePollingPackage)
if err := h.redis.RPush(ctx, key, strconv.FormatUint(uint64(cardID), 10)).Err(); err != nil {
h.logger.Warn("触发套餐检查失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// updateStats 更新监控统计
func (h *PollingHandler) updateStats(ctx context.Context, taskType string, success bool, duration time.Duration) {
key := constants.RedisPollingStatsKey(taskType)
pipe := h.redis.Pipeline()
if success {
pipe.HIncrBy(ctx, key, "success_count_1h", 1)
} else {
pipe.HIncrBy(ctx, key, "failure_count_1h", 1)
}
pipe.HIncrBy(ctx, key, "total_duration_1h", duration.Milliseconds())
pipe.Expire(ctx, key, 2*time.Hour)
_, _ = pipe.Exec(ctx)
}
// insertDataUsageRecord 插入流量历史记录
func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, currentUsageMB, previousUsageMB float64, checkTime time.Time, isManual bool) {
// 计算流量增量
var dataIncreaseMB int64
if currentUsageMB > previousUsageMB {
dataIncreaseMB = int64(currentUsageMB - previousUsageMB)
}
// 确定数据来源
source := "polling"
if isManual {
source = "manual"
}
// 创建流量记录
record := &model.DataUsageRecord{
IotCardID: cardID,
DataUsageMB: int64(currentUsageMB),
DataIncreaseMB: dataIncreaseMB,
CheckTime: checkTime,
Source: source,
}
// 插入记录
if err := h.dataUsageRecordStore.Create(ctx, record); err != nil {
h.logger.Warn("插入流量历史记录失败",
zap.Uint("card_id", cardID),
zap.Int64("data_usage_mb", record.DataUsageMB),
zap.Int64("data_increase_mb", record.DataIncreaseMB),
zap.String("source", source),
zap.Error(err))
} else {
h.logger.Debug("流量历史记录已插入",
zap.Uint("card_id", cardID),
zap.Int64("data_usage_mb", record.DataUsageMB),
zap.Int64("data_increase_mb", record.DataIncreaseMB),
zap.String("source", source))
}
}
// updateCardCache 更新卡缓存
func (h *PollingHandler) updateCardCache(ctx context.Context, cardID uint, updates map[string]any) {
key := constants.RedisPollingCardInfoKey(cardID)
// 转换为 []any 用于 HSet
args := make([]any, 0, len(updates)*2)
for k, v := range updates {
args = append(args, k, v)
}
if len(args) > 0 {
if err := h.redis.HSet(ctx, key, args...).Err(); err != nil {
h.logger.Warn("更新卡缓存失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
}
// getCardWithCache 优先从 Redis 缓存获取卡信息miss 时查 DB
// 大幅减少数据库查询,避免高并发时连接池耗尽
func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*model.IotCard, error) {
key := constants.RedisPollingCardInfoKey(cardID)
// 尝试从 Redis 读取
result, err := h.redis.HGetAll(ctx, key).Result()
if err == nil && len(result) > 0 {
// 缓存命中,构建卡对象
card := &model.IotCard{}
card.ID = cardID
if v, ok := result["iccid"]; ok {
card.ICCID = v
}
if v, ok := result["card_category"]; ok {
card.CardCategory = v
}
if v, ok := result["real_name_status"]; ok {
if status, err := strconv.Atoi(v); err == nil {
card.RealNameStatus = status
}
}
if v, ok := result["network_status"]; ok {
if status, err := strconv.Atoi(v); err == nil {
card.NetworkStatus = status
}
}
if v, ok := result["carrier_id"]; ok {
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
card.CarrierID = uint(id)
}
}
if v, ok := result["current_month_usage_mb"]; ok {
if usage, err := strconv.ParseFloat(v, 64); err == nil {
card.CurrentMonthUsageMB = usage
}
}
if v, ok := result["series_id"]; ok {
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
seriesID := uint(id)
card.SeriesID = &seriesID
}
}
return card, nil
}
// 缓存 miss查询数据库
card, err := h.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return nil, err
}
// 异步写入缓存
go func() {
cacheCtx := context.Background()
cacheData := map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"cached_at": time.Now().Unix(),
}
if card.SeriesID != nil {
cacheData["series_id"] = *card.SeriesID
}
pipe := h.redis.Pipeline()
pipe.HSet(cacheCtx, key, cacheData)
pipe.Expire(cacheCtx, key, 7*24*time.Hour)
_, _ = pipe.Exec(cacheCtx)
}()
return card, nil
}