feat: 实现 IoT 卡轮询系统(支持千万级卡规模)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m35s
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
506
internal/service/polling/alert_service.go
Normal file
506
internal/service/polling/alert_service.go
Normal 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))
|
||||
}
|
||||
347
internal/service/polling/cleanup_service.go
Normal file
347
internal/service/polling/cleanup_service.go
Normal 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
|
||||
}
|
||||
188
internal/service/polling/concurrency_service.go
Normal file
188
internal/service/polling/concurrency_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
252
internal/service/polling/config_service.go
Normal file
252
internal/service/polling/config_service.go
Normal 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: ¤tUserID,
|
||||
UpdatedBy: ¤tUserID,
|
||||
}
|
||||
|
||||
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 = ¤tUserID
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
477
internal/service/polling/manual_trigger_service.go
Normal file
477
internal/service/polling/manual_trigger_service.go
Normal 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 = ¤tShopID
|
||||
|
||||
return nil
|
||||
}
|
||||
283
internal/service/polling/monitoring_service.go
Normal file
283
internal/service/polling/monitoring_service.go
Normal 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)
|
||||
}
|
||||
@@ -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 := ""
|
||||
|
||||
Reference in New Issue
Block a user