Files
junhong_cmp_fiber/internal/polling/carrierqueue/sr_store.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

349 lines
14 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"
stderrors "errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// StopResumeCommand 是写入停复机队列的一条指令。
type StopResumeCommand struct {
CardID uint
CarrierID uint
Action string // constants.StopResumeActionStop / StopResumeActionStart
Reason string // 停机原因
Scenes []string // 触发场景
Source string // 请求来源(日志与排查)
}
// CommandState 是卡当前停复机指令的解析结果。
type CommandState struct {
Exists bool
Action string
Reason string
Scenes []string
Source string
CarrierID uint
Version int64
FailCount int
FirstFailed time.Time
LastError string
Pending bool
Unknown int
WrittenAt time.Time
NextAt time.Time // 队列中的可执行时间(零值表示不在队列中)
LastCallAt time.Time
}
// WriteCommand 写入或覆盖卡的停复机指令,返回新版本号。
func (s *Store) WriteCommand(ctx context.Context, cmd StopResumeCommand) (int64, error) {
if cmd.CardID == 0 || cmd.CarrierID == 0 {
return 0, stderrors.New("停复机指令缺少卡或运营商账户")
}
return srWriteScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeCmdKey(cmd.CardID), constants.RedisCarrierStopResumeQueueKey(cmd.CarrierID),
constants.RedisCardStopResumeClaimKey(cmd.CardID)},
cmd.Action, cmd.Reason, constants.JoinStopResumeScenes(cmd.Scenes), cmd.Source, cmd.CarrierID, time.Now().UnixMilli(), cmd.CardID,
).Int64()
}
// SRClaimInput 是一次账户停复机领取的参数。
type SRClaimInput struct {
CarrierID uint
Lease time.Duration
Capacity int // 执行上限与剩余 QPS 的较小值
TokenPrefix string
MinInterval time.Duration
}
// SRClaimed 是一条被领取的停复机指令。
type SRClaimed struct {
CardID uint
Token string
Version int64
Action string
Pending bool
}
// ClaimStopResume 领取账户到期停复机指令,返回领取结果与本轮清理的过期执行数。
func (s *Store) ClaimStopResume(ctx context.Context, input SRClaimInput) ([]SRClaimed, int64, error) {
raw, err := srClaimScript.Run(ctx, s.redis,
[]string{constants.RedisCarrierStopResumeQueueKey(input.CarrierID), constants.RedisCarrierStopResumeRunningKey(input.CarrierID)},
time.Now().UnixMilli(), input.Lease.Milliseconds(), input.Capacity, input.TokenPrefix, input.MinInterval.Milliseconds(),
).Slice()
if err != nil || len(raw) == 0 {
return nil, 0, err
}
expired, _ := raw[0].(int64)
claimed := make([]SRClaimed, 0, (len(raw)-1)/5)
for index := 1; index+4 < len(raw); index += 5 {
cardID, _ := strconv.ParseUint(fmt.Sprint(raw[index]), 10, 64)
version, _ := strconv.ParseInt(fmt.Sprint(raw[index+2]), 10, 64)
claimed = append(claimed, SRClaimed{CardID: uint(cardID), Token: fmt.Sprint(raw[index+1]), Version: version,
Action: fmt.Sprint(raw[index+3]), Pending: fmt.Sprint(raw[index+4]) == "1"})
}
return claimed, expired, nil
}
// AcquireStopResumeExec 抢停复机指令执行权:1 抢到,0 处理中标记已不是该 token,-1 已有执行者。
func (s *Store) AcquireStopResumeExec(ctx context.Context, cardID uint, token, execValue string, ttl time.Duration) (int64, error) {
return acquireExecScript.Run(ctx, s.redis, []string{constants.RedisCardStopResumeClaimKey(cardID), constants.RedisCardStopResumeExecKey(cardID)},
token, execValue, ttl.Milliseconds()).Int64()
}
// SRFinish 是一次停复机指令收尾。
type SRFinish struct {
CardID uint
CarrierID uint
Token string
Version int64
Done bool // true 结束并删除指令
NextAt time.Time
Set map[string]string
Del []string
}
// FinishStopResume 停复机指令收尾:返回 0 token 不一致、1 正常、2 版本已变大保留新指令。
func (s *Store) FinishStopResume(ctx context.Context, finish SRFinish) (int64, error) {
done := "0"
if finish.Done {
done = "1"
}
now := time.Now()
next := finish.NextAt
if next.IsZero() || next.Before(now) {
next = now
}
args := []any{finish.Token, finish.CardID, finish.Version, done, next.UnixMilli(), now.UnixMilli(), len(finish.Set)}
for field, value := range finish.Set {
args = append(args, field, value)
}
for _, field := range finish.Del {
args = append(args, field)
}
return srFinishScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeCmdKey(finish.CardID), constants.RedisCardStopResumeClaimKey(finish.CardID),
constants.RedisCardStopResumeExecKey(finish.CardID), constants.RedisCarrierStopResumeRunningKey(finish.CarrierID),
constants.RedisCarrierStopResumeQueueKey(finish.CarrierID)},
args...).Int64()
}
// ReleaseStopResume 发布失败时释放停复机指令处理中标记并恢复为立即可领取。
func (s *Store) ReleaseStopResume(ctx context.Context, cardID, carrierID uint, token string) error {
return srReleaseScript.Run(ctx, s.redis,
[]string{constants.RedisCardStopResumeClaimKey(cardID), constants.RedisCarrierStopResumeRunningKey(carrierID),
constants.RedisCarrierStopResumeQueueKey(carrierID)},
token, cardID, time.Now().UnixMilli()).Err()
}
// MarkStopResumeCall 记录本卡一次停复机网关调用(停机与复机合计),供单卡最小重试间隔判断。
func (s *Store) MarkStopResumeCall(ctx context.Context, cardID uint, at time.Time, keep time.Duration) error {
if keep < time.Hour {
keep = time.Hour
}
return s.redis.Set(ctx, constants.RedisCardStopResumeLastCallKey(cardID), at.UnixMilli(), keep).Err()
}
// LastStopResumeCall 返回本卡最近一次停复机网关调用时间;没有记录返回零值。
func (s *Store) LastStopResumeCall(ctx context.Context, cardID uint) (time.Time, error) {
ms, err := s.redis.Get(ctx, constants.RedisCardStopResumeLastCallKey(cardID)).Int64()
if stderrors.Is(err, redis.Nil) {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms), nil
}
// ReadCommand 读取卡当前停复机指令与队列位置。
func (s *Store) ReadCommand(ctx context.Context, cardID uint) (CommandState, error) {
raw, err := s.redis.HGetAll(ctx, constants.RedisCardStopResumeCmdKey(cardID)).Result()
if err != nil {
return CommandState{}, err
}
state := ParseCommand(raw)
if last, lastErr := s.LastStopResumeCall(ctx, cardID); lastErr == nil {
state.LastCallAt = last
}
if state.Exists && state.CarrierID > 0 {
member := strconv.FormatUint(uint64(cardID), 10)
if score, scoreErr := s.redis.ZScore(ctx, constants.RedisCarrierStopResumeQueueKey(state.CarrierID), member).Result(); scoreErr == nil {
state.NextAt = time.UnixMilli(int64(score))
}
}
return state, nil
}
// ParseCommand 解析停复机指令 Hash。
func ParseCommand(raw map[string]string) CommandState {
state := CommandState{Exists: raw[srFieldAction] != ""}
state.Action, state.Reason, state.Source, state.LastError = raw[srFieldAction], raw[srFieldReason], raw[srFieldSource], raw[srFieldError]
state.Scenes = constants.SplitStopResumeScenes(raw[srFieldScenes])
if value, err := strconv.ParseUint(raw[srFieldCarrier], 10, 64); err == nil {
state.CarrierID = uint(value)
}
state.Version, _ = strconv.ParseInt(raw[srFieldVersion], 10, 64)
state.FailCount, _ = strconv.Atoi(raw[srFieldFail])
state.Unknown, _ = strconv.Atoi(raw[srFieldUnknown])
state.Pending = raw[srFieldPending] == "1"
if ms, err := strconv.ParseInt(raw[srFieldFirst], 10, 64); err == nil && ms > 0 {
state.FirstFailed = time.UnixMilli(ms)
}
if ms, err := strconv.ParseInt(raw[srFieldAt], 10, 64); err == nil && ms > 0 {
state.WrittenAt = time.UnixMilli(ms)
}
return state
}
// 停复机指令字段名导出给执行器组装收尾写入。
const (
SRFieldFail = srFieldFail
SRFieldFirst = srFieldFirst
SRFieldError = srFieldError
SRFieldPending = srFieldPending
SRFieldUnknown = srFieldUnknown
)
// RequestDeviceEval 请求一次设备维度判断:递增版本、合并场景、设备进入判断队列。
func (s *Store) RequestDeviceEval(ctx context.Context, deviceID uint, scenes []string) error {
if deviceID == 0 {
return stderrors.New("设备判断缺少设备")
}
args := []any{deviceID, time.Now().UnixMilli()}
for _, scene := range constants.SplitStopResumeScenes(constants.JoinStopResumeScenes(scenes)) {
args = append(args, scene)
}
return deviceRequestScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalVersionKey(deviceID), constants.RedisDeviceEvalScenesKey(deviceID), constants.RedisDeviceEvalQueueKey,
constants.RedisDeviceEvalClaimKey(deviceID)},
args...).Err()
}
// DeviceEvalClaimed 是一次被领取的设备判断。
type DeviceEvalClaimed struct {
DeviceID uint
Token string
Version string
Scenes []string
}
// ClaimDeviceEvals 领取到期设备判断。
func (s *Store) ClaimDeviceEvals(ctx context.Context, lease time.Duration, limit int, tokenPrefix string) ([]DeviceEvalClaimed, int64, error) {
raw, err := deviceClaimScript.Run(ctx, s.redis, []string{constants.RedisDeviceEvalQueueKey, constants.RedisDeviceEvalRunningKey},
time.Now().UnixMilli(), lease.Milliseconds(), limit, tokenPrefix).Slice()
if err != nil || len(raw) == 0 {
return nil, 0, err
}
expired, _ := raw[0].(int64)
claimed := make([]DeviceEvalClaimed, 0, (len(raw)-1)/4)
for index := 1; index+3 < len(raw); index += 4 {
deviceID, _ := strconv.ParseUint(fmt.Sprint(raw[index]), 10, 64)
claimed = append(claimed, DeviceEvalClaimed{DeviceID: uint(deviceID), Token: fmt.Sprint(raw[index+1]),
Version: fmt.Sprint(raw[index+2]), Scenes: constants.SplitStopResumeScenes(fmt.Sprint(raw[index+3]))})
}
return claimed, expired, nil
}
// AcquireDeviceEvalLock 抢设备判断锁(执行权):1 抢到,0 处理中标记已不是该 token,-1 已有判断在执行。
func (s *Store) AcquireDeviceEvalLock(ctx context.Context, deviceID uint, token, value string, ttl time.Duration) (int64, error) {
return acquireExecScript.Run(ctx, s.redis, []string{constants.RedisDeviceEvalClaimKey(deviceID), constants.RedisDeviceEvalLockKey(deviceID)},
token, value, ttl.Milliseconds()).Int64()
}
// FinishDeviceEval 设备判断收尾:返回 0 token 不一致、1 完成、2 版本变大已重新排队、3 已推后。
func (s *Store) FinishDeviceEval(ctx context.Context, claimed DeviceEvalClaimed, deferFor time.Duration) (int64, error) {
args := []any{claimed.Token, claimed.DeviceID, claimed.Version, time.Now().UnixMilli(), deferFor.Milliseconds()}
for _, scene := range claimed.Scenes {
args = append(args, scene)
}
return deviceFinishScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalClaimKey(claimed.DeviceID), constants.RedisDeviceEvalLockKey(claimed.DeviceID),
constants.RedisDeviceEvalRunningKey, constants.RedisDeviceEvalVersionKey(claimed.DeviceID),
constants.RedisDeviceEvalScenesKey(claimed.DeviceID), constants.RedisDeviceEvalQueueKey},
args...).Int64()
}
// ReleaseDeviceEval 发布失败时释放设备判断处理中标记。
func (s *Store) ReleaseDeviceEval(ctx context.Context, deviceID uint, token string) error {
return srReleaseScript.Run(ctx, s.redis,
[]string{constants.RedisDeviceEvalClaimKey(deviceID), constants.RedisDeviceEvalRunningKey, constants.RedisDeviceEvalQueueKey},
token, deviceID, time.Now().UnixMilli()).Err()
}
// StopResumeQueueStats 是账户停复机队列的即时统计。
type StopResumeQueueStats struct {
Queued int64
Due int64
Running int64
Pending int64
}
// StopResumeStats 读取账户停复机队列长度、到期数与执行中数;待确认数按队列成员抽样统计(最多 500 个)。
func (s *Store) StopResumeStats(ctx context.Context, carrierID uint) (StopResumeQueueStats, error) {
now := strconv.FormatInt(time.Now().UnixMilli(), 10)
queueKey := constants.RedisCarrierStopResumeQueueKey(carrierID)
pipe := s.redis.Pipeline()
queued := pipe.ZCard(ctx, queueKey)
due := pipe.ZCount(ctx, queueKey, "-inf", now)
running := pipe.ZCount(ctx, constants.RedisCarrierStopResumeRunningKey(carrierID), now, "+inf")
members := pipe.ZRange(ctx, queueKey, 0, 499)
if _, err := pipe.Exec(ctx); err != nil && !stderrors.Is(err, redis.Nil) {
return StopResumeQueueStats{}, err
}
stats := StopResumeQueueStats{Queued: queued.Val(), Due: due.Val(), Running: running.Val()}
if ids := members.Val(); len(ids) > 0 {
check := s.redis.Pipeline()
results := make([]*redis.StringCmd, 0, len(ids))
for _, id := range ids {
cardID, convErr := strconv.ParseUint(strings.TrimSpace(id), 10, 64)
if convErr != nil {
continue
}
results = append(results, check.HGet(ctx, constants.RedisCardStopResumeCmdKey(uint(cardID)), srFieldPending))
}
_, _ = check.Exec(ctx)
for _, result := range results {
if result.Val() == "1" {
stats.Pending++
}
}
}
return stats, nil
}
// IncrStopResumeStat 累加账户停复机执行计数(10 分钟分桶,保留 2 小时)。
func (s *Store) IncrStopResumeStat(ctx context.Context, carrierID uint, field string) {
key := constants.RedisCarrierStopResumeStatKey(carrierID, time.Now().Unix()/constants.StopResumeStatBucketSeconds)
pipe := s.redis.Pipeline()
pipe.HIncrBy(ctx, key, field, 1)
pipe.Expire(ctx, key, 2*time.Hour)
_, _ = pipe.Exec(ctx)
}
// StopResumeStatLastHour 汇总账户近 1 小时(最近 6 个分桶)的停复机执行计数。
func (s *Store) StopResumeStatLastHour(ctx context.Context, carrierID uint) map[string]int64 {
current := time.Now().Unix() / constants.StopResumeStatBucketSeconds
pipe := s.redis.Pipeline()
cmds := make([]*redis.MapStringStringCmd, 0, 6)
for bucket := current - 5; bucket <= current; bucket++ {
cmds = append(cmds, pipe.HGetAll(ctx, constants.RedisCarrierStopResumeStatKey(carrierID, bucket)))
}
_, _ = pipe.Exec(ctx)
totals := map[string]int64{}
for _, cmd := range cmds {
for field, value := range cmd.Val() {
count, _ := strconv.ParseInt(value, 10, 64)
totals[field] += count
}
}
return totals
}