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

@@ -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 := ""