feat(admin): 新增邀请码生成及注册校验功能

- 在管理员页面新增邀请码生成面板,支持限制使用次数和有效期
- 新增后端接口支持创建邀请码,邀请码存储在Redis并设置过期时间
- 用户注册接口新增邀请码参数,校验邀请码有效性和剩余使用次数
- 注册时成功使用邀请码后,Redis中对应邀请码的使用次数减1
- 登录接口及相关服务层逻辑新增邀请码字段支持
- 后端权限配置增加/admin路径的root角色校验
- 优化角色权限同步时Redis存储格式为列表类型
- 调整SaToken相关接口实现以支持角色ID转换逻辑
This commit is contained in:
lbw
2025-12-29 15:44:05 +08:00
parent bddf6c0936
commit 5858bf2ecc
14 changed files with 163 additions and 24 deletions

View File

@@ -24,24 +24,12 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.back();
SaRouter.match("/**")
.notMatch("/class/list")
.notMatch("/exam/words/get")
.notMatch("/exam/words/detail")
.notMatch("/exam/words/student/history")
.notMatch("grade/list")
.notMatch("/student/list")
.notMatch("/student/detail")
.notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history")
.notMatch("/student/analyze")
.notMatch("/student/mastery/detail")
.notMatch("/unit/list")
.notMatch("/vocabulary/list")
.notMatch("/vocabulary/student/detail")
.notMatch("/plan/download")
.notMatch("/login/**")
.check(r -> StpUtil.checkLogin());
SaRouter.match("/admin/**")
.check(r -> StpUtil.checkRole("root"));
}))
.addPathPatterns("/**")
.excludePathPatterns("/error");

View File

@@ -28,7 +28,11 @@ public class StpInterfaceImpl implements StpInterface {
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return userToRole((Long) loginId) ;
long l = 0L;
if (loginId instanceof String loginIdStr) {
l = Long.parseLong(loginIdStr);
}
return userToRole(l);
}
private List<String> userToRole(Long userId) {

View File

@@ -4,7 +4,13 @@ public class UserRedisConstants {
public static final String USER_LOGIN_CODE = "user:login:code:";
public static final String USER_INVITATION_CODE = "user:invitation:code:";
public static String buildUserLoginCode(String phone) {
return USER_LOGIN_CODE + phone;
}
public static String buildUserInvitationCode(String code) {
return USER_INVITATION_CODE + code;
}
}

View File

@@ -1,7 +1,10 @@
package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
import com.yinlihupo.enlish.service.domain.dataobject.RoleDO;
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeReqVO;
import com.yinlihupo.enlish.service.model.vo.admin.CreateInvitationCodeRspVO;
import com.yinlihupo.enlish.service.model.vo.user.CreateUserReqVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRepVO;
import com.yinlihupo.enlish.service.model.vo.user.FindUserListRspVO;
@@ -11,6 +14,7 @@ import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
import com.yinlihupo.framework.common.response.PageResponse;
import com.yinlihupo.framework.common.response.Response;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -19,6 +23,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@RequestMapping("/admin/")
@RestController
@@ -30,6 +36,8 @@ public class AdminController {
private PasswordEncoder passwordEncoder;
@Resource
private RoleService roleService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@PostMapping("user/list")
@ApiOperationLog(description = "查询用户列表")
@@ -66,4 +74,18 @@ public class AdminController {
return Response.success();
}
@PostMapping("user/create/invitation/code")
@ApiOperationLog(description = "创建邀请码")
public Response<CreateInvitationCodeRspVO> createInvitationCode(@RequestBody CreateInvitationCodeReqVO createInvitationCodeReqVO) {
String code = String.valueOf(new Random().nextInt(1000000));
Integer limit = createInvitationCodeReqVO.getLimit();
Integer expire = createInvitationCodeReqVO.getExpire();
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(code), limit);
redisTemplate.expire(UserRedisConstants.buildUserInvitationCode(code), expire, TimeUnit.DAYS);
return Response.success(CreateInvitationCodeRspVO.builder().invitationCode(code).build());
}
}

View File

@@ -25,7 +25,7 @@ public class LoginController {
@ApiOperationLog(description = "登录")
public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
try {
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode());
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode(), loginReqVO.getInvitationCode());
return Response.success(StpUtil.getTokenInfo().getTokenValue());
} catch (Exception e) {
log.error("注册或登录失败 {}", e.getMessage());

View File

@@ -0,0 +1,18 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeReqVO {
// 限制人数
private Integer limit;
// 有效期
private Integer expire;
}

View File

@@ -0,0 +1,16 @@
package com.yinlihupo.enlish.service.model.vo.admin;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class CreateInvitationCodeRspVO {
private String invitationCode;
}

View File

@@ -15,4 +15,5 @@ public class LoginReqVO {
private String name;
private String password;
private String code;
private String invitationCode;
}

View File

@@ -2,7 +2,7 @@ package com.yinlihupo.enlish.service.service;
public interface LoginService {
void login(String phone, String name, String reqPassword, String reqCode);
void login(String phone, String name, String reqPassword, String reqCode, String invitationCode);
void sendVerificationCode(String phone);
}

View File

@@ -13,11 +13,14 @@ import com.yinlihupo.enlish.service.service.LoginService;
import com.yinlihupo.framework.common.util.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.Objects;
@Service
@Slf4j
@@ -28,20 +31,32 @@ public class LoginServiceImpl implements LoginService {
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private StringRedisTemplate stringRedisTemplate;
private RedisTemplate<String, Object> redisTemplate;
@Resource
private Client client;
@Override
public void login(String phone, String name, String reqPassword, String reqCode) {
@Transactional(rollbackFor = Exception.class)
public void login(String phone, String name, String reqPassword, String reqCode, String invitationCode) {
UserDO userDO = userDOMapper.selectByPhone(phone);
log.info("userDO:{}", userDO);
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone));
String code = JsonUtils.toJsonString(redisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone)));
if (userDO == null) {
if (code == null || !code.equals(reqCode)) {
throw new RuntimeException("验证码错误");
}
Object invitationObj = redisTemplate.opsForValue().get(UserRedisConstants.buildUserInvitationCode(invitationCode));
if (invitationObj == null) {
throw new RuntimeException("邀请码错误");
}
int invitationLimit = Integer.parseInt(JsonUtils.toJsonString(invitationObj));
if (invitationLimit <= 0) {
throw new RuntimeException("邀请码已使用完毕");
}
redisTemplate.opsForValue().set(UserRedisConstants.buildUserInvitationCode(invitationCode), invitationLimit - 1);
userDO = UserDO.builder()
.phone(phone)
.name(name)
@@ -68,7 +83,7 @@ public class LoginServiceImpl implements LoginService {
@Override
public void sendVerificationCode(String phone) {
String code = RandomUtil.randomNumbers(6);
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
redisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
String signName = "短信测试";
String templateCode = "SMS_154950909";
String templateParam = String.format("{\"code\":\"%s\"}", code);

View File

@@ -48,7 +48,7 @@ public class RoleServiceImpl implements RoleService {
List<RoleDO> roleDOs = roleIds.stream().map(roleId2RoleDO::get).toList();
List<String> user2RoleKeys = roleDOs.stream().map(RoleDO::getRoleKey).toList();
log.info("将用户 {} 的角色同步到 redis 中, {}", userId, roleKeys);
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), JsonUtils.toJsonString(user2RoleKeys));
redisTemplate.opsForValue().set(RoleConstants.buildUserRoleKey(userId), user2RoleKeys);
});
}

View File

@@ -8,3 +8,6 @@ export function createUser(data) {
return axios.post('/admin/user/create', data)
}
export function createInvitationCode(data) {
return axios.post('/admin/user/create/invitation/code', data)
}

View File

@@ -41,6 +41,9 @@
<el-form-item prop="password_repeat">
<el-input v-model="form.password_repeat" type="password" maxlength="20" placeholder="重复密码" />
</el-form-item>
<el-form-item prop="invitationCode">
<el-input v-model="form.invitationCode" maxlength="6" placeholder="邀请码" />
</el-form-item>
<el-form-item prop="code">
<div class="flex items-center gap-2 w-full">
<el-input v-model="form.code" maxlength="6" placeholder="验证码" />
@@ -86,6 +89,7 @@ const form = reactive({
password: '',
password_repeat: '',
code: '',
invitationCode: '',
})
const rules = {
@@ -143,6 +147,7 @@ async function userLogin() {
name: form.name.trim(),
password: form.password.trim(),
code: form.code.trim(),
invitationCode: form.invitationCode.trim()
}
const res = await login(payload)
const data = res.data

View File

@@ -31,6 +31,29 @@
</div>
</el-card>
<el-card class="mt-4">
<template #header>
<div class="flex items-center justify-between">
<span>生成邀请码</span>
</div>
</template>
<el-form :model="inviteForm" :rules="inviteRules" ref="inviteFormRef" label-width="120px">
<el-form-item label="使用次数限制" prop="limit">
<el-input-number v-model="inviteForm.limit" :min="1" :max="9999" />
</el-form-item>
<el-form-item label="有效期(天)" prop="expire">
<el-input-number v-model="inviteForm.expire" :min="1" :max="3650" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="inviteLoading" @click="submitInvite">生成邀请码</el-button>
<el-button class="ml-2" @click="resetInvite">重置</el-button>
</el-form-item>
</el-form>
<div v-if="inviteCode" class="mt-2">
<el-alert type="success" :closable="false" :title="`邀请码:${inviteCode}`" show-icon />
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增用户" width="420px">
<el-form :model="createForm" :rules="rules" ref="createFormRef" label-width="80px">
<el-form-item label="姓名" prop="name">
@@ -57,7 +80,7 @@
<script setup>
import Header from '@/layouts/components/Header.vue'
import { ref, reactive, onMounted } from 'vue'
import { getUserList, createUser } from '@/api/admin'
import { getUserList, createUser, createInvitationCode } from '@/api/admin'
import { showMessage } from '@/composables/util.js'
const loading = ref(false)
@@ -144,4 +167,42 @@ onMounted(() => {
fetchList()
})
const inviteForm = reactive({ limit: 10, expire: 3 })
const inviteFormRef = ref()
const inviteLoading = ref(false)
const inviteCode = ref('')
const inviteRules = {
limit: [{ required: true, message: '请输入使用次数限制', trigger: 'change' }],
expire: [{ required: true, message: '请输入有效期', trigger: 'change' }],
}
function resetInvite() {
inviteForm.limit = 10
inviteForm.expire = 3
inviteCode.value = ''
}
function submitInvite() {
inviteFormRef.value?.validate(async (valid) => {
if (!valid) return
inviteLoading.value = true
try {
const r = await createInvitationCode({ limit: inviteForm.limit, expire: inviteForm.expire })
const d = r?.data
if (d?.success) {
inviteCode.value = d?.data?.invitationCode || ''
if (inviteCode.value) {
showMessage('邀请码生成成功', 'success')
} else {
showMessage('生成成功,但未返回邀请码', 'warning')
}
} else {
showMessage(d?.message || '邀请码生成失败', 'error')
}
} finally {
inviteLoading.value = false
}
})
}
</script>