feat: 新增H5会员管理接口模块

需求文档:
- 新增H5会员接口需求与技术方案文档

认证模块(/h5/auth):
- 图形验证码获取
- 阿里云短信验证码发送
- 密码登录
- 短信验证码登录
- 会员注册
- Token刷新
- 退出登录

会员模块(/h5/member):
- 获取会员信息
- 修改会员信息(昵称/性别/生日)
- 修改密码
- 教育身份管理(教师)
- 学生绑定管理(家长/教师均可)

基础数据模块(/h5/base):
- 区域树查询
- 学校列表查询
- 年级列表查询
- 班级列表查询
- 学科列表查询

安全配置:
- 放行H5公开接口(/h5/auth/**、/h5/base/**)
This commit is contained in:
神码-方晓辉 2026-02-02 21:39:12 +08:00
parent e8c4f3f568
commit 905e263ca8
22 changed files with 2279 additions and 0 deletions

View File

@ -116,6 +116,9 @@ security:
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
# H5公开接口
- /h5/auth/**
- /h5/base/**
# 多租户配置
tenant:

View File

@ -0,0 +1,88 @@
package org.dromara.pangu.h5.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.service.H5AuthService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
/**
* H5认证接口
*
* @author 湖北新华业务中台研发团队
*/
@SaIgnore
@Validated
@RestController
@RequestMapping("/h5/auth")
@RequiredArgsConstructor
public class H5AuthController {
private final H5AuthService authService;
/**
* 获取图形验证码
*/
@GetMapping("/captcha")
public R<H5CaptchaVo> getCaptcha() {
return R.ok(authService.getCaptcha());
}
/**
* 发送短信验证码
*/
@PostMapping("/sms/send")
public R<Void> sendSmsCode(@Valid @RequestBody H5SmsSendDto dto) {
authService.sendSmsCode(dto);
return R.ok();
}
/**
* 密码登录
*/
@PostMapping("/login/password")
public R<H5LoginVo> loginByPassword(@Valid @RequestBody H5PasswordLoginDto dto) {
return R.ok(authService.loginByPassword(dto));
}
/**
* 短信验证码登录
*/
@PostMapping("/login/sms")
public R<H5LoginVo> loginBySms(@Valid @RequestBody H5SmsLoginDto dto) {
return R.ok(authService.loginBySms(dto));
}
/**
* 注册
*/
@PostMapping("/register")
public R<H5LoginVo> register(@Valid @RequestBody H5RegisterDto dto) {
return R.ok(authService.register(dto));
}
/**
* 刷新Token
*/
@PostMapping("/refresh")
public R<H5LoginVo> refreshToken(@RequestParam String refreshToken) {
return R.ok(authService.refreshToken(refreshToken));
}
/**
* 退出登录
*/
@PostMapping("/logout")
public R<Void> logout() {
authService.logout();
return R.ok();
}
}

View File

@ -0,0 +1,200 @@
package org.dromara.pangu.h5.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.pangu.base.domain.PgRegion;
import org.dromara.pangu.base.domain.PgSubject;
import org.dromara.pangu.base.mapper.PgRegionMapper;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.PgSchoolClass;
import org.dromara.pangu.school.domain.PgSchoolGrade;
import org.dromara.pangu.school.mapper.PgSchoolClassMapper;
import org.dromara.pangu.school.mapper.PgSchoolGradeMapper;
import org.dromara.pangu.school.mapper.PgSchoolMapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;
import java.util.stream.Collectors;
/**
* H5基础数据接口
*
* @author 湖北新华业务中台研发团队
*/
@SaIgnore
@RestController
@RequestMapping("/h5/base")
@RequiredArgsConstructor
public class H5BaseDataController {
private final PgRegionMapper regionMapper;
private final PgSchoolMapper schoolMapper;
private final PgSchoolGradeMapper schoolGradeMapper;
private final PgSchoolClassMapper schoolClassMapper;
private final PgGradeMapper gradeMapper;
private final PgClassMapper classMapper;
private final PgSubjectMapper subjectMapper;
/**
* 获取区域树
*/
@GetMapping("/regions")
public R<List<Map<String, Object>>> getRegions() {
List<PgRegion> regions = regionMapper.selectList(
new LambdaQueryWrapper<PgRegion>()
.eq(PgRegion::getStatus, "0")
.orderByAsc(PgRegion::getOrderNum)
);
// 构建树形结构
List<Map<String, Object>> tree = buildRegionTree(regions, 0L);
return R.ok(tree);
}
/**
* 根据区域获取学校列表
*/
@GetMapping("/schools")
public R<List<Map<String, Object>>> getSchools(@RequestParam Long regionId) {
List<PgSchool> schools = schoolMapper.selectList(
new LambdaQueryWrapper<PgSchool>()
.eq(PgSchool::getRegionId, regionId)
.eq(PgSchool::getStatus, "0")
.eq(PgSchool::getDelFlag, "0")
);
List<Map<String, Object>> result = schools.stream().map(school -> {
Map<String, Object> map = new HashMap<>();
map.put("schoolId", school.getSchoolId());
map.put("schoolName", school.getSchoolName());
map.put("schoolCode", school.getSchoolCode());
return map;
}).collect(Collectors.toList());
return R.ok(result);
}
/**
* 根据学校获取年级列表
*/
@GetMapping("/grades")
public R<List<Map<String, Object>>> getGrades(@RequestParam Long schoolId) {
List<PgSchoolGrade> schoolGrades = schoolGradeMapper.selectList(
new LambdaQueryWrapper<PgSchoolGrade>()
.eq(PgSchoolGrade::getSchoolId, schoolId)
);
// 获取基础年级信息
Set<Long> gradeIds = schoolGrades.stream()
.map(PgSchoolGrade::getGradeId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> gradeNameMap = new HashMap<>();
if (!gradeIds.isEmpty()) {
List<PgGrade> grades = gradeMapper.selectByIds(gradeIds);
gradeNameMap = grades.stream()
.collect(Collectors.toMap(PgGrade::getGradeId, PgGrade::getGradeName));
}
Map<Long, String> finalGradeNameMap = gradeNameMap;
List<Map<String, Object>> result = schoolGrades.stream().map(sg -> {
Map<String, Object> map = new HashMap<>();
map.put("schoolGradeId", sg.getId());
map.put("gradeId", sg.getGradeId());
map.put("gradeName", finalGradeNameMap.getOrDefault(sg.getGradeId(), ""));
return map;
}).collect(Collectors.toList());
return R.ok(result);
}
/**
* 根据年级获取班级列表
*/
@GetMapping("/classes")
public R<List<Map<String, Object>>> getClasses(@RequestParam Long schoolGradeId) {
List<PgSchoolClass> schoolClasses = schoolClassMapper.selectList(
new LambdaQueryWrapper<PgSchoolClass>()
.eq(PgSchoolClass::getSchoolGradeId, schoolGradeId)
);
// 获取基础班级信息
Set<Long> classIds = schoolClasses.stream()
.map(PgSchoolClass::getClassId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> classNameMap = new HashMap<>();
if (!classIds.isEmpty()) {
List<PgClass> classes = classMapper.selectByIds(classIds);
classNameMap = classes.stream()
.collect(Collectors.toMap(PgClass::getClassId, PgClass::getClassName));
}
Map<Long, String> finalClassNameMap = classNameMap;
List<Map<String, Object>> result = schoolClasses.stream().map(sc -> {
Map<String, Object> map = new HashMap<>();
map.put("schoolClassId", sc.getId());
map.put("classId", sc.getClassId());
map.put("className", finalClassNameMap.getOrDefault(sc.getClassId(), ""));
return map;
}).collect(Collectors.toList());
return R.ok(result);
}
/**
* 获取学科列表
*/
@GetMapping("/subjects")
public R<List<Map<String, Object>>> getSubjects() {
List<PgSubject> subjects = subjectMapper.selectList(
new LambdaQueryWrapper<PgSubject>()
.eq(PgSubject::getStatus, "0")
.orderByAsc(PgSubject::getOrderNum)
);
List<Map<String, Object>> result = subjects.stream().map(subject -> {
Map<String, Object> map = new HashMap<>();
map.put("subjectId", subject.getSubjectId());
map.put("subjectName", subject.getSubjectName());
map.put("subjectCode", subject.getSubjectCode());
return map;
}).collect(Collectors.toList());
return R.ok(result);
}
/**
* 构建区域树
*/
private List<Map<String, Object>> buildRegionTree(List<PgRegion> regions, Long parentId) {
return regions.stream()
.filter(r -> parentId.equals(r.getParentId()))
.map(region -> {
Map<String, Object> map = new HashMap<>();
map.put("regionId", region.getRegionId());
map.put("regionName", region.getRegionName());
map.put("regionCode", region.getRegionCode());
map.put("level", region.getLevel());
List<Map<String, Object>> children = buildRegionTree(regions, region.getRegionId());
if (!children.isEmpty()) {
map.put("children", children);
}
return map;
})
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,118 @@
package org.dromara.pangu.h5.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.pangu.h5.domain.dto.H5EducationDto;
import org.dromara.pangu.h5.domain.dto.H5MemberUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5PasswordUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5StudentBindDto;
import org.dromara.pangu.h5.domain.vo.H5EducationVo;
import org.dromara.pangu.h5.domain.vo.H5MemberInfoVo;
import org.dromara.pangu.h5.domain.vo.H5StudentVo;
import org.dromara.pangu.h5.service.H5MemberService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* H5会员接口
*
* @author 湖北新华业务中台研发团队
*/
@Validated
@RestController
@RequestMapping("/h5/member")
@RequiredArgsConstructor
public class H5MemberController {
private final H5MemberService memberService;
/**
* 获取当前会员信息
*/
@GetMapping("/info")
public R<H5MemberInfoVo> getInfo() {
return R.ok(memberService.getMemberInfo());
}
/**
* 修改会员信息昵称/性别/生日
*/
@PutMapping("/info")
public R<Void> updateInfo(@RequestBody H5MemberUpdateDto dto) {
memberService.updateMemberInfo(dto);
return R.ok();
}
/**
* 修改密码
*/
@PutMapping("/password")
public R<Void> updatePassword(@Valid @RequestBody H5PasswordUpdateDto dto) {
memberService.updatePassword(dto);
return R.ok();
}
/**
* 添加/修改教育身份
*/
@PostMapping("/education")
public R<Void> saveEducation(@Valid @RequestBody H5EducationDto dto) {
memberService.saveEducation(dto);
return R.ok();
}
/**
* 获取教育身份
*/
@GetMapping("/education")
public R<H5EducationVo> getEducation() {
return R.ok(memberService.getEducation());
}
/**
* 删除教育身份
*/
@DeleteMapping("/education")
public R<Void> deleteEducation() {
memberService.deleteEducation();
return R.ok();
}
/**
* 绑定学生
*/
@PostMapping("/student")
public R<Void> bindStudent(@Valid @RequestBody H5StudentBindDto dto) {
memberService.bindStudent(dto);
return R.ok();
}
/**
* 获取绑定的学生列表
*/
@GetMapping("/students")
public R<List<H5StudentVo>> getStudents() {
return R.ok(memberService.getStudents());
}
/**
* 修改学生信息
*/
@PutMapping("/student/{studentId}")
public R<Void> updateStudent(@PathVariable Long studentId, @Valid @RequestBody H5StudentBindDto dto) {
memberService.updateStudent(studentId, dto);
return R.ok();
}
/**
* 解绑学生
*/
@DeleteMapping("/student/{studentId}")
public R<Void> unbindStudent(@PathVariable Long studentId) {
memberService.unbindStudent(studentId);
return R.ok();
}
}

View File

@ -0,0 +1,37 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* H5教育身份请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5EducationDto {
/**
* 学校ID
*/
@NotNull(message = "请选择学校")
private Long schoolId;
/**
* 年级ID
*/
@NotNull(message = "请选择年级")
private Long schoolGradeId;
/**
* 班级ID
*/
@NotNull(message = "请选择班级")
private Long schoolClassId;
/**
* 学科ID
*/
@NotNull(message = "请选择学科")
private Long subjectId;
}

View File

@ -0,0 +1,29 @@
package org.dromara.pangu.h5.domain.dto;
import lombok.Data;
import java.util.Date;
/**
* H5会员信息修改请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5MemberUpdateDto {
/**
* 昵称
*/
private String nickname;
/**
* 性别0未知 1男 2女
*/
private String gender;
/**
* 生日
*/
private Date birthday;
}

View File

@ -0,0 +1,44 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5密码登录请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5PasswordLoginDto {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
private String password;
/**
* 图形验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;
/**
* 验证码标识
*/
@NotBlank(message = "验证码标识不能为空")
private String uuid;
/**
* 记住我
*/
private Boolean rememberMe = false;
}

View File

@ -0,0 +1,33 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* H5修改密码请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5PasswordUpdateDto {
/**
* 当前密码
*/
@NotBlank(message = "当前密码不能为空")
private String oldPassword;
/**
* 新密码
*/
@NotBlank(message = "新密码不能为空")
@Size(min = 6, message = "新密码至少6位")
private String newPassword;
/**
* 确认新密码
*/
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}

View File

@ -0,0 +1,47 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* H5注册请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5RegisterDto {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 短信验证码
*/
@NotBlank(message = "短信验证码不能为空")
private String smsCode;
/**
* 图形验证码
*/
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
/**
* 验证码标识
*/
@NotBlank(message = "验证码标识不能为空")
private String uuid;
/**
* 密码
*/
@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码至少6位")
private String password;
}

View File

@ -0,0 +1,39 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5短信验证码登录请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5SmsLoginDto {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 短信验证码
*/
@NotBlank(message = "短信验证码不能为空")
private String smsCode;
/**
* 图形验证码
*/
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
/**
* 验证码标识
*/
@NotBlank(message = "验证码标识不能为空")
private String uuid;
}

View File

@ -0,0 +1,39 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5发送短信验证码请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5SmsSendDto {
/**
* 手机号
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
/**
* 图形验证码
*/
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
/**
* 验证码标识
*/
@NotBlank(message = "验证码标识不能为空")
private String uuid;
/**
* 类型login-登录register-注册
*/
@NotBlank(message = "类型不能为空")
private String type;
}

View File

@ -0,0 +1,64 @@
package org.dromara.pangu.h5.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Date;
/**
* H5绑定学生请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5StudentBindDto {
/**
* 学生姓名
*/
@NotBlank(message = "学生姓名不能为空")
private String studentName;
/**
* 学号
*/
@NotBlank(message = "学号不能为空")
private String studentNo;
/**
* 出生日期
*/
@NotNull(message = "出生日期不能为空")
private Date birthday;
/**
* 性别0未知 1男 2女
*/
@NotBlank(message = "性别不能为空")
private String gender;
/**
* 区域ID
*/
@NotNull(message = "请选择所在地区")
private Long regionId;
/**
* 学校ID
*/
@NotNull(message = "请选择学校")
private Long schoolId;
/**
* 年级ID
*/
@NotNull(message = "请选择年级")
private Long schoolGradeId;
/**
* 班级ID
*/
@NotNull(message = "请选择班级")
private Long schoolClassId;
}

View File

@ -0,0 +1,22 @@
package org.dromara.pangu.h5.domain.vo;
import lombok.Data;
/**
* H5图形验证码响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5CaptchaVo {
/**
* 验证码标识
*/
private String uuid;
/**
* 验证码图片Base64
*/
private String captchaImg;
}

View File

@ -0,0 +1,52 @@
package org.dromara.pangu.h5.domain.vo;
import lombok.Data;
/**
* H5教育身份响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5EducationVo {
/**
* 学校ID
*/
private Long schoolId;
/**
* 学校名称
*/
private String schoolName;
/**
* 年级ID
*/
private Long schoolGradeId;
/**
* 年级名称
*/
private String gradeName;
/**
* 班级ID
*/
private Long schoolClassId;
/**
* 班级名称
*/
private String className;
/**
* 学科ID
*/
private Long subjectId;
/**
* 学科名称
*/
private String subjectName;
}

View File

@ -0,0 +1,52 @@
package org.dromara.pangu.h5.domain.vo;
import lombok.Data;
/**
* H5登录响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5LoginVo {
/**
* 访问令牌
*/
private String accessToken;
/**
* 刷新令牌
*/
private String refreshToken;
/**
* 过期时间
*/
private Long expiresIn;
/**
* 会员ID
*/
private Long memberId;
/**
* 会员编号
*/
private String memberCode;
/**
* 手机号脱敏
*/
private String phone;
/**
* 昵称
*/
private String nickname;
/**
* 身份类型
*/
private String identityType;
}

View File

@ -0,0 +1,70 @@
package org.dromara.pangu.h5.domain.vo;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* H5会员信息响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5MemberInfoVo {
/**
* 会员ID
*/
private Long memberId;
/**
* 会员编号
*/
private String memberCode;
/**
* 手机号脱敏
*/
private String phone;
/**
* 昵称
*/
private String nickname;
/**
* 头像
*/
private String avatar;
/**
* 性别0未知 1男 2女
*/
private String gender;
/**
* 生日
*/
private Date birthday;
/**
* 注册时间
*/
private Date registerTime;
/**
* 身份类型parent/teacher
*/
private String identityType;
/**
* 教育信息教师身份
*/
private H5EducationVo education;
/**
* 绑定的学生列表
*/
private List<H5StudentVo> students;
}

View File

@ -0,0 +1,79 @@
package org.dromara.pangu.h5.domain.vo;
import lombok.Data;
import java.util.Date;
/**
* H5学生信息响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
public class H5StudentVo {
/**
* 学生ID
*/
private Long studentId;
/**
* 学生姓名
*/
private String studentName;
/**
* 学号
*/
private String studentNo;
/**
* 性别
*/
private String gender;
/**
* 出生日期
*/
private Date birthday;
/**
* 区域ID
*/
private Long regionId;
/**
* 区域名称
*/
private String regionName;
/**
* 学校ID
*/
private Long schoolId;
/**
* 学校名称
*/
private String schoolName;
/**
* 年级ID
*/
private Long schoolGradeId;
/**
* 年级名称
*/
private String gradeName;
/**
* 班级ID
*/
private Long schoolClassId;
/**
* 班级名称
*/
private String className;
}

View File

@ -0,0 +1,51 @@
package org.dromara.pangu.h5.service;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
/**
* H5认证服务接口
*
* @author 湖北新华业务中台研发团队
*/
public interface H5AuthService {
/**
* 获取图形验证码
*/
H5CaptchaVo getCaptcha();
/**
* 发送短信验证码
*/
void sendSmsCode(H5SmsSendDto dto);
/**
* 密码登录
*/
H5LoginVo loginByPassword(H5PasswordLoginDto dto);
/**
* 短信验证码登录
*/
H5LoginVo loginBySms(H5SmsLoginDto dto);
/**
* 注册
*/
H5LoginVo register(H5RegisterDto dto);
/**
* 刷新Token
*/
H5LoginVo refreshToken(String refreshToken);
/**
* 退出登录
*/
void logout();
}

View File

@ -0,0 +1,69 @@
package org.dromara.pangu.h5.service;
import org.dromara.pangu.h5.domain.dto.H5EducationDto;
import org.dromara.pangu.h5.domain.dto.H5MemberUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5PasswordUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5StudentBindDto;
import org.dromara.pangu.h5.domain.vo.H5EducationVo;
import org.dromara.pangu.h5.domain.vo.H5MemberInfoVo;
import org.dromara.pangu.h5.domain.vo.H5StudentVo;
import java.util.List;
/**
* H5会员服务接口
*
* @author 湖北新华业务中台研发团队
*/
public interface H5MemberService {
/**
* 获取当前会员信息
*/
H5MemberInfoVo getMemberInfo();
/**
* 修改会员信息
*/
void updateMemberInfo(H5MemberUpdateDto dto);
/**
* 修改密码
*/
void updatePassword(H5PasswordUpdateDto dto);
/**
* 添加/修改教育身份
*/
void saveEducation(H5EducationDto dto);
/**
* 获取教育身份
*/
H5EducationVo getEducation();
/**
* 删除教育身份
*/
void deleteEducation();
/**
* 绑定学生
*/
void bindStudent(H5StudentBindDto dto);
/**
* 获取绑定的学生列表
*/
List<H5StudentVo> getStudents();
/**
* 修改学生信息
*/
void updateStudent(Long studentId, H5StudentBindDto dto);
/**
* 解绑学生
*/
void unbindStudent(Long studentId);
}

View File

@ -0,0 +1,387 @@
package org.dromara.pangu.h5.service.impl;
import cn.dev33.satoken.stp.SaLoginModel;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.service.H5AuthService;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Service;
import java.awt.*;
import java.time.Duration;
import java.util.Date;
import java.util.LinkedHashMap;
/**
* H5认证服务实现
*
* @author 湖北新华业务中台研发团队
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class H5AuthServiceImpl implements H5AuthService {
private final PgMemberMapper memberMapper;
/**
* H5会员登录设备标识
*/
private static final String H5_DEVICE = "h5";
/**
* 短信验证码Redis前缀
*/
private static final String H5_SMS_CODE_KEY = "h5:sms:code:";
/**
* RefreshToken Redis前缀
*/
private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:";
/**
* accessToken有效期- 2小时
*/
private static final long ACCESS_TOKEN_EXPIRE = 2 * 60 * 60;
/**
* refreshToken有效期- 7天
*/
private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 60 * 60;
/**
* refreshToken有效期- 记住我30天
*/
private static final long REFRESH_TOKEN_EXPIRE_REMEMBER = 30 * 24 * 60 * 60;
@Override
public H5CaptchaVo getCaptcha() {
String uuid = IdUtil.simpleUUID();
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid;
// 生成数学验证码
MathGenerator codeGenerator = new MathGenerator(1, false);
WaveAndCircleCaptcha captcha = new WaveAndCircleCaptcha(160, 60);
captcha.setFont(new Font("Arial", Font.BOLD, 45));
captcha.setGenerator(codeGenerator);
captcha.createCode();
// 计算验证码结果
String code = captcha.getCode();
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
code = exp.getValue(String.class);
// 存入Redis5分钟有效
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
H5CaptchaVo vo = new H5CaptchaVo();
vo.setUuid(uuid);
vo.setCaptchaImg(captcha.getImageBase64());
return vo;
}
@Override
public void sendSmsCode(H5SmsSendDto dto) {
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
String phone = dto.getPhone();
String type = dto.getType();
// 校验类型
if (!"login".equals(type) && !"register".equals(type)) {
throw new ServiceException("验证码类型错误");
}
// 注册时校验手机号是否已注册
if ("register".equals(type)) {
PgMember existMember = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, phone)
);
if (existMember != null) {
throw new ServiceException("该手机号已注册");
}
}
// 登录时校验手机号是否存在
if ("login".equals(type)) {
PgMember existMember = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, phone)
);
if (existMember == null) {
throw new ServiceException("该手机号未注册");
}
}
// 生成6位验证码
String code = RandomUtil.randomNumbers(6);
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
// 检查是否在60秒内重复发送
if (RedisUtils.hasKey(codeKey)) {
Long ttl = RedisUtils.getTimeToLive(codeKey);
if (ttl != null && ttl > 240) {
throw new ServiceException("验证码发送过于频繁,请稍后再试");
}
}
// 存入Redis5分钟有效
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5));
// 调用阿里云短信发送
try {
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
// 使用配置的阿里云短信
SmsBlend smsBlend = SmsFactory.getSmsBlend("alibaba");
// 模板ID需要在配置文件中设置
SmsResponse smsResponse = smsBlend.sendMessage(phone, map);
if (!smsResponse.isSuccess()) {
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
throw new ServiceException("短信发送失败,请稍后重试");
}
log.info("短信发送成功: phone={}, type={}", phone, type);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试");
}
}
@Override
public H5LoginVo loginByPassword(H5PasswordLoginDto dto) {
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
// 查询会员
PgMember member = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, dto.getPhone())
);
if (member == null) {
throw new ServiceException("手机号或密码错误");
}
// 校验密码
if (!BCrypt.checkpw(dto.getPassword(), member.getPassword())) {
throw new ServiceException("手机号或密码错误");
}
// 校验状态
if ("1".equals(member.getStatus())) {
throw new ServiceException("账号已被禁用");
}
// 执行登录
return doLogin(member, dto.getRememberMe());
}
@Override
public H5LoginVo loginBySms(H5SmsLoginDto dto) {
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
// 校验短信验证码
validateSmsCode(dto.getPhone(), dto.getSmsCode(), "login");
// 查询会员
PgMember member = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, dto.getPhone())
);
if (member == null) {
throw new ServiceException("该手机号未注册");
}
// 校验状态
if ("1".equals(member.getStatus())) {
throw new ServiceException("账号已被禁用");
}
// 执行登录
return doLogin(member, false);
}
@Override
public H5LoginVo register(H5RegisterDto dto) {
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
// 校验短信验证码
validateSmsCode(dto.getPhone(), dto.getSmsCode(), "register");
// 校验手机号是否已注册
PgMember existMember = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, dto.getPhone())
);
if (existMember != null) {
throw new ServiceException("该手机号已注册");
}
// 创建会员
PgMember member = new PgMember();
member.setPhone(dto.getPhone());
member.setPassword(BCrypt.hashpw(dto.getPassword()));
member.setMemberCode(generateMemberCode());
member.setNickname("user_" + dto.getPhone().substring(7));
member.setRegisterSource("2"); // H5注册
member.setRegisterTime(new Date());
member.setStatus("0");
member.setLoginCount(0);
memberMapper.insert(member);
log.info("H5新会员注册: phone={}, memberId={}", dto.getPhone(), member.getMemberId());
// 自动登录
return doLogin(member, false);
}
@Override
public H5LoginVo refreshToken(String refreshToken) {
if (StringUtils.isBlank(refreshToken)) {
throw new ServiceException("refreshToken不能为空");
}
// 从Redis获取memberId
String memberId = RedisUtils.getCacheObject(H5_REFRESH_TOKEN_KEY + refreshToken);
if (StringUtils.isBlank(memberId)) {
throw new ServiceException("refreshToken已过期请重新登录");
}
// 查询会员
PgMember member = memberMapper.selectById(Long.parseLong(memberId));
if (member == null) {
throw new ServiceException("会员不存在");
}
// 校验状态
if ("1".equals(member.getStatus())) {
throw new ServiceException("账号已被禁用");
}
// 删除旧的refreshToken
RedisUtils.deleteObject(H5_REFRESH_TOKEN_KEY + refreshToken);
// 重新登录
return doLogin(member, false);
}
@Override
public void logout() {
if (StpUtil.isLogin()) {
StpUtil.logout();
}
}
/**
* 执行登录
*/
private H5LoginVo doLogin(PgMember member, Boolean rememberMe) {
// Sa-Token登录指定设备类型为h5
long timeout = rememberMe != null && rememberMe ? REFRESH_TOKEN_EXPIRE_REMEMBER : REFRESH_TOKEN_EXPIRE;
StpUtil.login(member.getMemberId(), new SaLoginModel()
.setDevice(H5_DEVICE)
.setTimeout(ACCESS_TOKEN_EXPIRE)
);
// 生成refreshToken
String refreshToken = IdUtil.fastSimpleUUID();
RedisUtils.setCacheObject(
H5_REFRESH_TOKEN_KEY + refreshToken,
member.getMemberId().toString(),
Duration.ofSeconds(timeout)
);
// 更新登录信息
member.setLastLoginTime(new Date());
member.setLoginCount(member.getLoginCount() == null ? 1 : member.getLoginCount() + 1);
memberMapper.updateById(member);
// 构建返回结果
H5LoginVo vo = new H5LoginVo();
vo.setAccessToken(StpUtil.getTokenValue());
vo.setRefreshToken(refreshToken);
vo.setExpiresIn(ACCESS_TOKEN_EXPIRE);
vo.setMemberId(member.getMemberId());
vo.setMemberCode(member.getMemberCode());
vo.setPhone(maskPhone(member.getPhone()));
vo.setNickname(member.getNickname());
vo.setIdentityType(member.getIdentityType());
return vo;
}
/**
* 校验图形验证码
*/
private void validateCaptcha(String uuid, String code) {
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid;
String captcha = RedisUtils.getCacheObject(verifyKey);
if (captcha == null) {
throw new ServiceException("验证码已过期");
}
if (!captcha.equalsIgnoreCase(code)) {
throw new ServiceException("验证码错误");
}
// 验证成功后删除
RedisUtils.deleteObject(verifyKey);
}
/**
* 校验短信验证码
*/
private void validateSmsCode(String phone, String code, String type) {
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
String smsCode = RedisUtils.getCacheObject(codeKey);
if (smsCode == null) {
throw new ServiceException("短信验证码已过期");
}
if (!smsCode.equals(code)) {
throw new ServiceException("短信验证码错误");
}
// 验证成功后删除
RedisUtils.deleteObject(codeKey);
}
/**
* 生成会员编号
*/
private String generateMemberCode() {
return "M" + System.currentTimeMillis() + RandomUtil.randomNumbers(4);
}
/**
* 手机号脱敏
*/
private String maskPhone(String phone) {
if (StringUtils.isBlank(phone) || phone.length() != 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
}

View File

@ -0,0 +1,442 @@
package org.dromara.pangu.h5.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.domain.PgSubject;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
import org.dromara.pangu.h5.domain.dto.H5EducationDto;
import org.dromara.pangu.h5.domain.dto.H5MemberUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5PasswordUpdateDto;
import org.dromara.pangu.h5.domain.dto.H5StudentBindDto;
import org.dromara.pangu.h5.domain.vo.H5EducationVo;
import org.dromara.pangu.h5.domain.vo.H5MemberInfoVo;
import org.dromara.pangu.h5.domain.vo.H5StudentVo;
import org.dromara.pangu.h5.service.H5MemberService;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.PgSchoolClass;
import org.dromara.pangu.school.domain.PgSchoolGrade;
import org.dromara.pangu.school.mapper.PgSchoolClassMapper;
import org.dromara.pangu.school.mapper.PgSchoolGradeMapper;
import org.dromara.pangu.school.mapper.PgSchoolMapper;
import org.dromara.pangu.student.domain.PgStudent;
import org.dromara.pangu.student.mapper.PgStudentMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* H5会员服务实现
*
* @author 湖北新华业务中台研发团队
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class H5MemberServiceImpl implements H5MemberService {
private final PgMemberMapper memberMapper;
private final PgStudentMapper studentMapper;
private final PgSchoolMapper schoolMapper;
private final PgSchoolGradeMapper schoolGradeMapper;
private final PgSchoolClassMapper schoolClassMapper;
private final PgGradeMapper gradeMapper;
private final PgClassMapper classMapper;
private final PgSubjectMapper subjectMapper;
@Override
public H5MemberInfoVo getMemberInfo() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
H5MemberInfoVo vo = new H5MemberInfoVo();
vo.setMemberId(member.getMemberId());
vo.setMemberCode(member.getMemberCode());
vo.setPhone(maskPhone(member.getPhone()));
vo.setNickname(member.getNickname());
vo.setAvatar(member.getAvatar());
vo.setGender(member.getGender());
vo.setBirthday(member.getBirthday());
vo.setRegisterTime(member.getRegisterTime());
vo.setIdentityType(member.getIdentityType());
// 获取教育身份信息
if ("2".equals(member.getIdentityType()) && member.getSchoolId() != null) {
vo.setEducation(buildEducationVo(member));
}
// 获取绑定的学生列表
vo.setStudents(getStudents());
return vo;
}
@Override
public void updateMemberInfo(H5MemberUpdateDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 只允许修改昵称性别生日
if (StringUtils.isNotBlank(dto.getNickname())) {
member.setNickname(dto.getNickname());
}
if (StringUtils.isNotBlank(dto.getGender())) {
member.setGender(dto.getGender());
}
if (dto.getBirthday() != null) {
member.setBirthday(dto.getBirthday());
}
memberMapper.updateById(member);
log.info("H5会员信息修改: memberId={}", memberId);
}
@Override
public void updatePassword(H5PasswordUpdateDto dto) {
// 校验新密码与确认密码
if (!dto.getNewPassword().equals(dto.getConfirmPassword())) {
throw new ServiceException("两次输入的密码不一致");
}
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 校验旧密码
if (!BCrypt.checkpw(dto.getOldPassword(), member.getPassword())) {
throw new ServiceException("当前密码错误");
}
// 更新密码
member.setPassword(BCrypt.hashpw(dto.getNewPassword()));
memberMapper.updateById(member);
log.info("H5会员密码修改: memberId={}", memberId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveEducation(H5EducationDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 校验学校年级班级学科是否存在
PgSchool school = schoolMapper.selectById(dto.getSchoolId());
if (school == null) {
throw new ServiceException("学校不存在");
}
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(dto.getSchoolGradeId());
if (schoolGrade == null || !schoolGrade.getSchoolId().equals(dto.getSchoolId())) {
throw new ServiceException("年级不存在或不属于该学校");
}
PgSchoolClass schoolClass = schoolClassMapper.selectById(dto.getSchoolClassId());
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(dto.getSchoolGradeId())) {
throw new ServiceException("班级不存在或不属于该年级");
}
PgSubject subject = subjectMapper.selectById(dto.getSubjectId());
if (subject == null) {
throw new ServiceException("学科不存在");
}
// 更新会员教育信息
member.setIdentityType("2"); // 教师
member.setSchoolId(dto.getSchoolId());
member.setSchoolGradeId(dto.getSchoolGradeId());
member.setSchoolClassId(dto.getSchoolClassId());
// 学科信息需要在member表添加字段这里先用remark暂存
member.setRemark("subjectId:" + dto.getSubjectId());
memberMapper.updateById(member);
log.info("H5教育身份保存: memberId={}, schoolId={}", memberId, dto.getSchoolId());
}
@Override
public H5EducationVo getEducation() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
if (!"2".equals(member.getIdentityType()) || member.getSchoolId() == null) {
return null;
}
return buildEducationVo(member);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteEducation() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 清除教育信息
member.setIdentityType(null);
member.setSchoolId(null);
member.setSchoolGradeId(null);
member.setSchoolClassId(null);
member.setRemark(null);
memberMapper.updateById(member);
log.info("H5教育身份删除: memberId={}", memberId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindStudent(H5StudentBindDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 校验学校年级班级是否存在
validateSchoolInfo(dto.getSchoolId(), dto.getSchoolGradeId(), dto.getSchoolClassId());
// 创建学生
PgStudent student = new PgStudent();
student.setStudentName(dto.getStudentName());
student.setStudentNo(dto.getStudentNo());
student.setBirthday(dto.getBirthday());
student.setGender(dto.getGender());
student.setRegionId(dto.getRegionId());
student.setSchoolId(dto.getSchoolId());
student.setSchoolGradeId(dto.getSchoolGradeId());
student.setSchoolClassId(dto.getSchoolClassId());
student.setMemberId(memberId);
student.setStatus("0");
studentMapper.insert(student);
// 如果会员没有身份默认设为家长
if (StringUtils.isBlank(member.getIdentityType())) {
member.setIdentityType("1"); // 家长
memberMapper.updateById(member);
}
log.info("H5绑定学生: memberId={}, studentId={}", memberId, student.getStudentId());
}
@Override
public List<H5StudentVo> getStudents() {
Long memberId = getCurrentMemberId();
List<PgStudent> students = studentMapper.selectList(
new LambdaQueryWrapper<PgStudent>()
.eq(PgStudent::getMemberId, memberId)
.eq(PgStudent::getDelFlag, "0")
);
List<H5StudentVo> voList = new ArrayList<>();
for (PgStudent student : students) {
H5StudentVo vo = new H5StudentVo();
BeanUtil.copyProperties(student, vo);
// 填充学校年级班级名称
fillStudentNames(vo, student);
voList.add(vo);
}
return voList;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateStudent(Long studentId, H5StudentBindDto dto) {
Long memberId = getCurrentMemberId();
// 查询学生并校验归属
PgStudent student = studentMapper.selectById(studentId);
if (student == null || !memberId.equals(student.getMemberId())) {
throw new ServiceException("学生不存在或无权限修改");
}
// 校验学校年级班级是否存在
validateSchoolInfo(dto.getSchoolId(), dto.getSchoolGradeId(), dto.getSchoolClassId());
// 更新学生信息
student.setStudentName(dto.getStudentName());
student.setStudentNo(dto.getStudentNo());
student.setBirthday(dto.getBirthday());
student.setGender(dto.getGender());
student.setRegionId(dto.getRegionId());
student.setSchoolId(dto.getSchoolId());
student.setSchoolGradeId(dto.getSchoolGradeId());
student.setSchoolClassId(dto.getSchoolClassId());
studentMapper.updateById(student);
log.info("H5修改学生: memberId={}, studentId={}", memberId, studentId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindStudent(Long studentId) {
Long memberId = getCurrentMemberId();
// 查询学生并校验归属
PgStudent student = studentMapper.selectById(studentId);
if (student == null || !memberId.equals(student.getMemberId())) {
throw new ServiceException("学生不存在或无权限操作");
}
// 解绑清除会员关联
student.setMemberId(null);
studentMapper.updateById(student);
log.info("H5解绑学生: memberId={}, studentId={}", memberId, studentId);
}
/**
* 获取当前登录会员ID
*/
private Long getCurrentMemberId() {
if (!StpUtil.isLogin()) {
throw new ServiceException("请先登录");
}
return StpUtil.getLoginIdAsLong();
}
/**
* 手机号脱敏
*/
private String maskPhone(String phone) {
if (StringUtils.isBlank(phone) || phone.length() != 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
/**
* 构建教育身份VO
*/
private H5EducationVo buildEducationVo(PgMember member) {
H5EducationVo vo = new H5EducationVo();
vo.setSchoolId(member.getSchoolId());
vo.setSchoolGradeId(member.getSchoolGradeId());
vo.setSchoolClassId(member.getSchoolClassId());
// 获取学校名称
PgSchool school = schoolMapper.selectById(member.getSchoolId());
if (school != null) {
vo.setSchoolName(school.getSchoolName());
}
// 获取年级名称
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(member.getSchoolGradeId());
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
if (grade != null) {
vo.setGradeName(grade.getGradeName());
}
}
// 获取班级名称
PgSchoolClass schoolClass = schoolClassMapper.selectById(member.getSchoolClassId());
if (schoolClass != null && schoolClass.getClassId() != null) {
PgClass cls = classMapper.selectById(schoolClass.getClassId());
if (cls != null) {
vo.setClassName(cls.getClassName());
}
}
// 获取学科信息从remark解析
if (StringUtils.isNotBlank(member.getRemark()) && member.getRemark().startsWith("subjectId:")) {
try {
Long subjectId = Long.parseLong(member.getRemark().replace("subjectId:", ""));
vo.setSubjectId(subjectId);
PgSubject subject = subjectMapper.selectById(subjectId);
if (subject != null) {
vo.setSubjectName(subject.getSubjectName());
}
} catch (Exception e) {
// 忽略解析错误
}
}
return vo;
}
/**
* 校验学校信息
*/
private void validateSchoolInfo(Long schoolId, Long schoolGradeId, Long schoolClassId) {
PgSchool school = schoolMapper.selectById(schoolId);
if (school == null) {
throw new ServiceException("学校不存在");
}
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(schoolGradeId);
if (schoolGrade == null || !schoolGrade.getSchoolId().equals(schoolId)) {
throw new ServiceException("年级不存在或不属于该学校");
}
PgSchoolClass schoolClass = schoolClassMapper.selectById(schoolClassId);
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(schoolGradeId)) {
throw new ServiceException("班级不存在或不属于该年级");
}
}
/**
* 填充学生名称信息
*/
private void fillStudentNames(H5StudentVo vo, PgStudent student) {
// 学校名称
if (student.getSchoolId() != null) {
PgSchool school = schoolMapper.selectById(student.getSchoolId());
if (school != null) {
vo.setSchoolName(school.getSchoolName());
}
}
// 年级名称
if (student.getSchoolGradeId() != null) {
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(student.getSchoolGradeId());
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
if (grade != null) {
vo.setGradeName(grade.getGradeName());
}
}
}
// 班级名称
if (student.getSchoolClassId() != null) {
PgSchoolClass schoolClass = schoolClassMapper.selectById(student.getSchoolClassId());
if (schoolClass != null && schoolClass.getClassId() != null) {
PgClass cls = classMapper.selectById(schoolClass.getClassId());
if (cls != null) {
vo.setClassName(cls.getClassName());
}
}
}
}
}

View File

@ -0,0 +1,314 @@
# H5 会员管理接口需求与技术方案
> 作者:湖北新华业务中台研发团队
> 创建时间2026-02-02
> 版本v1.0
---
## 一、需求概述
为H5前端提供会员管理相关接口包括认证、注册、个人信息管理、教育身份管理、学生绑定等功能。
---
## 二、接口需求设计
### 2.1 认证模块OAuth 2.0
#### 2.1.1 密码登录
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/login/password` |
| **请求参数** | phone手机号、password密码、captchaCode图形验证码、uuid验证码标识、rememberMe记住我 |
| **返回** | accessToken、refreshToken、expiresIn、会员基本信息 |
#### 2.1.2 短信验证码登录
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/login/sms` |
| **请求参数** | phone手机号、smsCode短信验证码、captchaCode图形验证码、uuid |
| **短信平台** | **阿里云短信服务** |
| **返回** | accessToken、refreshToken、expiresIn、会员基本信息 |
#### 2.1.3 获取图形验证码
| 项目 | 说明 |
|------|------|
| **接口** | `GET /h5/auth/captcha` |
| **返回** | uuid、captchaImgBase64图片 |
#### 2.1.4 发送短信验证码(阿里云)
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/sms/send` |
| **请求参数** | phone、captchaCode、uuid、typelogin/register |
| **短信平台** | **阿里云短信服务** |
| **返回** | 发送结果、倒计时秒数60秒 |
#### 2.1.5 刷新Token
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/refresh` |
| **请求参数** | refreshToken |
| **返回** | 新的 accessToken、refreshToken |
#### 2.1.6 退出登录
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/logout` |
---
### 2.2 注册模块
#### 2.2.1 注册 - 提交基本信息
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/auth/register` |
| **请求参数** | phone、smsCode、captchaCode、uuid、password |
| **短信平台** | **阿里云短信服务** |
| **业务逻辑** | 1. 校验手机号未注册<br>2. 校验验证码<br>3. 创建会员账号<br>4. 自动登录返回Token |
| **返回** | accessToken、refreshToken、memberId |
#### 2.2.2 完善身份信息(可选)
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/member/identity` |
| **请求参数** | identityTypeparent/teacher |
---
### 2.3 会员信息模块
#### 2.3.1 获取当前会员信息
| 项目 | 说明 |
|------|------|
| **接口** | `GET /h5/member/info` |
| **返回** | memberId、phone脱敏、nickname、avatar、gender、birthday、registerTime、identityType、教育信息、绑定学生列表 |
#### 2.3.2 修改会员基本信息
| 项目 | 说明 |
|------|------|
| **接口** | `PUT /h5/member/info` |
| **可修改字段** | nickname昵称、gender性别、birthday生日 |
| **备注** | 手机号不可修改 |
---
### 2.4 密码管理
#### 2.4.1 修改密码
| 项目 | 说明 |
|------|------|
| **接口** | `PUT /h5/member/password` |
| **请求参数** | oldPassword当前密码、newPassword新密码、confirmPassword确认新密码 |
| **校验** | 新密码至少6位包含字母、数字或符号 |
---
### 2.5 教育身份管理(教师身份)
#### 2.5.1 添加/修改教育身份
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/member/education` |
| **请求参数** | schoolId学校、schoolGradeId年级、schoolClassId班级、subjectId学科 |
| **业务逻辑** | 设置会员身份为"教师",关联学校班级学科信息 |
#### 2.5.2 获取教育身份信息
| 项目 | 说明 |
|------|------|
| **接口** | `GET /h5/member/education` |
| **返回** | 学校、年级、班级、学科名称及ID |
#### 2.5.3 删除教育身份
| 项目 | 说明 |
|------|------|
| **接口** | `DELETE /h5/member/education` |
---
### 2.6 绑定学生管理(家长/教师均可)
#### 2.6.1 绑定学生
| 项目 | 说明 |
|------|------|
| **接口** | `POST /h5/member/student` |
| **请求参数** | studentName学生姓名、studentNo学号、birthday出生日期、gender性别、regionId地区、schoolId、schoolGradeId、schoolClassId |
| **业务逻辑** | 1. 创建学生记录<br>2. 关联当前会员<br>3. **家长和教师身份均可绑定学生** |
#### 2.6.2 获取绑定的学生列表
| 项目 | 说明 |
|------|------|
| **接口** | `GET /h5/member/students` |
| **返回** | 学生列表(含学校、年级、班级名称) |
#### 2.6.3 修改学生信息
| 项目 | 说明 |
|------|------|
| **接口** | `PUT /h5/member/student/{studentId}` |
| **请求参数** | 同绑定学生 |
#### 2.6.4 解绑学生
| 项目 | 说明 |
|------|------|
| **接口** | `DELETE /h5/member/student/{studentId}` |
---
### 2.7 基础数据接口(公开)
| 接口 | 说明 |
|------|------|
| `GET /h5/base/regions` | 获取区域树(省市区) |
| `GET /h5/base/schools?regionId=xxx` | 根据区域获取学校列表 |
| `GET /h5/base/grades?schoolId=xxx` | 根据学校获取年级列表 |
| `GET /h5/base/classes?schoolGradeId=xxx` | 根据年级获取班级列表 |
| `GET /h5/base/subjects` | 获取学科列表 |
---
### 2.8 接口规范
| 规范项 | 说明 |
|------|------|
| **认证方式** | OAuth 2.0 Bearer TokenHeader: `Authorization: Bearer {accessToken}` |
| **Token有效期** | accessToken: 2小时refreshToken: 7天记住我30天 |
| **响应格式** | `{ code: 200, msg: "success", data: {...} }` |
| **错误码** | 401-未认证403-无权限10001-验证码错误10002-密码错误10003-手机号已注册 |
| **短信服务** | 阿里云短信 |
---
## 三、技术方案
### 3.1 项目结构设计
`pangu-business` 模块下新建 `h5` 包:
```
backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/
├── h5/ # H5接口模块
│ ├── controller/
│ │ ├── H5AuthController.java # 认证接口
│ │ ├── H5MemberController.java # 会员信息接口
│ │ └── H5BaseDataController.java # 基础数据接口
│ ├── service/
│ │ ├── H5AuthService.java # 认证服务接口
│ │ ├── impl/
│ │ │ └── H5AuthServiceImpl.java # 认证服务实现
│ │ ├── H5MemberService.java # 会员服务接口
│ │ └── impl/
│ │ └── H5MemberServiceImpl.java # 会员服务实现
│ └── domain/
│ ├── dto/ # 请求DTO
│ │ ├── H5PasswordLoginDto.java # 密码登录
│ │ ├── H5SmsLoginDto.java # 短信登录
│ │ ├── H5RegisterDto.java # 注册
│ │ ├── H5SmsSendDto.java # 发送短信
│ │ ├── H5MemberUpdateDto.java # 会员信息修改
│ │ ├── H5PasswordUpdateDto.java # 密码修改
│ │ ├── H5EducationDto.java # 教育身份
│ │ └── H5StudentBindDto.java # 绑定学生
│ └── vo/ # 响应VO
│ ├── H5LoginVo.java # 登录响应
│ ├── H5CaptchaVo.java # 验证码响应
│ ├── H5MemberInfoVo.java # 会员信息
│ ├── H5EducationVo.java # 教育身份
│ └── H5StudentVo.java # 学生信息
```
### 3.2 接口路径规划
| 模块 | 路径前缀 | 认证要求 |
|------|---------|---------|
| 认证接口 | `/h5/auth/*` | 无需认证 |
| 会员接口 | `/h5/member/*` | 需要Token |
| 基础数据 | `/h5/base/*` | 无需认证 |
### 3.3 认证方案Sa-Token
#### Token 策略
| 配置项 | 值 | 说明 |
|--------|-----|------|
| Token 存储 | Redis | 支持分布式 |
| accessToken 有效期 | 2小时 | 常规访问 |
| refreshToken 有效期 | 7天/30天 | 记住我时30天 |
| Token 传递方式 | Header | `Authorization: Bearer {token}` |
| 设备类型 | h5 | 区分多端登录 |
#### 安全配置
`SecurityConfig.java` 中添加H5接口放行规则
```java
// H5公开接口无需认证
.excludePathPatterns(
"/h5/auth/**", // 登录/注册/验证码
"/h5/base/**" // 基础数据
)
```
### 3.4 短信服务(阿里云)
复用现有 `sms4j` 模块,配置阿里云:
```yaml
sms:
blends:
alibaba:
supplier: alibaba
access-key-id: ${ALIYUN_SMS_ACCESS_KEY}
access-key-secret: ${ALIYUN_SMS_ACCESS_SECRET}
signature: 盘古教育
template-id: SMS_XXXXXX
```
短信验证码逻辑:
1. 校验图形验证码
2. 频率限制60秒内不可重发
3. 生成6位数字验证码5分钟有效
4. 存入Redis`h5:sms:{phone}:{type}`
5. 调用阿里云API发送
### 3.5 数据库设计
复用现有表结构,无需新增表:
- `pg_member` - 会员表
- `pg_student` - 学生表
- `pg_school` / `pg_school_grade` / `pg_school_class` - 学校相关
- `pg_region` - 区域表
- `pg_subject` - 学科表
### 3.6 依赖关系
```
H5 Controller
H5 Service新建
复用现有 Service/Mapper
├── IPgMemberService
├── IPgStudentService
├── IPgSchoolService
└── 基础数据 Service
```
---
## 四、开发清单
| 序号 | 内容 | 文件 |
|------|------|------|
| 1 | DTO类 | H5PasswordLoginDto, H5SmsLoginDto, H5RegisterDto, H5SmsSendDto, H5MemberUpdateDto, H5PasswordUpdateDto, H5EducationDto, H5StudentBindDto |
| 2 | VO类 | H5LoginVo, H5CaptchaVo, H5MemberInfoVo, H5EducationVo, H5StudentVo |
| 3 | 认证服务 | H5AuthService, H5AuthServiceImpl |
| 4 | 会员服务 | H5MemberService, H5MemberServiceImpl |
| 5 | Controller | H5AuthController, H5MemberController, H5BaseDataController |
| 6 | 安全配置 | SecurityConfig添加放行规则 |
| 7 | 短信配置 | application-dev.yml |
---
*最后更新: 2026-02-02*