feat(server): web 用户中心后端(1/3) — GetMe 补字段 + logout + redeem 路径别名 + 用户表 totp/sub_token

为 web 用户中心接通真后端做准备(保持 snake_case 不破 app):
- migration 000013:users 加 sub_token / totp_secret_enc / totp_enabled。
- GetMe 扩展:补 devices_used/devices_max/quota_today_min/data_today_gb/
  weekly_gb/totp_enabled/expires_at(聚合 plans+usage_daily+devices+totp 列),
  保留原 expire_at 等字段不破 app。
- POST /v1/auth/logout:X-Refresh-Token 头 → RevokeRefresh,幂等 204。
- /v1/me/redeem 别名(web 用),保留 /v1/redeem(app 用)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-17 08:35:09 +08:00
parent caeef20df3
commit 009dbb8d07
7 changed files with 150 additions and 22 deletions
+10
View File
@@ -28,6 +28,16 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/auth/register", h.Register)
r.Post("/auth/login", h.Login)
r.Post("/auth/refresh", h.Refresh)
r.Post("/auth/logout", h.Logout)
}
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
// the X-Refresh-Token header (the access token in Authorization is not enough —
// revocation keys on the refresh JTI). Always 204; the client clears its session
// regardless of the server-side outcome.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
h.svc.Logout(r.Context(), r.Header.Get("X-Refresh-Token"))
w.WriteHeader(http.StatusNoContent)
}
// ---- request/response bodies (mirror openapi.yaml) ----
+9
View File
@@ -261,6 +261,15 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*To
return pair, 0, nil
}
// Logout revokes the given refresh token. Idempotent: an empty, unknown, or
// malformed token is a no-op (the client clears its own session regardless).
func (s *Service) Logout(ctx context.Context, refreshToken string) {
if refreshToken == "" {
return
}
_ = s.tokens.RevokeRefresh(ctx, refreshToken)
}
// Refresh validates and rotates a refresh token.
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) {
if refreshToken == "" {
+11
View File
@@ -234,6 +234,17 @@ func (tm *TokenManager) Revoke(ctx context.Context, jti string) error {
return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err()
}
// RevokeRefresh parses a refresh token and revokes its JTI. Used by logout.
// Returns an error only when the token is structurally invalid; a missing or
// already-rotated JTI is a no-op (logout is idempotent).
func (tm *TokenManager) RevokeRefresh(ctx context.Context, refreshToken string) error {
claims, err := tm.parse(refreshToken, typRefresh)
if err != nil {
return err
}
return tm.Revoke(ctx, claims.ID)
}
// whitelist stores the refresh JTI with the refresh TTL.
func (tm *TokenManager) whitelist(ctx context.Context, jti string, userID int64) error {
if err := tm.rdb.Set(ctx, refreshKeyPrefix+jti, userID, tm.refreshTTL).Err(); err != nil {