Skip to content

用户状态管理规范

本文档定义 CodeNote 项目的用户注册、登录、登出、切换账号、Token 管理及三端交互的完整规则。


一、概述

采用统一身份管理架构,三端共享用户资料、Token、Session 管理。核心约定:user_sessions(user_id, device_id) 唯一约束,不堆历史记录;所有登录事件记录到 login_records 纯追加表。


二、核心概念

术语定义
账号(user)users 表一行,含档案(昵称/头像/配额)与用户名密码凭据
身份(identity)user_identities 表一行,一种登录方式(PHONE/EMAIL/DEVICE/WECHAT...)。一个账号可绑定多种身份
设备身份DEVICE provider,与手机/邮箱平级,不再有"设备用户/普通用户"之分;一账号可绑多台设备
Session用户在某一设备上的登录态,(user_id, device_id) 唯一,不累积版本
Access TokenJWT,有效期 7 天
Refresh TokensessionId.uuidPart,uuidPart 以 BCrypt 存储,有效期 30 天
Account Link用户 A 关联用户 B 的关系,用于快速切换账号(保留)

三、三端职责划分

核心职责关键功能
Android会话创建、Token 存储、请求认证、会话切换、多账号管理Token 管理、API 拦截、登录页引导、一键登录(点击触发)、身份绑定
Admin会话管理、用户管理、权限控制后台管理界面、管理员操作、会话监控
Server会话验证、Token 生成、会话管理、安全审计Token 签发、会话持久化、过期清理、用户资料存储

四、架构设计

4.1 整体架构

┌─────────────────────────────────────────────────────────────────┐
│                     统一身份管理层                              │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │UserProfileStore││  TokenStore   │  │ SessionStore │         │
│  │ (用户资料)   │  │ (令牌管理)   │  │ (会话管理)   │         │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘         │
│         │                  │                  │                 │
│         └──────────────────┼──────────────────┘                 │
│                            ▼                                   │
│              ┌──────────────────────────┐                       │
│              │    IdentityManager      │                       │
│              │    (统一身份管理器)      │                       │
│              └───────────┬──────────────┘                       │
│                          │                                     │
├───────────────────────────┼─────────────────────────────────────┤
│                          ▼                                     │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │   Android    │  │    Admin     │  │    Server    │         │
│  └──────────────┘  └──────────────┘  └──────────────┘         │
└─────────────────────────────────────────────────────────────────┘

4.2 核心组件职责

组件职责
IdentityManager统一管理身份生命周期(登录、登出、切换账号、切换会话)
UserProfileStore安全存储用户资料(用户名、邮箱、头像等)
TokenStore安全存储 Access Token 和 Refresh Token
SessionStore管理本地会话缓存(用于 Token 刷新同步、关联账号本地缓存)、登出时清理;账号切换已改为服务端管理,不再通过 SessionStore 做本地切换
AuthInterceptor自动为 API 请求添加 Authorization 头
TokenRefresher自动检测并刷新过期 Token
SessionService服务端会话管理逻辑
SessionRepository会话数据访问层

五、数据表设计

5.1 users — 账号档案表

sql
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE,
    nickname VARCHAR(50) NOT NULL DEFAULT '',
    password VARCHAR(255),
    avatar VARCHAR(500),
    signature VARCHAR(200) DEFAULT NULL,
    role VARCHAR(20) NOT NULL DEFAULT 'USER',
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    storage_quota BIGINT NOT NULL DEFAULT 104857600,
    storage_used BIGINT NOT NULL DEFAULT 0,
    qr_code_quota INT NOT NULL DEFAULT 500,
    token_version INT DEFAULT 0,
    current_org_id BIGINT DEFAULT NULL,
    current_org_name VARCHAR(100) DEFAULT NULL,
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL
);

说明:认证字段(email/phone/device_id/is_device_user/bound_at/verified 等)已全部迁出,仅保留档案 + username/password(用户名密码登录凭据)。绑定型登录方式(手机/邮箱/设备/微信)统一存 user_identities

5.1b user_identities — 用户认证身份表

sql
CREATE TABLE user_identities (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    provider VARCHAR(32) NOT NULL COMMENT 'PHONE/EMAIL/DEVICE/WECHAT/APPLE...',
    identifier VARCHAR(255) NOT NULL COMMENT '手机号/邮箱/设备ID/微信openid',
    credential VARCHAR(255) DEFAULT NULL COMMENT '第三方unionid等',
    verified BOOLEAN NOT NULL DEFAULT FALSE,
    extra TEXT DEFAULT NULL COMMENT '设备名/第三方昵称/头像等',
    created_at BIGINT NOT NULL,
    updated_at BIGINT NOT NULL,
    UNIQUE KEY uk_provider_identifier (provider, identifier),
    INDEX idx_user_id (user_id),
    CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) COMMENT '用户认证身份表';

规则

  • (provider, identifier) 全局唯一(等价于旧 email/phone 唯一约束)
  • PHONE/EMAIL/WECHAT:每用户一条(业务层校验,换绑=删旧插新)
  • DEVICE:每用户多条(一个账号可绑多台设备)

用户资料存于 users 表,各端通过以下组件统一管理:

  • 服务端:User + UserIdentity 实体
  • Android:UserProfileStore + DeviceUserManager
  • Admin:localStorage 中的 UserProfile 对象

5.2 user_sessions — 用户登录态

sql
CREATE TABLE user_sessions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT DEFAULT 0,   -- TODO: 迁移为 BIGINT NOT NULL(当前 V1 schema 为 DEFAULT 0,Entity 为 NOT NULL)
    session_id VARCHAR(64) UNIQUE NOT NULL,
    device_id VARCHAR(128) NOT NULL,
    device_name VARCHAR(128),
    access_token TEXT NOT NULL,
    refresh_token TEXT NOT NULL,
    login_time BIGINT NOT NULL,
    last_active_time BIGINT NOT NULL,
    ip_address VARCHAR(64),
    status VARCHAR(32) DEFAULT 'ACTIVE',  -- ACTIVE | LOGGED_OUT | DELETED | EXPIRED
    is_current BOOLEAN DEFAULT FALSE,     -- ⚠️ 废弃字段,代码中不读写,仅在 schema 中保留避免迁移
    expired_at DATETIME,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_sessions_user_id (user_id),
    INDEX idx_user_sessions_session_id (session_id),
    INDEX idx_user_sessions_status (status),
    INDEX idx_user_sessions_expired_at (expired_at),
    UNIQUE INDEX idx_user_device (user_id, device_id)
);

核心规则

  • (user_id, device_id) 唯一索引确保同用户同设备只有一条 ACTIVE session
  • 多次登录不 INSERT 新记录,而是 UPDATE 已有记录(accessToken / lastActiveTime)
  • 登出后状态变更为 LOGGED_OUT,不占用唯一索引
  • session_id 用于 refresh token 解析和 API 管理
  • is_current 字段废弃,不用于任何业务逻辑

5.3 login_records — 登录审计

sql
CREATE TABLE login_records (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT DEFAULT 0,
    session_id VARCHAR(64),
    device_id VARCHAR(128),          -- ⚠️ 长度改为 128,与 user_sessions.device_id 一致
    device_name VARCHAR(128),
    ip_address VARCHAR(45),
    login_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    login_type VARCHAR(32),             -- register | login | token_refresh | session_create
    status VARCHAR(32) NOT NULL,          -- success | failed
    failure_reason VARCHAR(256),
    user_agent TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_login_user_time (user_id, login_time DESC),
    INDEX idx_login_time (login_time DESC),
    INDEX idx_login_device (device_id)
);

特点:纯追加表,不 UPDATE、不 DELETE。成功/失败都记录。

sql
CREATE TABLE account_links (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT DEFAULT 0,
    linked_user_id BIGINT DEFAULT 0,
    link_token VARCHAR(64) NOT NULL UNIQUE,
    status VARCHAR(32) DEFAULT 'ACTIVE',   -- ACTIVE | DEACTIVATED
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (linked_user_id) REFERENCES users(id) ON DELETE CASCADE,
    UNIQUE KEY uk_user_linked_user (user_id, linked_user_id),
    INDEX idx_account_links_user_id (user_id),
    INDEX idx_account_links_linked_user_id (linked_user_id)
);

user_id 无外键约束,用户被删除后关联记录需业务代码单独清理;linked_user_idON DELETE CASCADE 外键。

5.5 表间关系

users.id ──────→ user_identities.user_id    (1:N,ON DELETE CASCADE)
users.id ──────→ user_sessions.user_id      (1:N,无外键)
users.id ──────→ login_records.user_id      (1:N,无外键)
users.id ──────→ account_links.user_id      (1:N)
users.id ──────→ account_links.linked_user_id (N:1,有外键)

六、API 接口设计

接口HTTP路径实现 Controller说明
用户登录POST/api/auth/loginAuthController用户名/邮箱 + 密码登录
用户注册POST/api/auth/registerAuthController创建用户名密码用户
用户登出POST/api/auth/logoutAuthController注销设备或指定会话
刷新 TokenPOST/api/auth/refreshAuthController使用 Refresh Token 获取新 Token
设备登录POST/api/auth/device-loginAuthController查 DEVICE identity,无则自动建用户+登记设备(返回 LoginResponse)
一键登录POST/api/auth/sms/oneclick-loginAuthControllerAndroid SDK 取号后自动注册/登录
发送验证码POST/api/auth/sms/send-codeAuthController发送登录验证码
验证码登录POST/api/auth/phone-loginAuthController手机号+验证码登录(自动注册)
手机号+密码登录POST/api/auth/phone-password-loginAuthController手机号+密码登录
发送绑定验证码POST/api/auth/sms/bind-codeAuthController发送绑定手机号验证码
验证码绑定POST/api/auth/phone-bindAuthController验证码绑定手机号(写 PHONE identity)
本机号码校验绑定POST/api/auth/sms/verify-mobileAuthController一键授权取号绑定(SDK token → 服务端 getMobile 取号,不传 phone)
设备列表GET/api/auth/devicesAuthController当前账号绑定设备列表(DEVICE identities)
移除设备DELETE/api/auth/devices/{deviceId}AuthController移除设备身份并吊销该设备会话(设备丢失处置)
获取用户资料GET/api/users/meAuthController获取当前用户资料
更新用户资料PUT/api/users/meAuthController更新用户资料
设置当前组织PUT/api/users/me/current-orgAuthController设置当前活跃组织

| 创建会话 | POST | /api/auth/sessions | AuthController | 手动创建会话记录 | | 获取会话 | GET | /api/auth/sessions/{sessionId} | AuthController | 获取指定会话详情 | | 删除会话 | DELETE | /api/auth/sessions/{sessionId} | AuthController | 删除指定会话 | | 获取会话列表 | GET | /api/auth/sessions | AccountSwitchController | 获取当前用户所有 ACTIVE 会话 | | 切换会话 | POST | /api/auth/sessions/{sessionId}/switch | AccountSwitchController | 生成新 token,写 login_records | | 获取关联账号 | GET | /api/auth/accounts | AuthController | 通过 account_links 获取 | | 切换账号 | POST | /api/auth/accounts/{userId}/switch | AuthController | ⚠️ 已废弃:不操作 session 且不带 X-Device-Id | | 添加关联 | POST | /api/auth/link | AccountSwitchController | 需验证对方密码 | | 关联列表 | GET | /api/auth/link/list | AccountSwitchController | 支持 userId 参数 | | 关联切换 | POST | /api/auth/link/{linkId}/switch | AccountSwitchController | 推荐方式 | | 移除关联 | DELETE | /api/auth/link/{linkId} | AccountSwitchController | status = DEACTIVATED | | 管理后台登录 | POST | /api/admin/auth/login | AdminController | 管理员登录 |


七、操作与数据表变更

7.1 注册

POST /api/auth/register {username, password, email?}
操作说明
usersINSERT建账号(username/password);注册填的邮箱写 EMAIL identity(未验证)
user_sessionsINSERT创建初始 session。如果请求未传 X-Device-Id,deviceId 为 ""(空字符串)。后续登录时,如果传了具体的 X-Device-Id,将创建第二个 session
login_recordsINSERTloginType = "register", status = "success"

返回:accessToken + refreshToken + expiresIn

建议:Android 端在注册请求中始终携带 X-Device-Id,避免产生空 deviceId 的 session 导致注册与登录的 session 不关联。

7.2 登录

POST /api/auth/login {username, password}
Header: X-Device-Id
操作说明
users只读校验用户名/邮箱匹配 + 密码验证
user_sessionsUPDATE 或 INSERT先查 (user_id, device_id) 任意状态记录:存在 → UPDATE(accessToken, lastActiveTime, 非ACTIVE状态恢复为ACTIVE, expiredAt+30天);不存在 → INSERT。不再查 status=ACTIVE 作为唯一判断条件
login_recordsINSERT成功:loginType = "login";失败:status = "failed" + failureReason

失败登录的 user_id:用户不存在时写 0,密码错误时写真实 userId。

登录并发控制:同一设备并发登录请求可能出现双方同时查无记录都执行 INSERT 的场景,导致唯一索引冲突。应使用数据库级 INSERT ... ON DUPLICATE KEY UPDATE@Lock(PESSIMISTIC_WRITE) 确保原子性。

同一设备登出后重新登录

  • 如果该 (user_id, device_id) 存在 LOGGED_OUT 的记录,直接 UPDATE 该记录 status→ACTIVE, accessToken→newToken, lastActiveTime→now, expiredAt→+30天,不执行 INSERT
  • 避免 idx_user_device 唯一索引冲突(LOGGED_OUT 的记录不被唯一索引约束,但物理上仍按同一行存在,所以需先尝试 UPDATE 再 INSERT)

同一设备登录不同用户:当用户在已有一个 ACTIVE session 的设备上登录另一个用户(例如设备用户注册后又在同一设备上 login 另一个账号),登录逻辑会为该新用户 (targetUserId, deviceId) 创建新 session。先前的 (oldUserId, deviceId) session 不会被自动影响。旧 session 的 accessToken 仍然有效直到过期。客户端应自行清理本地 DeviceUserManager 中的旧用户数据。

活跃会话上限:每次登录前检查该用户 ACTIVE 会话数是否已达 MAX_ACTIVE_SESSIONS (10),如果已达上限且当前设备无已登出会话可复用,则拒绝登录并提示「已达到最大活跃会话数限制」。

7.3 设备登录(免注册,自动注册)

POST /api/auth/device-login {deviceId, deviceName?}

设备身份与手机/邮箱验证码登录同模式:查 (DEVICE, device_id) identity → 无则自动建用户 + 登记设备身份 → 返回统一 LoginResponse

操作说明
users无 identity 时 INSERT自动创建账号(nickname 自动生成)
user_identitiesINSERT (DEVICE, deviceId) 或复用verified=true,extra 存设备名
user_sessionsUPDATE 或 INSERT复用/创建该设备 session
login_recordsINSERT新设备:loginType = device_register;已有设备:loginType = DEVICE

一账号多设备:凭据登录(用户名密码/手机/邮箱)成功时,若当前设备无 DEVICE identity,自动登记为当前账号的设备身份(先到先得,不抢占他人设备)。

设备丢失/更换:仅 DEVICE 身份的用户 hasRecoverableCredential=false,客户端提示绑定手机/邮箱;已绑定用户可在「设备管理」移除丢失设备身份。

7.5 登出

POST /api/auth/logout?deviceId=xxx
POST /api/auth/logout?sessionId=xxx
操作说明
user_sessionsUPDATE status = "LOGGED_OUT", expired_at = NOW()deviceId → 注销该用户+该设备所有 session;sessionId → 注销单条
user_identitiesDELETE 该设备 DEVICE identity登出 = 解除设备身份绑定(deviceId 路径):此后「免注册登录」无法绕过登出进入本账号,会创建新账号;原账号仍可用手机/邮箱/用户名密码登录(登录时重新登记设备)
login_recordsINSERT登出记 logout;解绑设备额外记 device_unbind
users不动

Android 端:服务端调用 try-catch 忽略失败,本地清空 token / profile / session / device user。

管理后台:调登录 API(POST /api/auth/logout?deviceId=""?sessionId=xxx,sessionId 需从 localStorage 中获取),成功后再清本地数据。

7.6 Token 刷新

POST /api/auth/refresh {refreshToken}
  refreshToken = sessionId.uuidPart
  存储:user_sessions.refresh_token = BCrypt(uuidPart)
操作说明
user_sessionsUPDATE accessToken, refreshToken, lastActiveTime, expiredAt(+30天)需验证 session 为 ACTIVE 且未超期;刷新成功后 expired_at 重置为当前时间 + 配置的 RefreshToken 有效期(30 天,滑动窗口)
login_recordsINSERTloginType = "token_refresh", status = "success"

Access Token 规范

  • 格式:JWT
  • 有效期:7 天
  • 包含字段:subject(userId), exp, iat
  • 注意:服务端生成的 JWT 仅包含 userId(作为 subject),不含 sessionId。sessionId 通过 refresh token 格式 sessionId.uuidPart 尾部解析,不嵌入 JWT 中。这是无状态 JWT 的意图设计。

Refresh Token 规范

  • 格式:sessionId.uuidPart,uuidPart 为 UUID
  • 有效期:30 天
  • 存储:uuidPart 以 BCrypt 哈希存储

请求与响应

POST /api/auth/refresh
Content-Type: application/json

请求体(RefreshTokenRequest):
{
  "refreshToken": "sessionId.uuidPart"
}

响应体(LoginResponse,即 ApiResponse<LoginResponse> 的 data 字段):
{
  "token": "eyJhbGciOiJIUzUxMiJ9...",       // 新的 Access Token(JWT)
  "refreshToken": "sessionId.newUuidPart",   // 新的 Refresh Token
  "expiresIn": 604800,                        // Access Token 有效期(秒)
  "userId": 2,
  "username": "小明",
  "email": "xxx@example.com",
  "avatar": "https://...",
  "currentOrgId": null,
  "currentOrgName": null
}

注意:服务端返回的 access token 字段名为 token(不是 accessToken),Android 端 RefreshTokenResponse 需以 @SerializedName("token") 映射该字段,避免反序列化得到 null 导致 token 被清空。

7.6.1 客户端刷新时机全清单

A. 调用 refresh 接口(真刷新 → expired_at 重置 +30 天)

#时机位置触发方式
1冷启动进入 AppAndroidSplashViewModel.checkAuth()force
2启动流程 autoRegisterLogin(绑定用户)AndroidAuthViewModel.autoRegisterLogin()force
3token 变化时(距过期 <5 分钟)AndroidAuthInterceptor collect accessTokenFlow阈值检查
4每 60 秒定时检查(距过期 <5 分钟)AndroidAuthInterceptor 定时协程阈值检查
5获取用户信息前(getUserInfo()AndroidAuthRepositoryImpl阈值检查
6任意请求 401(非公开路径)AndroidAuthInterceptorforce + 重放请求
7401 后绑定用户二次刷新AndroidhandleBoundUserTokenFailure()force
8WebSocket 通知连接前(绑定用户)AndroidNotificationWebSocketManager.tryRefreshTokenIfNeeded()force
9每次请求前(距过期 <5 分钟)Adminutils/request.ts 请求拦截器阈值检查 + 并发队列
10任意请求 401Adminutils/request.ts 响应拦截器force + _retry 重放

B. 签发新 token(不调 refresh,但效果等价:新 JWT + expired_at 重置 +30 天)

#时机接口
11登录(用户名/邮箱+密码)POST /api/auth/login
12注册POST /api/auth/register
13手机号验证码登录(自动注册)POST /api/auth/phone-login
14手机号+密码登录POST /api/auth/phone-password-login
15一键登录(运营商取号)POST /api/auth/sms/oneclick-login
16邮箱验证码登录POST /api/auth/email-login

| 19 | 关联账号切换 | POST /api/auth/link/{linkId}/switch | | 20 | 会话切换 | POST /api/auth/sessions/{sessionId}/switch |

D. 不会触发刷新的情况

#场景说明
25公开路径的 401login/register/refresh/device-login/验证码/手机号/邮箱等公开路径,401 不处理不刷新
26Android 后台静默60s 定时检查只检查不刷新——距过期 ≥5 分钟时零请求
27Admin 无定时检查仅请求驱动(网页端合理)
28后端无主动刷新纯被动;SessionCleanupJob 每日 3:00 只做标记/清理,不刷新

滑动窗口效果:任何 A/B/C 类时机触发 → expired_at = now + 30 天。只要 30 天内 App 有打开/登录/操作/请求,会话永不过期;30 天完全无任何触发 → refresh token 过期 → 客户端刷新失败 → 跳登录。

刷新失败策略(客户端):网络层错误(断网/超时/DNS)或服务端临时故障(5xx)不清理本地 token,仅记录日志,待后续请求 401 时兑底;仅业务失败(refresh token 无效/会话失效/账户禁用/401)才清理本地登录态并跳转登录页。

7.7 绑定 / 解绑身份

绑定手机 POST /api/auth/phone-bind:验证码校验 → (PHONE, phone) 全局占用检查 → 当前用户插 PHONE identity(换绑=删旧插新,verified=true)。

绑定邮箱 POST /api/auth/email-bind:逻辑同上,写 EMAIL identity。

解绑手机/邮箱 POST /api/auth/phone/unbind / POST /api/auth/email/unbind:删除对应 identity。

解绑保护:解绑后必须仍有可登录方式(用户名+密码 / 其他 identity),否则 400「这是当前账号唯一的登录方式,无法解绑。请先设置用户名和密码,或绑定其他方式」——防账号变孤儿。 绑定 = 给同一账号追加身份。解绑后若仅剩 DEVICE 身份(且无密码),hasRecoverableCredential=false,客户端提示绑定可找回方式。

注销账户 POST /api/auth/account/delete(登录态,管理员禁止):删除账号身份与全部登录方式(user_identities CASCADE),吊销全部会话;业务数据由 FK SET NULL 保留,用户创建的组织保留(owner_id 悬空)。


八、切换账号

8.1 关联切换

POST /api/auth/link/{linkId}/switch              (推荐)
POST /api/auth/accounts/{targetUserId}/switch     (旧方式)

关联切换视同登录一个新账号,服务端行为与 §7.2 登录一致:

操作说明
account_links只读校验验证 link 为 ACTIVE 且属于当前用户
user_sessionsUPDATE 或 INSERT操作目标用户的 (targetUserId, 当前request的X-Device-Id) session,遵循与 §7.2 登录完全相同的逻辑(优先 UPDATE 已有记录,无记录则 INSERT,修复登出状态,检查 ACTIVE 会话数上限)
login_recordsINSERTloginType = "account_switch", status = "success"

返回:新的 accessToken + refreshToken + expiresIn(等同于登录返回格式)。客户端需按 LoginResponse 处理响应(含 refreshToken)。

SwitchAccountResponse DTO 需要增加 refreshToken 字段(String?),否则客户端刷新 token 时无 refreshToken 可用。

POST /api/auth/accounts/{userId}/switch(旧方式)应废弃,原因是它不传 X-Device-Id 且不操作 session,导致切换后 device binding 校验失败。

关联管理

POST   /api/auth/link                   → 添加关联(需验证对方密码)
DELETE /api/auth/link/{linkId}          → 移除关联(status = DEACTIVATED)
GET    /api/auth/link/list              → 获取用户关联列表(支持 userId 参数)
  • 唯一约束 (user_id, linked_user_id),不能关联自己

8.2 会话切换

POST /api/auth/sessions/{sessionId}/switch
  • 验证 session 为 ACTIVE 且属于当前用户
  • 生成新 accessToken + refreshToken,更新目标 session
  • 写 login_records(loginType = "session_switch"
  • 返回:SwitchSessionResponse(含新的 accessToken + refreshToken)

8.3 账号关联管理

添加关联账号

POST /api/auth/link
  • 请求体:{ "linkedUserId": Long, "password": String }
  • 验证:检查目标用户存在且密码正确
  • 写入 account_links 表建立关联关系
  • 返回:LinkedAccountDto

切换关联账号

POST /api/auth/link/{linkId}/switch
  • 验证:account_links 记录存在且为 ACTIVE,且属于当前用户
  • 视同登录目标账号,操作 user_sessions(遵循登录逻辑)
  • login_recordsloginType = "account_switch"
  • 返回:LoginResponse(含新的 accessToken + refreshToken)

移除关联账号

DELETE /api/auth/link/{linkId}
  • 验证:account_links 记录属于当前用户
  • 删除关联关系,不影响双方用户数据

获取关联账号列表

GET /api/auth/link/list
  • 返回当前用户所有 ACTIVE 状态的关联账号

九、设备管理

GET    /api/auth/devices              → 当前账号绑定设备列表(DEVICE identities + 设备名/绑定时间)
DELETE /api/auth/devices/{deviceId}   → 移除设备身份 + 吊销该设备活跃会话

设备丢失/更换处置:用已绑定手机/邮箱登录 → 移除丢失设备身份 → 该设备无法再以设备方式登录本账号。


十、Session 状态机

注册/登录


 ACTIVE ──── 30天无刷新 ────→ EXPIRED

    ├── 用户登出 ──────→ LOGGED_OUT
    ├── 主动删除 ──────→ DELETED
    └── 到期 ──────────→ EXPIRED

所有状态变更不物理删除数据。过期/已注销的记录由定时任务 SessionCleanupJob(每日凌晨 3:00)物理清理:

  • DELETED → 立即清理(在下一个凌晨 3:00 批量删除)
  • LOGGED_OUT 超过 3 天 → 物理删除
  • EXPIRED 超过 7 天 → 物理删除

十一、login_records 类型清单

loginType触发场景status
register注册完成success
DEVICE设备登录(已有 DEVICE identity)success
device_register设备登录自动注册(新设备)success
login登录(含密码错误、用户不存在)success / failed
token_refreshtoken 刷新success
session_create手动创建 sessionsuccess
account_switch关联账号切换(§8.1)success
session_switch会话切换(§8.2)success

| logout | 用户登出(deviceId 或 sessionId 路径均记录) | success | | device_bind | 凭据登录自动登记设备(ensureDeviceIdentity) | success | | device_unbind | 登出解除设备身份绑定 | success |


十二、管理后台差异

项目说明
端点前缀/api/admin/auth/,用户 role = ADMIN
Router 守卫router.beforeEach 检查 token,无 token 且非 /login 路由时重定向到 /login
退出按钮handleLogout → 调 POST /api/auth/logout → 成功后清 localStorage → router.push('/login')
会话管理管理员可查询/注销任意用户会话(AccountManagement.vue
用户禁用管理员禁用用户(ACTIVE→非ACTIVE)时,服务端同步注销该用户所有 ACTIVE 会话并黑名单旧 access token,现有登录态立即失效
账号关联管理管理员可查看/添加/移除用户关联
Token 自动刷新已实现:请求拦截器在 token 距过期 <5 分钟时预刷新,401 时刷新重试;刷新失败清 localStorage 跳登录页(utils/request.ts

管理后台的切换账号/会话复用普通用户相同的后端逻辑。

注:管理后台退出按钮在调登出 API 后清 localStorage。服务端将 deviceId="" 对应的 session 标记为 LOGGED_OUT。


十三、启动流程

13.1 概述

应用启动时,客户端决策只依赖本地 token:有 token → refresh 续期进主页;无 token → 显示登录页(不静默注册,由用户主动选择登录方式:一键登录/免注册/手机验证码/账号密码/邮箱)。登出后重启同样回登录页。

设备身份与手机/邮箱平级,不再区分"设备用户/普通用户"启动路径。

13.2 决策树

                       ┌───────────────────────────┐
                       │   应用启动                 │
                       └────────────┬──────────────┘


                    ┌───────────────┴───────────────┐
                    │     本地有 accessToken?       │
                    └───────┬───────────────┬───────┘
                      是    │               │    否
                           ▼               ▼
              ┌────────────────────┐   ┌──────────────────────────┐
              │ refresh 续期       │   │  显示登录页              │
              │ (force=true)       │   │ (用户选择登录方式)      │
              └──┬────────────┬───┘   └──────────────┬────────┘
              成功│         失败│                     │
                 ▼            ▼                      ▼
              ┌────┐      ┌────────┐             ┌────────┐
              │主页 │      │清 token │             │登录页  │
              └────┘      │→ 登录页 │             └────────┘
                          └────────┘

13.3 启动时序伪代码(Kotlin 风格)

kotlin
/**
 * 应用启动时调用一次,返回 true → 进主页,false → 显示登录页。
 */
suspend fun autoRegisterLogin(): Boolean {
    // ─── 阶段 1:本地 token 检查 ───
    val token = tokenStore.getAccessToken()
    if (token != null) {
        return try {
            tokenRefresher.refreshIfNeeded(force = true)
            tokenStore.getAccessToken() != null
        } catch (e: Exception) {
            tokenStore.clearTokens()
            deviceLoginFallback()
        }
    }
    // ─── 阶段 2:无 token → 显示登录页(不静默注册) ───
    return false
}

### 13.4 客户端实现要点

1. **`blockingInitialize()` 只读本地**:Android 端的 `DeviceUserManager.blockingInitialize()` 在 `Application.onCreate` 中调用,只读取本地 DataStore 确保 `deviceId` 就绪,**不调网络**。由 `autoRegisterLogin` 独立控制网络请求时机。

2. **`awaitInitialized()` 直接返回**:因为 `blockingInitialize` 不再调网络,无需等待,直接返回即可。

3. **路由守卫**:`AppNavHost` 在 `LaunchedEffect(Unit)` 中调用 `authViewModel.autoRegisterLogin()`,等待返回后动态设置 `startDestination`,避免启动时先闪 HOME 再跳 LOGIN。

4. **条件渲染**:调用 `autoRegisterLogin()` 返回之前,显示白色启动画面(`Box(background=White)`),防止布局不稳。

### 13.5 切换账号的启动后处理

切换账号成功后(`POST /api/auth/link/{linkId}/switch` 返回成功),`AccountSwitchViewModel.switchLinkedAccount()` 保存目标账号 token 与本地 profile 即可。启动流程不再依赖本地 `isBound` 决策,统一走 refresh / 设备登录。

---

## 十四、三端通信规范

### 14.1 请求头规范

| Header | 说明 | 来源 |
|--------|------|------|
| `Authorization` | Bearer + Access Token | Admin |
| `X-Device-Id` | 设备唯一标识 | Android |
| `X-Platform` | 平台标识 (Admin) | Admin |
| `X-App-Version` | 应用版本 | Android |
| `X-Request-Id` | 请求追踪 ID | Admin |

### 14.2 响应状态码

| 状态码 | 含义 | 处理方式 |
|--------|------|----------|
| **200** | 成功 | 正常处理 |
| **401** | 未授权 / Token 过期 | 刷新 Token 或重新登录 |
| **403** | 禁止访问 | 提示无权限 |
| **404** | 资源不存在 | 提示错误 |
| **500** | 服务器错误 | 重试或提示错误 |

### 14.3 错误响应格式

```json
{
  "code": 401,
  "message": "Token已过期",
  "error": "UNAUTHORIZED",
  "timestamp": 1699012345678
}

14.4 注册请求建议携带 X-Device-Id

Android 端在所有认证请求(register/login/phone-login/device-login 等)中始终携带 X-Device-Id,确保 user_sessions(user_id, device_id) 记录一致,并触发服务端自动登记设备身份。详见 §7.1 注释。

14.5 deviceId 持久化策略

Android 端 deviceId 的生成和持久化规则:

生成时机策略是否恢复
首次启动Settings.Secure.ANDROID_ID(卸载重装不变)或 UUID 回退✅ 卸载重装后不变
回退方案UUID 存 DataStore❌ 卸载重装后变化

建议:优先使用 Settings.Secure.ANDROID_ID(Android 8.0+ 稳定)。如果 ANDROID_ID 不可用(如某些定制 ROM 返回空值),使用 UUID.randomUUID() 并写入 EncryptedSharedPreferences

deviceId 不变时,device-login 可查回旧设备用户;deviceId 变化时,服务端视为新设备,旧用户数据无法恢复。

Admin 端不生成 deviceId。所有 Admin 请求的 X-Device-Id 为 null(或未传),服务端跳过 DeviceBinding 校验。


十五、安全规范

15.1 Android 端安全

措施说明
Token 加密存储使用 EncryptedSharedPreferences + AES-256
禁止日志打印禁止在日志中输出 Token
SSL 固定防止中间人攻击
设备绑定验证设备 ID 与活跃会话一致性(DeviceBindingFilter),认证入口端点(register/login/refresh/device-login)以及 POST /api/auth/sessions(创建会话)跳过校验

注:POST /api/auth/sessions 是唯一跳过设备绑定的 sessions 路由;GET /api/auth/sessionsGET /api/auth/sessions/{sessionId}DELETE /api/auth/sessions/{sessionId} 仍需校验。

15.2 Admin 端安全

措施说明
HTTPS 强制所有请求必须通过 HTTPS
权限控制基于角色的访问控制 (RBAC)
操作日志记录所有管理员操作(admin_logs 表)
会话超时后台管理会话自动超时

15.3 Server 端安全

措施说明
JWT 签名使用 HS512 签名
Refresh Token 哈希存储 BCrypt 哈希值而非明文
会话过期定期清理过期会话
登录频率限制同一 IP/DeviceId 在短时间内(如 5 分钟内)连续登录失败 N 次后,暂时锁定该设备

十六、监控与日志

16.1 监控指标

指标说明
会话创建数每分钟创建的会话数
Token 刷新次数每分钟 Token 刷新次数
401 错误率未授权错误占比
会话过期率过期会话占比
登录成功率登录成功次数占比

16.2 日志记录

事件日志内容
登录成功userId, sessionId, deviceId, ipAddress
登录失败userId, deviceId, reason
Token 刷新userId, sessionId, result
会话销毁userId, sessionId, reason
账号切换fromUserId, toUserId, sessionId

十七、配置规范

⚠️ 以下值由 codenote-server/src/main/resources/application.ymlcodenote.jwt.* 配置驱动,代码中禁止硬编码。修改仅需改配置一处,全局生效。

配置配置键
AccessToken 有效期7 天(604800 秒)codenote.jwt.access-token-expiration
RefreshToken 有效期30 天(2592000 秒)codenote.jwt.refresh-token-expiration
同用户最大 ACTIVE 会话数10AuthService.MAX_ACTIVE_SESSIONS(常量)
同用户同设备 ACTIVE 会话数最多 1 条(idx_user_device 唯一索引)
JWT 算法HS512JwtService

十八、Android 项目实现状态

18.1 已实现

功能状态位置
SessionHelpercore/data/util/SessionHelper.kt
AccountStorecore/data/local/AccountStore.kt
DeviceUserManagercore/data/local/DeviceUserManager.kt
AccountRepositoryImplcore/data/repository/AccountRepositoryImpl.kt
AuthRepositoryImplcore/data/repository/RepositoryImpls.kt
TokenStorecore/data/local/TokenStore.kt
AuthInterceptorcore/data/remote/AuthInterceptor.kt
TokenRefreshercore/data/util/TokenRefresher.kt
UserProfileStore接口 + DataStore 实现,已注册 DI
SessionStore接口 + DataStore 实现,已注册 DI
IdentityManager接口 + 实现,统一管理身份生命周期
请求头规范AuthInterceptor 已添加 X-Device-Id/X-Platform/X-App-Version/X-Request-Id
Token 自动刷新TokenRefresher(带互斥锁);token 变化即查 + 每 60s 定时检查 + 获取用户信息前检查,距过期 <5 分钟静默预刷新;401 刷新失败跳登录(AuthInterceptor)
加密存储使用 EncryptedSharedPreferences + AES-256 加密
账号关联管理全部使用服务端 account_links 表,支持添加/移除/切换关联账号

十九、核心原则

  1. user_sessions 不堆记录(user_id, device_id) 唯一索引确保同用户同设备最多一条 ACTIVE session,每次登录刷新而非新增
  2. 历史全在 login_records — 所有登录/注册/刷新按时间顺序记录,纯追加
  3. 切换账号即登录 — 关联切换视同登录目标账号,会操作 user_sessions 并记录 login_records
  4. device_id 是跨表桥梁 — 同一设备的所有账号通过 user_sessions.device_id 查询
  5. 三表无外键约束 — 全部由业务代码维护一致性

二十、部署安全

20.1 服务器重部署(数据库清空)防护

如果服务器进行重建、MySQL 容器重置或全量迁移重新执行,所有用户数据(users、user_sessions、login_records)将被清空。客户端可能持有旧 JWT token:

场景旧 JWT 签名密钥结果
密钥不变Token 仍可通过 JWT 解析,但 userRepository.findById(userId) 为空JwtAuthFilter.ensureDeviceUserExists() 会用旧 token 中的 userId 创建一个新的设备用户,旧用户数据丢失且用户无感知
密钥变更Token 解析失败 → 401 → 客户端走 refresh → refresh token 对应 session 不存在 → 完全失败客户端引导用户重新登录或重新注册

部署规范(推荐)

  • 生产 JWT_SECRET 通过环境变量注入,设置一次固定随机值(≥512 bit),不随部署变更——避免每次部署强制全员重新认证
  • 需要强制吊销旧 token 时:递增 users.token_version(tokenVersion 机制已实现于 JwtAuthFilter,随部署脚本递增),而非更换密钥
  • 若确需更换 JWT_SECRET:旧 token 立即失效;活跃客户端经 refresh 无感续期(refresh token 仍有效),仅 30 天未活跃用户需重新登录
  • 本地开发:application.yml 默认值(${JWT_SECRET:默认值})即可,无需设置环境变量

客户端兜底:如果服务端重部署且 JWT 密钥未变更,旧 token 仍可解析但 DB 中用户/会话数据已清空,JwtAuthFilter 会自动创建设备用户但无 user_sessions 记录。此时 ensureDeviceUserExists() 必须同步创建 UserSession,否则 DeviceBindingFilter 会返回 403。客户端应对 403 状态码做如下处理:

  1. 先尝试调用 /api/auth/refresh 刷新 token
  2. 如果刷新失败(refresh token 对应 session 不存在),清除本地 TokenStore
  3. 检查本地 DeviceUserManager 是否有 deviceId,调用 device-login 确认设备状态
  4. 引导用户重新登录(或设备用户自动重新注册)

20.2 登录并发控制

同一设备并发登录请求(同一 (user_id, device_id) 同时不存在 ACTIVE session时),双方都可能执行 INSERT,触发 (user_id, device_id) 唯一索引冲突。方案:

  • 使用 INSERT ... ON DUPLICATE KEY UPDATE 代替独立的 find + INSERT/UPDATE 两步操作
  • 或在登录方法上加 @Lock(PESSIMISTIC_WRITE) 配合事务
  • 已有 session 被登出后重新登录的场景见 §7.2「同一设备登出后重新登录」


附录:用户认证身份与账号形态

模型

  • users = 账号档案(含 username/password);user_identities = 绑定型登录方式(PHONE/EMAIL/DEVICE/WECHAT...)
  • 不再有 is_device_user / bound_at / credentials_set / email_verified / phone_verified 概念
  • 设备 = DEVICE identity,与手机/邮箱平级;一账号可绑多设备;凭据登录自动登记设备(先到先得)

账号形态汇总

形态username/passwordPHONEEMAILDEVICE可恢复(hasRecoverableCredential)
用户名密码注册可选可选登录自动登记
手机验证码注册可选登录自动登记
邮箱验证码注册可选登录自动登记
纯设备(首次免注册)❌(需提醒绑定)

绑定/解绑语义

  • 绑定手机/邮箱:给当前账号插对应 identity(换绑=删旧插新)
  • 解绑手机/邮箱:删对应 identity
  • hasRecoverableCredential = password 非空 ‖ 有 PHONE/EMAIL identity;false 时客户端提醒「设备丢失/更换将无法登录,建议绑定手机或邮箱」

设备管理

  • GET /api/auth/devicesDELETE /api/auth/devices/{deviceId}(设备丢失处置:移除设备身份+吊销该设备会话)

十三、联系人聊天数据清理(新增)

完整设计见专用文档 /designs/messaging/contacts-chat.md(§8.4 注销清理 / §2.10 退出清理)。

场景服务端行为客户端行为
注销账户(deleteAccount)DataCleanupService.purgeUserData 追加:删 contacts(双向行)+ contact_requests + contact_blocks + user_chat_settings + conversation_members;孤儿会话(无成员)连带消息删除;有剩余成员的会话消息保留(对方视角留痕,发送者显示「已注销用户」)-
退出登录 / 切换账号-ChatWsBootstrap 监听 token 流:token 置空 → 断开 /ws/chat + 清空本地 chat 三表(防串号)
消息保留期ChatCleanupJob(每天 4 点):chat.retention_days > 0 时删除过期消息并刷新会话冗余列;默认 0=永久-