new-api/service/concurrency.go
zizi cf5160c9f2 feat: add concurrency ops snapshot API
Expose admin Redis snapshot for user and channel concurrency plus current user RPM usage.
2026-05-20 14:46:06 +08:00

229 lines
5.9 KiB
Go

package service
import (
"context"
"sort"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
)
const (
userConcurrencyKeyPrefix = "user:concurrency:"
channelConcurrencyKeyPrefix = "channel:concurrency:"
userRPMKeyPrefix = "user:rpm:"
concurrencyTTL = 5 * time.Minute
rpmTTL = 2 * time.Minute
)
type OpsConcurrencySnapshot struct {
RedisEnabled bool `json:"redis_enabled"`
Users []OpsUserConcurrencySnapshot `json:"users"`
Channels []OpsChannelConcurrencySnapshot `json:"channels"`
GeneratedAt int64 `json:"generated_at"`
}
type OpsUserConcurrencySnapshot struct {
UserID int `json:"user_id"`
CurrentConcurrency int `json:"current_concurrency"`
CurrentRPM int `json:"current_rpm"`
}
type OpsChannelConcurrencySnapshot struct {
ChannelID int `json:"channel_id"`
CurrentConcurrency int `json:"current_concurrency"`
}
func AcquireUserConcurrency(userId, limit int) (bool, func()) {
return acquireConcurrency(userConcurrencyKeyPrefix+strconv.Itoa(userId), limit)
}
func AcquireChannelConcurrency(channelId, limit int) (bool, func()) {
return acquireConcurrency(channelConcurrencyKeyPrefix+strconv.Itoa(channelId), limit)
}
func CheckUserRPM(userId, limit int) bool {
if limit <= 0 || !redisAvailable() {
return true
}
key := userRPMKeyPrefix + strconv.Itoa(userId) + ":" + time.Now().Format("200601021504")
ctx := context.Background()
count, err := common.RDB.Incr(ctx, key).Result()
if err != nil {
return true
}
if err = common.RDB.Expire(ctx, key, rpmTTL).Err(); err != nil {
return true
}
return count <= int64(limit)
}
func GetOpsConcurrencySnapshot() OpsConcurrencySnapshot {
now := time.Now()
snapshot := OpsConcurrencySnapshot{
RedisEnabled: redisAvailable(),
Users: []OpsUserConcurrencySnapshot{},
Channels: []OpsChannelConcurrencySnapshot{},
GeneratedAt: now.Unix(),
}
if !snapshot.RedisEnabled {
return snapshot
}
ctx := context.Background()
userConcurrency, ok := scanIntValues(ctx, userConcurrencyKeyPrefix+"*", parseUserConcurrencyKey)
if !ok {
snapshot.RedisEnabled = false
return emptyOpsConcurrencySnapshot(snapshot)
}
currentRPMMinute := now.Format("200601021504")
userRPM, ok := scanIntValues(ctx, userRPMKeyPrefix+"*:"+currentRPMMinute, func(key string) (int, bool) {
return parseUserRPMKey(key, currentRPMMinute)
})
if !ok {
snapshot.RedisEnabled = false
return emptyOpsConcurrencySnapshot(snapshot)
}
channelConcurrency, ok := scanIntValues(ctx, channelConcurrencyKeyPrefix+"*", parseChannelConcurrencyKey)
if !ok {
snapshot.RedisEnabled = false
return emptyOpsConcurrencySnapshot(snapshot)
}
for userID, concurrency := range userConcurrency {
snapshot.Users = append(snapshot.Users, OpsUserConcurrencySnapshot{
UserID: userID,
CurrentConcurrency: concurrency,
CurrentRPM: userRPM[userID],
})
delete(userRPM, userID)
}
for userID, rpm := range userRPM {
snapshot.Users = append(snapshot.Users, OpsUserConcurrencySnapshot{
UserID: userID,
CurrentRPM: rpm,
})
}
for channelID, concurrency := range channelConcurrency {
snapshot.Channels = append(snapshot.Channels, OpsChannelConcurrencySnapshot{
ChannelID: channelID,
CurrentConcurrency: concurrency,
})
}
sort.Slice(snapshot.Users, func(i, j int) bool {
return snapshot.Users[i].UserID < snapshot.Users[j].UserID
})
sort.Slice(snapshot.Channels, func(i, j int) bool {
return snapshot.Channels[i].ChannelID < snapshot.Channels[j].ChannelID
})
return snapshot
}
func acquireConcurrency(key string, limit int) (bool, func()) {
release := func() {}
if limit <= 0 || !redisAvailable() {
return true, release
}
ctx := context.Background()
count, err := common.RDB.Incr(ctx, key).Result()
if err != nil {
return true, release
}
release = func() {
if redisAvailable() {
_ = common.RDB.Decr(context.Background(), key).Err()
}
}
if err = common.RDB.Expire(ctx, key, concurrencyTTL).Err(); err != nil {
return true, release
}
if count > int64(limit) {
release()
return false, func() {}
}
return true, release
}
func redisAvailable() bool {
return common.RedisEnabled && common.RDB != nil
}
func emptyOpsConcurrencySnapshot(snapshot OpsConcurrencySnapshot) OpsConcurrencySnapshot {
snapshot.Users = []OpsUserConcurrencySnapshot{}
snapshot.Channels = []OpsChannelConcurrencySnapshot{}
return snapshot
}
func scanIntValues(ctx context.Context, pattern string, parseKey func(string) (int, bool)) (map[int]int, bool) {
values := make(map[int]int)
var cursor uint64
for {
keys, nextCursor, err := common.RDB.Scan(ctx, cursor, pattern, 100).Result()
if err != nil {
return nil, false
}
for _, key := range keys {
id, ok := parseKey(key)
if !ok {
continue
}
value, err := common.RDB.Get(ctx, key).Int()
if err != nil {
continue
}
values[id] = value
}
if nextCursor == 0 {
break
}
cursor = nextCursor
}
return values, true
}
func parseUserConcurrencyKey(key string) (int, bool) {
if !strings.HasPrefix(key, userConcurrencyKeyPrefix) {
return 0, false
}
return parsePositiveID(strings.TrimPrefix(key, userConcurrencyKeyPrefix))
}
func parseChannelConcurrencyKey(key string) (int, bool) {
if !strings.HasPrefix(key, channelConcurrencyKeyPrefix) {
return 0, false
}
return parsePositiveID(strings.TrimPrefix(key, channelConcurrencyKeyPrefix))
}
func parseUserRPMKey(key, minute string) (int, bool) {
prefix := userRPMKeyPrefix
suffix := ":" + minute
if !strings.HasPrefix(key, prefix) || !strings.HasSuffix(key, suffix) {
return 0, false
}
return parsePositiveID(strings.TrimSuffix(strings.TrimPrefix(key, prefix), suffix))
}
func parsePositiveID(value string) (int, bool) {
if value == "" {
return 0, false
}
id, err := strconv.Atoi(value)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}