Files
junhong_cmp_fiber/internal/polling/carrierqueue/sr_dispatch.go
邹进 934a3a27a1
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 停复机指令队列与设备维度判断
自动停复机统一经停复机指令队列执行(全局开关 polling.stop_resume.queue_enabled,默认关闭):
- 统一判断函数 decideCard,新旧路径共用;暂停轮询的卡在套餐到期、退款场景照常停机
- 同卡一条指令、版本号覆盖;领取校验单卡最小重试间隔;先于卡任务领取,不受 scheduler_mode 限制
- 执行器只调用一次网关,按错误分类退避与告警,结果未知在队列内确认
- 设备维度判断合并请求与场景,版本变化再判断,旧路径持设备锁时推后
- 套餐激活、重置、订单、流量扣减、套餐到期、退款、实名管控修改改为请求评估
- 监控增加停复机队列与近 1 小时执行计数,单卡轮询状态返回当前指令
- 迁移 000255 插入开关与告警、退避参数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:36:26 +09:00

121 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package carrierqueue
import (
"context"
"encoding/json"
"strconv"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeMessage 是停复机指令 NSQ 消息体。
type StopResumeMessage struct {
CardID uint `json:"card_id"`
CarrierID uint `json:"carrier_id"`
Token string `json:"token"`
Version int64 `json:"version"`
Action string `json:"action"`
Pending bool `json:"pending"`
DispatchedAt int64 `json:"dispatched_at"`
}
// DeviceEvalMessage 是设备判断 NSQ 消息体。
type DeviceEvalMessage struct {
DeviceID uint `json:"device_id"`
Token string `json:"token"`
Version string `json:"version"`
Scenes []string `json:"scenes"`
DispatchedAt int64 `json:"dispatched_at"`
}
// deviceEvalLimit 是全局同时执行的设备判断上限:判断只读数据库与写指令,毫秒级。
const deviceEvalLimit = 50
// stopResumeLease 是停复机指令与设备判断的租约:单次执行只调一次网关,远小于该值。
func (d *Dispatcher) stopResumeLease() time.Duration {
return 2 * time.Minute
}
// dispatchStopResume 在卡任务之前领取账户停复机指令:领取数 = min(停复机所在限流组本秒剩余, 执行上限 − 执行中)。
func (d *Dispatcher) dispatchStopResume(ctx context.Context, carrierID uint) {
params := d.ParamsFor(carrierID)
capacity := params.SRInflightLimit
processes := d.cfg.SubscriberProcesses
if processes <= 0 {
processes = 1
}
if consume := params.SRMaxInFlight * processes; consume > 0 && capacity > consume {
capacity = consume
}
if d.budgets != nil {
ops := []string{constants.GatewayOpStop, constants.GatewayOpStart}
if groups, _, limited := d.budgets.Budget(ctx, carrierID, ops); limited {
for _, remaining := range groups {
if remaining < capacity {
capacity = remaining
}
}
}
}
if capacity <= 0 {
return
}
claimed, expired, err := d.store.ClaimStopResume(ctx, SRClaimInput{
CarrierID: carrierID, Lease: d.stopResumeLease(), Capacity: capacity, MinInterval: params.SRMinInterval,
TokenPrefix: d.instanceID + ":sr:" + strconv.FormatInt(time.Now().UnixNano(), 36),
})
if err != nil {
d.logger.Warn("运营商账户停复机指令领取失败", zap.Uint("carrier_id", carrierID), zap.Error(err))
return
}
if expired > 0 {
d.logger.Warn("停复机指令处理中标记到期(执行崩溃或超时)", zap.Uint("carrier_id", carrierID), zap.Int64("count", expired),
zap.String("metric", "stop_resume.claim_expired"))
}
topic := constants.NSQStopResumeTopic(carrierID)
for _, item := range claimed {
body, _ := json.Marshal(StopResumeMessage{CardID: item.CardID, CarrierID: carrierID, Token: item.Token, Version: item.Version,
Action: item.Action, Pending: item.Pending, DispatchedAt: time.Now().UnixMilli()})
if err := d.publisher.Publish(topic, body); err != nil {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
if releaseErr := d.store.ReleaseStopResume(releaseCtx, item.CardID, carrierID, item.Token); releaseErr != nil {
d.logger.Warn("发布失败后释放停复机指令失败", zap.Uint("card_id", item.CardID), zap.Error(releaseErr))
}
cancel()
d.logger.Warn("发布停复机指令失败,已释放", zap.Uint("card_id", item.CardID), zap.String("topic", topic), zap.Error(err))
}
}
}
// dispatchDeviceEvals 领取到期设备判断并发布;全局分发锁保证每轮只有一个实例领取。
func (d *Dispatcher) dispatchDeviceEvals(ctx context.Context, lockTTL time.Duration) {
ok, err := d.store.redis.SetNX(ctx, constants.RedisDeviceEvalDispatchLockKey, d.instanceID, lockTTL).Result()
if err != nil || !ok {
return
}
claimed, expired, err := d.store.ClaimDeviceEvals(ctx, d.stopResumeLease(), deviceEvalLimit,
d.instanceID+":dev:"+strconv.FormatInt(time.Now().UnixNano(), 36))
if err != nil {
d.logger.Warn("设备判断领取失败", zap.Error(err))
return
}
if expired > 0 {
d.logger.Warn("设备判断处理中标记到期(执行崩溃或超时)", zap.Int64("count", expired), zap.String("metric", "stop_resume.device_eval_expired"))
}
for _, item := range claimed {
body, _ := json.Marshal(DeviceEvalMessage{DeviceID: item.DeviceID, Token: item.Token, Version: item.Version, Scenes: item.Scenes,
DispatchedAt: time.Now().UnixMilli()})
if err := d.publisher.Publish(constants.NSQDeviceEvalTopic, body); err != nil {
releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
if releaseErr := d.store.ReleaseDeviceEval(releaseCtx, item.DeviceID, item.Token); releaseErr != nil {
d.logger.Warn("发布失败后释放设备判断失败", zap.Uint("device_id", item.DeviceID), zap.Error(releaseErr))
}
cancel()
d.logger.Warn("发布设备判断失败,已释放", zap.Uint("device_id", item.DeviceID), zap.Error(err))
}
}
}