邮箱登录和注册
1.接口
1.1 注册(Regisater)
1.1.1 前端请求
当用户提交注册信息时,前端将用户名(邮箱)、密码和验证类型发送给后端,后端根据允许的验证类型处理注册及验证流程。
前端请求参数:
- email: 用户的邮箱地址。
- password: 用户的密码。
- username: 用户名,可选。邮箱注册时可以不传,只传 email 即可。
前端请求示例:
POST /api/v1/register
Content-Type: application/json
Origin: http://localhost:8100
{
"email": "user@example.com",
"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?email=litongjava001@gmail.com&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": "user@example.com",
"password": "userpassword"
}1.2.2 后端处理逻辑
后端进行登录验证。登录成功后,生成并返回 idToken 给前端。
后端返回参数:
- idToken: idToken。
- expires_in: 令牌过期时间,单位秒
后端返回示例(登录成功):
响应数据示例
{
"data": {
"user_id":"user_id",
"display_name": "Tong Li",
"email": "litongjava001@gmail.com",
"refresh_token":"token",
"token": "token",
"expires_in": xxx
},
"code": 1,
"msg": null,
"ok": true
}{
"data": {
"user_id": "488388937360424960",
"display_name": "Tong Li",
"email": "litongjava001@gmail.com",
"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/verify?email=litongjava001@gmail.com&code=3584121.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.liKfYdWzULDPCeKfktyKyEanZIO3Q7uhZ9mAKMfjWyM1.6 删除用户
从系统中删除该用户
GET /api/v1/user/remove
Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NDIwODgzNjgsInVzZXJJZCI6IjQ4ODIzNjI3MjMzODg3MDI3MiJ9.liKfYdWzULDPCeKfktyKyEanZIO3Q7uhZ9mAKMfjWyM2. 后端代码实现
下面给出一套基于 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(绕过邮箱验证)等字段。正确做法是用白名单覆盖这个路由,只放行资料类字段:
javaprivate 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=noreply@example.com
lark.mail.password=1111
lark.mail.from=no-reply生产环境推荐国内邮件推送服务,对 QQ、163 邮箱的进箱率明显更好。以阿里云邮件推送为例:
mail.provider=aliyun
mail.host=smtpdm.aliyun.com
mail.port=465
mail.user=noreply@notice.example.com
mail.password=****
mail.from=noreply@notice.example.com
mail.from.name=Your App Namemail.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", "noreply@notice.example.com"); // 发信地址
request.putBodyParameter("AddressType", "1"); // 1 发信地址,0 随机地址
request.putBodyParameter("ReplyToAddress", "false");
request.putBodyParameter("ToAddress", "user@example.com");
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=60480010. 总结
- 注册:前端提交邮箱、密码和验证类型,后端完成用户记录创建(包含密码加盐与哈希);
verification_type为1时发送验证码邮件,为2时发送验证链接邮件,为0时不发信。发送成功只代表邮件已投递,不代表邮箱已验证。 - 登录:前端提交邮箱(或用户名)和密码,后端验证后生成 token(以及 refreshToken),即使邮箱未验证也允许登录。
- 邮箱验证码发送与验证:提供独立接口用于发送验证码邮件和验证验证码;验证成功后把验证码记录置为已使用,并更新用户表中的邮箱验证状态。
- 发信实现:实现
EmailSender并注册到TioBootServer;生产环境建议使用国内邮件推送服务,保证验证码邮件的进箱率。 - 奖励发放时机:注册赠送、邀请奖励等一次性奖励建议延后到邮箱验证通过后发放,避免被批量注册刷走。
以上示例实现了邮箱密码注册和登录,并包含邮箱验证码发送和验证逻辑。所有代码均保留了原始逻辑和必要注释,方便直接参考和二次开发。
