外观
邮箱登录功能说明
1. 概述
为 CodeNote 系统增加邮箱验证码登录能力,作为手机号登录的补充。
1.1 各端支持方式
| 端 | 邮箱+密码登录 | 邮箱验证码登录 |
|---|---|---|
| Android | ✅ 已有(第3页账号密码页,输入邮箱即可) | ✅ 新增(第4页独立滑动页) |
| 管理后台 | ❌ 管理后台保持 username+password,不支持邮箱登录 | ❌ |
1.2 邮箱配置
使用阿里企业邮箱,配置如下:
| 项目 | 值 |
|---|---|
| 邮箱地址 | service@la998.com(环境变量注入) |
| SMTP 服务器 | smtp.qiye.aliyun.com |
| SMTP 端口 | 465(SSL) |
| POP3 | ✅ 已开启 |
| IMAP | ✅ 已开启 |
| SMTP | ✅ 已开启 |
| 外域收信 | ✅ 允许 |
| 自动转发 | ✅ 允许 |
| 附件上限 | 50MB |
| 单封收件人上限 | 300人 |
凭据管理:SMTP 密码通过服务器 .env 环境变量注入,不在 application.yml 中硬编码,先写入本地.env加入git提交。
2. 系统流程
2.1 登录流程(Android)
登录页共 5 页(HorizontalPager index 0-4)
Page 0 — 一键登录(不变)
Page 1 — 手机验证码(不变)
Page 2 — 免注册设备登录(不变)
Page 3 — 账号密码(账号/邮箱 + 密码,改标题及提示)
Page 4 — 邮箱验证码(新增)PageIndicator:5 个标签
["一键登录", "验证码", "免注册", "密码", "邮箱"]。第 3 页简写为"密码"防止窄屏溢出,第 4 页全称为"邮箱"与第 3 页作区分。
Page 3:账号密码(支持邮箱/密码)
第 3 页「密码/邮箱」
│
├─ 输入框:账号或邮箱(placeholder "账号 / 邮箱")
├─ 密码框:不变
├─ 后台识别格式:
│ ├─ 包含 @ → 视为邮箱 → findByEmail() + 密码校验
│ └─ 不含 @ → 视为账号(username)→ findByUsername() + 密码校验(已有逻辑)
├─ 安全校验:
│ └─ 用户存在但 password = null(通过验证码注册的用户)
│ → 抛异常"该邮箱未设置密码,请使用验证码登录"
└─ 调用 login(account, password, deviceId, deviceName) 统一入口
注意:管理后台的 login 保持 findByUsername() 逻辑,不做邮箱检测Page 4:邮箱验证码(新增滑动页)
第 4 页「邮箱验证码」
│
├─ 输入邮箱 → 获取验证码 → 输入验证码 → 登录
│
└─ 后端逻辑:
POST /api/auth/email/send-code {email}
→ EmailService.sendCode(email) → 发送 4 位验证码到邮箱
→ 复用 credential_codes 表(type=EMAIL_LOGIN)
POST /api/auth/email-login {email, code}
→ 验证码校验
→ userRepository.findByEmail(email)
├─ 存在 → 登录
└─ 不存在 → 自动注册
User(email=email, username="user_{email前缀}", isDeviceUser=false, password=null)2.2 绑定邮箱(用户中心)
用户中心 → 设置 → 绑定邮箱
│
├─ 发送绑定验证码 POST /api/auth/email/bind-code {email}
├─ 验证码绑定 POST /api/auth/email-bind {email, code}
│ └─ 端点命名与 phone-bind 保持一致
└─ 登录审计:login_records.login_type = "email_bind"3. 后端设计
3.1 配置文件
yaml
# application.yml
aliyun:
email:
host: smtp.qiye.aliyun.com
port: 465
username: ${EMAIL_USERNAME}
password: ${EMAIL_PASSWORD}
from: ${EMAIL_FROM:service@la998.com}
# ... 现有配置bash
# 服务器 .env / 本地 .env.local(SMTP 密码由环境变量注入,不硬编码)
EMAIL_USERNAME=<邮箱账号>
EMAIL_PASSWORD=<SMTP 密码>
EMAIL_FROM=<发件人地址>3.2 数据库变更
sms_codes 表 — 重命名为 credential_codes
为兼容手机号和邮箱,将表重命名,phone 列更名为 credential:
sql
-- V1__init_schema.sql 中修改
CREATE TABLE credential_codes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
credential VARCHAR(100) NOT NULL, -- 手机号或邮箱
code VARCHAR(10) NOT NULL,
type VARCHAR(32) NOT NULL, -- LOGIN | REGISTER | BIND | EMAIL_LOGIN | EMAIL_BIND
expired_at DATETIME NOT NULL,
send_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
verified_at DATETIME,
INDEX idx_credential_type (credential, type),
INDEX idx_expired_at (expired_at)
);type 枚举扩展:
| type | 说明 |
|---|---|
LOGIN | 手机号登录验证码 |
REGISTER | 手机号注册验证码 |
BIND | 手机号绑定验证码 |
EMAIL_LOGIN | 邮箱登录验证码(新增) |
EMAIL_BIND | 邮箱绑定验证码(新增) |
注:SmsService 中所有操作
sms_codes的代码同步改为操作credential_codes。SmsService 拆分为CredentialCodeService(验证码公共逻辑)和SmsService(仅保留 PNVS 发送和手机号频率限制),参见 §3.3。
login_records.login_type 扩展
在用户状态管理规范 §11 的 login_type 清单中新增:
| loginType | 触发场景 | status |
|---|---|---|
email_login | 邮箱+验证码登录(新增) | success |
email_bind | 绑定邮箱(新增) | success |
由于部署时会清空全部数据再重建,
sms_codes→credential_codes直接执行 DDL 即可,无需编写数据迁移脚本。
3.3 CredentialCodeService + EmailService(新建)
CredentialCodeService — 验证码公共逻辑
从 SmsService 中提取验证码的生成/过期/持久化/校验逻辑,同时被短信和邮箱复用。
职责边界:
- 生成验证码、过期旧码、写入
credential_codes表、查询校验 - 不含频率限制(SMS 日上限 10,邮箱日上限 5,由各自调用方实现)
- 不含发送(短信走 PNVS,邮箱走 SMTP)
- DB 持久化计数:频率限制从缓存改为查询
credential_codes表今日记录数,确保重启后计数不丢失
kotlin
/**
* 验证码公共服务
*/
@Service
class CredentialCodeService(
private val credentialCodeRepository: CredentialCodeRepository
) {
private val random = SecureRandom()
companion object {
const val CODE_LENGTH = 4
const val CODE_TTL_MINUTES = 5L
}
/**
* 生成验证码并写入 DB。
* 调用方自行检查频率限制后再调用。
* @param credential 手机号或邮箱
* @param type EMAIL_LOGIN / EMAIL_BIND / LOGIN / BIND
* @return 生成的 4 位验证码
*/
@Transactional
fun generateAndSaveCode(credential: String, type: String): String {
val code = StringBuilder(CODE_LENGTH).apply {
repeat(CODE_LENGTH) { append(random.nextInt(10)) }
}.toString()
credentialCodeRepository.expireAllUnused(credential, type)
credentialCodeRepository.save(CredentialCode(
credential = credential,
code = code,
type = type,
status = "UNUSED",
expiredAt = LocalDateTime.now().plusMinutes(CODE_TTL_MINUTES),
createdAt = LocalDateTime.now()
))
return code
}
/**
* 校验验证码
*/
@Transactional
fun verifyCode(credential: String, code: String, type: String) {
val record = credentialCodeRepository
.findFirstByCredentialAndTypeAndStatusOrderByCreatedAtDesc(credential, type, "UNUSED")
?: throw BusinessException(message = "请先获取验证码")
if (record.expiredAt.isBefore(LocalDateTime.now())) {
record.status = "EXPIRED"
credentialCodeRepository.save(record)
throw BusinessException(message = "验证码已过期,请重新获取")
}
if (record.code != code) {
throw BusinessException(message = "验证码错误")
}
record.status = "USED"
credentialCodeRepository.save(record)
}
}EmailService — 邮件发送(新建)
kotlin
@Service
class EmailService(
@Value("\${aliyun.email.username}") private val username: String,
@Value("\${aliyun.email.password}") private val password: String,
@Value("\${aliyun.email.from}") private val from: String,
@Value("\${aliyun.email.host}") private val host: String,
@Value("\${aliyun.email.port:465}") private val port: Int,
private val credentialCodeService: CredentialCodeService,
private val credentialCodeRepository: CredentialCodeRepository // DB 查询做频率限制
) {
companion object {
private const val RESEND_INTERVAL_SECONDS = 60L
private const val DAILY_MAX_PER_EMAIL = 5 // 邮箱日上限(短信是 10)
}
/**
* 发送邮箱验证码
* 1. 频率限制(60s + 日上限5,DB 查询)
* 2. generateAndSaveCode() 生成码并落库
* 3. SMTP 发送(异常转业务异常)
*/
fun sendCode(email: String, type: String) {
checkRateLimit(email)
val code = credentialCodeService.generateAndSaveCode(email, type)
sendEmailSafe(email, "CodeNote 登录验证码", "您的验证码是:$code,5分钟内有效。")
// incrementDailyCounter 已由 generateAndSaveCode 写入记录,DB 查询自动计次
}
private fun sendEmailSafe(to: String, subject: String, body: String) {
try {
val props = Properties().apply {
put("mail.smtp.host", host)
put("mail.smtp.port", port)
put("mail.smtp.auth", "true")
put("mail.smtp.ssl.enable", "true")
}
val session = Session.getInstance(props, object : Authenticator() {
override fun getPasswordAuthentication() = PasswordAuthentication(username, password)
})
val message = MimeMessage(session).apply {
setFrom(InternetAddress(from))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(to))
setSubject(subject)
setText(body)
}
Transport.send(message)
} catch (e: MessagingException) {
throw BusinessException(message = "邮件发送失败,请稍后重试")
}
}
// ========== 频率限制(DB 查询 credential_codes 表,重启不丢计数) ==========
private fun checkRateLimit(email: String) {
// 60s 重发间隔 — 查最后一条记录
val lastRecord = credentialCodeRepository
.findFirstByCredentialAndTypeInOrderByCreatedAtDesc(email, listOf("EMAIL_LOGIN", "EMAIL_BIND"))
if (lastRecord != null) {
val elapsed = ChronoUnit.SECONDS.between(lastRecord.createdAt, LocalDateTime.now())
if (elapsed < RESEND_INTERVAL_SECONDS) {
throw BusinessException(errorCode = 429, message = "请 ${RESEND_INTERVAL_SECONDS - elapsed} 秒后再试")
}
}
// 今日发送次数上限 — 统计今日记录数
val todayStart = LocalDate.now().atStartOfDay()
val todayCount = credentialCodeRepository
.countByCredentialAndTypeInAndCreatedAtAfter(email, listOf("EMAIL_LOGIN", "EMAIL_BIND"), todayStart)
if (todayCount >= DAILY_MAX_PER_EMAIL) {
throw BusinessException(message = "今日验证码已达上限,请明天再试")
}
}
}注意:
SmsService中DAILY_MAX_PER_PHONE = 10不变,频率限制仍使用smsRate/smsDaily缓存(与短信 SDK 遗留逻辑保持一致)。EmailService的频率限制使用 DB 查询,重启不丢计数。
3.4 新增/修改的 API
| 端点 | 方法 | 说明 | Auth |
|---|---|---|---|
/api/auth/email/send-code | POST | 发送邮箱登录验证码 | 无 |
/api/auth/email-login | POST | 邮箱+验证码登录(自动注册) | 无 |
/api/auth/email/bind-code | POST | 发送绑定邮箱验证码 | Bearer |
/api/auth/email-bind | POST | 绑定邮箱(与 phone-bind 命名一致) | Bearer |
邮箱验证码登录
POST /api/auth/email/send-code
Content-Type: application/json
Request: { "email": "user@example.com" }
Response 200: { "code": 200, "data": null }POST /api/auth/email-login
Content-Type: application/json
Request: { "email": "user@example.com", "code": "1234", "deviceId": "xxx", "deviceName": "Xiaomi 14" }
Response 200: { "code": 200, "data": { ...PhoneLoginResponse } }3.5 后端逻辑
AuthService.emailLogin() — 邮箱+验证码登录
kotlin
@Transactional
fun emailLogin(request: EmailLoginRequest, deviceId: String, deviceName: String): LoginResponse {
val email = request.email.trim().lowercase()
credentialCodeService.verifyCode(email, request.code, "EMAIL_LOGIN")
val user = userRepository.findByEmail(email)
if (user != null) {
return buildLoginResponse(user, isNewUser = false, deviceId, deviceName)
}
// 自动注册 — password 为 null,不可通过邮箱+密码登录
val username = "user_${email.substringBefore("@")}"
val newUser = User(
username = username,
email = email,
password = null,
isDeviceUser = false,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
userRepository.save(newUser)
return buildLoginResponse(newUser, isNewUser = true, deviceId, deviceName)
}AuthService.login() — 邮箱/账号+密码登录(改造)
kotlin
/**
* 账号密码登录 — 支持 username 和 email
* 输入含 @ 按邮箱查找,否则按 username 查找。
* 管理后台 login() 保持 findByUsername() 逻辑,不做邮箱检测。
*/
fun login(account: String, password: String, deviceId: String, deviceName: String): LoginResponse {
val user = if (account.contains("@")) {
userRepository.findByEmail(account.trim().lowercase())
} else {
userRepository.findByUsername(account.trim())
} ?: throw BusinessException(ErrorCode.USER_NOT_FOUND)
// 通过验证码注册的用户无密码,不能使用密码登录
if (user.password == null) {
throw BusinessException(ErrorCode.EMAIL_NO_PASSWORD, "该邮箱未设置密码,请使用验证码登录")
}
if (!passwordEncoder.matches(password, user.password)) {
throw BusinessException(ErrorCode.PASSWORD_ERROR)
}
return buildLoginResponse(user, isNewUser = false, deviceId, deviceName)
}AuthService.emailBind() — 绑定邮箱
kotlin
@Transactional
fun emailBind(userId: Long, request: EmailBindRequest) {
credentialCodeService.verifyCode(request.email, request.code, "EMAIL_BIND")
userRepository.updateEmail(userId, request.email.trim().lowercase())
// 记录审计
loginRecordRepository.save(LoginRecord(
userId = userId,
loginType = "email_bind",
status = "success"
))
}3.6 安全配置变更
SecurityConfig.kt — 新增公开路径
kotlin
// 在 permitAll() 路径中补充
"/api/auth/email/send-code",
"/api/auth/email-login"DeviceBindingFilter — 跳过路径
kotlin
// 在 skipPaths 集合中补充
"/api/auth/email/send-code",
"/api/auth/email-login"3.7 新增/修改文件清单
| 文件 | 变更 | 说明 |
|---|---|---|
EmailService.kt | 新建 | 邮件发送服务 |
CredentialCodeService.kt | 新建 | 提取验证码公共逻辑 generateAndSaveCode() / verifyCode(),统一 credential 概念 |
SmsService.kt | 修改 | 移除 generateAndSaveCode() 和 verifyCode() 到 CredentialCodeService,保留 PNVS 发送和频率限制(DAILY_MAX_PER_PHONE=10) |
AuthDto.kt | 修改 | 新增 EmailLoginRequest / SendEmailCodeRequest / EmailBindRequest |
AuthService.kt | 修改 | 新增 emailLogin() / emailBind();修改 login() 支持邮箱检测(含 password=null 保护) |
AuthController.kt | 修改 | 新增 4 个端点 |
SecurityConfig.kt | 修改 | 新增 /api/auth/email/** 公开路径 |
DeviceBindingFilter.java | 修改 | 新增邮箱认证端点跳过路径 |
application.yml | 修改 | 新增 aliyun.email.* 配置(凭据用环境变量引用) |
.env / .env.local / .env.server | 修改 | 新增 EMAIL_USERNAME / EMAIL_PASSWORD / EMAIL_FROM |
build.gradle.kts | 修改 | 新增 spring-boot-starter-mail 依赖 |
V1__init_schema.sql | 修改 | sms_codes → credential_codes,phone → credential |
/designs/auth/user-state.md | 修改 | §11(login_type 清单新增 email_login / email_bind)和 §5.2(credential_codes 表结构描述)同步更新 |
4. Android 端设计
4.1 登录页
HorizontalPager 从 4 页改为 5 页:
| 页 | 标题 | 组件 | 说明 |
|---|---|---|---|
| 0 | 一键登录 | OneClickTab | 不变 |
| 1 | 验证码 | SmsLoginTab | 不变 |
| 2 | 免注册 | DeviceLoginTab | 不变 |
| 3 | 密码/邮箱 | AccountPasswordTab | 改造:输入提示改为"账号 / 邮箱",支持邮箱+密码登录 |
| 4 | 邮箱验证码 | EmailCodeLoginTab(新建) | 邮箱+验证码登录/注册 |
PageIndicator 页标签文本:["一键登录", "验证码", "免注册", "密码/邮箱", "邮箱验证码"]。第 3 页简写为"密码/邮箱"避免窄屏换行,第 4 页写明"邮箱验证码"。
4.2 AccountPasswordTab — 改造
kotlin
@Composable
private fun AccountPasswordTab(
account: String, onAccountChange: (String) -> Unit,
password: String, onPasswordChange: (String) -> Unit,
isLoading: Boolean, error: String?,
onLogin: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("密码/邮箱", style = MaterialTheme.typography.labelLarge, ...)
Spacer(4.dp)
OutlinedTextField(
value = account, onValueChange = onAccountChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("账号 / 邮箱") }, // ← 改 placeholder
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next)
)
Spacer(8.dp)
OutlinedTextField(
value = password, onValueChange = onPasswordChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("密码") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done)
)
Spacer(24.dp)
Button(onClick = onLogin, enabled = account.isNotBlank() && password.isNotBlank()) {
Text("登录")
}
}
}4.3 EmailCodeLoginTab(新建)
kotlin
@Composable
private fun EmailCodeLoginTab(
email: String, onEmailChange: (String) -> Unit,
code: String, onCodeChange: (String) -> Unit,
countdown: Int,
isLoading: Boolean, error: String?,
onSendCode: () -> Unit,
onEmailLogin: () -> Unit
) {
// 邮箱格式校验
val isValidEmail = remember(email) {
email.contains("@") && email.contains(".") && email.length >= 5
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("邮箱验证码登录", style = MaterialTheme.typography.labelLarge, ...)
Spacer(8.dp)
OutlinedTextField(
value = email, onValueChange = onEmailChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("请输入邮箱") },
singleLine = true,
isError = email.isNotEmpty() && !isValidEmail,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next)
)
Spacer(16.dp)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = code, onValueChange = onCodeChange,
modifier = Modifier.weight(1f),
placeholder = { Text("4 位验证码") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done)
)
Spacer(12.dp)
Button(onClick = onSendCode, enabled = isValidEmail && countdown == 0) {
Text(if (countdown > 0) "${countdown}s" else "获取验证码")
}
}
Spacer(24.dp)
Button(onClick = onEmailLogin, enabled = isValidEmail && code.length == 4) {
Text("登录 / 注册")
}
}
}4.4 ViewModel / API
kotlin
// AuthViewModel
fun sendEmailCode(email: String) { ... }
fun emailLogin(email: String, code: String) { ... }
// ApiService
@POST("api/auth/email/send-code")
suspend fun sendEmailCode(@Body req: SendEmailCodeRequest): ApiResponse<Unit>
@POST("api/auth/email-login")
suspend fun emailLogin(@Body req: EmailLoginRequest): ApiResponse<LoginResponse>4.5 AuthInterceptor 公开路径更新
kotlin
// AuthInterceptor.kt — publicPaths 集合追加
"/api/auth/email/send-code",
"/api/auth/email-login",
"/api/auth/email/bind-code",
"/api/auth/email-bind"4.6 改动文件清单
| 文件 | 变更 | 说明 |
|---|---|---|
LoginScreen.kt | 修改 | 新增 EmailCodeLoginTab,HoriPager 5页,PageIndicator 5标签 |
AccountPasswordTab(LoginScreen 内) | 修改 | placeholder 改为"账号 / 邮箱",键盘类型 Ascii |
AuthViewModel.kt | 修改 | 新增 sendEmailCode() / emailLogin() |
ApiService.kt | 修改 | 新增 2 个 Retrofit 方法 |
Dtos.kt | 修改 | 新增 SendEmailCodeRequest / EmailLoginRequest |
AuthInterceptor.kt | 修改 | publicPaths 新增邮箱端点 |
5. 安全说明
| 防护 | 说明 |
|---|---|
| 60s 发送间隔 | 复用 credential_codes 的发送间隔限制 |
| 日上限 | 同一邮箱每天最多5次 |
| 验证码有效期 | 5 分钟 |
| 邮箱格式校验 | 后端 @Email 注解 + Android 端正则 |
| SMTP 凭据 | 通过环境变量 EMAIL_PASSWORD 注入,不硬编码 |
| 邮箱+密码登录保护 | user.password == null 时抛明确异常 |
| 管理后台隔离 | 管理后台的 login() 不做邮箱检测,仅支持 username+password |
| 公开端点跳过认证 | SecurityConfig + DeviceBindingFilter + AuthInterceptor 均需新增跳过路径 |
| 登录审计 | login_records 新增 email_login / email_bind 类型 |
6. 实施步骤
| Phase | 内容 | 预估工期 |
|---|---|---|
| 0 数据库 | sms_codes → credential_codes 改造 + phone → credential + type 枚举扩展 | 1h |
| 1 后端邮件服务 | EmailService + spring-boot-starter-mail 依赖 + .env 配置 | 1h |
| 2 后端安全配置 | SecurityConfig permitAll + DeviceBindingFilter 跳过路径 | 0.5h |
| 3 后端 API | 4 个端点 + AuthService 逻辑 + DTO;改造 login() 含 password=null 保护 | 3h |
| 4 Android UI | EmailCodeLoginTab + AccountPasswordTab 改造 + ViewModel + API | 3h |
| 5 Android 网络 | AuthInterceptor publicPaths 更新 | 0.5h |
| 6 集成测试 | 全流程测试 + 错误处理 | 1h |
| 总计 | ~10h |
7. 依赖
kotlin
// build.gradle.kts (服务器)
implementation("org.springframework.boot:spring-boot-starter-mail")附录:邮箱登录账户设置补充
- 自动注册不生成 user_xxx 用户名:emailLogin 自动注册 username=null,自动生成昵称,写 EMAIL identity(verified=true)
- 绑定邮箱(email-bind):
(EMAIL, email)全局占用校验(排除自身);换绑 = 删旧插新;成功后 verified=true - 新增解绑邮箱:POST /api/auth/email/unbind(登录态)→ email=null, email_verified=false → recalculateIdentity
- 忘记密码支持邮箱凭证:forgot-code 含@自动识别为邮箱;重置后 tokenVersion+1 全端登出
- 修改密码支持邮箱验证码:change-code channel=EMAIL 发到当前用户已绑定邮箱(防轰炸)
- 凭证归一化:所有入口统一 normalizeEmail(lowercase+trim)
- 限频:EmailService 限频 type 集合加入 PASSWORD_RESET(60s/日5上限)