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
+9 -1
View File
@@ -131,8 +131,16 @@ func (h *Handlers) MobileLogin(c *gin.Context) {
}
// Logout POST /v1/auth/logout(需登录)
// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便
// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。
func (h *Handlers) Logout(c *gin.Context) {
jti := c.GetString(CtxJTI)
_ = h.JWT.Revoke(c, jti, time.Now().Add(8*24*time.Hour)) // 覆盖最长 TTL
exp, _ := c.Get(CtxExpires)
expiresAt, _ := exp.(time.Time)
if expiresAt.IsZero() {
// 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。
expiresAt = time.Now().Add(time.Duration(h.JWT.ttl))
}
_ = h.JWT.Revoke(c, jti, expiresAt)
c.Status(http.StatusNoContent)
}
+40 -9
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
@@ -41,21 +42,32 @@ func (j *JWT) Sign(userID string) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(j.secret)
}
// Parse 校验签名、过期与黑名单,返回 userID 与 jti
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, err error) {
// Parse 校验签名、过期与黑名单,返回 userID、jti 与 token 自然过期时间
// 黑名单检查 fail-closedRedis 不可用(Exists 返回 error)时拒绝放行,
// 宁可误拒也不能让已登出/被踢出的 token 在 Redis 故障窗口复活(17A)。
func (j *JWT) Parse(ctx context.Context, token string) (userID, jti string, expiresAt time.Time, err error) {
var claims Claims
_, err = jwt.ParseWithClaims(token, &claims, func(t *jwt.Token) (any, error) {
return j.secret, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return "", "", err
return "", "", time.Time{}, err
}
if j.rdb != nil {
if n, _ := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result(); n == 1 {
return "", "", jwt.ErrTokenExpired
n, exErr := j.rdb.Exists(ctx, store.KeyJwtBlock(claims.ID)).Result()
if exErr != nil {
// fail-closed:无法确认黑名单状态时拒绝,避免故障期放行被撤销的 token。
slog.Warn("jwt blacklist check failed, rejecting (fail-closed)", "jti", claims.ID, "err", exErr)
return "", "", time.Time{}, exErr
}
if n == 1 {
return "", "", time.Time{}, jwt.ErrTokenExpired
}
}
return claims.Subject, claims.ID, nil
if claims.ExpiresAt != nil {
expiresAt = claims.ExpiresAt.Time
}
return claims.Subject, claims.ID, expiresAt, nil
}
// Revoke 将 jti 拉黑至 token 自然过期(logout / 踢出)。
@@ -68,8 +80,9 @@ func (j *JWT) Revoke(ctx context.Context, jti string, expiresAt time.Time) error
}
const (
CtxUserID = "auth.user_id"
CtxJTI = "auth.jti"
CtxUserID = "auth.user_id"
CtxJTI = "auth.jti"
CtxExpires = "auth.expires_at" // token 自然过期时间(time.Time),供 Logout 拉黑到期用
)
// Middleware gin 鉴权中间件:Authorization: Bearer <JWT>。
@@ -80,13 +93,31 @@ func (j *JWT) Middleware() gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
return
}
uid, jti, err := j.Parse(c.Request.Context(), token)
uid, jti, exp, err := j.Parse(c.Request.Context(), token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, protocol.NewAPIError(protocol.ErrUnauthorized))
return
}
c.Set(CtxUserID, uid)
c.Set(CtxJTI, jti)
c.Set(CtxExpires, exp)
c.Next()
}
}
// OptionalMiddleware 可选鉴权(17F):带合法 Authorization 时注入 user_id
// 无 token 或解析失败(含 Redis 故障导致的 fail-closed)一律放行匿名,不拦截。
// 用于 /v1/metrics/batch 等既接受匿名打点、又希望登录用户能关联 user_id 的端点。
func (j *JWT) OptionalMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
if token != "" {
if uid, jti, exp, err := j.Parse(c.Request.Context(), token); err == nil {
c.Set(CtxUserID, uid)
c.Set(CtxJTI, jti)
c.Set(CtxExpires, exp)
}
}
c.Next()
}
}
+28 -3
View File
@@ -19,21 +19,46 @@ func TestSignParseRevoke(t *testing.T) {
if err != nil {
t.Fatal(err)
}
uid, jti, err := j.Parse(ctx, token)
uid, jti, exp, err := j.Parse(ctx, token)
if err != nil || uid != "u1" || jti == "" {
t.Fatalf("parse failed: uid=%s jti=%s err=%v", uid, jti, err)
}
if exp.IsZero() || time.Until(exp) <= 0 {
t.Fatalf("expiresAt should be in the future, got %v", exp)
}
// 拉黑后解析失败
if err := j.Revoke(ctx, jti, time.Now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if _, _, err := j.Parse(ctx, token); err == nil {
if _, _, _, err := j.Parse(ctx, token); err == nil {
t.Fatal("expect parse failure after revoke")
}
// 篡改签名失败
if _, _, err := j.Parse(ctx, token+"x"); err == nil {
if _, _, _, err := j.Parse(ctx, token+"x"); err == nil {
t.Fatal("expect parse failure on tampered token")
}
}
// TestParseFailClosed Redis 不可用时黑名单检查应 fail-closed:拒绝放行(17A)。
func TestParseFailClosed(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
j := NewJWT("test-secret", time.Hour, rdb)
ctx := context.Background()
token, err := j.Sign("u1")
if err != nil {
t.Fatal(err)
}
// 正常情况下放行
if _, _, _, err := j.Parse(ctx, token); err != nil {
t.Fatalf("parse should pass while redis up: %v", err)
}
// Redis 故障:Exists 返回 error → 必须拒绝(不得 fail-open)。
mr.Close()
if _, _, _, err := j.Parse(ctx, token); err == nil {
t.Fatal("expect parse failure when redis is unavailable (fail-closed)")
}
}