fix: 应用 xhigh 代码评审的跨端修复
来自 xhigh code review 的正确性/健壮性修复,覆盖全部五端: - server:鉴权 fail-closed、计量交叉校验与配额扣穿处理、WS 网关并发与关闭顺序、 billing 行锁、redis Lua 过期与设备槽刷新、config 解析 - desktop:会话 epoch 防串话、WS 重连与 401 处理、api 客户端复用、统一 usePoll 轮询 - android:握手时序、请求头封装、账户状态派生、按需重组 - ios:finalize 宽限、串行采集、错误文案服务端优先、删除死代码 CommitController Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -47,7 +46,9 @@ else
|
||||
end
|
||||
end
|
||||
if sum + val > limit then return 0 end
|
||||
redis.call('ZADD', key, now, now .. '-' .. redis.call('INCR', key .. ':seq') .. ':' .. val)
|
||||
local seq = redis.call('INCR', key .. ':seq')
|
||||
redis.call('EXPIRE', key .. ':seq', window + 60) -- 17C:seq 计数器与 ZSET 同寿命,避免按设备永久泄漏
|
||||
redis.call('ZADD', key, now, now .. '-' .. seq .. ':' .. val)
|
||||
redis.call('EXPIRE', key, window + 60)
|
||||
return 1
|
||||
`)
|
||||
@@ -59,13 +60,6 @@ func AllowSession(ctx context.Context, rdb *redis.Client, deviceID string, now t
|
||||
return ok == 1, err
|
||||
}
|
||||
|
||||
// AllowAudioSeconds 设备维度时长准入(原子检查并记录):30 分钟内累计 ≤1800s。
|
||||
func AllowAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string, seconds int, now time.Time) (bool, error) {
|
||||
ok, err := slideScript.Run(ctx, rdb, []string{KeyRateSecs(deviceID)},
|
||||
now.Unix(), 30*60, 30*60, seconds, "sum").Int()
|
||||
return ok == 1, err
|
||||
}
|
||||
|
||||
// AudioWindowExhausted 会话 start 时检查时长窗口是否已满(只查不记;本次秒数在结束时
|
||||
// 经 RecordAudioSeconds 记录——音频已实际消耗,结束时无条件记账)。
|
||||
func AudioWindowExhausted(ctx context.Context, rdb *redis.Client, deviceID string, now time.Time) (bool, error) {
|
||||
@@ -80,9 +74,28 @@ func RecordAudioSeconds(ctx context.Context, rdb *redis.Client, deviceID string,
|
||||
now.Unix(), 30*60, 1<<30, seconds, "sum").Err()
|
||||
}
|
||||
|
||||
const deviceSlotTTL = 4 * time.Minute
|
||||
|
||||
// AcquireDeviceSlot 单设备同时仅 1 路识别会话(SET NX + TTL 兜底防泄漏)。
|
||||
//
|
||||
// 续期契约(17D):槽位 TTL 仅 deviceSlotTTL(4min)作为崩溃/泄漏兜底,并非会话上限。
|
||||
// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL),
|
||||
// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick(2s)
|
||||
// 应顺带调用 RefreshDeviceSlot 续命;会话正常/异常结束时由 ReleaseDeviceSlot 主动释放。
|
||||
func AcquireDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
|
||||
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, 4*time.Minute).Result()
|
||||
return rdb.SetNX(ctx, KeyActiveSession(deviceID), sessionID, deviceSlotTTL).Result()
|
||||
}
|
||||
|
||||
// RefreshDeviceSlot 仅当槽位仍由本会话持有时续期 TTL(17D)。
|
||||
// 返回 (true,nil) 表示续期成功;(false,nil) 表示槽位已不属于自己(被抢占/已释放),
|
||||
// 调用方应据此判定会话是否仍合法持有槽位。供 gateway usageLoop 每 tick 调用。
|
||||
func RefreshDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) (bool, error) {
|
||||
// 持有者校验 + EXPIRE 原子化,避免续到别人刚抢占的槽位。
|
||||
script := redis.NewScript(`
|
||||
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) end
|
||||
return 0`)
|
||||
n, err := script.Run(ctx, rdb, []string{KeyActiveSession(deviceID)}, sessionID, int(deviceSlotTTL.Seconds())).Int()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
func ReleaseDeviceSlot(ctx context.Context, rdb *redis.Client, deviceID, sessionID string) error {
|
||||
@@ -103,5 +116,3 @@ func IncrDailyCounter(ctx context.Context, rdb *redis.Client, key string) (int64
|
||||
}
|
||||
return incr.Val(), nil
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf // keep fmt for future use
|
||||
|
||||
@@ -35,21 +35,25 @@ func TestAllowSessionWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAudioSecondsWindow(t *testing.T) {
|
||||
// TestAudioWindowExhausted 复用生产路径(AudioWindowExhausted 只查 + RecordAudioSeconds 记账)。
|
||||
func TestAudioWindowExhausted(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
// 1700s 放行
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1700, now); !ok {
|
||||
t.Fatal("1700s should pass")
|
||||
// 初始未满
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now); full {
|
||||
t.Fatal("fresh window should not be exhausted")
|
||||
}
|
||||
// 再 100s(累计 1800)放行
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 100, now.Add(time.Second)); !ok {
|
||||
t.Fatal("cumulative 1800s should pass")
|
||||
// 记满后窗口耗尽(AudioWindowExhausted 只查不记,val=0,sum>limit 才算满)
|
||||
if err := RecordAudioSeconds(ctx, r, "dev1", 1801, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 再 1s 超限拒绝
|
||||
if ok, _ := AllowAudioSeconds(ctx, r, "dev1", 1, now.Add(2*time.Second)); ok {
|
||||
t.Fatal("1801s should be rejected")
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev1", now.Add(time.Second)); !full {
|
||||
t.Fatal("window should be exhausted after exceeding 1800s")
|
||||
}
|
||||
// 其他设备不受影响
|
||||
if full, _ := AudioWindowExhausted(ctx, r, "dev2", now); full {
|
||||
t.Fatal("other device window should not be exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,3 +82,24 @@ func TestDeviceSlot(t *testing.T) {
|
||||
t.Fatal("acquire after release should pass")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshDeviceSlot 仅持有者能续期;非持有者续期无效(17D)。
|
||||
func TestRefreshDeviceSlot(t *testing.T) {
|
||||
r := rdb(t)
|
||||
ctx := context.Background()
|
||||
if ok, err := AcquireDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||||
t.Fatalf("acquire should pass: %v", err)
|
||||
}
|
||||
// 持有者续期成功
|
||||
if ok, err := RefreshDeviceSlot(ctx, r, "dev1", "s1"); err != nil || !ok {
|
||||
t.Fatalf("holder refresh should succeed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// 非持有者续期失败
|
||||
if ok, _ := RefreshDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||||
t.Fatal("non-holder refresh should fail")
|
||||
}
|
||||
// 续期未释放槽位:他人仍抢不到
|
||||
if ok, _ := AcquireDeviceSlot(ctx, r, "dev1", "s2"); ok {
|
||||
t.Fatal("slot should still be held after refresh")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user