diff --git a/docs/mobile-api/deployment.md b/docs/mobile-api/deployment.md
new file mode 100644
index 0000000..171ed71
--- /dev/null
+++ b/docs/mobile-api/deployment.md
@@ -0,0 +1,91 @@
+# MeBox Mobile API 接入与部署说明
+
+更新日期:2026-10-17
+适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
+
+## 1. 路径与版本
+
+- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
+- 健康检查:`GET /api/health`。
+- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
+- 契约字段与错误码见同目录 `openapi.yaml`。
+
+`server-info.capabilities` 必须反映真实运行能力。当前分支只有
+`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
+`false`,App 不得据此进入对应流程。
+
+## 2. 连接与传输安全
+
+- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
+- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
+ 使用明文,不会把 MeBox `Authorization` 发往其它主机。
+- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
+ 不会静默截断为根路径。
+- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
+ 日志对账。
+
+## 3. 反向代理检查项
+
+以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
+
+| 项目 | 要求 |
+| --- | --- |
+| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
+| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
+| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
+| WebSocket | 升级头透传,读超时大于心跳间隔 |
+| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
+
+目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
+
+## 4. 认证与会话
+
+- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
+- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
+ 返回 401,不能借旧入口绕过。
+- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
+ App 需要重新登录。
+- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
+- 设备管理中的踢出操作会同步撤销该设备认证会话。
+
+### 刷新轮换边界
+
+- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
+- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
+ 且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
+ 该边界,未实现持久化幂等轮换。
+- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
+
+## 5. 错误契约
+
+统一响应:
+
+```json
+{
+ "code": "SESSION_REVOKED",
+ "message": "auth session revoked or expired",
+ "request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
+ "retryable": false
+}
+```
+
+- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
+- 403 表示权限或账号状态,不触发刷新。
+- 429 带 `Retry-After`,客户端必须等待。
+- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
+
+## 6. 数据迁移与回滚
+
+- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
+ 创建,属于增量变更,不清库。
+- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
+ `go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
+- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
+ 依赖 `sid` 即时撤销能力。
+
+## 7. 已知限制
+
+- 单实例部署;未实现多副本共享会话。
+- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
+- 反代子路径、自签名证书信任、多服务器配置均未支持。
+- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
diff --git a/docs/mobile-api/openapi.yaml b/docs/mobile-api/openapi.yaml
new file mode 100644
index 0000000..0889838
--- /dev/null
+++ b/docs/mobile-api/openapi.yaml
@@ -0,0 +1,389 @@
+openapi: 3.0.3
+info:
+ title: MeBox Mobile API
+ version: 1.0.0
+ description: |
+ MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
+ 本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
+
+ 约定:
+ - JSON 字段使用 snake_case;
+ - 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
+ - 集合为空时返回 `[]`,未知字段客户端必须忽略;
+ - 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
+ - 能力开关只有在接口实现且当前可运行时才为 true。
+servers:
+ - url: /api
+security:
+ - bearerAuth: []
+tags:
+ - name: server
+ - name: auth
+paths:
+ /v1/server-info:
+ get:
+ tags: [server]
+ summary: 匿名读取实例身份与移动端能力
+ security: []
+ responses:
+ '200':
+ description: 实例可连接
+ headers:
+ Cache-Control:
+ schema:
+ type: string
+ description: 固定为 no-store
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ServerInfo'
+ '503':
+ description: 暂时无法读取实例身份
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ /v1/auth/login:
+ post:
+ tags: [auth]
+ summary: 账号密码登录并创建认证会话
+ security: []
+ parameters:
+ - $ref: '#/components/parameters/RequestId'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ responses:
+ '200':
+ description: 登录成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthResponse'
+ '400':
+ $ref: '#/components/responses/InvalidArgument'
+ '401':
+ $ref: '#/components/responses/InvalidCredentials'
+ '403':
+ description: 账号被禁用或已到期
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/auth/refresh:
+ post:
+ tags: [auth]
+ summary: 使用刷新令牌轮换令牌对
+ security: []
+ description: |
+ 刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
+ 并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
+ 会话被撤销后刷新返回 SESSION_REVOKED。
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RefreshRequest'
+ responses:
+ '200':
+ description: 刷新成功,新令牌继续归属同一 auth_session_id
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthResponse'
+ '400':
+ $ref: '#/components/responses/InvalidArgument'
+ '401':
+ description: 刷新令牌无效、过期或会话已撤销
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/auth/session:
+ get:
+ tags: [auth]
+ summary: 读取当前用户与认证会话
+ responses:
+ '200':
+ description: 会话有效
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SessionResponse'
+ '401':
+ $ref: '#/components/responses/SessionInvalid'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+ /v1/auth/logout:
+ post:
+ tags: [auth]
+ summary: 撤销当前认证会话
+ description: 幂等;重复退出同样返回 200。
+ responses:
+ '200':
+ description: 已退出(本机与远端)
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: ok
+ '401':
+ $ref: '#/components/responses/SessionInvalid'
+ '503':
+ $ref: '#/components/responses/ServiceUnavailable'
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ parameters:
+ RequestId:
+ name: X-Request-ID
+ in: header
+ required: false
+ description: |
+ 可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
+ 响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
+ schema:
+ type: string
+ responses:
+ InvalidArgument:
+ description: 请求字段缺失或格式错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ InvalidCredentials:
+ description: 用户名或密码错误
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ SessionInvalid:
+ description: 访问令牌无效、过期或认证会话已撤销
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ RateLimited:
+ description: 请求过于频繁
+ headers:
+ Retry-After:
+ schema:
+ type: integer
+ description: 建议等待秒数
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ ServiceUnavailable:
+ description: 暂时性服务/存储错误,可安全重试
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MobileError'
+ schemas:
+ ServerInfo:
+ type: object
+ required:
+ - name
+ - server_id
+ - server_version
+ - api_version
+ - min_supported_app_version_code
+ - capabilities
+ properties:
+ name:
+ type: string
+ example: MeBox
+ server_id:
+ type: string
+ format: uuid
+ description: 实例持久身份;备份克隆成独立实例时必须显式重置。
+ server_version:
+ type: string
+ example: MeBox-v0.1.16
+ api_version:
+ type: string
+ example: v1
+ min_supported_app_version_code:
+ type: integer
+ minimum: 1
+ capabilities:
+ $ref: '#/components/schemas/ServerCapabilities'
+ ServerCapabilities:
+ type: object
+ required:
+ - device_sessions
+ - playback_sessions
+ - reader_progress_v1
+ - reader_content_batch
+ - local_transcode
+ properties:
+ device_sessions:
+ type: boolean
+ description: M1 起为 true
+ playback_sessions:
+ type: boolean
+ description: 播放会话接口未实现前必须为 false
+ reader_progress_v1:
+ type: boolean
+ description: v1 阅读锚点/版本冲突未实现前必须为 false
+ reader_content_batch:
+ type: boolean
+ description: v1 批量正文与资源引用未实现前必须为 false
+ local_transcode:
+ type: boolean
+ description: 移动端可用的本地转码会话未实现前必须为 false
+ MobileUser:
+ type: object
+ required: [id, username, role, tier, is_active, force_password_reset]
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ nickname:
+ type: string
+ role:
+ type: string
+ enum: [admin, user]
+ tier:
+ type: string
+ example: free
+ is_active:
+ type: boolean
+ force_password_reset:
+ type: boolean
+ expires_at:
+ type: string
+ format: date-time
+ nullable: true
+ TokenPair:
+ type: object
+ required: [access_token, refresh_token, expires_in, token_type]
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ expires_in:
+ type: integer
+ description: 访问令牌有效秒数
+ token_type:
+ type: string
+ example: Bearer
+ LoginRequest:
+ type: object
+ required: [username, password, device_id]
+ properties:
+ username:
+ type: string
+ password:
+ type: string
+ device_id:
+ type: string
+ maxLength: 128
+ description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
+ device_name:
+ type: string
+ maxLength: 128
+ client_name:
+ type: string
+ maxLength: 64
+ example: MeBox Android
+ client_version:
+ type: string
+ maxLength: 64
+ RefreshRequest:
+ type: object
+ required: [refresh_token]
+ properties:
+ refresh_token:
+ type: string
+ AuthResponse:
+ type: object
+ required: [auth_session_id, user, tokens]
+ properties:
+ auth_session_id:
+ type: string
+ format: uuid
+ user:
+ $ref: '#/components/schemas/MobileUser'
+ tokens:
+ $ref: '#/components/schemas/TokenPair'
+ SessionDevice:
+ type: object
+ properties:
+ device_id:
+ type: string
+ device_name:
+ type: string
+ client_name:
+ type: string
+ client_version:
+ type: string
+ last_used_at:
+ type: string
+ format: date-time
+ expires_at:
+ type: string
+ format: date-time
+ SessionResponse:
+ type: object
+ required: [auth_session_id, user]
+ properties:
+ auth_session_id:
+ type: string
+ format: uuid
+ user:
+ $ref: '#/components/schemas/MobileUser'
+ session:
+ $ref: '#/components/schemas/SessionDevice'
+ MobileError:
+ type: object
+ required: [code, message, request_id, retryable]
+ properties:
+ code:
+ type: string
+ enum:
+ - INVALID_ARGUMENT
+ - INVALID_CREDENTIALS
+ - TOKEN_EXPIRED
+ - SESSION_REVOKED
+ - ACCOUNT_DISABLED
+ - ACCOUNT_EXPIRED
+ - PERMISSION_DENIED
+ - PROFILE_PIN_REQUIRED
+ - RATE_LIMITED
+ - UPSTREAM_UNAVAILABLE
+ - TRANSCODE_BUSY
+ - PLAYBACK_SESSION_NOT_FOUND
+ - RESOURCE_EXPIRED
+ - PROGRESS_CONFLICT
+ - SERVICE_UNAVAILABLE
+ message:
+ type: string
+ request_id:
+ type: string
+ retryable:
+ type: boolean
+ retry_after_sec:
+ type: integer
+ minimum: 0
+ details:
+ description: 可选的稳定结构化细节,未定义时客户端必须忽略。
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 46a2397..069ec85 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
registerPublicAuthRoutes(api, svc, log)
+ registerMobileRoutes(api, svc, log)
+
registerAuthenticatedRoutes(api, cfg, svc)
registerAdminRoutes(api, cfg, svc)
diff --git a/internal/handler/mobile_auth.go b/internal/handler/mobile_auth.go
new file mode 100644
index 0000000..346ce31
--- /dev/null
+++ b/internal/handler/mobile_auth.go
@@ -0,0 +1,395 @@
+// Package handler — 移动端 /api/v1 认证端点。
+package handler
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/golang-jwt/jwt/v5"
+ "go.uber.org/zap"
+
+ "github.com/truewhile/MeBox/internal/middleware"
+ "github.com/truewhile/MeBox/internal/model"
+ "github.com/truewhile/MeBox/internal/repository"
+ "github.com/truewhile/MeBox/internal/service"
+)
+
+// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
+// 使用的偏好和内部状态带进新契约。
+type mobileUserPayload struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname,omitempty"`
+ Role string `json:"role"`
+ Tier string `json:"tier"`
+ IsActive bool `json:"is_active"`
+ ForcePasswordReset bool `json:"force_password_reset"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+func toMobileUserPayload(u *model.User) *mobileUserPayload {
+ if u == nil {
+ return nil
+ }
+ return &mobileUserPayload{
+ ID: u.ID,
+ Username: u.Username,
+ Nickname: u.Nickname,
+ Role: u.Role,
+ Tier: u.Tier,
+ IsActive: u.IsActive,
+ ForcePasswordReset: u.ForcePasswordReset,
+ ExpiresAt: u.ExpiredAt,
+ }
+}
+
+type mobileSessionPayload struct {
+ AuthSessionID string `json:"auth_session_id"`
+ DeviceID string `json:"device_id"`
+ DeviceName string `json:"device_name,omitempty"`
+ ClientName string `json:"client_name,omitempty"`
+ ClientVersion string `json:"client_version,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ LastUsedAt time.Time `json:"last_used_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
+ if s == nil {
+ return nil
+ }
+ return &mobileSessionPayload{
+ AuthSessionID: s.ID,
+ DeviceID: s.DeviceID,
+ DeviceName: s.DeviceName,
+ ClientName: s.ClientName,
+ ClientVersion: s.ClientVersion,
+ CreatedAt: s.CreatedAt,
+ LastUsedAt: s.LastUsedAt,
+ ExpiresAt: s.ExpiresAt,
+ }
+}
+
+type mobileLoginRequest struct {
+ Username string `json:"username" binding:"required"`
+ Password string `json:"password" binding:"required"`
+ DeviceID string `json:"device_id" binding:"required"`
+ DeviceName string `json:"device_name"`
+ ClientName string `json:"client_name"`
+ ClientVersion string `json:"client_version"`
+}
+
+type mobileRefreshRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
+
+// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
+// 并统一输出 v1 错误契约。
+func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ raw := middleware.ExtractToken(c)
+ if raw == "" {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "access token required", false, 0)
+ return
+ }
+ claims, err := svc.Token.ValidateAccessToken(raw)
+ if err != nil {
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
+ "access token expired", false, 0)
+ return
+ }
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "invalid access token", false, 0)
+ return
+ }
+ if strings.TrimSpace(claims.Purpose) != "" {
+ writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
+ "token scope denied for this endpoint", false, 0)
+ return
+ }
+ sessionID := strings.TrimSpace(claims.Sid)
+ if sessionID == "" {
+ // v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "auth session required; sign in again", false, 0)
+ return
+ }
+ session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
+ if err != nil {
+ if service.IsTransientDatabaseLock(err) {
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "auth session check temporarily unavailable", true, 2)
+ return
+ }
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
+ "auth session revoked or expired", false, 0)
+ return
+ }
+ c.Set(middleware.CtxUserID, claims.UserID)
+ c.Set(middleware.CtxUserRole, claims.Role)
+ c.Set(middleware.CtxUserTier, claims.Tier)
+ c.Set(middleware.CtxAuthSessionID, sessionID)
+ c.Set(mobileCtxAuthSession, session)
+ // 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
+ // 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
+ if time.Since(session.LastUsedAt) > time.Minute {
+ svc.AuthSessions.Touch(c.Request.Context(), sessionID)
+ }
+ c.Next()
+ }
+}
+
+// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
+// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
+// 被会话校验拦成 401。
+func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ raw := middleware.ExtractToken(c)
+ if raw == "" {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "access token required", false, 0)
+ return
+ }
+ claims, err := svc.Token.ValidateAccessToken(raw)
+ if err != nil {
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
+ "access token expired", false, 0)
+ return
+ }
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "invalid access token", false, 0)
+ return
+ }
+ sessionID := strings.TrimSpace(claims.Sid)
+ if sessionID == "" {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "auth session required; sign in again", false, 0)
+ return
+ }
+ c.Set(middleware.CtxUserID, claims.UserID)
+ c.Set(middleware.CtxAuthSessionID, sessionID)
+ c.Next()
+ }
+}
+
+func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req mobileLoginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
+ "username, password and device_id are required", false, 0)
+ return
+ }
+ req.Username = strings.TrimSpace(req.Username)
+ req.DeviceID = strings.TrimSpace(req.DeviceID)
+ if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
+ writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
+ "invalid username or device_id", false, 0)
+ return
+ }
+ ctx := c.Request.Context()
+ user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
+ if err != nil {
+ writeMobileAuthError(c, log, err)
+ return
+ }
+
+ deviceName := strings.TrimSpace(req.DeviceName)
+ clientName := strings.TrimSpace(req.ClientName)
+ if clientName == "" {
+ clientName = "MeBox Android"
+ }
+ session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
+ UserID: user.ID,
+ DeviceID: req.DeviceID,
+ DeviceName: deviceName,
+ ClientName: clientName,
+ ClientVersion: req.ClientVersion,
+ })
+ if err != nil {
+ if isTransientServiceError(err) {
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "cannot create auth session right now", true, 2)
+ return
+ }
+ writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
+ "invalid device information", false, 0)
+ return
+ }
+
+ tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
+ if err != nil {
+ // 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
+ _ = svc.AuthSessions.Revoke(ctx, session.ID)
+ writeMobileAuthError(c, log, err)
+ return
+ }
+ if svc.Device != nil {
+ svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
+ }
+ if svc.Audit != nil {
+ svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "auth_session_id": session.ID,
+ "user": toMobileUserPayload(user),
+ "tokens": tokens,
+ })
+ }
+}
+
+func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req mobileRefreshRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
+ "refresh_token required", false, 0)
+ return
+ }
+ ctx := c.Request.Context()
+ rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
+ if err != nil {
+ if isTransientServiceError(err) {
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "token store temporarily unavailable", true, 2)
+ return
+ }
+ writeMobileAuthError(c, log, err)
+ return
+ }
+ // 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
+ // 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
+ // 由 TokenService 判定。
+ if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "refresh token is not bound to a device session; sign in again", false, 0)
+ return
+ }
+ if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
+ if isTransientServiceError(err) {
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "auth session check temporarily unavailable", true, 2)
+ return
+ }
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
+ "auth session revoked or expired", false, 0)
+ return
+ }
+
+ tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
+ if err != nil {
+ writeMobileAuthError(c, log, err)
+ return
+ }
+ user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
+ if err != nil {
+ writeMobileAuthError(c, log, err)
+ return
+ }
+ if user == nil {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "user not found", false, 0)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "auth_session_id": rt.AuthSessionID,
+ "user": toMobileUserPayload(user),
+ "tokens": tokens,
+ })
+ }
+}
+
+func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ctx := c.Request.Context()
+ userID := c.GetString(middleware.CtxUserID)
+ user, err := svc.Repo.User.FindByID(ctx, userID)
+ if err != nil {
+ writeMobileAuthError(c, svc.Log, err)
+ return
+ }
+ if user == nil {
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "user not found", false, 0)
+ return
+ }
+ session, _ := c.Get(mobileCtxAuthSession)
+ sessionModel, _ := session.(*model.AuthSession)
+ c.JSON(http.StatusOK, gin.H{
+ "auth_session_id": c.GetString(middleware.CtxAuthSessionID),
+ "user": toMobileUserPayload(user),
+ "session": toMobileSessionPayload(sessionModel),
+ })
+ }
+}
+
+func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ sessionID := c.GetString(middleware.CtxAuthSessionID)
+ if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
+ if isTransientServiceError(err) {
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "cannot revoke session right now", true, 2)
+ return
+ }
+ writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
+ "logout failed", false, 0)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ }
+}
+
+// writeMobileAuthError 把 service 层错误映射为稳定错误码。
+// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
+func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
+ switch {
+ case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
+ "invalid username, password or refresh token", false, 0)
+ case errors.Is(err, service.ErrTokenExpired):
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
+ "refresh token expired", false, 0)
+ case errors.Is(err, service.ErrTokenRevoked),
+ errors.Is(err, service.ErrAuthSessionRevoked),
+ errors.Is(err, service.ErrAuthSessionExpired),
+ errors.Is(err, service.ErrAuthSessionRequired):
+ writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
+ "auth session revoked or expired", false, 0)
+ case errors.Is(err, service.ErrUserInactive):
+ writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
+ "account is disabled", false, 0)
+ case errors.Is(err, service.ErrUserExpired):
+ writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
+ "account has expired", false, 0)
+ case isTransientServiceError(err):
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "service temporarily unavailable, please retry", true, 2)
+ default:
+ if log != nil {
+ log.Warn("mobile auth request failed", zap.Error(err))
+ }
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "service temporarily unavailable, please retry", true, 2)
+ }
+}
+
+func isTransientServiceError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if service.IsTransientDatabaseLock(err) {
+ return true
+ }
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return true
+ }
+ return false
+}
diff --git a/internal/handler/mobile_auth_test.go b/internal/handler/mobile_auth_test.go
new file mode 100644
index 0000000..f349731
--- /dev/null
+++ b/internal/handler/mobile_auth_test.go
@@ -0,0 +1,434 @@
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "go.uber.org/zap"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "github.com/truewhile/MeBox/internal/config"
+ "github.com/truewhile/MeBox/internal/model"
+ "github.com/truewhile/MeBox/internal/repository"
+ "github.com/truewhile/MeBox/internal/service"
+)
+
+const mobileTestSecret = "mobile-test-secret"
+
+func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
+ // shared cache 名字在并行测试间串库。
+ sqlDB.SetMaxOpenConns(1)
+ t.Cleanup(func() { _ = sqlDB.Close() })
+
+ if err := db.AutoMigrate(
+ &model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
+ &model.UserDevice{}, &model.UserPermission{},
+ ); err != nil {
+ t.Fatal(err)
+ }
+ repos := repository.New(db)
+ cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
+ svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
+
+ router := gin.New()
+ Register(router, cfg, zap.NewNop(), svc)
+ return router, svc
+}
+
+func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
+ t.Helper()
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
+ if err := svc.Repo.User.Create(t.Context(), u); err != nil {
+ t.Fatal(err)
+ }
+ return u
+}
+
+func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ var reader *bytes.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader = bytes.NewReader(raw)
+ } else {
+ reader = bytes.NewReader(nil)
+ }
+ req := httptest.NewRequest(method, path, reader)
+ req.Header.Set("Content-Type", "application/json")
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ return w
+}
+
+func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
+ t.Helper()
+ if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
+ t.Fatalf("decode body %q: %v", w.Body.String(), err)
+ }
+}
+
+func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
+ router, _ := newMobileTestEnv(t)
+ w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
+ }
+ if got := w.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("Cache-Control = %q, want no-store", got)
+ }
+ var info service.ServerInfo
+ decodeMobileJSON(t, w, &info)
+ if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
+ t.Fatalf("unexpected server info: %+v", info)
+ }
+ if !info.Capabilities.DeviceSessions {
+ t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
+ }
+ if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
+ t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
+ }
+ if info.MinSupportedAppVersionCode < 1 {
+ t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
+ }
+
+ // 匿名访问不应要求 Authorization,也不应记录 cookie。
+ if len(w.Result().Cookies()) != 0 {
+ t.Fatalf("server-info should not set cookies")
+ }
+}
+
+func TestMobileAuthLifecycle(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ user := seedMobileUser(t, svc, "alice", "secret-123")
+
+ // 1. 错误密码:401 + 稳定错误码 + request_id。
+ w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "alice", "password": "wrong", "device_id": "device-1",
+ }, map[string]string{"X-Request-ID": "mobile-test-0001"})
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
+ }
+ var authErr mobileErrorBody
+ decodeMobileJSON(t, w, &authErr)
+ if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
+ t.Fatalf("unexpected auth error: %+v", authErr)
+ }
+ if authErr.Retryable {
+ t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
+ }
+
+ // 2. 缺少 device_id:400 INVALID_ARGUMENT。
+ w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "alice", "password": "secret-123",
+ }, nil)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
+ }
+ decodeMobileJSON(t, w, &authErr)
+ if authErr.Code != mobileCodeInvalidArgument {
+ t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
+ }
+
+ // 3. 正常登录。
+ w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "alice", "password": "secret-123", "device_id": "device-1",
+ "device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
+ }, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
+ }
+ var loginResp struct {
+ AuthSessionID string `json:"auth_session_id"`
+ User mobileUserPayload `json:"user"`
+ Tokens service.TokenPair `json:"tokens"`
+ }
+ decodeMobileJSON(t, w, &loginResp)
+ if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
+ t.Fatalf("incomplete login response: %+v", loginResp)
+ }
+ if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
+ t.Fatalf("unexpected user payload: %+v", loginResp.User)
+ }
+ claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
+ if err != nil {
+ t.Fatalf("validate access token: %v", err)
+ }
+ if claims.Sid != loginResp.AuthSessionID {
+ t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
+ }
+ rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
+ if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
+ t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
+ }
+
+ // 4. 会话读取。
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
+ }
+ var sessionResp struct {
+ AuthSessionID string `json:"auth_session_id"`
+ User mobileUserPayload `json:"user"`
+ Session *mobileSessionPayload `json:"session"`
+ }
+ decodeMobileJSON(t, w, &sessionResp)
+ if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
+ t.Fatalf("unexpected session response: %+v", sessionResp)
+ }
+ if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
+ t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
+ }
+
+ // 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
+ w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
+ "refresh_token": loginResp.Tokens.RefreshToken,
+ }, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
+ }
+ var refreshResp struct {
+ AuthSessionID string `json:"auth_session_id"`
+ Tokens service.TokenPair `json:"tokens"`
+ }
+ decodeMobileJSON(t, w, &refreshResp)
+ if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
+ t.Fatalf("unexpected refresh response: %+v", refreshResp)
+ }
+ if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
+ t.Fatal("refresh token should rotate")
+ }
+ oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
+ if err != nil || oldRT == nil || !oldRT.Revoked {
+ t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
+ }
+
+ // 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
+ }
+
+ // 7. 退出:成功且可重复。
+ for i := 0; i < 2; i++ {
+ w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
+ }
+ }
+
+ // 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
+ }
+ decodeMobileJSON(t, w, &authErr)
+ if authErr.Code != mobileCodeSessionRevoked {
+ t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
+ }
+
+ // 9. 被踢后的未过期 JWT 不能绕过旧 API。
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
+ }
+
+ // 10. 刷新令牌随会话一起撤销。
+ w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
+ "refresh_token": refreshResp.Tokens.RefreshToken,
+ }, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
+ }
+ decodeMobileJSON(t, w, &authErr)
+ if authErr.Code != mobileCodeSessionRevoked {
+ t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
+ }
+}
+
+func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ user := seedMobileUser(t, svc, "legacy", "secret-123")
+ pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
+ t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
+ }
+
+ w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
+ }
+ var body mobileErrorBody
+ decodeMobileJSON(t, w, &body)
+ if body.Code != mobileCodeInvalidCredentials {
+ t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
+ }
+
+ // 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
+ }
+}
+
+func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ user := seedMobileUser(t, svc, "bob", "secret-123")
+ pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
+ "refresh_token": pair.RefreshToken,
+ }, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
+ }
+ var body mobileErrorBody
+ decodeMobileJSON(t, w, &body)
+ if body.Code != mobileCodeInvalidCredentials {
+ t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
+ }
+}
+
+func TestMobileDeviceKickRevokesSession(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ user := seedMobileUser(t, svc, "carol", "secret-123")
+
+ w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "carol", "password": "secret-123", "device_id": "device-kick",
+ "device_name": "Kick Me", "client_name": "MeBox Android",
+ }, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
+ }
+ var loginResp struct {
+ AuthSessionID string `json:"auth_session_id"`
+ Tokens service.TokenPair `json:"tokens"`
+ }
+ decodeMobileJSON(t, w, &loginResp)
+
+ if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
+ t.Fatalf("kick device: %v", err)
+ }
+ session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
+ if err != nil || session == nil || !session.Revoked {
+ t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
+ }
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
+ }
+}
+
+func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ seedMobileUser(t, svc, "erin", "secret-123")
+
+ login := func(deviceID string) string {
+ w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "erin", "password": "secret-123", "device_id": deviceID,
+ "device_name": deviceID, "client_name": "MeBox Android",
+ }, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
+ }
+ var resp struct {
+ Tokens service.TokenPair `json:"tokens"`
+ }
+ decodeMobileJSON(t, w, &resp)
+ return resp.Tokens.AccessToken
+ }
+
+ tokenA := login("device-a")
+ tokenB := login("device-b")
+
+ if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
+ t.Fatalf("kick device a: %v", err)
+ }
+
+ w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
+ }
+ w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
+ }
+}
+
+func svcUserID(t *testing.T, svc *service.Container, username string) string {
+ t.Helper()
+ u, err := svc.Repo.User.FindByUsername(t.Context(), username)
+ if err != nil || u == nil {
+ t.Fatalf("find user %s: %v", username, err)
+ }
+ return u.ID
+}
+
+func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
+ router, svc := newMobileTestEnv(t)
+ seedMobileUser(t, svc, "dave", "secret-123")
+
+ w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
+ "username": "dave", "password": "secret-123", "device_id": "device-default",
+ }, nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
+ }
+ var loginResp struct {
+ AuthSessionID string `json:"auth_session_id"`
+ Tokens service.TokenPair `json:"tokens"`
+ }
+ decodeMobileJSON(t, w, &loginResp)
+ session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
+ if err != nil || session == nil {
+ t.Fatalf("load session: %v", err)
+ }
+ if session.ClientName != "MeBox Android" {
+ t.Fatalf("client_name default = %q", session.ClientName)
+ }
+ if session.ExpiresAt.Before(time.Now()) {
+ t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
+ }
+ if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
+ t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
+ }
+}
diff --git a/internal/handler/mobile_errors.go b/internal/handler/mobile_errors.go
new file mode 100644
index 0000000..6558f83
--- /dev/null
+++ b/internal/handler/mobile_errors.go
@@ -0,0 +1,129 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "github.com/truewhile/MeBox/internal/middleware"
+)
+
+// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
+const (
+ mobileCodeInvalidArgument = "INVALID_ARGUMENT"
+ mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
+ mobileCodeTokenExpired = "TOKEN_EXPIRED"
+ mobileCodeSessionRevoked = "SESSION_REVOKED"
+ mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
+ mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
+ mobileCodePermissionDenied = "PERMISSION_DENIED"
+ mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
+ mobileCodeRateLimited = "RATE_LIMITED"
+ mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
+ mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
+ mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
+ mobileCodeResourceExpired = "RESOURCE_EXPIRED"
+ mobileCodeProgressConflict = "PROGRESS_CONFLICT"
+ mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
+)
+
+const (
+ mobileCtxRequestID = "mobile_request_id"
+ mobileCtxAuthSession = "mobile_auth_session"
+ mobileRequestIDHeader = "X-Request-ID"
+)
+
+// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
+// code 分流,不需要再判断后端历史包装层。
+type mobileErrorBody struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ RequestID string `json:"request_id"`
+ Retryable bool `json:"retryable"`
+ RetryAfterSec int `json:"retry_after_sec,omitempty"`
+ Details any `json:"details,omitempty"`
+}
+
+// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
+// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
+// 重新生成;该值仅用于诊断,绝不参与鉴权。
+func mobileRequestIDMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
+ if !validRequestID(id) {
+ id = uuid.NewString()
+ }
+ c.Set(mobileCtxRequestID, id)
+ c.Header(mobileRequestIDHeader, id)
+ c.Next()
+ }
+}
+
+func validRequestID(id string) bool {
+ if len(id) < 8 || len(id) > 128 {
+ return false
+ }
+ for _, r := range id {
+ if r > unicode.MaxASCII {
+ return false
+ }
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ continue
+ }
+ switch r {
+ case '-', '_', '.', ':':
+ continue
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+func currentMobileRequestID(c *gin.Context) string {
+ if c == nil {
+ return ""
+ }
+ if v, ok := c.Get(mobileCtxRequestID); ok {
+ if s, ok := v.(string); ok && s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
+// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
+func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
+ requestID := currentMobileRequestID(c)
+ if requestID == "" {
+ requestID = uuid.NewString()
+ c.Header(mobileRequestIDHeader, requestID)
+ }
+ if retryAfterSec > 0 {
+ c.Header("Retry-After", strconv.Itoa(retryAfterSec))
+ }
+ c.AbortWithStatusJSON(status, mobileErrorBody{
+ Code: code,
+ Message: message,
+ RequestID: requestID,
+ Retryable: retryable,
+ RetryAfterSec: retryAfterSec,
+ })
+}
+
+// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
+func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !limiter.Allow(c.ClientIP()) {
+ writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
+ "too many requests, please try again later", true, 60)
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/internal/handler/mobile_routes.go b/internal/handler/mobile_routes.go
new file mode 100644
index 0000000..a04de50
--- /dev/null
+++ b/internal/handler/mobile_routes.go
@@ -0,0 +1,81 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ "github.com/truewhile/MeBox/internal/middleware"
+ "github.com/truewhile/MeBox/internal/service"
+)
+
+// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
+//
+// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
+// 或书源业务逻辑。
+func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
+ if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
+ // 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
+ return
+ }
+ wireAuthSessionValidator(svc)
+
+ v1 := api.Group("/v1")
+ v1.Use(mobileRequestIDMiddleware())
+
+ v1.GET("/server-info", mobileServerInfoHandler(svc, log))
+
+ authLimiter := middleware.NewRateLimiter(30, time.Minute)
+ v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
+ v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
+
+ authed := v1.Group("/auth")
+ {
+ authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
+ authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
+ }
+}
+
+// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
+// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
+func wireAuthSessionValidator(svc *service.Container) {
+ middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
+ if svc == nil || svc.AuthSessions == nil {
+ return nil
+ }
+ _, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, service.ErrAuthSessionRevoked) ||
+ errors.Is(err, service.ErrAuthSessionExpired) ||
+ errors.Is(err, service.ErrAuthSessionRequired) {
+ return middleware.ErrSessionRevoked
+ }
+ return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
+ })
+}
+
+func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
+ defer cancel()
+ info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
+ if err != nil {
+ if log != nil {
+ log.Warn("mobile server info failed", zap.Error(err))
+ }
+ writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
+ "server identity temporarily unavailable", true, 2)
+ return
+ }
+ // 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
+ c.Header("Cache-Control", "no-store")
+ c.JSON(http.StatusOK, info)
+ }
+}
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
index 5370ab4..622a0f3 100644
--- a/internal/middleware/auth.go
+++ b/internal/middleware/auth.go
@@ -17,6 +17,8 @@ type Claims struct {
Tier string `json:"tier,omitempty"`
Purpose string `json:"purpose,omitempty"`
MediaID string `json:"media_id,omitempty"`
+ // Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
+ Sid string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
return
}
+ // 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则
+ // 设备被踢后仍能用未过期的 JWT 访问 /api/*。
+ if sid := strings.TrimSpace(claims.Sid); sid != "" {
+ if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil {
+ if errors.Is(err, ErrSessionUnavailable) {
+ c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"})
+ return
+ }
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"})
+ return
+ }
+ c.Set(CtxAuthSessionID, sid)
+ }
syncAccessTokenCookie(c, raw, claims)
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
}
return ""
}
+
+// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
+func ExtractToken(c *gin.Context) string {
+ return extractToken(c)
+}
diff --git a/internal/middleware/auth_session.go b/internal/middleware/auth_session.go
new file mode 100644
index 0000000..a6cf5a4
--- /dev/null
+++ b/internal/middleware/auth_session.go
@@ -0,0 +1,48 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+)
+
+// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
+// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
+var (
+ // ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
+ ErrSessionRevoked = errors.New("auth session revoked")
+ // ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
+ ErrSessionUnavailable = errors.New("auth session unavailable")
+)
+
+// SessionValidator 校验认证会话是否仍属于该用户。
+type SessionValidator func(ctx context.Context, sessionID, userID string) error
+
+var (
+ sessionValidatorMu sync.RWMutex
+ sessionValidator SessionValidator
+)
+
+// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
+func SetSessionValidator(fn SessionValidator) {
+ sessionValidatorMu.Lock()
+ defer sessionValidatorMu.Unlock()
+ sessionValidator = fn
+}
+
+// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
+// 以保持旧测试和嵌入式调用的兼容行为。
+func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil
+ }
+ sessionValidatorMu.RLock()
+ fn := sessionValidator
+ sessionValidatorMu.RUnlock()
+ if fn == nil {
+ return nil
+ }
+ return fn(ctx, sessionID, userID)
+}
diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go
index 970f4f2..42a41a6 100644
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -9,6 +9,8 @@ const (
CtxUserTier = "ctx_user_tier"
CtxTokenPurpose = "ctx_token_purpose"
CtxTokenMediaID = "ctx_token_media_id"
+ // CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
+ CtxAuthSessionID = "ctx_auth_session_id"
// AccessTokenCookieName carries the web access token for browser-managed
// resource requests such as
, which cannot attach Authorization.
diff --git a/internal/model/auth_session.go b/internal/model/auth_session.go
new file mode 100644
index 0000000..3009ceb
--- /dev/null
+++ b/internal/model/auth_session.go
@@ -0,0 +1,49 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// AuthSession 是可撤销的设备认证会话。
+//
+// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新
+// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此
+// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。
+//
+// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制
+// 把历史刷新令牌绑定到任何设备。
+type AuthSession struct {
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ UserID string `gorm:"index;size:36;not null" json:"user_id"`
+ DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"`
+ DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
+ ClientName string `gorm:"size:64" json:"client_name,omitempty"`
+ ClientVersion string `gorm:"size:64" json:"client_version,omitempty"`
+ Revoked bool `gorm:"not null;default:false" json:"revoked"`
+ RevokedAt *time.Time `json:"revoked_at,omitempty"`
+ LastUsedAt time.Time `json:"last_used_at"`
+ ExpiresAt time.Time `gorm:"index" json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// BeforeCreate 生成 UUID 主键。
+func (s *AuthSession) BeforeCreate(_ *gorm.DB) error {
+ if s.ID == "" {
+ s.ID = uuid.NewString()
+ }
+ return nil
+}
+
+// IsExpired 判断会话是否超过最长有效期。
+func (s *AuthSession) IsExpired() bool {
+ return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt)
+}
+
+// IsValid 判断会话当前是否可继续使用。
+func (s *AuthSession) IsValid() bool {
+ return s != nil && !s.Revoked && !s.IsExpired()
+}
diff --git a/internal/model/model.go b/internal/model/model.go
index ac7a64e..83a7a3f 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
+ &AuthSession{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
diff --git a/internal/model/refresh_token.go b/internal/model/refresh_token.go
index 98d82b3..77cdfcc 100644
--- a/internal/model/refresh_token.go
+++ b/internal/model/refresh_token.go
@@ -11,12 +11,14 @@ import (
// RefreshToken 用于双令牌认证机制中的刷新令牌。
// 存储时使用 SHA256 哈希,原始令牌不存储。
type RefreshToken struct {
- ID string `gorm:"primaryKey;size:36" json:"id"`
- UserID string `gorm:"index;size:36;not null" json:"user_id"`
- TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
- ExpiresAt time.Time `gorm:"index" json:"expires_at"`
- CreatedAt time.Time `json:"created_at"`
- Revoked bool `gorm:"default:false" json:"revoked"`
+ ID string `gorm:"primaryKey;size:36" json:"id"`
+ UserID string `gorm:"index;size:36;not null" json:"user_id"`
+ TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
+ // AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
+ AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
+ ExpiresAt time.Time `gorm:"index" json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ Revoked bool `gorm:"default:false" json:"revoked"`
}
// BeforeCreate 生成 UUID。
diff --git a/internal/repository/auth_session_repository.go b/internal/repository/auth_session_repository.go
new file mode 100644
index 0000000..f39a2b1
--- /dev/null
+++ b/internal/repository/auth_session_repository.go
@@ -0,0 +1,101 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "gorm.io/gorm"
+
+ "github.com/truewhile/MeBox/internal/model"
+)
+
+// AuthSessionRepository persists model.AuthSession records.
+type AuthSessionRepository struct{ db *gorm.DB }
+
+// Create inserts a new authentication session.
+func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error {
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Create(s).Error
+ })
+}
+
+// FindByID returns the session or (nil, nil) when absent.
+func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) {
+ if id == "" {
+ return nil, nil
+ }
+ var s model.AuthSession
+ err := withSQLiteBusyRetry(ctx, func() error {
+ s = model.AuthSession{}
+ return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
+ })
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
+
+// TouchLastUsed 更新会话最近使用时间。
+func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error {
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Model(&model.AuthSession{}).
+ Where("id = ? AND revoked = ?", id, false).
+ Update("last_used_at", at).Error
+ })
+}
+
+// Revoke 撤销单个会话(幂等)。
+func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error {
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Model(&model.AuthSession{}).
+ Where("id = ? AND revoked = ?", id, false).
+ Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
+ })
+}
+
+// RevokeByUser 撤销某用户的全部会话(幂等)。
+func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error {
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Model(&model.AuthSession{}).
+ Where("user_id = ? AND revoked = ?", userID, false).
+ Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
+ })
+}
+
+// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。
+func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error {
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Model(&model.AuthSession{}).
+ Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false).
+ Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
+ })
+}
+
+// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。
+func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) {
+ var rows []model.AuthSession
+ err := withSQLiteBusyRetry(ctx, func() error {
+ rows = nil
+ return r.db.WithContext(ctx).
+ Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
+ Order("last_used_at desc, created_at desc").
+ Find(&rows).Error
+ })
+ return rows, err
+}
+
+// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。
+func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) {
+ var rows []model.AuthSession
+ err := withSQLiteBusyRetry(ctx, func() error {
+ rows = nil
+ return r.db.WithContext(ctx).
+ Where("user_id = ? AND device_id = ?", userID, deviceID).
+ Find(&rows).Error
+ })
+ return rows, err
+}
diff --git a/internal/repository/refresh_token_repository.go b/internal/repository/refresh_token_repository.go
index d0c7d89..b759d97 100644
--- a/internal/repository/refresh_token_repository.go
+++ b/internal/repository/refresh_token_repository.go
@@ -79,7 +79,7 @@ func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
})
}
-// Revoke revokes a specific refresh token.
+// Revoke 撤销某个刷新令牌。
func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
@@ -87,6 +87,18 @@ func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error
})
}
+// RevokeByAuthSessionID 撤销某个认证会话名下的全部刷新令牌。
+// 设备被踢或主动退出后,旧刷新令牌不能再换出新令牌。
+func (r *RefreshTokenRepository) RevokeByAuthSessionID(ctx context.Context, sessionID string) error {
+ if sessionID == "" {
+ return nil
+ }
+ return withSQLiteBusyRetry(ctx, func() error {
+ return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
+ Where("auth_session_id = ?", sessionID).Update("revoked", true).Error
+ })
+}
+
// HashToken returns the SHA256 hash of a token.
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index 37551a9..666a4b5 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -23,6 +23,7 @@ type Container struct {
Log *AccessLogRepository
Permission *PermissionRepository
RefreshToken *RefreshTokenRepository
+ AuthSession *AuthSessionRepository
ApiConfig *ApiConfigRepository
PlayProfile *PlayProfileRepository
RegCode *RegistrationCodeRepository
@@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container {
Log: &AccessLogRepository{db: db},
Permission: &PermissionRepository{db: db},
RefreshToken: &RefreshTokenRepository{db: db},
+ AuthSession: &AuthSessionRepository{db: db},
ApiConfig: &ApiConfigRepository{db: db},
PlayProfile: &PlayProfileRepository{db: db},
RegCode: &RegistrationCodeRepository{db: db},
diff --git a/internal/service/auth.go b/internal/service/auth.go
index 7ca85fb..a1ed5d2 100644
--- a/internal/service/auth.go
+++ b/internal/service/auth.go
@@ -34,6 +34,7 @@ type AuthService struct {
repo *repository.Container
tokenSvc *TokenService
permissionSvc *PermissionService
+ sessionSvc *AuthSessionService
tempPassMu sync.RWMutex
tempPasswords map[string]temporaryPasswordEntry
@@ -51,6 +52,11 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
}
}
+// SetAuthSessionService 注入设备会话服务;旧登出路径会同时撤销全部会话。
+func (s *AuthService) SetAuthSessionService(sessions *AuthSessionService) {
+ s.sessionSvc = sessions
+}
+
// Common service-level errors.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
@@ -162,6 +168,23 @@ type LoginResponse struct {
// Login validates credentials and returns the user + a fresh JWT token pair.
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
+ u, err := s.Authenticate(ctx, username, password)
+ if err != nil {
+ return nil, err
+ }
+ // 签发令牌对
+ tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
+ if err != nil {
+ return nil, err
+ }
+ s.touchLoginBestEffort(u.ID)
+ return &LoginResponse{User: u, Tokens: tokens}, nil
+}
+
+// Authenticate 只校验账号与密码,不签发任何令牌。
+// 移动端 v1 登录需要先确认账号有效,再创建设备会话并签发带 sid 的令牌;
+// 分离这一步可以避免先签发一对无会话的令牌再作废。
+func (s *AuthService) Authenticate(ctx context.Context, username, password string) (*model.User, error) {
u, err := s.repo.User.FindByUsername(ctx, username)
if err != nil {
return nil, err
@@ -180,13 +203,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
- // 签发令牌对
- tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
- if err != nil {
- return nil, err
- }
- s.touchLoginBestEffort(u.ID)
- return &LoginResponse{User: u, Tokens: tokens}, nil
+ return u, nil
}
func (s *AuthService) touchLoginBestEffort(userID string) {
diff --git a/internal/service/auth_session.go b/internal/service/auth_session.go
new file mode 100644
index 0000000..f153db7
--- /dev/null
+++ b/internal/service/auth_session.go
@@ -0,0 +1,187 @@
+// Package service — 设备认证会话。
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+ "unicode"
+
+ "go.uber.org/zap"
+
+ "github.com/truewhile/MeBox/internal/model"
+ "github.com/truewhile/MeBox/internal/repository"
+)
+
+// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和
+// 「临时基础设施故障」。
+var (
+ ErrAuthSessionRequired = errors.New("auth session required")
+ ErrAuthSessionRevoked = errors.New("auth session revoked")
+ ErrAuthSessionExpired = errors.New("auth session expired")
+)
+
+// AuthSessionService 管理设备会话的创建、校验和撤销。
+type AuthSessionService struct {
+ log *zap.Logger
+ repo *repository.Container
+ now func() time.Time
+}
+
+// NewAuthSessionService 创建会话服务。
+func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService {
+ return &AuthSessionService{log: log, repo: repo, now: time.Now}
+}
+
+// CreateAuthSessionInput 是创建会话所需的最小设备信息。
+type CreateAuthSessionInput struct {
+ UserID string
+ DeviceID string
+ DeviceName string
+ ClientName string
+ ClientVersion string
+}
+
+// Create 创建一次登录对应的认证会话。
+//
+// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度
+// 与字符白名单校验,避免任意字符串进入索引和日志。
+func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) {
+ userID := strings.TrimSpace(in.UserID)
+ deviceID := strings.TrimSpace(in.DeviceID)
+ if userID == "" || deviceID == "" {
+ return nil, errors.New("user id and device id required")
+ }
+ if len(deviceID) > 128 || !validDeviceID(deviceID) {
+ return nil, errors.New("invalid device id")
+ }
+ now := s.currentTime()
+ session := &model.AuthSession{
+ UserID: userID,
+ DeviceID: deviceID,
+ DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128),
+ ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64),
+ ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64),
+ LastUsedAt: now,
+ ExpiresAt: now.Add(RefreshTokenDuration),
+ }
+ if err := s.repo.AuthSession.Create(ctx, session); err != nil {
+ return nil, err
+ }
+ return session, nil
+}
+
+// Validate 校验会话仍可用于给定用户。
+//
+// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录,
+// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。
+func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil, ErrAuthSessionRequired
+ }
+ session, err := s.repo.AuthSession.FindByID(ctx, sessionID)
+ if err != nil {
+ return nil, err
+ }
+ if session == nil || session.UserID != userID {
+ return nil, ErrAuthSessionRevoked
+ }
+ if session.Revoked {
+ return nil, ErrAuthSessionRevoked
+ }
+ if session.IsExpired() {
+ return nil, ErrAuthSessionExpired
+ }
+ return session, nil
+}
+
+// Touch 更新会话最近使用时间,失败只记日志,不影响请求。
+func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) {
+ if s == nil || sessionID == "" {
+ return
+ }
+ if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil {
+ s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err))
+ }
+}
+
+// Revoke 撤销单个会话及其名下刷新令牌(幂等)。
+func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil
+ }
+ if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil {
+ return err
+ }
+ return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID)
+}
+
+// RevokeByUser 撤销某用户的全部会话(幂等)。
+func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error {
+ userID = strings.TrimSpace(userID)
+ if userID == "" {
+ return nil
+ }
+ sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID)
+ if err != nil {
+ return err
+ }
+ for i := range sessions {
+ if err := s.Revoke(ctx, sessions[i].ID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// RevokeDevice 撤销某用户某设备的全部会话(幂等)。
+func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error {
+ userID = strings.TrimSpace(userID)
+ deviceID = strings.TrimSpace(deviceID)
+ if userID == "" || deviceID == "" {
+ return nil
+ }
+ sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID)
+ if err != nil {
+ return err
+ }
+ for i := range sessions {
+ if sessions[i].Revoked {
+ continue
+ }
+ if err := s.Revoke(ctx, sessions[i].ID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *AuthSessionService) currentTime() time.Time {
+ if s == nil || s.now == nil {
+ return time.Now()
+ }
+ return s.now()
+}
+
+func validDeviceID(id string) bool {
+ for _, r := range id {
+ if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') {
+ return false
+ }
+ }
+ return true
+}
+
+func truncateRunes(s string, max int) string {
+ if max <= 0 {
+ return ""
+ }
+ runes := []rune(s)
+ if len(runes) <= max {
+ return s
+ }
+ return string(runes[:max])
+}
diff --git a/internal/service/auth_session_test.go b/internal/service/auth_session_test.go
new file mode 100644
index 0000000..e4b7ee7
--- /dev/null
+++ b/internal/service/auth_session_test.go
@@ -0,0 +1,214 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/truewhile/MeBox/internal/config"
+ "github.com/truewhile/MeBox/internal/model"
+ "github.com/truewhile/MeBox/internal/repository"
+)
+
+func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) {
+ t.Helper()
+ db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{})
+ repos := repository.New(db)
+ sessions := NewAuthSessionService(zap.NewNop(), repos)
+ cfg := &config.Config{}
+ cfg.Secrets.JWTSecret = "test-secret"
+ tokens := NewTokenService(cfg, zap.NewNop(), repos)
+ tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
+ _, err := sessions.Validate(ctx, sessionID, userID)
+ return err
+ })
+ return sessions, tokens, repos
+}
+
+func seedSessionUser(t *testing.T, repos *repository.Container) *model.User {
+ t.Helper()
+ u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
+ if err := repos.User.Create(t.Context(), u); err != nil {
+ t.Fatal(err)
+ }
+ return u
+}
+
+func TestAuthSessionLifecycle(t *testing.T) {
+ sessions, tokens, repos := newAuthSessionTestEnv(t)
+ ctx := context.Background()
+ user := seedSessionUser(t, repos)
+
+ session, err := sessions.Create(ctx, CreateAuthSessionInput{
+ UserID: user.ID,
+ DeviceID: "device-abc",
+ DeviceName: "Pixel",
+ ClientName: "MeBox Android",
+ })
+ if err != nil {
+ t.Fatalf("create session: %v", err)
+ }
+ if session.ID == "" || session.ExpiresAt.IsZero() {
+ t.Fatal("session missing id or expiry")
+ }
+ if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil {
+ t.Fatalf("validate active session: %v", err)
+ }
+ if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) {
+ t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err)
+ }
+
+ pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
+ if err != nil {
+ t.Fatalf("issue pair: %v", err)
+ }
+ claims, err := tokens.ValidateAccessToken(pair.AccessToken)
+ if err != nil {
+ t.Fatalf("validate access token: %v", err)
+ }
+ if claims.Sid != session.ID {
+ t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID)
+ }
+ rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
+ if err != nil || rt == nil {
+ t.Fatalf("refresh token row = %v, err = %v", rt, err)
+ }
+ if rt.AuthSessionID != session.ID {
+ t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID)
+ }
+
+ if err := sessions.Revoke(ctx, session.ID); err != nil {
+ t.Fatalf("revoke session: %v", err)
+ }
+ if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) {
+ t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err)
+ }
+ rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
+ if err != nil || rt == nil {
+ t.Fatalf("reload refresh token: %v", err)
+ }
+ if !rt.Revoked {
+ t.Fatal("refresh token should be revoked with the session")
+ }
+
+ // 幂等:重复撤销不报错。
+ if err := sessions.Revoke(ctx, session.ID); err != nil {
+ t.Fatalf("second revoke: %v", err)
+ }
+}
+
+func TestRefreshRejectsRevokedSession(t *testing.T) {
+ sessions, tokens, repos := newAuthSessionTestEnv(t)
+ ctx := context.Background()
+ user := seedSessionUser(t, repos)
+
+ session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sessions.Revoke(ctx, session.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) {
+ t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err)
+ }
+}
+
+func TestRefreshKeepsSessionAssociation(t *testing.T) {
+ sessions, tokens, repos := newAuthSessionTestEnv(t)
+ ctx := context.Background()
+ user := seedSessionUser(t, repos)
+
+ session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rotated, err := tokens.Refresh(ctx, pair.RefreshToken)
+ if err != nil {
+ t.Fatalf("refresh: %v", err)
+ }
+ claims, err := tokens.ValidateAccessToken(rotated.AccessToken)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claims.Sid != session.ID {
+ t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID)
+ }
+ // 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。
+ var rt *model.RefreshToken
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken))
+ if err == nil && rt != nil {
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ if err != nil || rt == nil {
+ t.Fatalf("rotated refresh row: %v err=%v", rt, err)
+ }
+ if rt.AuthSessionID != session.ID {
+ t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID)
+ }
+}
+
+func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
+ db := newServiceTestDB(t, &model.Setting{})
+ repos := repository.New(db)
+ ctx := context.Background()
+
+ first := NewServerIdentityService(repos, "MeBox-v1.2.3")
+ id1, err := first.ServerID(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id1 == "" {
+ t.Fatal("empty server id")
+ }
+
+ // 独立服务实例(模拟重启)读到同一个持久身份。
+ second := NewServerIdentityService(repos, "MeBox-v1.2.3")
+ id2, err := second.ServerID(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id2 != id1 {
+ t.Fatalf("server id changed across instances: %q != %q", id2, id1)
+ }
+
+ info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" {
+ t.Fatalf("unexpected info: %+v", info)
+ }
+ if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions {
+ t.Fatalf("unexpected capabilities: %+v", info.Capabilities)
+ }
+}
+
+func TestAuthSessionExpiry(t *testing.T) {
+ sessions, _, repos := newAuthSessionTestEnv(t)
+ user := seedSessionUser(t, repos)
+ sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) }
+ session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sessions.now = time.Now
+ if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) {
+ t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err)
+ }
+}
diff --git a/internal/service/auth_tokens.go b/internal/service/auth_tokens.go
index cb7cef9..7d6eb37 100644
--- a/internal/service/auth_tokens.go
+++ b/internal/service/auth_tokens.go
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
return s.tokenSvc.Refresh(ctx, refreshToken)
}
-// Logout 撤销用户的所有刷新令牌。
+// Logout 撤销用户的所有刷新令牌与认证会话。
func (s *AuthService) Logout(ctx context.Context, userID string) error {
- return s.tokenSvc.RevokeAll(ctx, userID)
+ if err := s.tokenSvc.RevokeAll(ctx, userID); err != nil {
+ return err
+ }
+ if s.sessionSvc != nil {
+ return s.sessionSvc.RevokeByUser(ctx, userID)
+ }
+ return nil
}
diff --git a/internal/service/device_listing.go b/internal/service/device_listing.go
index 061f935..57e71d3 100644
--- a/internal/service/device_listing.go
+++ b/internal/service/device_listing.go
@@ -7,6 +7,8 @@ import (
"strings"
"time"
+ "go.uber.org/zap"
+
"github.com/truewhile/MeBox/internal/model"
)
@@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
if err != nil {
return err
}
+ // 立即撤销该设备的认证会话:只标记 UserDevice 不足以让已签发的 JWT
+ // 失效,新协议令牌必须同时被会话校验拒绝。
+ if s.authSessions != nil {
+ if err := s.authSessions.RevokeDevice(ctx, userID, d.DeviceID); err != nil && s.log != nil {
+ s.log.Warn("revoke auth sessions for kicked device failed",
+ zap.String("user_id", userID), zap.String("device_id", d.DeviceID), zap.Error(err))
+ }
+ }
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:%s\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
return nil
}
@@ -37,6 +47,12 @@ func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
return err
}
+ if s.authSessions != nil {
+ if err := s.authSessions.RevokeByUser(ctx, userID); err != nil && s.log != nil {
+ s.log.Warn("revoke auth sessions for kicked user failed",
+ zap.String("user_id", userID), zap.Error(err))
+ }
+ }
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
return nil
}
diff --git a/internal/service/device_service.go b/internal/service/device_service.go
index 07bfe8b..77e4b01 100644
--- a/internal/service/device_service.go
+++ b/internal/service/device_service.go
@@ -31,6 +31,8 @@ type DeviceService struct {
log *zap.Logger
repo *repository.Container
sessions *SessionTrackerService
+ // authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。
+ authSessions *AuthSessionService
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
@@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
s.sessions = tracker
}
+// SetAuthSessionService 注入认证会话服务,使踢出设备同时撤销其新协议令牌。
+func (s *DeviceService) SetAuthSessionService(sessions *AuthSessionService) {
+ s.authSessions = sessions
+}
+
// fingerprint derives a stable terminal hash from the device name. Client/app
// names are deliberately ignored so one phone/TV/PC using multiple apps is
// still counted as one terminal device; Client remains a login channel label.
diff --git a/internal/service/favorites_sync.go b/internal/service/favorites_sync.go
index cc72d5d..4b6e446 100644
--- a/internal/service/favorites_sync.go
+++ b/internal/service/favorites_sync.go
@@ -35,16 +35,28 @@ func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, med
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
if favorite {
var existing model.Favorite
- err := repo.DB.WithContext(ctx).
+ // 收藏行的删除是软删除;重新收藏时必须连已软删除的行一起查出来,
+ // 否则会再次插入同一 (user_id, media_id),撞上唯一索引后稳定返回
+ // 500,用户表现为“取消收藏后无法再收藏”。
+ err := repo.DB.WithContext(ctx).Unscoped().
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&existing).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
+ switch {
+ case errors.Is(err, gorm.ErrRecordNotFound):
return repo.DB.WithContext(ctx).Create(&model.Favorite{
UserID: userID,
MediaID: mediaID,
}).Error
+ case err != nil:
+ return err
+ case existing.DeletedAt.Valid:
+ return repo.DB.WithContext(ctx).Unscoped().
+ Model(&model.Favorite{}).
+ Where("id = ?", existing.ID).
+ Update("deleted_at", nil).Error
+ default:
+ return nil
}
- return err
}
return repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
diff --git a/internal/service/favorites_sync_test.go b/internal/service/favorites_sync_test.go
index 0c7e85c..64366c1 100644
--- a/internal/service/favorites_sync_test.go
+++ b/internal/service/favorites_sync_test.go
@@ -44,6 +44,19 @@ func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
if favorite {
t.Fatal("expected remote favourite to be removed locally")
}
+
+ // 软删除后重新收藏必须恢复原行:唯一索引 (user_id, media_id) 不允许
+ // 再插入一条,旧的实现会在这里返回约束错误。
+ if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
+ t.Fatalf("SyncUserFavorite re-favorite: %v", err)
+ }
+ favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
+ if err != nil {
+ t.Fatalf("IsUserFavorite after re-favorite: %v", err)
+ }
+ if !favorite {
+ t.Fatal("expected re-favourite to restore the soft-deleted row")
+ }
}
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
diff --git a/internal/service/server_identity.go b/internal/service/server_identity.go
new file mode 100644
index 0000000..43e3d84
--- /dev/null
+++ b/internal/service/server_identity.go
@@ -0,0 +1,126 @@
+// Package service — 服务器身份与移动端能力声明。
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+
+ "github.com/google/uuid"
+
+ "github.com/truewhile/MeBox/internal/repository"
+)
+
+// 服务器身份与移动端契约常量。
+const (
+ // ServerIDSettingKey 是持久化实例身份的设置键。普通重启、升级和同实例
+ // 数据恢复都沿用同一个值;把备份克隆成独立服务时需要显式重置。
+ ServerIDSettingKey = "mobile.server_id"
+
+ // MobileAPIVersion 是 /api/v1 契约版本。
+ MobileAPIVersion = "v1"
+
+ // MinSupportedAppVersionCode 是首个移动端契约要求的最低 App versionCode。
+ MinSupportedAppVersionCode = 1
+
+ // MobileServerName 是 server-info 暴露的实例名称。
+ MobileServerName = "MeBox"
+)
+
+// ServerCapabilities 是 server-info 的能力开关。
+//
+// 每项都由「接口已实现且当前可运行」共同决定;仅注册了 handler 但功能
+// 不可用时必须返回 false,避免 App 依据虚假能力进入不可用流程。
+type ServerCapabilities struct {
+ DeviceSessions bool `json:"device_sessions"`
+ PlaybackSessions bool `json:"playback_sessions"`
+ ReaderProgressV1 bool `json:"reader_progress_v1"`
+ ReaderContentBatch bool `json:"reader_content_batch"`
+ LocalTranscode bool `json:"local_transcode"`
+}
+
+// ServerInfo 是 GET /api/v1/server-info 的响应。
+type ServerInfo struct {
+ Name string `json:"name"`
+ ServerID string `json:"server_id"`
+ ServerVersion string `json:"server_version"`
+ APIVersion string `json:"api_version"`
+ MinSupportedAppVersionCode int `json:"min_supported_app_version_code"`
+ Capabilities ServerCapabilities `json:"capabilities"`
+}
+
+// ServerIdentityService 负责生成并持久化实例身份。
+type ServerIdentityService struct {
+ repo *repository.Container
+ version string
+
+ mu sync.Mutex
+ cached string
+}
+
+// NewServerIdentityService 创建实例身份服务。
+func NewServerIdentityService(repo *repository.Container, version string) *ServerIdentityService {
+ return &ServerIdentityService{repo: repo, version: strings.TrimSpace(version)}
+}
+
+// ServerID 返回持久化的实例 ID,首次调用时生成并写入设置表。
+func (s *ServerIdentityService) ServerID(ctx context.Context) (string, error) {
+ if s == nil || s.repo == nil || s.repo.Setting == nil {
+ return "", errors.New("server identity unavailable")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.cached != "" {
+ return s.cached, nil
+ }
+ stored, err := s.repo.Setting.Get(ctx, ServerIDSettingKey)
+ if err != nil {
+ return "", err
+ }
+ stored = strings.TrimSpace(stored)
+ if stored != "" {
+ s.cached = stored
+ return stored, nil
+ }
+ generated := uuid.NewString()
+ if err := s.repo.Setting.Set(ctx, ServerIDSettingKey, generated); err != nil {
+ return "", err
+ }
+ s.cached = generated
+ return generated, nil
+}
+
+// Info 组装 server-info 响应。capabilities 由调用方按真实可用服务推导。
+func (s *ServerIdentityService) Info(ctx context.Context, capabilities ServerCapabilities) (ServerInfo, error) {
+ serverID, err := s.ServerID(ctx)
+ if err != nil {
+ return ServerInfo{}, err
+ }
+ version := strings.TrimSpace(s.version)
+ if version == "" {
+ version = "dev"
+ }
+ return ServerInfo{
+ Name: MobileServerName,
+ ServerID: serverID,
+ ServerVersion: version,
+ APIVersion: MobileAPIVersion,
+ MinSupportedAppVersionCode: MinSupportedAppVersionCode,
+ Capabilities: capabilities,
+ }, nil
+}
+
+// CurrentServerCapabilities 依据服务容器推导移动端能力。
+//
+// M1 只启用了设备会话;播放会话、阅读 v1 进度与批量正文属于后续阶段,
+// 在对应任务真正实现并通过契约测试前保持 false。
+func CurrentServerCapabilities(c *Container) ServerCapabilities {
+ return ServerCapabilities{
+ DeviceSessions: c != nil && c.AuthSessions != nil,
+ PlaybackSessions: false,
+ ReaderProgressV1: false,
+ ReaderContentBatch: false,
+ LocalTranscode: false,
+ }
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 500ac69..964380e 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -60,6 +60,8 @@ type Container struct {
OrganizePipeline *OrganizePipelineService
Douban *DoubanProvider
Token *TokenService
+ AuthSessions *AuthSessionService
+ ServerIdentity *ServerIdentityService
ApiConfig *ApiConfigService
Device *DeviceService
Telegram *TelegramService
diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go
index f2c9b43..c7717fe 100644
--- a/internal/service/service_builder.go
+++ b/internal/service/service_builder.go
@@ -234,10 +234,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
func (b *serviceContainerBuilder) initIdentityServices() {
b.c.Token = NewTokenService(b.cfg, b.log, b.repos)
+ b.c.AuthSessions = NewAuthSessionService(b.log, b.repos)
+ b.c.ServerIdentity = NewServerIdentityService(b.repos, b.version)
+ // 刷新令牌轮换时必须校验所属会话仍然有效,否则被踢设备可以用旧刷新
+ // 令牌换出新令牌。
+ b.c.Token.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
+ _, err := b.c.AuthSessions.Validate(ctx, sessionID, userID)
+ return err
+ })
b.c.Auth = NewAuthService(b.cfg, b.log, b.repos, b.c.Token, b.c.Permissions)
+ b.c.Auth.SetAuthSessionService(b.c.AuthSessions)
b.c.Sessions = NewSessionTrackerService(b.log)
b.c.Device = NewDeviceService(b.log, b.repos)
b.c.Device.SetSessionTracker(b.c.Sessions)
+ b.c.Device.SetAuthSessionService(b.c.AuthSessions)
// Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。
b.c.Telegram = NewTelegramService(b.log, b.repos)
b.c.Device.SetNotifier(b.c.Telegram.SendToUser)
diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go
index faec6af..b887765 100644
--- a/internal/service/token_svc.go
+++ b/internal/service/token_svc.go
@@ -33,6 +33,8 @@ type Claims struct {
Tier string `json:"tier,omitempty"`
Purpose string `json:"purpose,omitempty"`
MediaID string `json:"media_id,omitempty"`
+ // Sid 是设备认证会话 ID。旧令牌没有该字段,继续走兼容路径。
+ Sid string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
@@ -60,6 +62,10 @@ type TokenService struct {
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
// 幂等复用(见 token_refresh_rotation.go)。
rotations map[string]rotatedRefreshToken
+
+ // sessionValidator 校验刷新令牌所属认证会话是否仍有效;由服务构建器
+ // 注入,nil 表示不做会话校验(测试或旧配置)。
+ sessionValidator func(ctx context.Context, sessionID, userID string) error
}
// NewTokenService 创建令牌服务实例。
@@ -92,19 +98,35 @@ var (
// IssuePair 为用户签发新的令牌对。
func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
- return s.issuePair(ctx, userID, role, tier, false)
+ return s.issuePair(ctx, userID, role, tier, "", false)
}
// IssuePairBestEffort 为登录签发令牌。SQLite 被后台扫描长期写锁占用时,
// 登录不能因为 refresh token 暂时无法落库而失败:先返回可用 access token,
// 再在后台把 refresh token 补写进库。
func (s *TokenService) IssuePairBestEffort(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
- return s.issuePair(ctx, userID, role, tier, true)
+ return s.issuePair(ctx, userID, role, tier, "", true)
}
-func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, bestEffort bool) (*TokenPair, error) {
+// IssuePairForSession 为指定认证会话签发令牌对,访问令牌携带 sid,
+// 刷新令牌记录关联该会话。
+func (s *TokenService) IssuePairForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
+ return s.issuePair(ctx, userID, role, tier, sessionID, false)
+}
+
+// IssuePairBestEffortForSession 与 IssuePairBestEffort 相同,但绑定会话。
+func (s *TokenService) IssuePairBestEffortForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
+ return s.issuePair(ctx, userID, role, tier, sessionID, true)
+}
+
+// SetSessionValidator 注入认证会话校验器。
+func (s *TokenService) SetSessionValidator(fn func(ctx context.Context, sessionID, userID string) error) {
+ s.sessionValidator = fn
+}
+
+func (s *TokenService) issuePair(ctx context.Context, userID, role, tier, sessionID string, bestEffort bool) (*TokenPair, error) {
// 生成 Access Token
- accessToken, err := s.issueAccessToken(userID, role, tier)
+ accessToken, err := s.issueAccessToken(userID, role, tier, sessionID)
if err != nil {
return nil, err
}
@@ -118,12 +140,13 @@ func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string,
// 存储 Refresh Token 哈希
tokenHash := repository.HashToken(refreshToken)
rt := &model.RefreshToken{
- UserID: userID,
- TokenHash: tokenHash,
- ExpiresAt: time.Now().Add(RefreshTokenDuration),
+ UserID: userID,
+ TokenHash: tokenHash,
+ AuthSessionID: sessionID,
+ ExpiresAt: time.Now().Add(RefreshTokenDuration),
}
if bestEffort {
- s.storeRefreshTokenBestEffort(userID, tokenHash, rt.ExpiresAt)
+ s.storeRefreshTokenBestEffort(userID, tokenHash, sessionID, rt.ExpiresAt)
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
@@ -162,11 +185,12 @@ func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int {
}
// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
-func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
+func (s *TokenService) issueAccessToken(userID, role, tier, sessionID string) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
Tier: tier,
+ Sid: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
@@ -232,14 +256,28 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
return nil, ErrInvalidRefreshToken
}
rt = &model.RefreshToken{
- UserID: pending.UserID,
- TokenHash: tokenHash,
- ExpiresAt: pending.ExpiresAt,
+ UserID: pending.UserID,
+ TokenHash: tokenHash,
+ AuthSessionID: pending.SessionID,
+ ExpiresAt: pending.ExpiresAt,
+ }
+ // 尚未落库的令牌同样受会话撤销约束。
+ if rt.AuthSessionID != "" && s.sessionValidator != nil {
+ if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
+ return nil, err
+ }
}
}
// 检查是否已撤销
if rt.Revoked {
+ // 会话被撤销时必须在宽限期复用之前拦截:否则登出/被踢后的 60 秒
+ // 内,旧刷新令牌仍能从内存复用表换回一对有效令牌。
+ if rt.AuthSessionID != "" && s.sessionValidator != nil {
+ if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
+ return nil, err
+ }
+ }
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
// 客户端拿到 revoked 401 后清空整个会话。
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
@@ -268,14 +306,22 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
return nil, ErrUserExpired
}
+ // 会话被踢出/撤销后,旧刷新令牌不能再换出新会话。检查必须发生在撤销
+ // 旧令牌之前,否则一次失败的重试会把仍然有效的令牌标记成已撤销。
+ if rt.AuthSessionID != "" && s.sessionValidator != nil {
+ if err := s.sessionValidator(ctx, rt.AuthSessionID, user.ID); err != nil {
+ return nil, err
+ }
+ }
+
// 撤销旧的 Refresh Token(包括可能仍在后台补写队列里的副本)。
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
s.log.Warn("failed to revoke old refresh token", zap.Error(err))
}
s.untrackDelayedStore(rt.UserID, tokenHash)
- // 签发新的令牌对
- pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
+ // 签发新的令牌对;轮换后的令牌继续归属同一个认证会话。
+ pair, err := s.IssuePairBestEffortForSession(ctx, user.ID, user.Role, user.Tier, rt.AuthSessionID)
if err != nil {
return nil, err
}
diff --git a/internal/service/token_svc_pending.go b/internal/service/token_svc_pending.go
index 662bb20..3d7df45 100644
--- a/internal/service/token_svc_pending.go
+++ b/internal/service/token_svc_pending.go
@@ -16,11 +16,12 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond
type pendingRefreshToken struct {
UserID string
+ SessionID string
ExpiresAt time.Time
}
-func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, expiresAt time.Time) {
- if !s.trackDelayedStore(userID, tokenHash, expiresAt) {
+func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash, sessionID string, expiresAt time.Time) {
+ if !s.trackDelayedStore(userID, tokenHash, sessionID, expiresAt) {
return
}
done := make(chan error, 1)
@@ -29,16 +30,17 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
defer cancel()
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
return s.storeRefreshToken(ctx, &model.RefreshToken{
- UserID: userID,
- TokenHash: tokenHash,
- ExpiresAt: expiresAt,
+ UserID: userID,
+ TokenHash: tokenHash,
+ AuthSessionID: sessionID,
+ ExpiresAt: expiresAt,
})
})
done <- err
}()
select {
case err := <-done:
- s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
+ s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
case <-time.After(loginRefreshTokenStoreTimeout):
if s.log != nil {
s.log.Warn("refresh token store delayed; login will continue",
@@ -47,12 +49,12 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
}
go func() {
err := <-done
- s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
+ s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
}()
}
}
-func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash string, expiresAt time.Time, err error) {
+func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID string, expiresAt time.Time, err error) {
if err == nil {
s.untrackDelayedStore(userID, tokenHash)
return
@@ -63,7 +65,7 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
zap.String("user_id", userID),
zap.Error(err))
}
- s.storeRefreshTokenEventually(userID, tokenHash, expiresAt)
+ s.storeRefreshTokenEventually(userID, tokenHash, sessionID, expiresAt)
return
}
s.untrackDelayedStore(userID, tokenHash)
@@ -72,22 +74,23 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
}
}
-func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, expiresAt time.Time) {
+func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash, sessionID string, expiresAt time.Time) {
defer s.untrackDelayedStore(userID, tokenHash)
delay := time.Second
for attempt := 1; attempt <= 8; attempt++ {
timer := time.NewTimer(delay)
<-timer.C
- // 令牌可能已在等待期间被轮换/登出(从 pending 表移除),
- // 此时绝不能再写库,否则会复活一个已被替换的旧令牌。
+ // ?????????????/???? pending ?????
+ // ?????????????????????????
if _, stillPending := s.pendingDelayedStore(tokenHash); !stillPending {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := s.storeRefreshToken(ctx, &model.RefreshToken{
- UserID: userID,
- TokenHash: tokenHash,
- ExpiresAt: expiresAt,
+ UserID: userID,
+ TokenHash: tokenHash,
+ AuthSessionID: sessionID,
+ ExpiresAt: expiresAt,
})
cancel()
if err == nil {
@@ -114,7 +117,7 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp
}
}
-func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt time.Time) bool {
+func (s *TokenService) trackDelayedStore(userID, tokenHash, sessionID string, expiresAt time.Time) bool {
if s == nil {
return false
}
@@ -126,7 +129,7 @@ func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt tim
if _, ok := s.delayedStores[tokenHash]; ok {
return false
}
- s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, ExpiresAt: expiresAt}
+ s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, SessionID: sessionID, ExpiresAt: expiresAt}
return true
}
@@ -139,7 +142,7 @@ func (s *TokenService) untrackDelayedStore(userID, tokenHash string) {
s.delayedStoreMu.Unlock()
}
-// pendingDelayedStore 返回尚未落库的 refresh token 信息(如果存在)。
+// pendingDelayedStore ??????? refresh token ?????????
func (s *TokenService) pendingDelayedStore(tokenHash string) (pendingRefreshToken, bool) {
if s == nil {
return pendingRefreshToken{}, false
diff --git a/internal/service/token_svc_pending_test.go b/internal/service/token_svc_pending_test.go
index 7e1719a..9abe0b8 100644
--- a/internal/service/token_svc_pending_test.go
+++ b/internal/service/token_svc_pending_test.go
@@ -34,7 +34,7 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
refreshToken := "pending-token-value"
hash := repository.HashToken(refreshToken)
- if !svc.trackDelayedStore(u.ID, hash, time.Now().Add(time.Hour)) {
+ if !svc.trackDelayedStore(u.ID, hash, "", time.Now().Add(time.Hour)) {
t.Fatal("trackDelayedStore returned false")
}
@@ -97,7 +97,7 @@ func TestRefreshRejectsExpiredPendingToken(t *testing.T) {
refreshToken := "expired-pending"
hash := repository.HashToken(refreshToken)
- svc.trackDelayedStore("user-x", hash, time.Now().Add(-time.Minute))
+ svc.trackDelayedStore("user-x", hash, "", time.Now().Add(-time.Minute))
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
t.Fatal("expired pending token should be rejected")