From 905e263ca81abd8f8efc5f1edc877594b8e40e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A5=9E=E7=A0=81-=E6=96=B9=E6=99=93=E8=BE=89?= Date: Mon, 2 Feb 2026 21:39:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EH5=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求文档: - 新增H5会员接口需求与技术方案文档 认证模块(/h5/auth): - 图形验证码获取 - 阿里云短信验证码发送 - 密码登录 - 短信验证码登录 - 会员注册 - Token刷新 - 退出登录 会员模块(/h5/member): - 获取会员信息 - 修改会员信息(昵称/性别/生日) - 修改密码 - 教育身份管理(教师) - 学生绑定管理(家长/教师均可) 基础数据模块(/h5/base): - 区域树查询 - 学校列表查询 - 年级列表查询 - 班级列表查询 - 学科列表查询 安全配置: - 放行H5公开接口(/h5/auth/**、/h5/base/**) --- .../src/main/resources/application.yml | 3 + .../pangu/h5/controller/H5AuthController.java | 88 ++++ .../h5/controller/H5BaseDataController.java | 200 ++++++++ .../h5/controller/H5MemberController.java | 118 +++++ .../pangu/h5/domain/dto/H5EducationDto.java | 37 ++ .../h5/domain/dto/H5MemberUpdateDto.java | 29 ++ .../h5/domain/dto/H5PasswordLoginDto.java | 44 ++ .../h5/domain/dto/H5PasswordUpdateDto.java | 33 ++ .../pangu/h5/domain/dto/H5RegisterDto.java | 47 ++ .../pangu/h5/domain/dto/H5SmsLoginDto.java | 39 ++ .../pangu/h5/domain/dto/H5SmsSendDto.java | 39 ++ .../pangu/h5/domain/dto/H5StudentBindDto.java | 64 +++ .../pangu/h5/domain/vo/H5CaptchaVo.java | 22 + .../pangu/h5/domain/vo/H5EducationVo.java | 52 +++ .../dromara/pangu/h5/domain/vo/H5LoginVo.java | 52 +++ .../pangu/h5/domain/vo/H5MemberInfoVo.java | 70 +++ .../pangu/h5/domain/vo/H5StudentVo.java | 79 ++++ .../pangu/h5/service/H5AuthService.java | 51 ++ .../pangu/h5/service/H5MemberService.java | 69 +++ .../h5/service/impl/H5AuthServiceImpl.java | 387 +++++++++++++++ .../h5/service/impl/H5MemberServiceImpl.java | 442 ++++++++++++++++++ docs/02-技术方案/H5会员接口需求与技术方案.md | 314 +++++++++++++ 22 files changed, 2279 insertions(+) create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5BaseDataController.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5MemberController.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5EducationDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5MemberUpdateDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordLoginDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordUpdateDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5RegisterDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsLoginDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsSendDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5StudentBindDto.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5CaptchaVo.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5EducationVo.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5LoginVo.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5MemberInfoVo.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5StudentVo.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5MemberService.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java create mode 100644 backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5MemberServiceImpl.java create mode 100644 docs/02-技术方案/H5会员接口需求与技术方案.md diff --git a/backend/ruoyi-admin/src/main/resources/application.yml b/backend/ruoyi-admin/src/main/resources/application.yml index 6e14cfa..9c03514 100644 --- a/backend/ruoyi-admin/src/main/resources/application.yml +++ b/backend/ruoyi-admin/src/main/resources/application.yml @@ -116,6 +116,9 @@ security: - /*/api-docs - /*/api-docs/** - /warm-flow-ui/config + # H5公开接口 + - /h5/auth/** + - /h5/base/** # 多租户配置 tenant: diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java new file mode 100644 index 0000000..9879a8e --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java @@ -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 getCaptcha() { + return R.ok(authService.getCaptcha()); + } + + /** + * 发送短信验证码 + */ + @PostMapping("/sms/send") + public R sendSmsCode(@Valid @RequestBody H5SmsSendDto dto) { + authService.sendSmsCode(dto); + return R.ok(); + } + + /** + * 密码登录 + */ + @PostMapping("/login/password") + public R loginByPassword(@Valid @RequestBody H5PasswordLoginDto dto) { + return R.ok(authService.loginByPassword(dto)); + } + + /** + * 短信验证码登录 + */ + @PostMapping("/login/sms") + public R loginBySms(@Valid @RequestBody H5SmsLoginDto dto) { + return R.ok(authService.loginBySms(dto)); + } + + /** + * 注册 + */ + @PostMapping("/register") + public R register(@Valid @RequestBody H5RegisterDto dto) { + return R.ok(authService.register(dto)); + } + + /** + * 刷新Token + */ + @PostMapping("/refresh") + public R refreshToken(@RequestParam String refreshToken) { + return R.ok(authService.refreshToken(refreshToken)); + } + + /** + * 退出登录 + */ + @PostMapping("/logout") + public R logout() { + authService.logout(); + return R.ok(); + } +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5BaseDataController.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5BaseDataController.java new file mode 100644 index 0000000..81530e4 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5BaseDataController.java @@ -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>> getRegions() { + List regions = regionMapper.selectList( + new LambdaQueryWrapper() + .eq(PgRegion::getStatus, "0") + .orderByAsc(PgRegion::getOrderNum) + ); + + // 构建树形结构 + List> tree = buildRegionTree(regions, 0L); + return R.ok(tree); + } + + /** + * 根据区域获取学校列表 + */ + @GetMapping("/schools") + public R>> getSchools(@RequestParam Long regionId) { + List schools = schoolMapper.selectList( + new LambdaQueryWrapper() + .eq(PgSchool::getRegionId, regionId) + .eq(PgSchool::getStatus, "0") + .eq(PgSchool::getDelFlag, "0") + ); + + List> result = schools.stream().map(school -> { + Map 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>> getGrades(@RequestParam Long schoolId) { + List schoolGrades = schoolGradeMapper.selectList( + new LambdaQueryWrapper() + .eq(PgSchoolGrade::getSchoolId, schoolId) + ); + + // 获取基础年级信息 + Set gradeIds = schoolGrades.stream() + .map(PgSchoolGrade::getGradeId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map gradeNameMap = new HashMap<>(); + if (!gradeIds.isEmpty()) { + List grades = gradeMapper.selectByIds(gradeIds); + gradeNameMap = grades.stream() + .collect(Collectors.toMap(PgGrade::getGradeId, PgGrade::getGradeName)); + } + + Map finalGradeNameMap = gradeNameMap; + List> result = schoolGrades.stream().map(sg -> { + Map 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>> getClasses(@RequestParam Long schoolGradeId) { + List schoolClasses = schoolClassMapper.selectList( + new LambdaQueryWrapper() + .eq(PgSchoolClass::getSchoolGradeId, schoolGradeId) + ); + + // 获取基础班级信息 + Set classIds = schoolClasses.stream() + .map(PgSchoolClass::getClassId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map classNameMap = new HashMap<>(); + if (!classIds.isEmpty()) { + List classes = classMapper.selectByIds(classIds); + classNameMap = classes.stream() + .collect(Collectors.toMap(PgClass::getClassId, PgClass::getClassName)); + } + + Map finalClassNameMap = classNameMap; + List> result = schoolClasses.stream().map(sc -> { + Map 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>> getSubjects() { + List subjects = subjectMapper.selectList( + new LambdaQueryWrapper() + .eq(PgSubject::getStatus, "0") + .orderByAsc(PgSubject::getOrderNum) + ); + + List> result = subjects.stream().map(subject -> { + Map 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> buildRegionTree(List regions, Long parentId) { + return regions.stream() + .filter(r -> parentId.equals(r.getParentId())) + .map(region -> { + Map map = new HashMap<>(); + map.put("regionId", region.getRegionId()); + map.put("regionName", region.getRegionName()); + map.put("regionCode", region.getRegionCode()); + map.put("level", region.getLevel()); + + List> children = buildRegionTree(regions, region.getRegionId()); + if (!children.isEmpty()) { + map.put("children", children); + } + return map; + }) + .collect(Collectors.toList()); + } +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5MemberController.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5MemberController.java new file mode 100644 index 0000000..6f141f8 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5MemberController.java @@ -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 getInfo() { + return R.ok(memberService.getMemberInfo()); + } + + /** + * 修改会员信息(昵称/性别/生日) + */ + @PutMapping("/info") + public R updateInfo(@RequestBody H5MemberUpdateDto dto) { + memberService.updateMemberInfo(dto); + return R.ok(); + } + + /** + * 修改密码 + */ + @PutMapping("/password") + public R updatePassword(@Valid @RequestBody H5PasswordUpdateDto dto) { + memberService.updatePassword(dto); + return R.ok(); + } + + /** + * 添加/修改教育身份 + */ + @PostMapping("/education") + public R saveEducation(@Valid @RequestBody H5EducationDto dto) { + memberService.saveEducation(dto); + return R.ok(); + } + + /** + * 获取教育身份 + */ + @GetMapping("/education") + public R getEducation() { + return R.ok(memberService.getEducation()); + } + + /** + * 删除教育身份 + */ + @DeleteMapping("/education") + public R deleteEducation() { + memberService.deleteEducation(); + return R.ok(); + } + + /** + * 绑定学生 + */ + @PostMapping("/student") + public R bindStudent(@Valid @RequestBody H5StudentBindDto dto) { + memberService.bindStudent(dto); + return R.ok(); + } + + /** + * 获取绑定的学生列表 + */ + @GetMapping("/students") + public R> getStudents() { + return R.ok(memberService.getStudents()); + } + + /** + * 修改学生信息 + */ + @PutMapping("/student/{studentId}") + public R updateStudent(@PathVariable Long studentId, @Valid @RequestBody H5StudentBindDto dto) { + memberService.updateStudent(studentId, dto); + return R.ok(); + } + + /** + * 解绑学生 + */ + @DeleteMapping("/student/{studentId}") + public R unbindStudent(@PathVariable Long studentId) { + memberService.unbindStudent(studentId); + return R.ok(); + } +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5EducationDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5EducationDto.java new file mode 100644 index 0000000..9da69b9 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5EducationDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5MemberUpdateDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5MemberUpdateDto.java new file mode 100644 index 0000000..6bb6377 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5MemberUpdateDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordLoginDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordLoginDto.java new file mode 100644 index 0000000..5b91994 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordLoginDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordUpdateDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordUpdateDto.java new file mode 100644 index 0000000..3f3e46c --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5PasswordUpdateDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5RegisterDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5RegisterDto.java new file mode 100644 index 0000000..631ea32 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5RegisterDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsLoginDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsLoginDto.java new file mode 100644 index 0000000..40f572c --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsLoginDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsSendDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsSendDto.java new file mode 100644 index 0000000..ebfff94 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5SmsSendDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5StudentBindDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5StudentBindDto.java new file mode 100644 index 0000000..fd5cc96 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/H5StudentBindDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5CaptchaVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5CaptchaVo.java new file mode 100644 index 0000000..5da944a --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5CaptchaVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5EducationVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5EducationVo.java new file mode 100644 index 0000000..c3338bf --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5EducationVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5LoginVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5LoginVo.java new file mode 100644 index 0000000..68a82fa --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5LoginVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5MemberInfoVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5MemberInfoVo.java new file mode 100644 index 0000000..c7a0032 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5MemberInfoVo.java @@ -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 students; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5StudentVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5StudentVo.java new file mode 100644 index 0000000..e295205 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/H5StudentVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java new file mode 100644 index 0000000..a4d63b0 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java @@ -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(); +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5MemberService.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5MemberService.java new file mode 100644 index 0000000..38d73d0 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5MemberService.java @@ -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 getStudents(); + + /** + * 修改学生信息 + */ + void updateStudent(Long studentId, H5StudentBindDto dto); + + /** + * 解绑学生 + */ + void unbindStudent(Long studentId); +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java new file mode 100644 index 0000000..fc301a2 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java @@ -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); + + // 存入Redis,5分钟有效 + 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().eq(PgMember::getPhone, phone) + ); + if (existMember != null) { + throw new ServiceException("该手机号已注册"); + } + } + + // 登录时校验手机号是否存在 + if ("login".equals(type)) { + PgMember existMember = memberMapper.selectOne( + new LambdaQueryWrapper().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("验证码发送过于频繁,请稍后再试"); + } + } + + // 存入Redis,5分钟有效 + RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(5)); + + // 调用阿里云短信发送 + try { + LinkedHashMap 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().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().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().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); + } +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5MemberServiceImpl.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5MemberServiceImpl.java new file mode 100644 index 0000000..7667308 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5MemberServiceImpl.java @@ -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 getStudents() { + Long memberId = getCurrentMemberId(); + + List students = studentMapper.selectList( + new LambdaQueryWrapper() + .eq(PgStudent::getMemberId, memberId) + .eq(PgStudent::getDelFlag, "0") + ); + + List 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()); + } + } + } + } +} diff --git a/docs/02-技术方案/H5会员接口需求与技术方案.md b/docs/02-技术方案/H5会员接口需求与技术方案.md new file mode 100644 index 0000000..cf8fa3a --- /dev/null +++ b/docs/02-技术方案/H5会员接口需求与技术方案.md @@ -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、captchaImg(Base64图片) | + +#### 2.1.4 发送短信验证码(阿里云) +| 项目 | 说明 | +|------|------| +| **接口** | `POST /h5/auth/sms/send` | +| **请求参数** | phone、captchaCode、uuid、type(login/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. 校验手机号未注册
2. 校验验证码
3. 创建会员账号
4. 自动登录返回Token | +| **返回** | accessToken、refreshToken、memberId | + +#### 2.2.2 完善身份信息(可选) +| 项目 | 说明 | +|------|------| +| **接口** | `POST /h5/member/identity` | +| **请求参数** | identityType(parent/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. 创建学生记录
2. 关联当前会员
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 Token,Header: `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*