邮箱登录和注册
1.接口
1.1 注册(Regisater)
1.1.1 前端请求
当用户提交注册信息时,前端将用户名(邮箱)、密码和验证类型发送给后端,后端根据允许的验证类型处理注册及验证流程。
前端请求参数:
- email: 用户的邮箱地址。
- password: 用户的密码。
- username: 用户名,可选。邮箱注册时可以不传,只传 email 即可。
前端请求示例:
POST /api/v1/register
Content-Type: application/json
Origin: http://localhost:8100
{
"email": "[email protected]",
"password": "userpassword",
"user_type": 1,
"verification_type": 2
}
Origin 如果进行邮箱验证,是必填信息,通常浏览器会自动携带 user_type 根据业务需求自定义,示例中 0 匿名 1 普通用户 2 高级用户 verification_type 验证类型:0 不验证,1 邮箱验证码验证,2 邮箱链接验证,3 短信验证码验证。完整约定见验证类型。 user_id是用户的id,如果从匿名接口获取了id则填写
本文邮箱注册示例处理 0、1、2,尚未接入 3。短信验证码流程见腾讯云短信登录注册;统一注册接口支持短信验证时,还需要接收手机号并接入相应的发送与核验流程。未知或尚未支持的类型必须在创建用户前拒绝,不能落入“不验证”分支。是否允许 0 由后端业务策略控制。
需要注意,内置的注册处理器只对 1、2 分支做处理,其余取值会落入“不验证”分支,也就是传 3 或任意非法值都会按不验证处理。因此业务侧要么在调用前自行校验并拒绝未知类型,要么用自己的处理器覆盖注册路由(详见验证类型)。
1.1.2 后端处理逻辑
后端先校验验证类型,再创建用户。类型 0 不发送验证信息,类型 1 发送邮箱验证码,类型 2 发送邮箱验证链接。发送成功不代表邮箱已经验证通过。
后端返回示例:
{ "data": null, "ok": true, "msg": null, "code": 1 }
如果注册失败会返回
{
"code": 0,
"msg": null,
"ok": false,
"error": null,
"data": [
{
"field": "password",
"messages": [
"Failed to valiate password:00000000"
]
}
]
}
此时需要对password标红,并并显示messages中的信息
1.1.3 前端处理逻辑
前端根据本次采用的验证类型展示后续操作:类型 0 完成注册,类型 1 引导用户输入邮件中的验证码,类型 2 引导用户打开邮箱验证链接。
1.1.4 用户验证
用户打开邮箱,邮箱内容示例如下.链接进行验证,验证通过后 会自动跳转到下面的前端地址 正式环境中 localhost:8100 是前端的域名
Dear User,
Thank you for signing up for College Bot AI!
Please verify your email address by clicking the link below:
http://localhost:8100/verification/[email protected]&code=358412
This will ensure your account is secure and fully activated.
If you did not request this verification, please disregard this email.
Best regards,
The College Bot AI Team
用户打开邮箱后会点击上面的地址,前端应该使用一个路由拦截到上面的地址并获取 email 和 code 参数,并请后端发送请求确认验证码有效, 验证失败前端自行处理失败,例如显示失败信息 验证成功的前端跳转到登录地址
/api/v1/login?role={instructor|student} 统一为小写
1.2. 登录(Login)
1.2.1 前端请求
当用户提交登录信息时,前端将邮箱和密码发送到后端,后端完成身份验证并返回用户的 idToken
前端请求参数:
- email: 用户的邮箱地址。
- password: 用户的密码。
前端请求示例:
POST /api/v1/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "userpassword"
}
1.2.2 后端处理逻辑
后端进行登录验证。登录成功后,生成并返回 idToken 给前端。
后端返回参数:
- idToken: idToken。
- expires_in: 令牌过期时间,单位秒
后端返回示例(登录成功):
响应数据示例
{
"data": {
"user_id":"user_id",
"display_name": "Tong Li",
"email": "[email protected]",
"refresh_token":"token",
"token": "token",
"expires_in": xxx
},
"code": 1,
"msg": null,
"ok": true
}
{
"data": {
"user_id": "488388937360424960",
"display_name": "Tong Li",
"email": "[email protected]",
"phone": null,
"photo_url": "https://lh3.googleusercontent.com/a/ACg8ocKy5qtXCrfvGrNoyhGY_Jv8b3aF9GcBXxoAO85RVNLNg88dpCw=s96-c",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi0xLCJ1c2VySWQiOiI0ODgzODg5MzczNjA0MjQ5NjAifQ==.VaziGyn3ellkiFChfWuU0m70mybJKN-yRtPKC0Twp78",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDI0OTY2NTAsInVzZXJJZCI6IjQ4ODM4ODkzNzM2MDQyNDk2MCJ9.bKHkuneji6zlS10bWLcZT-71Hhm7aWlv11FRSahftv8",
"expires_in": 1742496650
},
"ok": true,
"code": 1,
"msg": null,
"error": null
}
1.2.3 前端处理逻辑
前端接收到 idToken 后,后续请求如果需在请求头中携带该 token:格式为
authorization: Bearer {token}
1.3 验证接口
http://localhost:8100/api/v1/[email protected]&code=358412
1.4 刷新 token
携带 token
POST /api/v1/user/refresh
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi0xLCJ1c2VySWQiOiI0ODgyMzYyNzIzMzg4NzAyNzIifQ==.a2Nipd4YQc0DQ4S5lUlFVrh6Xn_GuI4qBbFS7yRddIQ"
}
{
"data": {
"user_id": "488236272338870272",
"expires_in": 604800,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjYwNDgwMCwidXNlcklkIjoiNDg4MjM2MjcyMzM4ODcwMjcyIn0=.fCbcrxdxgpbdC81wS3nPRMRyuKA0_G7iTyBfH5qOF2w"
},
"error": null,
"ok": true,
"code": 1,
"msg": null
}
1.5 登出用户
退出当前系统的登录 注意:系统采用 单 Token 设计,后端不会存储后端 token,退出登录后上次生成的 token 依旧有效,所以用户登出实际上不用调用任何接口,前端将 token 删除即可.如果考虑后续的扩展,前端可以使用该接口
GET /api/v1/logout
Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDIwODgzNjgsInVzZXJJZCI6IjQ4ODIzNjI3MjMzODg3MDI3MiJ9.liKfYdWzULDPCeKfktyKyEanZIO3Q7uhZ9mAKMfjWyM
1.6 删除用户
从系统中删除该用户
GET /api/v1/user/remove
Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDIwODgzNjgsInVzZXJJZCI6IjQ4ODIzNjI3MjMzODg3MDI3MiJ9.liKfYdWzULDPCeKfktyKyEanZIO3Q7uhZ9mAKMfjWyM
2. 后端代码实现
下面给出一套基于 Java(Tio-boot 框架风格)的示例实现,包含注册、登录、发送邮件以及邮箱验证码验证等接口。示例代码中保留了 SQL 建表语句和详细注释,便于理解各个步骤的逻辑。注意:以下代码仅为示例,实际项目中需要根据项目情况进行完善(如密码加盐算法、Token 生成逻辑、异常处理等)。
下面实现注册、登录、发送验证邮件及邮箱验证逻辑。整体采用类似 Tio-boot 的 Handler 方式实现。
2.1 注册接口
当用户提交注册请求时,系统完成用户信息存储(密码进行加盐和哈希处理),同时调用发送邮件接口生成验证码记录并发送验证链接。
2.1.1 请求对象
package nexus.io.tio.boot.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppUserRegisterRequest {
private String username; // 可选,邮箱注册时可以不传
private String password;
private String email;
private int userType; // 0:匿名,1:普通, 2:高级
private int verification_type; // 0 不验证,1 邮箱验证码,2 邮箱链接,3 短信验证码;本文接口仅支持 0、1、2
private Long userId;
}
2.1.2 注册 Handler
package nexus.io.tio.boot.admin.handler;
import java.util.ArrayList;
import java.util.List;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.model.validate.ValidateResult;
import nexus.io.tio.boot.admin.services.AppEmailService;
import nexus.io.tio.boot.admin.services.AppUserService;
import nexus.io.tio.boot.admin.vo.AppUserRegisterRequest;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.json.Json;
import nexus.io.tio.utils.validator.EmailValidator;
import nexus.io.tio.utils.validator.PasswordValidator;
public class AppUserRegisterHandler {
AppUserService appUserService = Aop.get(AppUserService.class);
public HttpResponse register(HttpRequest request) {
String origin = request.getOrigin();
HttpResponse response = TioRequestContext.getResponse();
String body = request.getBodyString();
AppUserRegisterRequest req = Json.getJson().parse(body, AppUserRegisterRequest.class);
List<ValidateResult> validateResults = new ArrayList<>();
boolean ok = true;
// 只有需要邮箱验证时才强制要求 Origin,它用于拼接邮件里的验证链接
if (req.getVerification_type() != 0) {
if (StrUtil.isEmpty(origin)) {
ValidateResult validateResult = ValidateResult.by("origin", "Failed to valiate origin:" + origin);
validateResults.add(validateResult);
ok = false;
}
}
// 解析注册请求参数
boolean validate = false;
String username = req.getUsername();
String email = req.getEmail();
if (username == null && email == null) {
ValidateResult validateResult = ValidateResult.by("username", "Username and email cannot both be empty.");
validateResults.add(validateResult);
ok = false;
}
if (email != null) {
validate = EmailValidator.validate(email);
if (!validate) {
ValidateResult validateResult = ValidateResult.by("email", "Failed to valiate email:" + email);
validateResults.add(validateResult);
ok = false;
}
}
String password = req.getPassword();
validate = PasswordValidator.validate(password);
if (!validate) {
ValidateResult validateResult = ValidateResult.by("password", "Failed to valiate password:" + password);
validateResults.add(validateResult);
ok = false;
}
if (!ok) {
return response.setJson(RespBodyVo.failData(validateResults));
}
if (email != null) {
boolean exists = appUserService.existsEmail(email);
if (exists) {
ValidateResult validateResult = ValidateResult.by("email", "email already taken " + email);
validateResults.add(validateResult);
ok = false;
}
}
if (username != null) {
boolean exists = appUserService.existsUsername(username);
if (exists) {
ValidateResult validateResult = ValidateResult.by("email", "username already taken" + username);
validateResults.add(validateResult);
ok = false;
}
}
if (!ok) {
return response.setJson(RespBodyVo.failData(validateResults));
}
boolean success = false;
Long userId = req.getUserId();
if (userId != null && appUserService.exists(userId.toString())) {
success = appUserService.registerUserByUserId(req, origin);
} else {
// 注册用户(内部会处理密码加盐和哈希等逻辑)
success = appUserService.registerUser(req.getEmail(), req.getUsername(), req.getPassword(), req.getUserType(),
origin);
}
if (success) {
if (req.getVerification_type() == 1) {
// 注册成功后发送邮箱验证码
AppEmailService emailService = Aop.get(AppEmailService.class);
boolean sent = emailService.sendVerificationCodeEmail(req.getEmail(), origin);
if (sent) {
return response.setJson(RespBodyVo.ok());
} else {
return response.setJson(RespBodyVo.fail("Failed to send email"));
}
} else if (req.getVerification_type() == 2) {
// 注册成功后发送邮箱验证链接
AppEmailService emailService = Aop.get(AppEmailService.class);
boolean sent = emailService.sendVerificationEmail(req.getEmail(), origin);
if (sent) {
return response.setJson(RespBodyVo.ok());
} else {
return response.setJson(RespBodyVo.fail("Failed to send email"));
}
} else {
return response.setJson(RespBodyVo.ok());
}
} else {
return response.setJson(RespBodyVo.fail());
}
}
}
2.2 登录接口
登录时前端提交邮箱和密码。后端进行密码校验后生成 Token(及 refreshToken),不再强制要求邮箱验证(即使 email_verified 为 false,也允许登录)。
如果业务要求邮箱未验证不允许登录(注册流程要求验证邮箱时通常都应该这样),内置登录处理器不做这件事,需要在登录成功后追加一次校验,最简单的方式是用自己的处理器覆盖 /api/v1/login:密码校验通过后查一次 app_users.email_verified,不通过就返回失败。
建议同时把失败原因放在 data 里,前端才能把用户引到"重新发送验证码",例如:
if (!isEmailVerified(user.getId())) {
Kv data = Kv.by("reason", "email_not_verified").set("email", user.getEmail());
return response.setJson(RespBodyVo.fail("email not verified").setData(data));
}
private boolean isEmailVerified(String userId) {
// 直接查库,不依赖 AppUser 的字段映射
return Boolean.TRUE.equals(
Db.queryBoolean("select email_verified from app_users where id = ? and deleted = 0", userId));
}
另外两点配套改动,否则规则会被绕过:
- 注册接口必须拒绝未验证的注册类型。内置注册处理器只对
verification_type为1、2做发信处理, 传0或任意非法值会落入"不验证"分支,直接注册出一个未验证账号; /api/v1/user/update必须收口(见下一节的说明),否则用户可以把email_verified直接改成true。
已经上线的业务还要回填历史账号(update app_users set email_verified = true where ...), 否则老用户会被一起挡在门外。
2.2.1 请求对象
package nexus.io.tio.boot.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Data
public class AppUserLoginRequest {
private String username;
private String password;
private String email;
}
2.2.2 返回对象
package nexus.io.tio.boot.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppUserLoginVo {
private String user_id;
private String display_name;
private String email;
private String phone;
private String photo_url;
private String refresh_token;
private String token;
private Integer expires_in;
public AppUserLoginVo(String userId, String displayName, String refreshToken, String token, int expires_in) {
this.user_id = userId;
this.display_name = displayName;
this.refresh_token = refreshToken;
this.token = token;
this.expires_in = expires_in;
}
public AppUserLoginVo(String userId, String displayName, String email, String refreshToken, String token,
int expires_in) {
this.user_id = userId;
this.display_name = displayName;
this.email = email;
this.refresh_token = refreshToken;
this.token = token;
this.expires_in = expires_in;
}
public AppUserLoginVo(String userId, String displayName, String email, String photo_url, String refreshToken, String token, int expires_in) {
this.user_id = userId;
this.display_name = displayName;
this.email = email;
this.photo_url = photo_url;
this.refresh_token = refreshToken;
this.token = token;
this.expires_in = expires_in;
}
}
2.2.3 登录 Handler
package nexus.io.tio.boot.admin.handler;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.admin.services.AppUserService;
import nexus.io.tio.boot.admin.vo.AppUser;
import nexus.io.tio.boot.admin.vo.AppUserLoginRequest;
import nexus.io.tio.boot.admin.vo.AppUserLoginVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.utils.environment.EnvUtils;
import nexus.io.tio.utils.json.JsonUtils;
public class AppUserLoginHandler {
public HttpResponse login(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String body = request.getBodyString();
AppUserLoginRequest req = JsonUtils.parse(body, AppUserLoginRequest.class);
AppUserService appUserService = Aop.get(AppUserService.class);
String username = req.getUsername();
String email = req.getEmail();
if (username == null && email == null) {
return response.setJson(RespBodyVo.fail("username and password cannot both be empty"));
}
AppUser user = null;
if (username != null) {
user = appUserService.getUserByUsername(username);
} else if (email != null) {
user = appUserService.getUserByEmail(email);
}
// 此处允许未验证邮箱的用户登录
if (user != null && appUserService.verifyPassword(user, req.getPassword())) {
// 生成 token,有效期 7 天(604800秒)
Long timeout = EnvUtils.getLong("app.token.timeout", 604800L);
Long tokenTimeout = System.currentTimeMillis() / 1000 + timeout;
String userId = user.getId();
String token = appUserService.createToken(userId, tokenTimeout);
String refreshToken = appUserService.createRefreshToken(userId);
AppUserLoginVo appUserLoginVo = new AppUserLoginVo(userId, user.getDisplayName(), email, refreshToken, token,
tokenTimeout.intValue());
return response.setJson(RespBodyVo.ok(appUserLoginVo));
}
return response.setJson(RespBodyVo.fail("username or password is not correct"));
}
public HttpResponse logout(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String userId = TioRequestContext.getUserIdString();
AppUserService appUserService = Aop.get(AppUserService.class);
boolean logout = appUserService.logout(userId);
if (logout) {
response.setJson(RespBodyVo.ok());
} else {
response.setJson(RespBodyVo.fail());
}
return response;
}
}
2.3 用户接口
⚠️ 使用
/api/v1/user/update前必读:内置实现有两处问题,直接用于生产会有数据和安全风险。
- 请求字段名被原样当作列名。它把请求体转成 key-value 后直接拼 SQL,列名还会加引号, 所以前端常见的
{"displayName": "Tom"}会拼出"displayName",而表里是display_name, 更新必然报错。字段名要么和列名完全一致,要么自己写一层驼峰转下划线。- 除密码外任意列可写。它只剔除了
password_salt、password_hash, 其它列全部可写,登录用户可以自行修改coin(积分)、is_admin(提权)、email_verified(绕过邮箱验证)等字段。正确做法是用白名单覆盖这个路由,只放行资料类字段:
private static final Map<String, String> UPDATABLE_FIELDS = Map.of( "displayName", "display_name", "display_name", "display_name", "bio", "bio", "photoUrl", "photo_url", "photo_url", "photo_url"); Row row = Row.create(); for (Map.Entry<String, String> field : UPDATABLE_FIELDS.entrySet()) { if (params.containsKey(field.getKey())) { Object value = params.get(field.getKey()); row.set(field.getValue(), value == null ? null : String.valueOf(value)); } } if (row.getColumns().isEmpty()) { return response.setJson(RespBodyVo.fail("no updatable field")); } appUserService.updateById(userId, row);
package nexus.io.tio.boot.admin.handler;
import java.util.Map;
import com.jfinal.kit.Kv;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.admin.services.AppUserService;
import nexus.io.tio.boot.admin.utils.TioAdminEnvUtils;
import nexus.io.tio.boot.admin.vo.AppUser;
import nexus.io.tio.boot.admin.vo.AppUserRefreshTokenRequest;
import nexus.io.tio.boot.admin.vo.UserResetPasswordRequest;
import nexus.io.tio.boot.admin.vo.UserUpdatePasswordRequest;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.utils.hutool.StrUtil;
import nexus.io.tio.utils.json.FastJson2Utils;
import nexus.io.tio.utils.json.JsonUtils;
import nexus.io.tio.utils.jwt.JwtUtils;
public class AppUserHandler {
public HttpResponse refresh(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String bodyString = request.getBodyString();
if (StrUtil.isBlank(bodyString)) {
RespBodyVo fail = RespBodyVo.fail("body can not be empty");
return response.body(fail);
}
AppUserRefreshTokenRequest appUserRefreshTokenRequest = JsonUtils.parse(bodyString,
AppUserRefreshTokenRequest.class);
String refresh_token = appUserRefreshTokenRequest.getRefresh_token();
String key = TioAdminEnvUtils.getAdminSecretKey();
boolean verify = JwtUtils.verify(key, refresh_token);
if (verify) {
String userId = JwtUtils.parseUserIdString(refresh_token);
AppUserService appUserService = Aop.get(AppUserService.class);
// 生成 token,有效期 7 天(604800秒)
Long timeout = TioAdminEnvUtils.getTokenTimeout();
Long tokenTimeout = System.currentTimeMillis() / 1000 + timeout;
String token = appUserService.createToken(userId, tokenTimeout);
Kv kv = Kv.by("user_id", userId).set("token", token).set("expires_in", tokenTimeout.intValue());
response.setJson(RespBodyVo.ok(kv));
} else {
response.setJson(RespBodyVo.fail("Failed to validate refresh_token"));
}
return response;
}
public HttpResponse remove(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String userIdString = TioRequestContext.getUserIdString();
AppUserService appUserService = Aop.get(AppUserService.class);
boolean ok = appUserService.remove(userIdString);
if (ok) {
response.setJson(RespBodyVo.ok());
} else {
response.setJson(RespBodyVo.fail());
}
return response;
}
public HttpResponse resetPassword(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
String bodyString = request.getBodyString();
UserResetPasswordRequest userResetPassword = JsonUtils.parse(bodyString, UserResetPasswordRequest.class);
AppUserService appUserService = Aop.get(AppUserService.class);
RespBodyVo vo = appUserService.resetPassword(userResetPassword);
return response.setJson(vo);
}
public HttpResponse profile(HttpRequest request) {
String userIdString = TioRequestContext.getUserIdString();
AppUserService appUserService = Aop.get(AppUserService.class);
AppUser user = appUserService.getUserById(userIdString);
HttpResponse response = TioRequestContext.getResponse();
return response.body(RespBodyVo.ok(user));
}
public HttpResponse update(HttpRequest request) {
String userIdString = TioRequestContext.getUserIdString();
String bodyString = request.getBodyString();
Map<String, Object> requestMap = FastJson2Utils.parseToMap(bodyString, String.class, Object.class);
// 密码相关字段不允许通过这个接口修改
requestMap.remove("password_salt");
requestMap.remove("password_hash");
Row row = Row.fromMap(requestMap);
AppUserService appUserService = Aop.get(AppUserService.class);
HttpResponse response = TioRequestContext.getResponse();
return response.body(RespBodyVo.ok(appUserService.updateById(userIdString, row)));
}
public HttpResponse updatePassword(HttpRequest request) {
String userIdString = TioRequestContext.getUserIdString();
String bodyString = request.getBodyString();
UserUpdatePasswordRequest updatePasswordRequest = FastJson2Utils.parse(bodyString,
UserUpdatePasswordRequest.class);
AppUserService appUserService = Aop.get(AppUserService.class);
HttpResponse response = TioRequestContext.getResponse();
RespBodyVo respBodyvo = appUserService.updatePassword(userIdString, updatePasswordRequest);
return response.body(respBodyvo);
}
}
3. 服务层实现
3.1 admin_app_user.sql
--# app_users.findById
SELECT id,
email,
phone,
email_verified,
updated_profile,
display_name,
bio,
photo_url,
background_url,
phone_number,
disabled,
birthday,
coin,
invited_by_user_id,
"of",
platform,
third_platform_url,
school_id,
user_type,
provider_data,
mfa_info,
metadata,
user_info,
google_id,
google_info,
facebook_id,
facebook_info,
twitter_id,
twitter_info,
github_id,
github_info,
wechat_id,
wechat_info,
qq_id,
qq_info,
weibo_id,
weibo_info,
remark,
creator,
create_time,
updater,
update_time,
deleted,
tenant_id
FROM
app_users
WHERE
ID = ?
AND deleted = 0;
3.2 用户服务(AppUserService)
负责用户注册、密码验证、用户查询等操作。注意:此处示例使用简单的密码加密(SHA-256),实际项目中可采用更安全的加盐算法。
package nexus.io.tio.boot.admin.services;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.codec.digest.DigestUtils;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.model.validate.ValidateResult;
import nexus.io.tio.boot.admin.consts.AppConstant;
import nexus.io.tio.boot.admin.consts.TioBootAdminTableNames;
import nexus.io.tio.boot.admin.sql.AppUsersSql;
import nexus.io.tio.boot.admin.vo.AppUser;
import nexus.io.tio.boot.admin.vo.AppUserRegisterRequest;
import nexus.io.tio.boot.admin.vo.UserResetPasswordRequest;
import nexus.io.tio.boot.admin.vo.UserToken;
import nexus.io.tio.boot.admin.vo.UserUpdatePasswordRequest;
import nexus.io.tio.utils.environment.EnvUtils;
import nexus.io.tio.utils.jwt.JwtUtils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;
import nexus.io.tio.utils.validator.EmailValidator;
import nexus.io.tio.utils.validator.PasswordValidator;
public class AppUserService {
// 查询用户基本信息(不含密码相关字段),自动映射下划线到驼峰
private static final String SQL_SELECT_USER = AppUsersSql.findById;
// 仅查询密码盐与哈希
private static final String SQL_SELECT_PASSWORD = "SELECT id, password_salt, password_hash FROM app_users WHERE id = ? AND deleted = 0";
public boolean registerUserByUserId(AppUserRegisterRequest req, String origin) {
String password = req.getPassword();
String email = req.getEmail();
String displayName = null;
String username = req.getUsername();
if (email != null) {
int lastIndex = email.lastIndexOf("@");
if (lastIndex > 0) {
displayName = email.substring(0, lastIndex);
} else {
displayName = email;
}
} else {
displayName = username;
}
int userType = req.getUserType();
boolean exists = Db.exists(TioBootAdminTableNames.app_users, "username", username);
if (exists) {
return false;
}
exists = Db.exists(TioBootAdminTableNames.app_users, "email", email);
if (exists) {
return false;
}
// 生成加盐字符串(示例中直接使用随机数,实际可使用更复杂逻辑)
String salt = String.valueOf(System.currentTimeMillis());
// 生成密码哈希(密码+盐)
String passwordHash = DigestUtils.sha256Hex(password + salt);
// 插入用户记录(id 这里简单采用 email 作为唯一标识)
long id = SnowflakeIdUtils.id();
String insertSql = "update app_users set display_name=?, email=?, username=?,password_salt=?, password_hash=?, user_type=?,of=? where id=?";
int rows = Db.updateBySql(insertSql, displayName, email, username, salt, passwordHash, userType, origin, id + "");
return rows > 0;
}
// 注册用户:先检查邮箱是否已存在,然后插入用户记录
public boolean registerUser(String email, String username, String password, int userType, String orgin) {
boolean exists = Db.exists(TioBootAdminTableNames.app_users, "email", email);
if (exists) {
return true;
}
String displayName = null;
if (email != null) {
int lastIndex = email.lastIndexOf("@");
if (lastIndex > 0) {
displayName = email.substring(0, lastIndex);
} else {
displayName = email;
}
} else {
if (username != null) {
displayName = username;
}
}
// 生成加盐字符串(示例中直接使用随机数,实际可使用更复杂逻辑)
String salt = String.valueOf(System.currentTimeMillis());
// 生成密码哈希(密码+盐)
String passwordHash = DigestUtils.sha256Hex(password + salt);
// 插入用户记录(id 这里简单采用 email 作为唯一标识)
long id = SnowflakeIdUtils.id();
int rows;
if (username != null) {
String insertSql = "INSERT INTO app_users (id, display_name,email,username, password_salt, password_hash, user_type,of) VALUES (?,?,?,?,?,?,?,?)";
rows = Db.updateBySql(insertSql, id + "", displayName, email, username, salt, passwordHash, userType, orgin);
} else {
String insertSql = "INSERT INTO app_users (id, display_name,email, password_salt, password_hash, user_type,of) VALUES (?,?,?,?,?,?,?)";
rows = Db.updateBySql(insertSql, id + "", displayName, email, salt, passwordHash, userType, orgin);
}
return rows > 0;
}
// 根据邮箱获取用户信息
public AppUser getUserByEmail(String email) {
String sql = "SELECT * FROM app_users WHERE email=? AND deleted=0";
return Db.findFirst(AppUser.class, sql, email);
}
public AppUser getUserById(Long userId) {
return Db.findFirst(AppUser.class, SQL_SELECT_USER, userId);
}
public AppUser getUserById(String userIdString) {
return Db.findFirst(AppUser.class, SQL_SELECT_USER, userIdString);
}
public AppUser getUserPasswordById(Long userId) {
return Db.findFirst(AppUser.class, SQL_SELECT_PASSWORD, userId);
}
public AppUser getUserPasswordById(String userId) {
return Db.findFirst(AppUser.class, SQL_SELECT_PASSWORD, userId);
}
// 校验用户密码
public boolean verifyPassword(AppUser user, String password) {
String salt = user.getPasswordSalt();
String hash = DigestUtils.sha256Hex(password + salt);
return hash.equals(user.getPasswordHash());
}
public boolean verifyPassword(String email, String password) {
AppUser appUser = getUserByEmail(email);
return verifyPassword(appUser, password);
}
public boolean verifyPassword(Long userId, String password) {
AppUser appUser = getUserPasswordById(userId);
return verifyPassword(appUser, password);
}
public String createToken(String id, Long timeout) {
String key = EnvUtils.getStr(AppConstant.APP_ADMIN_SECRET_KEY);
return JwtUtils.createTokenByUserId(key, id, timeout);
}
public String createRefreshToken(String id) {
String key = EnvUtils.getStr(AppConstant.APP_ADMIN_SECRET_KEY);
return JwtUtils.createTokenByUserId(key, id, -1);
}
public boolean logout(String userId) {
return true;
}
public boolean remove(String userId) {
//String sql = "update app_users set deleted=1 WHERE id=?";
//Db.updateBySql(sql, userId);
String sql = "delete from app_users WHERE id=?";
Db.delete(sql, userId);
return true;
}
public RespBodyVo resetPassword(UserResetPasswordRequest req) {
List<ValidateResult> validateResults = new ArrayList<>();
boolean ok = true;
String email = req.getEmail();
String code = req.getCode();
boolean validate = EmailValidator.validate(email);
if (!validate) {
ValidateResult validateResult = ValidateResult.by("eamil", "Failed to valiate email:" + email);
validateResults.add(validateResult);
ok = false;
}
String password = req.getPassword();
validate = PasswordValidator.validate(password);
if (!validate) {
ValidateResult validateResult = ValidateResult.by("password", "Failed to valiate password:" + password);
validateResults.add(validateResult);
ok = false;
}
if (!ok) {
return RespBodyVo.failData(validateResults);
}
boolean exists = Db.exists(TioBootAdminTableNames.app_users, "email", email);
if (exists) {
ValidateResult validateResult = ValidateResult.by("eamil", "eamil already taken" + email);
validateResults.add(validateResult);
}
if (!ok) {
return RespBodyVo.failData(validateResults);
}
boolean verify = Aop.get(AppEmailService.class).verifyEmailCode(email, code);
if (!verify) {
return RespBodyVo.fail("Failed to verify code");
}
// 生成加盐字符串(示例中直接使用随机数,实际可使用更复杂逻辑)
String salt = String.valueOf(System.currentTimeMillis());
// 生成密码哈希(密码+盐)
String passwordHash = DigestUtils.sha256Hex(password + salt);
String updateSql = "update app_users set password_salt=?, password_hash=? where email=?";
Db.updateBySql(updateSql, salt, passwordHash, email);
return RespBodyVo.ok();
}
public RespBodyVo createAnonymousUser(String origin) {
long longId = SnowflakeIdUtils.id();
String userId = longId + "";
String insertSql = "INSERT INTO app_users (id,of,user_type) VALUES (?,?,0)";
Db.updateBySql(insertSql, userId, origin);
// 生成 token,有效期 7 天(604800秒)
Long timeout = EnvUtils.getLong("app.token.timeout", 604800L);
Long tokenTimeout = System.currentTimeMillis() / 1000 + timeout;
String token = createToken(userId, tokenTimeout);
String refreshToken = createRefreshToken(userId);
UserToken userToken = new UserToken(userId, token, tokenTimeout.intValue(), refreshToken, 0);
return RespBodyVo.ok(userToken);
}
public boolean exists(String userId) {
return Db.exists("app_users", "id", userId);
}
public String getUsernameById(String userId) {
return Db.queryStr("select username from app_users where id=?", userId);
}
public boolean existsEmail(String email) {
return Db.exists(TioBootAdminTableNames.app_users, "email", email);
}
public boolean existsUsername(String email) {
return Db.exists(TioBootAdminTableNames.app_users, "username", email);
}
public boolean updateById(String userIdString, Row row) {
row.set("id", userIdString);
return Db.update(TioBootAdminTableNames.app_users, row);
}
public RespBodyVo updatePassword(String userIdString, UserUpdatePasswordRequest updatePasswordRequest) {
String oldPassword = updatePasswordRequest.getOldPassword();
String newPassword = updatePasswordRequest.getNewPassword();
String sql = "select password_salt from app_users where id=?";
String salt = Db.queryStr(sql, userIdString);
// 生成密码哈希(密码+盐)
String passwordHash = DigestUtils.sha256Hex(oldPassword + salt);
sql = "select count(1) from app_users where id=? and password_hash=?";
boolean exists = Db.existsBySql(sql, userIdString, passwordHash);
if (exists) {
passwordHash = DigestUtils.sha256Hex(newPassword + newPassword);
sql = "update app_users set password_hash=? where id=?";
int effectd = Db.updateBySql(sql, passwordHash, userIdString);
return RespBodyVo.ok(effectd);
} else {
return RespBodyVo.fail();
}
}
}
⚠️ 注意:上面
updatePassword里新密码用的是newPassword + newPassword作为哈希输入,而校验密码用的是password + salt,两者规则不一致,改完密码后将无法再登录(接口还会返回成功)。接入时请改成DigestUtils.sha256Hex(newPassword + salt),沿用用户原有的盐,与注册、登录保持同一套规则。已经用旧规则写坏过密码的账号,库里存的是
sha256(密码+密码),按新规则同样进不来。两种处理方式: 让这些账号走重置密码流程重新设置密码;或者在密码校验失败时额外比对一次历史哈希,命中即放行并顺手把库里的哈希修正为sha256(密码+盐),这样登录一次就自动恢复,之后走正常分支。
4. 路由配置
最后在路由配置中绑定各个接口。下面示例代码与 Tio-boot 的路由配置类似:
package com.example.app.config;
import nexus.io.tio.boot.admin.handler.AppUserHandler;
import nexus.io.tio.boot.admin.handler.AppUserLoginHandler;
import nexus.io.tio.boot.admin.handler.AppUserRegisterHandler;
import nexus.io.tio.boot.admin.handler.EmailVerificationHandler;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;
public class AppHandlerConfiguration {
public void config() {
TioBootServer server = TioBootServer.me();
HttpRequestRouter r = server.getRequestRouter();
if (r == null) {
return;
}
AppUserRegisterHandler appUserRegisterHandler = new AppUserRegisterHandler();
AppUserLoginHandler loginHandler = new AppUserLoginHandler();
EmailVerificationHandler emailVerificationHandler = new EmailVerificationHandler();
AppUserHandler appUserHandler = new AppUserHandler();
// 注册接口
r.add("/api/v1/register", appUserRegisterHandler::register);
// 登录接口
r.add("/api/v1/login", loginHandler::login);
// 登出
r.add("/api/v1/logout", loginHandler::logout);
// 刷新 token
r.add("/api/v1/user/refresh", appUserHandler::refresh);
// 删除用户
r.add("/api/v1/user/remove", appUserHandler::remove);
r.add("/api/v1/user/profile", appUserHandler::profile);
r.add("/api/v1/user/update", appUserHandler::update);
r.add("/api/v1/user/updatePassword", appUserHandler::updatePassword);
// 重置密码
r.add("/api/v1/user/resetPassword", appUserHandler::resetPassword);
// 发送验证码邮件接口(邮箱验证码 / 邮箱验证链接)
r.add("/api/v1/sendVerification", emailVerificationHandler::sendVerification);
r.add("/api/v1/sendVerificationCode", emailVerificationHandler::sendVerificationCode);
// 邮箱验证接口
r.add("/api/v1/verify", emailVerificationHandler::verifyEmail);
r.add("/verification/email", emailVerificationHandler::verifyEmail);
}
}
5.拦截器配置
登录态由 Token 拦截器统一校验。默认的校验逻辑是 TioBootAdminTokenPredicate,它实现框架的 TokenPredicate 接口(不是 java.util.function.Predicate),返回 PredicateResult, 其中第二个参数是解析出来的用户 id:
package nexus.io.tio.boot.admin.services;
import nexus.io.tio.boot.admin.utils.TioAdminEnvUtils;
import nexus.io.tio.boot.token.PredicateResult;
import nexus.io.tio.boot.token.TokenPredicate;
import nexus.io.tio.utils.jwt.JwtUtils;
public class TioBootAdminTokenPredicate implements TokenPredicate {
public Long parseUserIdLong(String token) {
boolean verify = JwtUtils.verify(TioAdminEnvUtils.getAdminSecretKey(), token);
if (verify) {
return JwtUtils.parseUserIdLong(token);
}
return null;
}
public String parseUserIdString(String token) {
boolean verify = JwtUtils.verify(TioAdminEnvUtils.getAdminSecretKey(), token);
if (verify) {
return JwtUtils.parseUserIdString(token);
}
return null;
}
@Override
public PredicateResult validate(String token) {
String adminToken = TioAdminEnvUtils.getAdminToken();
// 系统 token,用户 id 固定为 0
if (adminToken != null && adminToken.equals(token)) {
return new PredicateResult(true, "0");
}
// 普通用户 token
String key = TioAdminEnvUtils.getAdminSecretKey();
if (JwtUtils.verify(key, token)) {
String userId = JwtUtils.parseUserIdString(token);
return new PredicateResult(true, userId);
}
return new PredicateResult(false);
}
}
需要自定义(例如接入第三方 token)时,实现 TokenPredicate 并在构造配置时传进去即可。
放行地址由 TioBootAdminUrls.ALLLOW_URLS 统一维护,业务侧只需要追加自己的免登录接口:
package nexus.io.tio.boot.admin.consts;
public interface TioBootAdminUrls {
String[] ALLLOW_URLS = { "", "/",
"/preflight", "/api/event/add",
"/register/*", "/api/login/account", "/api/login/outLogin",
//
"/api/v1/login", "/api/v1/register", "/api/v1/user/referesh",
//
"/api/v1/sendVerification", "/api/v1/sendVerificationCode", "/api/v1/verify", "/verification/email",
//
"/api/v1/user/resetPassword", "/api/v1/anonymous/create",
//
"/api/v1/google/login", "/api/v1/user/refresh",
"/table/json/tio_boot_admin_system_article/get/*",
//
"/table/json/tio_boot_admin_system_docx/get/*", "/table/json/tio_boot_admin_system_pdf/get/*" };
}
配置类本身只有三个可选项:放行地址、是否放行静态文件、自定义 token 校验逻辑。
package nexus.io.tio.boot.admin.config;
import nexus.io.tio.boot.admin.consts.TioBootAdminUrls;
import nexus.io.tio.boot.admin.services.TioBootAdminTokenPredicate;
import nexus.io.tio.boot.http.interceptor.HttpInteceptorConfigure;
import nexus.io.tio.boot.http.interceptor.HttpInterceptorModel;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.boot.token.TokenPredicate;
import nexus.io.tio.boot.token.UserTokenInterceptor;
public class TioAdminInterceptorConfiguration {
private String[] permitUrls;
// 默认放行静态文件
private boolean alloweStaticFile = true;
private TokenPredicate validateTokenLogic;
public TioAdminInterceptorConfiguration() {
}
public TioAdminInterceptorConfiguration(String[] permitUrls) {
this.permitUrls = permitUrls;
}
public TioAdminInterceptorConfiguration(String[] permitUrls, TokenPredicate validateTokenLogic) {
this.permitUrls = permitUrls;
this.validateTokenLogic = validateTokenLogic;
}
public TioAdminInterceptorConfiguration(String[] permitUrls, boolean b) {
this.permitUrls = permitUrls;
this.alloweStaticFile = b;
}
public void config() {
// token 校验逻辑,默认由 admin 模块提供
if (validateTokenLogic == null) {
validateTokenLogic = new TioBootAdminTokenPredicate();
}
UserTokenInterceptor userTokenInterceptor = new UserTokenInterceptor(validateTokenLogic);
HttpInterceptorModel model = new HttpInterceptorModel();
model.setName("tio-admin-token");
model.setInterceptor(userTokenInterceptor);
// 拦截所有路由
model.addBlockUrl("/**");
// 添加不拦截的路由
model.addAllowUrls(TioBootAdminUrls.ALLLOW_URLS);
// 业务侧补充的放行地址
if (permitUrls != null) {
model.addAllowUrls(permitUrls);
}
model.setAlloweStaticFile(alloweStaticFile);
HttpInteceptorConfigure inteceptorConfigure = TioBootServer.me().getHttpInteceptorConfigure();
if (inteceptorConfigure == null) {
inteceptorConfigure = new HttpInteceptorConfigure();
}
inteceptorConfigure.add(model);
// 将拦截器配置添加到 Tio 服务器
TioBootServer.me().setHttpInteceptorConfigure(inteceptorConfigure);
}
}
业务侧一般这样使用(permitUrls 是注册与邮箱验证之外还要放行的接口):
String[] permitUrls = { "/", "/ping", "/api/explanation/video" };
new TioAdminInterceptorConfiguration(permitUrls).config();
8.邮件发送
邮箱验证码、验证链接邮件都由 EmailSender 的实现发出。实现类注册到 TioBootServer 之后, 注册、重置密码等内置流程会自动调用它,业务代码不需要自己拼装发送逻辑。
8.1 配置发信参数
开发环境可以直接用飞书邮箱(lark.mail.* 是历史配置名,可继续使用):
lark.mail.host=smtp.larksuite.com
lark.mail.protocol=smtp
lark.mail.smpt.port=465
lark.mail.user=[email protected]
lark.mail.password=1111
lark.mail.from=no-reply
生产环境推荐国内邮件推送服务,对 QQ、163 邮箱的进箱率明显更好。以阿里云邮件推送为例:
mail.provider=aliyun
mail.host=smtpdm.aliyun.com
mail.port=465
mail.user=[email protected]
mail.password=****
mail.from=[email protected]
mail.from.name=Your App Name
mail.provider 用于选择服务商的默认 SMTP 地址,mail.host / mail.port 可以覆盖预设:
| mail.provider | 默认 SMTP 地址 |
|---|---|
aliyun | smtpdm.aliyun.com(阿里云邮件推送) |
lark | smtp.larksuite.com(飞书国际版) |
feishu | smtp.feishu.cn(飞书) |
exmail | smtp.exmail.qq.com(腾讯企业邮箱) |
qiye-aliyun | smtp.qiye.aliyun.com(阿里企业邮箱) |
qiye-163 | smtp.qiye.163.com(网易企业邮箱) |
qq | smtp.qq.com(QQ 邮箱) |
163 | smtp.163.com(163 邮箱) |
custom | 不使用预设,只读 mail.host |
接入邮件推送服务前必须准备好的三件事:
- 自有发信域名并配置 SPF、DKIM 记录,各家的记录值以控制台生成的为准。建议给验证码单独用 子域名(例如
notice.example.com),与日后的营销邮件隔离信誉,避免一方退信拖累另一方; - 新建发信地址并单独设置 SMTP 密码,它不是账号登录密码;
- 注意**信誉等级(每日发信量)**限制:新账号初始额度很低,需要靠低退信率逐级提额。上线前先 用 QQ、163 邮箱各发几封实测进箱情况。
端口约定:465 走隐式 SSL,587 走 STARTTLS,25 常被云厂商默认封禁。
8.2 邮件模板与注册发信实现
EmailEngine 以 classpath 根目录下的 emails/ 作为模板根目录,所以模板文件放在 src/main/resources/emails/ 下,模板里用 #(变量名) 取值。以验证链接邮件为例:
emails/register_mail.html
Dear User,
Thank you for signing up for College Bot AI!
Please verify your email address by clicking the link below:
#(link)
If you did not request this verification, please disregard this email.
验证码邮件用另一个模板,把 #(code) 显示成验证码即可(两个模板可以通过 EmailEngine.renderToString("verification_code_mail.html", kv) 渲染)。
package nexus.io.myget.config;
import nexus.io.annotation.AConfiguration;
import nexus.io.annotation.Initialization;
import nexus.io.jfinal.aop.Aop;
import nexus.io.myget.email.MyGetEmailSender;
import nexus.io.tio.boot.admin.config.*;
import nexus.io.tio.boot.admin.handler.SystemFileTencentCosHandler;
import nexus.io.tio.boot.server.TioBootServer;
import nexus.io.tio.http.server.router.HttpRequestRouter;
@AConfiguration
public class AdminAppConfig {
@Initialization
public void config() {
// 配置数据库相关
new TioAdminDbConfiguration().config();
new TioAdminRedisDbConfiguration().config();
new TioAdminMongoDbConfiguration().config();
new TioAdminInterceptorConfiguration().config();
new TioAdminHandlerConfiguration().config();
// 获取 HTTP 请求路由器
TioBootServer server = TioBootServer.me();
HttpRequestRouter r = server.getRequestRouter();
if (r != null) {
// 获取文件处理器,并添加文件上传和获取 URL 的接口
SystemFileTencentCosHandler systemUploadHandler = Aop.get(SystemFileTencentCosHandler.class);
r.add("/api/system/file/upload", systemUploadHandler::upload);
r.add("/api/system/file/url", systemUploadHandler::getUrl);
}
// 注册发信实现,注册与重置密码流程会自动使用它
server.setEmailSender(new MyGetEmailSender());
// 配置控制器
new TioAdminControllerConfiguration().config();
}
}
只使用飞书邮箱时,也可以直接用内置的 TioAdminLarkSuitMailConfig,它会初始化 LarkSuitEmailUtils(配置项固定为 lark.mail.*):
new TioAdminLarkSuitMailConfig().config();
8.3 用 OpenAPI(SDK)发信
阿里云邮件推送除了 SMTP,还可以走 OpenAPI 直连发信,接口是 2015-11-23 版本的 SingleSendMail。 这种方式用 AccessKey 鉴权,不需要发信地址的 SMTP 密码,并且在返回里能拿到 RequestId 方便排障。 官方 Java SDK 提供两种调用方式:产品 SDK(alibabacloud-dm20151123)和 CommonRequest(泛化调用)。 项目里已经依赖了阿里云 OpenAPI 的核心 SDK,用 CommonRequest 调用不需要额外引入依赖:
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysProtocol(ProtocolType.HTTPS);
request.setSysDomain("dm.aliyuncs.com");
request.setSysVersion("2015-11-23");
request.setSysAction("SingleSendMail");
request.setSysConnectTimeout(5000);
request.setSysReadTimeout(10000);
// 参数放在 Body 里提交时必须声明 Body 格式,否则 SDK 抛 "Content type may not be null"
request.setHttpContentType(FormatType.FORM);
request.putBodyParameter("AccountName", "[email protected]"); // 发信地址
request.putBodyParameter("AddressType", "1"); // 1 发信地址,0 随机地址
request.putBodyParameter("ReplyToAddress", "false");
request.putBodyParameter("ToAddress", "[email protected]");
request.putBodyParameter("Subject", "邮箱验证码");
request.putBodyParameter("HtmlBody", html);
request.putBodyParameter("FromAlias", "Your App Name"); // 可选
IAcsClient client = new DefaultAcsClient(DefaultProfile.getProfile("cn-hangzhou", accessKeyId, accessKeySecret));
CommonResponse response = client.getCommonResponse(request);
三个容易踩的坑:
- 必须设置 Body 格式(
FormatType.FORM),否则不等到发请求,SDK 就直接抛Content type may not be null; - 参数走 Body 而不是查询串,邮件 HTML 正文很长,放 URL 里容易被长度限制拦截;
- 鉴权用 AccessKey,不是发信地址的 SMTP 密码。建议用只授予邮件推送权限的 RAM 子账号, 官方也支持
ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET环境变量。
接口返回的错误码建议原样带进异常信息再抛出,排障时可以直接定位,常见的几个:
| 错误码 | 含义 |
|---|---|
InvalidUser.NotFound | 该 AccessKey 所属账号没有开通邮件推送(AccessKey 与邮件推送配置不在同一账号) |
InvalidMailAddress.NotFound | 发信地址不存在:控制台还没建这个发信地址,或发信域名未验证 |
InvalidAccountName | 发信地址格式不对,必须是完整邮箱 |
Forbidden / NoPermission | 当前 AccessKey 没有邮件推送权限 |
SignatureDoesNotMatch | 多为 AccessKey Secret 错误,或服务器时间与北京时间相差超过 15 分钟 |
上面这两个 NotFound 容易混:SMTP 只认「发信地址 + SMTP 密码」,所以地址配好了 SMTP 就能发; OpenAPI 是按 AccessKey 所属账号隔离配置的,换账号拿 AccessKey 就会出现"地址不存在/用户不存在"。
另外,无论走 SMTP 还是 OpenAPI,发信前都要先在控制台添加发信域名(配好所有权验证、SPF、DKIM) 并新建发信地址,否则接口会直接报发信地址不存在。
8.4 自定义 EmailSender
EmailSender 只有三个方法:send 发普通邮件,sendVerificationEmail 发验证链接, sendVerificationCodeEmail 发验证码。框架在需要发信时会通过 TioBootServer.me().getEmailSender() 取实现,所以必须在启动配置里注册,否则发信会失败。
package nexus.io.myget.email;
import com.jfinal.kit.Kv;
import com.jfinal.template.Template;
import nexus.io.template.EmailEngine;
import nexus.io.tio.boot.admin.mail.LarkSuitEmailUtils;
import nexus.io.tio.boot.email.EmailSender;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MyGetEmailSender implements EmailSender {
@Override
public boolean send(String to, String subject, String content) {
return false;
}
@Override
public boolean sendVerificationEmail(String email, String origin, String code) {
Template template = EmailEngine.getTemplate("register_mail.html");
String link = origin + "/verification/email?email=%s&code=%s";
link = String.format(link, email, code);
String content = template.renderToString(Kv.by("link", link));
String subject = "College Bot AI Email Verification";
return sendWithSmtp(email, subject, content);
}
@Override
public boolean sendVerificationCodeEmail(String email, String origin, String code) {
Template template = EmailEngine.getTemplate("verification_code_mail.html");
String content = template.renderToString(Kv.by("code", code));
String subject = "College Bot AI Email Verification";
return sendWithSmtp(email, subject, content);
}
private boolean sendWithSmtp(String email, String subject, String content) {
try {
LarkSuitEmailUtils.sendHtml(email, subject, content);
return true;
} catch (Exception e) {
log.error(e.getMessage(), e);
return false;
}
}
}
接入国内邮件推送服务时,把 LarkSuitEmailUtils 换成基于 mail.* 配置的 SMTP 客户端即可, EmailSender 的接口与业务代码都不需要改。
8.5 验证码记录与核验
验证码记录放在 app_email_verification 表(email、verification_code、expire_time、 verified、create_time),核验入口是 AppEmailService.verifyEmailCode(email, code): 校验未过期且未使用的验证码,成功后把该条记录置为已使用,并同步更新 app_users.email_verified。
两点实现提醒:
- 内置实现的主键是
int自增。如果自己的表用雪花 id(BIGINT)等大整数主键, 需要重写verifyEmailCode,否则主键映射溢出会导致记录无法被标记为已使用; - 发信接口是免登录放行的,建议对同一邮箱做重发频率限制(例如 60 秒一次),避免被刷验证码邮件。
8.6 奖励发放时机
注册赠送、邀请奖励这类一次性奖励,建议放在邮箱验证通过之后发放,而不是注册成功就发, 否则可以用不存在或不属于自己的邮箱批量注册刷奖励。做法是注册时只创建用户,在自己的 验证接口里核验成功后补发,并在补发前判断该用户是否已经发过,保证只发一次。
9.基本配置
app.admin.secret.key=123456
app.admin.token=123456
app.token.timeout=604800
10. 总结
- 注册:前端提交邮箱、密码和验证类型,后端完成用户记录创建(包含密码加盐与哈希);
verification_type为1时发送验证码邮件,为2时发送验证链接邮件,为0时不发信。发送成功只代表邮件已投递,不代表邮箱已验证。 - 登录:前端提交邮箱(或用户名)和密码,后端验证后生成 token(以及 refreshToken),即使邮箱未验证也允许登录。
- 邮箱验证码发送与验证:提供独立接口用于发送验证码邮件和验证验证码;验证成功后把验证码记录置为已使用,并更新用户表中的邮箱验证状态。
- 发信实现:实现
EmailSender并注册到TioBootServer;生产环境建议使用国内邮件推送服务,保证验证码邮件的进箱率。 - 奖励发放时机:注册赠送、邀请奖励等一次性奖励建议延后到邮箱验证通过后发放,避免被批量注册刷走。
以上示例实现了邮箱密码注册和登录,并包含邮箱验证码发送和验证逻辑。所有代码均保留了原始逻辑和必要注释,方便直接参考和二次开发。
