fix: 应用 xhigh 代码评审的跨端修复
ci / server (push) Failing after 13s
ci / design-tokens (push) Failing after 11s

来自 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:
wangjia
2026-06-13 11:50:08 +08:00
parent 50b49f3cbe
commit b5ab92a57e
42 changed files with 2125 additions and 469 deletions
+23 -12
View File
@@ -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) -- 17Cseq 计数器与 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 仅 deviceSlotTTL4min)作为崩溃/泄漏兜底,并非会话上限。
// 长会话期间持有者必须周期性调用 RefreshDeviceSlot 续期(间隔需 < TTL),
// 否则 TTL 到期后槽位被释放、并发设备可抢占。网关 usageLoop 每个 tick2s
// 应顺带调用 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 -10
View File
@@ -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=0sum>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")
}
}