Compare commits

...

8 Commits

Author SHA1 Message Date
神码-方晓辉 dcadd41e2b fix: 修复H5登录Token校验问题,添加测试报告
问题修复:
- H5AuthServiceImpl: 登录时设置extra信息(tenantId,userId,userName,clientid)
- SecurityConfig: H5设备跳过clientId校验,避免NPE

测试文档:
- 新增 docs/03-测试文档/H5接口测试报告.md
- 覆盖基础数据、认证、会员模块共16个测试用例
- 测试手机号: 15889762069
- 真实短信发送测试通过
2026-02-02 22:11:15 +08:00
神码-方晓辉 1ae754e56c docs: 补充短信模板内容
- 登录模板: 【湖北新华教育服务平台】您正在申请登录,验证码为:${code}
- 注册模板: 【湖北新华教育服务平台】您正在申请注册,验证码为:${code}
2026-02-02 21:56:19 +08:00
神码-方晓辉 e98fe86124 feat: 配置阿里云短信服务参数
从192.168.71.56服务器nacos获取阿里云短信配置:
- AccessKey: LTAI5tQLorTxf9Fzzh93pfGN
- 签名: 湖北新华教育服务平台
- 登录模板: SMS_461020580
- 注册模板: SMS_473140005

更新文件:
- application-dev.yml: 写入阿里云AccessKey和签名
- application.yml: 配置短信模板ID
- 技术方案文档: 更新阿里云账号和模板信息
2026-02-02 21:54:05 +08:00
神码-方晓辉 e13c9a7de3 docs: 完善阿里云短信配置说明
配置文件更新:
- application-dev.yml: 优化阿里云短信配置,添加环境变量支持
- application.yml: 完善H5短信配置注释,分组显示

文档更新:
- 添加阿里云准备工作步骤
- 添加短信模板示例
- 添加环境变量配置说明(推荐生产环境使用)
2026-02-02 21:51:07 +08:00
神码-方晓辉 0b35da7f85 docs: 添加H5接口Swagger文档注解
Controller层:
- H5AuthController: 添加详细的接口说明、参数说明、返回值说明
- H5MemberController: 添加认证要求、操作说明
- H5BaseDataController: 添加数据获取流程说明

DTO/VO层:
- 所有字段添加@Schema注解,包含描述、示例值、枚举值等
- 标注必填/选填、数据格式要求

其他:
- 统一作者为pangu
2026-02-02 21:48:36 +08:00
神码-方晓辉 a47536315b docs: 更新H5接口文档,添加短信防刷配置说明 2026-02-02 21:44:46 +08:00
神码-方晓辉 5fe9d1117f feat: 增强H5短信接口防刷机制
新增配置类 H5SmsProperties,支持以下配置项:
- enabled: 是否启用短信发送(测试模式)
- code-length: 验证码长度
- code-expire-minutes: 验证码有效期
- send-interval-seconds: 同手机号发送间隔
- daily-limit-per-phone: 手机号每日上限
- minute-limit-per-ip: IP每分钟上限
- daily-limit-per-ip: IP每日上限
- blacklist-minutes: 黑名单封禁时长
- blacklist-trigger-count: 触发黑名单的失败次数
- sms-config-name: 短信配置名称
- login/register-template-id: 短信模板ID

防刷策略:
1. 图形验证码校验
2. 手机号黑名单检查
3. IP黑名单检查
4. 发送间隔限制
5. 手机号每日上限
6. IP每分钟上限
7. IP每日上限
8. 验证码连续失败自动封禁
2026-02-02 21:43:38 +08:00
神码-方晓辉 905e263ca8 feat: 新增H5会员管理接口模块
需求文档:
- 新增H5会员接口需求与技术方案文档

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

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

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

安全配置:
- 放行H5公开接口(/h5/auth/**、/h5/base/**)
2026-02-02 21:39:12 +08:00
26 changed files with 3648 additions and 32 deletions

View File

@ -153,37 +153,32 @@ mail:
# Socket连接超时值单位毫秒缺省值不超时
connectionTimeout: 0
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
--- # sms 短信服务配置(阿里云)
# 文档: https://sms4j.com/doc3/
# 阿里云短信控制台: https://dysms.console.aliyun.com/
sms:
# 配置源类型用于标定配置来源(interface,yaml)
# 配置源类型: yaml(配置文件) 或 interface(接口配置)
config-type: yaml
# 用于标定yml中的配置是否开启短信拦截接口配置不受此限制
# 是否开启短信拦截(框架层面的限制)
restricted: true
# 短信拦截限制单手机号每分钟最大发送,只对开启了拦截的配置有效
# 单手机号每分钟最大发送次数
minute-max: 1
# 短信拦截限制单手机号每日最大发送量,只对开启了拦截的配置有效
# 单手机号每日最大发送次数
account-max: 30
# 以下配置来自于 org.dromara.sms4j.provider.config.BaseConfig类中
blends:
# 唯一ID 用于发送短信寻找具体配置 随便定义别用中文即可
# 可以同时存在两个相同厂商 例如: ali1 ali2 两个不同的阿里短信账号 也可用于区分租户
config1:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
# ============================================================
# 阿里云短信配置H5接口使用
# 配置名称 "alibaba" 与 h5.sms.sms-config-name 对应
# ============================================================
alibaba:
# 厂商标识(固定值)
supplier: alibaba
# 有些称为accessKey有些称之为apiKey也有称为sdkKey或者appId。
access-key-id: 您的accessKey
# 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent
access-key-id: 您的accessKey
access-key-secret: 您的accessKeySecret
signature: 您的短信签名
sdk-app-id: 您的sdkAppId
# AccessKey ID
access-key-id: LTAI5tQLorTxf9Fzzh93pfGN
# AccessKey Secret
access-key-secret: NhBZGwac5vvWg60R0Y4UndTsKK4zuh
# 短信签名
signature: 湖北新华教育服务平台
--- # 三方授权

View File

@ -116,6 +116,9 @@ security:
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
# H5公开接口
- /h5/auth/**
- /h5/base/**
# 多租户配置
tenant:
@ -261,3 +264,45 @@ warm-flow:
node-tooltip: true
# 默认Authorization如果有多个token用逗号分隔
token-name: ${sa-token.token-name},clientid
--- # H5短信配置阿里云
# ============================================================
# H5接口短信验证码配置
# 阿里云短信模板申请: https://dysms.console.aliyun.com/
# ============================================================
h5:
sms:
# ---------- 基础配置 ----------
# 是否真正发送短信false时仅打印日志用于开发测试
# 生产环境必须设置为 true
enabled: false
# 阿里云短信配置名称(对应 application-dev.yml 中 sms.blends 下的配置名)
sms-config-name: alibaba
# ---------- 短信模板配置 ----------
# 登录验证码模板ID
login-template-id: SMS_461020580
# 注册验证码模板ID
register-template-id: SMS_473140005
# ---------- 验证码配置 ----------
# 验证码长度(位数)
code-length: 6
# 验证码有效期(分钟)
code-expire-minutes: 5
# ---------- 防刷策略配置 ----------
# 同一手机号发送间隔(秒),防止频繁发送
send-interval-seconds: 60
# 同一手机号每日发送上限
daily-limit-per-phone: 10
# 同一IP每分钟发送上限
minute-limit-per-ip: 5
# 同一IP每日发送上限
daily-limit-per-ip: 50
# ---------- 黑名单配置 ----------
# 黑名单封禁时长(分钟)
blacklist-minutes: 30
# 连续验证失败多少次后加入黑名单
blacklist-trigger-count: 5

View File

@ -63,14 +63,19 @@ public class SecurityConfig implements WebMvcConfigurer {
StpUtil.checkLogin();
// 检查 header param 里的 clientid token 里的是否一致
String headerCid = request.getHeader(LoginHelper.CLIENT_KEY);
String paramCid = ServletUtils.getParameter(LoginHelper.CLIENT_KEY);
String clientId = StpUtil.getExtra(LoginHelper.CLIENT_KEY).toString();
if (!StringUtils.equalsAny(clientId, headerCid, paramCid)) {
// token 无效
throw NotLoginException.newInstance(StpUtil.getLoginType(),
"-100", "客户端ID与Token不匹配",
StpUtil.getTokenValue());
// H5设备跳过clientId校验
String device = StpUtil.getLoginDevice();
if (!"h5".equals(device)) {
String headerCid = request.getHeader(LoginHelper.CLIENT_KEY);
String paramCid = ServletUtils.getParameter(LoginHelper.CLIENT_KEY);
Object clientIdObj = StpUtil.getExtra(LoginHelper.CLIENT_KEY);
String clientId = clientIdObj != null ? clientIdObj.toString() : null;
if (clientId != null && !StringUtils.equalsAny(clientId, headerCid, paramCid)) {
// token 无效
throw NotLoginException.newInstance(StpUtil.getLoginType(),
"-100", "客户端ID与Token不匹配",
StpUtil.getTokenValue());
}
}
// 有效率影响 用于临时测试

View File

@ -0,0 +1,76 @@
package org.dromara.pangu.h5.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* H5短信配置属性
*
* @author pangu
*/
@Data
@Component
@ConfigurationProperties(prefix = "h5.sms")
public class H5SmsProperties {
/**
* 是否启用短信发送测试时可关闭
*/
private boolean enabled = true;
/**
* 验证码长度
*/
private int codeLength = 6;
/**
* 验证码有效期分钟
*/
private int codeExpireMinutes = 5;
/**
* 同一手机号发送间隔
*/
private int sendIntervalSeconds = 60;
/**
* 同一手机号每日发送上限
*/
private int dailyLimitPerPhone = 10;
/**
* 同一IP每分钟发送上限
*/
private int minuteLimitPerIp = 5;
/**
* 同一IP每日发送上限
*/
private int dailyLimitPerIp = 50;
/**
* 黑名单封禁时长分钟
*/
private int blacklistMinutes = 30;
/**
* 触发黑名单的失败次数连续验证失败
*/
private int blacklistTriggerCount = 5;
/**
* 阿里云短信配置名称
*/
private String smsConfigName = "alibaba";
/**
* 短信模板ID登录
*/
private String loginTemplateId = "";
/**
* 短信模板ID注册
*/
private String registerTemplateId = "";
}

View File

@ -0,0 +1,233 @@
package org.dromara.pangu.h5.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
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认证接口
* <p>
* 提供H5端用户认证相关功能包括登录注册验证码等
* 所有接口均无需Token认证
* </p>
*
* @author pangu
*/
@Tag(name = "H5认证接口", description = "提供H5端登录、注册、验证码等认证功能无需Token")
@SaIgnore
@Validated
@RestController
@RequestMapping("/h5/auth")
@RequiredArgsConstructor
public class H5AuthController {
private final H5AuthService authService;
/**
* 获取图形验证码
*/
@Operation(
summary = "获取图形验证码",
description = "生成数学运算验证码图片返回Base64编码的图片和uuid。uuid用于后续接口校验验证码。验证码5分钟内有效。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = H5CaptchaVo.class))),
@ApiResponse(responseCode = "500", description = "服务器错误")
})
@GetMapping("/captcha")
public R<H5CaptchaVo> getCaptcha() {
return R.ok(authService.getCaptcha());
}
/**
* 发送短信验证码
*/
@Operation(
summary = "发送短信验证码",
description = """
向指定手机号发送短信验证码
**使用流程**
1. 先调用 /captcha 获取图形验证码
2. 用户输入图形验证码后调用本接口
3. 验证码6位数字5分钟内有效
**限制规则**
- 同一手机号60秒内只能发送1次
- 同一手机号每日最多发送10次
- 同一IP每分钟最多发送5次
- 同一IP每日最多发送50次
- 连续验证失败5次将被封禁30分钟
**type参数说明**
- login: 登录验证码手机号必须已注册
- register: 注册验证码手机号必须未注册
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "发送成功"),
@ApiResponse(responseCode = "500", description = "发送失败,可能原因:验证码错误、手机号已注册/未注册、发送频率限制等")
})
@PostMapping("/sms/send")
public R<Void> sendSmsCode(@Valid @RequestBody H5SmsSendDto dto) {
authService.sendSmsCode(dto);
return R.ok();
}
/**
* 密码登录
*/
@Operation(
summary = "密码登录",
description = """
使用手机号和密码登录
**使用流程**
1. 先调用 /captcha 获取图形验证码
2. 用户输入手机号密码图形验证码
3. 调用本接口进行登录
**返回说明**
- accessToken: 访问令牌2小时有效用于请求需要认证的接口
- refreshToken: 刷新令牌7天有效记住我时30天用于刷新accessToken
- expiresIn: accessToken过期时间
**认证方式**
请求头添加Authorization: Bearer {accessToken}
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "登录成功",
content = @Content(schema = @Schema(implementation = H5LoginVo.class))),
@ApiResponse(responseCode = "500", description = "登录失败,可能原因:验证码错误、手机号或密码错误、账号被禁用")
})
@PostMapping("/login/password")
public R<H5LoginVo> loginByPassword(@Valid @RequestBody H5PasswordLoginDto dto) {
return R.ok(authService.loginByPassword(dto));
}
/**
* 短信验证码登录
*/
@Operation(
summary = "短信验证码登录",
description = """
使用手机号和短信验证码登录
**使用流程**
1. 先调用 /captcha 获取图形验证码
2. 调用 /sms/send 发送短信验证码type=login
3. 用户输入短信验证码和图形验证码
4. 调用本接口进行登录
**注意** 手机号必须已注册才能使用短信登录
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "登录成功",
content = @Content(schema = @Schema(implementation = H5LoginVo.class))),
@ApiResponse(responseCode = "500", description = "登录失败,可能原因:验证码错误、手机号未注册、账号被禁用")
})
@PostMapping("/login/sms")
public R<H5LoginVo> loginBySms(@Valid @RequestBody H5SmsLoginDto dto) {
return R.ok(authService.loginBySms(dto));
}
/**
* 注册
*/
@Operation(
summary = "会员注册",
description = """
新用户注册账号
**使用流程**
1. 先调用 /captcha 获取图形验证码
2. 调用 /sms/send 发送短信验证码type=register
3. 用户填写手机号短信验证码图形验证码密码
4. 调用本接口进行注册
**注册成功后**
- 自动登录返回Token
- 默认昵称为 user_手机号后4位
- 默认无身份类型需后续设置
**密码要求** 至少6位
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "注册成功并自动登录",
content = @Content(schema = @Schema(implementation = H5LoginVo.class))),
@ApiResponse(responseCode = "500", description = "注册失败,可能原因:验证码错误、手机号已注册")
})
@PostMapping("/register")
public R<H5LoginVo> register(@Valid @RequestBody H5RegisterDto dto) {
return R.ok(authService.register(dto));
}
/**
* 刷新Token
*/
@Operation(
summary = "刷新Token",
description = """
使用refreshToken获取新的accessToken
**使用场景**
当accessToken过期时2小时使用refreshToken换取新Token无需重新登录
**注意事项**
- refreshToken使用后会失效返回新的refreshToken
- refreshToken过期后需重新登录
- refreshToken有效期普通登录7天记住我30天
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "刷新成功",
content = @Content(schema = @Schema(implementation = H5LoginVo.class))),
@ApiResponse(responseCode = "500", description = "刷新失败refreshToken已过期请重新登录")
})
@Parameters({
@Parameter(name = "refreshToken", description = "刷新令牌", required = true, in = ParameterIn.QUERY)
})
@PostMapping("/refresh")
public R<H5LoginVo> refreshToken(@RequestParam String refreshToken) {
return R.ok(authService.refreshToken(refreshToken));
}
/**
* 退出登录
*/
@Operation(
summary = "退出登录",
description = "退出当前登录状态清除服务端Token。客户端需同时清除本地存储的Token。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "退出成功")
})
@PostMapping("/logout")
public R<Void> logout() {
authService.logout();
return R.ok();
}
}

View File

@ -0,0 +1,313 @@
package org.dromara.pangu.h5.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.domain.PgRegion;
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.PgRegionMapper;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
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基础数据接口
* <p>
* 提供H5端基础数据查询功能包括区域学校年级班级学科等
* 所有接口均无需Token认证
* </p>
*
* @author pangu
*/
@Tag(name = "H5基础数据接口", description = "提供区域、学校、年级、班级、学科等基础数据查询无需Token")
@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;
/**
* 获取区域树
*/
@Operation(
summary = "获取区域树",
description = """
获取省市区三级区域树形结构数据
**返回字段**
- regionId: 区域ID
- regionName: 区域名称
- regionCode: 区域编码
- level: 层级1省2市3区
- children: 子区域列表
**使用场景** 用于地区选择器如绑定学生时选择所在地区
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = List.class)))
})
@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);
}
/**
* 根据区域获取学校列表
*/
@Operation(
summary = "根据区域获取学校列表",
description = """
根据区域ID获取该区域下的学校列表
**返回字段**
- schoolId: 学校ID
- schoolName: 学校名称
- schoolCode: 学校编码
**调用顺序** 先调用 /regions 获取区域ID再调用本接口
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = List.class)))
})
@Parameters({
@Parameter(name = "regionId", description = "区域ID", required = true, in = ParameterIn.QUERY,
schema = @Schema(type = "integer", format = "int64"), example = "420100")
})
@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);
}
/**
* 根据学校获取年级列表
*/
@Operation(
summary = "根据学校获取年级列表",
description = """
根据学校ID获取该学校下的年级列表
**返回字段**
- schoolGradeId: 学校年级关联ID用于后续接口
- gradeId: 基础年级ID
- gradeName: 年级名称高一高二高三
**调用顺序** 先调用 /schools 获取学校ID再调用本接口
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = List.class)))
})
@Parameters({
@Parameter(name = "schoolId", description = "学校ID", required = true, in = ParameterIn.QUERY,
schema = @Schema(type = "integer", format = "int64"), example = "1")
})
@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);
}
/**
* 根据年级获取班级列表
*/
@Operation(
summary = "根据年级获取班级列表",
description = """
根据学校年级关联ID获取该年级下的班级列表
**返回字段**
- schoolClassId: 学校班级关联ID用于绑定学生等接口
- classId: 基础班级ID
- className: 班级名称1班2班3班
**调用顺序** 先调用 /grades 获取 schoolGradeId再调用本接口
**注意** 参数是 schoolGradeId学校年级关联ID不是 gradeId
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = List.class)))
})
@Parameters({
@Parameter(name = "schoolGradeId", description = "学校年级关联ID从/grades接口返回的schoolGradeId",
required = true, in = ParameterIn.QUERY,
schema = @Schema(type = "integer", format = "int64"), example = "1")
})
@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);
}
/**
* 获取学科列表
*/
@Operation(
summary = "获取学科列表",
description = """
获取所有可用的学科列表
**返回字段**
- subjectId: 学科ID
- subjectName: 学科名称语文数学英语
- subjectCode: 学科编码
**使用场景** 教师设置教育身份时选择任教学科
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = List.class)))
})
@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,282 @@
package org.dromara.pangu.h5.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
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会员接口
* <p>
* 提供H5端会员信息管理功能包括个人信息密码教育身份学生绑定等
* 所有接口均需要Token认证
* </p>
*
* @author pangu
*/
@Tag(name = "H5会员接口", description = "会员信息管理包括个人信息、密码、教育身份、学生绑定等需要Token认证")
@SecurityRequirement(name = "Authorization")
@Validated
@RestController
@RequestMapping("/h5/member")
@RequiredArgsConstructor
public class H5MemberController {
private final H5MemberService memberService;
/**
* 获取当前会员信息
*/
@Operation(
summary = "获取当前会员信息",
description = """
获取当前登录会员的详细信息
**返回内容包括**
- 基本信息会员ID编号手机号脱敏昵称头像性别生日注册时间
- 身份类型parent家长teacher教师
- 教育信息如果是教师身份包含学校年级班级学科信息
- 绑定学生当前会员绑定的所有学生列表
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = H5MemberInfoVo.class))),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@GetMapping("/info")
public R<H5MemberInfoVo> getInfo() {
return R.ok(memberService.getMemberInfo());
}
/**
* 修改会员信息
*/
@Operation(
summary = "修改会员基本信息",
description = """
修改当前会员的基本信息
**可修改字段**
- nickname: 昵称
- gender: 性别0未知1男2女
- birthday: 生日
**注意** 手机号不可修改
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "修改成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@PutMapping("/info")
public R<Void> updateInfo(@RequestBody H5MemberUpdateDto dto) {
memberService.updateMemberInfo(dto);
return R.ok();
}
/**
* 修改密码
*/
@Operation(
summary = "修改登录密码",
description = """
修改当前会员的登录密码
**要求**
- 需要验证当前密码
- 新密码至少6位
- 新密码和确认密码必须一致
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "修改成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "修改失败,可能原因:当前密码错误、两次密码不一致")
})
@PutMapping("/password")
public R<Void> updatePassword(@Valid @RequestBody H5PasswordUpdateDto dto) {
memberService.updatePassword(dto);
return R.ok();
}
/**
* 添加/修改教育身份
*/
@Operation(
summary = "设置教育身份(教师)",
description = """
设置或修改会员的教育身份信息设置后身份类型变为"教师"
**必填信息**
- 学校
- 年级
- 班级
- 学科
**数据获取**
- 学校列表GET /h5/base/schools?regionId=xxx
- 年级列表GET /h5/base/grades?schoolId=xxx
- 班级列表GET /h5/base/classes?schoolGradeId=xxx
- 学科列表GET /h5/base/subjects
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "设置成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "设置失败,可能原因:学校/年级/班级/学科不存在")
})
@PostMapping("/education")
public R<Void> saveEducation(@Valid @RequestBody H5EducationDto dto) {
memberService.saveEducation(dto);
return R.ok();
}
/**
* 获取教育身份
*/
@Operation(
summary = "获取教育身份信息",
description = "获取当前会员的教育身份信息学校、年级、班级、学科。如果未设置教育身份返回null。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = H5EducationVo.class))),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@GetMapping("/education")
public R<H5EducationVo> getEducation() {
return R.ok(memberService.getEducation());
}
/**
* 删除教育身份
*/
@Operation(
summary = "删除教育身份",
description = "清除当前会员的教育身份信息,身份类型将变为空。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "删除成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@DeleteMapping("/education")
public R<Void> deleteEducation() {
memberService.deleteEducation();
return R.ok();
}
/**
* 绑定学生
*/
@Operation(
summary = "绑定学生",
description = """
绑定一个学生到当前会员家长和教师身份均可绑定学生
**必填信息**
- 学生姓名
- 学号
- 出生日期
- 性别0未知1男2女
- 所在地区
- 学校
- 年级
- 班级
**注意**
- 一个会员可以绑定多个学生
- 首次绑定学生后如果会员无身份类型默认设为"家长"
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "绑定成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "绑定失败,可能原因:学校/年级/班级不存在")
})
@PostMapping("/student")
public R<Void> bindStudent(@Valid @RequestBody H5StudentBindDto dto) {
memberService.bindStudent(dto);
return R.ok();
}
/**
* 获取绑定的学生列表
*/
@Operation(
summary = "获取绑定的学生列表",
description = "获取当前会员绑定的所有学生信息,包含学校、年级、班级名称。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = H5StudentVo.class))),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@GetMapping("/students")
public R<List<H5StudentVo>> getStudents() {
return R.ok(memberService.getStudents());
}
/**
* 修改学生信息
*/
@Operation(
summary = "修改学生信息",
description = "修改指定学生的信息。只能修改当前会员绑定的学生。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "修改成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "修改失败,可能原因:学生不存在或无权限修改")
})
@Parameters({
@Parameter(name = "studentId", description = "学生ID", required = true, in = ParameterIn.PATH)
})
@PutMapping("/student/{studentId}")
public R<Void> updateStudent(@PathVariable Long studentId, @Valid @RequestBody H5StudentBindDto dto) {
memberService.updateStudent(studentId, dto);
return R.ok();
}
/**
* 解绑学生
*/
@Operation(
summary = "解绑学生",
description = "解除当前会员与指定学生的绑定关系。解绑后学生记录仍然保留,但不再关联到当前会员。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "解绑成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "解绑失败,可能原因:学生不存在或无权限操作")
})
@Parameters({
@Parameter(name = "studentId", description = "学生ID", required = true, in = ParameterIn.PATH)
})
@DeleteMapping("/student/{studentId}")
public R<Void> unbindStudent(@PathVariable Long studentId) {
memberService.unbindStudent(studentId);
return R.ok();
}
}

View File

@ -0,0 +1,31 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* H5教育身份请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "教育身份设置请求参数(教师身份)")
public class H5EducationDto {
@Schema(description = "学校ID从/h5/base/schools获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择学校")
private Long schoolId;
@Schema(description = "学校年级关联ID从/h5/base/grades获取的schoolGradeId", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择年级")
private Long schoolGradeId;
@Schema(description = "学校班级关联ID从/h5/base/classes获取的schoolClassId", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择班级")
private Long schoolClassId;
@Schema(description = "学科ID从/h5/base/subjects获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择学科")
private Long subjectId;
}

View File

@ -0,0 +1,25 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/**
* H5会员信息修改请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "会员信息修改请求参数")
public class H5MemberUpdateDto {
@Schema(description = "昵称", example = "张三")
private String nickname;
@Schema(description = "性别0-未知1-男2-女", example = "1", allowableValues = {"0", "1", "2"})
private String gender;
@Schema(description = "生日", example = "1990-01-15")
private Date birthday;
}

View File

@ -0,0 +1,36 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5密码登录请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "密码登录请求参数")
public class H5PasswordLoginDto {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotBlank(message = "验证码不能为空")
private String captchaCode;
@Schema(description = "图形验证码标识(从/captcha接口获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "验证码标识不能为空")
private String uuid;
@Schema(description = "记住我true时refreshToken有效期30天否则7天", example = "false")
private Boolean rememberMe = false;
}

View File

@ -0,0 +1,29 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* H5修改密码请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "修改密码请求参数")
public class H5PasswordUpdateDto {
@Schema(description = "当前密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "oldpass123")
@NotBlank(message = "当前密码不能为空")
private String oldPassword;
@Schema(description = "新密码至少6位", requiredMode = Schema.RequiredMode.REQUIRED, example = "newpass456")
@NotBlank(message = "新密码不能为空")
@Size(min = 6, message = "新密码至少6位")
private String newPassword;
@Schema(description = "确认新密码(必须与新密码一致)", requiredMode = Schema.RequiredMode.REQUIRED, example = "newpass456")
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}

View File

@ -0,0 +1,39 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* H5注册请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "会员注册请求参数")
public class H5RegisterDto {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "短信验证码6位数字", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "短信验证码不能为空")
private String smsCode;
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
@Schema(description = "图形验证码标识(从/captcha接口获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "验证码标识不能为空")
private String uuid;
@Schema(description = "登录密码至少6位", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码至少6位")
private String password;
}

View File

@ -0,0 +1,33 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5短信验证码登录请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "短信验证码登录请求参数")
public class H5SmsLoginDto {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "短信验证码6位数字", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "短信验证码不能为空")
private String smsCode;
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
@Schema(description = "图形验证码标识(从/captcha接口获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "验证码标识不能为空")
private String uuid;
}

View File

@ -0,0 +1,33 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* H5发送短信验证码请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "发送短信验证码请求参数")
public class H5SmsSendDto {
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
@Schema(description = "图形验证码标识(从/captcha接口获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "验证码标识不能为空")
private String uuid;
@Schema(description = "验证码类型login-登录验证码register-注册验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "login", allowableValues = {"login", "register"})
@NotBlank(message = "类型不能为空")
private String type;
}

View File

@ -0,0 +1,50 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Date;
/**
* H5绑定学生请求DTO
*
* @author pangu
*/
@Data
@Schema(description = "绑定学生请求参数")
public class H5StudentBindDto {
@Schema(description = "学生姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "张小明")
@NotBlank(message = "学生姓名不能为空")
private String studentName;
@Schema(description = "学号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024001")
@NotBlank(message = "学号不能为空")
private String studentNo;
@Schema(description = "出生日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2010-05-20")
@NotNull(message = "出生日期不能为空")
private Date birthday;
@Schema(description = "性别0-未知1-男2-女", requiredMode = Schema.RequiredMode.REQUIRED, example = "1", allowableValues = {"0", "1", "2"})
@NotBlank(message = "性别不能为空")
private String gender;
@Schema(description = "区域ID从/h5/base/regions获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "420100")
@NotNull(message = "请选择所在地区")
private Long regionId;
@Schema(description = "学校ID从/h5/base/schools获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择学校")
private Long schoolId;
@Schema(description = "学校年级关联ID从/h5/base/grades获取的schoolGradeId", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择年级")
private Long schoolGradeId;
@Schema(description = "学校班级关联ID从/h5/base/classes获取的schoolClassId", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择班级")
private Long schoolClassId;
}

View File

@ -0,0 +1,20 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* H5图形验证码响应VO
*
* @author pangu
*/
@Data
@Schema(description = "图形验证码响应结果")
public class H5CaptchaVo {
@Schema(description = "验证码标识(后续接口需要传递此值)", example = "abc123def456")
private String uuid;
@Schema(description = "验证码图片Base64编码前端使用<img src='data:image/png;base64,{captchaImg}'/>", example = "iVBORw0KGgoAAAANSUhEUgAA...")
private String captchaImg;
}

View File

@ -0,0 +1,38 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* H5教育身份响应VO
*
* @author pangu
*/
@Data
@Schema(description = "教育身份信息(教师)")
public class H5EducationVo {
@Schema(description = "学校ID", example = "1")
private Long schoolId;
@Schema(description = "学校名称", example = "武汉市第一中学")
private String schoolName;
@Schema(description = "学校年级关联ID", example = "1")
private Long schoolGradeId;
@Schema(description = "年级名称", example = "高一")
private String gradeName;
@Schema(description = "学校班级关联ID", example = "1")
private Long schoolClassId;
@Schema(description = "班级名称", example = "1班")
private String className;
@Schema(description = "学科ID", example = "1")
private Long subjectId;
@Schema(description = "学科名称", example = "语文")
private String subjectName;
}

View File

@ -0,0 +1,38 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* H5登录响应VO
*
* @author pangu
*/
@Data
@Schema(description = "登录响应结果")
public class H5LoginVo {
@Schema(description = "访问令牌用于请求需要认证的接口放在Header: Authorization: Bearer {token}", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...")
private String accessToken;
@Schema(description = "刷新令牌用于刷新accessTokenaccessToken过期时使用", example = "abc123def456ghi789")
private String refreshToken;
@Schema(description = "accessToken过期时间默认2小时=7200秒", example = "7200")
private Long expiresIn;
@Schema(description = "会员ID", example = "1234567890123456789")
private Long memberId;
@Schema(description = "会员编号", example = "M170900000012345678")
private String memberCode;
@Schema(description = "手机号脱敏中间4位用*替代)", example = "138****5678")
private String phone;
@Schema(description = "昵称", example = "user_5678")
private String nickname;
@Schema(description = "身份类型1-家长2-教师null-未设置", example = "1")
private String identityType;
}

View File

@ -0,0 +1,50 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* H5会员信息响应VO
*
* @author pangu
*/
@Data
@Schema(description = "会员信息响应结果")
public class H5MemberInfoVo {
@Schema(description = "会员ID", example = "1234567890123456789")
private Long memberId;
@Schema(description = "会员编号", example = "M170900000012345678")
private String memberCode;
@Schema(description = "手机号(脱敏)", example = "138****5678")
private String phone;
@Schema(description = "昵称", example = "张老师")
private String nickname;
@Schema(description = "头像URL", example = "https://example.com/avatar.jpg")
private String avatar;
@Schema(description = "性别0-未知1-男2-女", example = "1")
private String gender;
@Schema(description = "生日", example = "1990-01-15")
private Date birthday;
@Schema(description = "注册时间", example = "2024-01-01 12:00:00")
private Date registerTime;
@Schema(description = "身份类型1-家长2-教师", example = "2")
private String identityType;
@Schema(description = "教育身份信息(仅教师身份有值)")
private H5EducationVo education;
@Schema(description = "绑定的学生列表")
private List<H5StudentVo> students;
}

View File

@ -0,0 +1,55 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/**
* H5学生信息响应VO
*
* @author pangu
*/
@Data
@Schema(description = "学生信息")
public class H5StudentVo {
@Schema(description = "学生ID", example = "1234567890123456789")
private Long studentId;
@Schema(description = "学生姓名", example = "张小明")
private String studentName;
@Schema(description = "学号", example = "2024001")
private String studentNo;
@Schema(description = "性别0-未知1-男2-女", example = "1")
private String gender;
@Schema(description = "出生日期", example = "2010-05-20")
private Date birthday;
@Schema(description = "区域ID", example = "420100")
private Long regionId;
@Schema(description = "区域名称(如:湖北省武汉市武昌区)", example = "湖北省武汉市武昌区")
private String regionName;
@Schema(description = "学校ID", example = "1")
private Long schoolId;
@Schema(description = "学校名称", example = "武汉市第一中学")
private String schoolName;
@Schema(description = "学校年级关联ID", example = "1")
private Long schoolGradeId;
@Schema(description = "年级名称", example = "高一")
private String gradeName;
@Schema(description = "学校班级关联ID", example = "1")
private Long schoolClassId;
@Schema(description = "班级名称", example = "1班")
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 pangu
*/
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 pangu
*/
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,527 @@
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.ServletUtils;
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.config.H5SmsProperties;
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.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.LinkedHashMap;
/**
* H5认证服务实现
*
* @author pangu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class H5AuthServiceImpl implements H5AuthService {
private final PgMemberMapper memberMapper;
private final H5SmsProperties smsProperties;
/**
* H5会员登录设备标识
*/
private static final String H5_DEVICE = "h5";
/**
* Redis Key 前缀
*/
private static final String H5_SMS_CODE_KEY = "h5:sms:code:";
private static final String H5_SMS_DAILY_PHONE_KEY = "h5:sms:daily:phone:";
private static final String H5_SMS_DAILY_IP_KEY = "h5:sms:daily:ip:";
private static final String H5_SMS_MINUTE_IP_KEY = "h5:sms:minute:ip:";
private static final String H5_SMS_BLACKLIST_KEY = "h5:sms:blacklist:";
private static final String H5_SMS_FAIL_COUNT_KEY = "h5:sms:fail:";
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
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();
String clientIp = getClientIp();
// 校验类型
if (!"login".equals(type) && !"register".equals(type)) {
throw new ServiceException("验证码类型错误");
}
// ========== 防刷检查 ==========
// 1. 检查手机号黑名单
if (isPhoneBlacklisted(phone)) {
throw new ServiceException("操作过于频繁,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
}
// 2. 检查IP黑名单
if (isIpBlacklisted(clientIp)) {
throw new ServiceException("操作过于频繁,请稍后再试");
}
// 3. 检查发送间隔
String codeKey = H5_SMS_CODE_KEY + type + ":" + phone;
if (RedisUtils.hasKey(codeKey)) {
Long ttl = RedisUtils.getTimeToLive(codeKey);
long intervalCheck = (smsProperties.getCodeExpireMinutes() * 60L) - smsProperties.getSendIntervalSeconds();
if (ttl != null && ttl > intervalCheck) {
throw new ServiceException("验证码发送过于频繁,请" + smsProperties.getSendIntervalSeconds() + "秒后再试");
}
}
// 4. 检查手机号每日上限
String dailyPhoneKey = H5_SMS_DAILY_PHONE_KEY + getTodayStr() + ":" + phone;
Integer phoneCount = RedisUtils.getCacheObject(dailyPhoneKey);
if (phoneCount != null && phoneCount >= smsProperties.getDailyLimitPerPhone()) {
throw new ServiceException("今日发送次数已达上限,请明天再试");
}
// 5. 检查IP每分钟上限
String minuteIpKey = H5_SMS_MINUTE_IP_KEY + clientIp;
Integer minuteIpCount = RedisUtils.getCacheObject(minuteIpKey);
if (minuteIpCount != null && minuteIpCount >= smsProperties.getMinuteLimitPerIp()) {
throw new ServiceException("请求过于频繁,请稍后再试");
}
// 6. 检查IP每日上限
String dailyIpKey = H5_SMS_DAILY_IP_KEY + getTodayStr() + ":" + clientIp;
Integer dailyIpCount = RedisUtils.getCacheObject(dailyIpKey);
if (dailyIpCount != null && dailyIpCount >= smsProperties.getDailyLimitPerIp()) {
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("该手机号未注册");
}
}
// ========== 发送短信 ==========
// 生成验证码
String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
// 存入Redis
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
// 更新计数器
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
incrementCounter(minuteIpKey, Duration.ofMinutes(1));
incrementCounter(dailyIpKey, Duration.ofDays(1));
// 发送短信
if (smsProperties.isEnabled()) {
try {
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
String templateId = "login".equals(type) ? smsProperties.getLoginTemplateId() : smsProperties.getRegisterTemplateId();
SmsResponse smsResponse;
if (StringUtils.isNotBlank(templateId)) {
smsResponse = smsBlend.sendMessage(phone, templateId, map);
} else {
smsResponse = smsBlend.sendMessage(phone, map);
}
if (!smsResponse.isSuccess()) {
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
throw new ServiceException("短信发送失败,请稍后重试");
}
log.info("短信发送成功: phone={}, type={}, ip={}", phone, type, clientIp);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试");
}
} else {
// 测试模式打印验证码到日志
log.info("【测试模式】短信验证码: phone={}, code={}, type={}", phone, code, type);
}
}
@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;
// 生成H5专用clientId
String h5ClientId = "h5_" + member.getMemberId();
StpUtil.login(member.getMemberId(), new SaLoginModel()
.setDevice(H5_DEVICE)
.setTimeout(ACCESS_TOKEN_EXPIRE)
// 设置extra信息避免拦截器NPE
.setExtra("tenantId", "000000")
.setExtra("userId", member.getMemberId())
.setExtra("userName", member.getNickname())
.setExtra("deptId", null)
.setExtra("deptName", null)
.setExtra("deptCategory", null)
.setExtra("clientid", h5ClientId)
);
// 生成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 failKey = H5_SMS_FAIL_COUNT_KEY + phone;
String smsCode = RedisUtils.getCacheObject(codeKey);
if (smsCode == null) {
throw new ServiceException("短信验证码已过期");
}
if (!smsCode.equals(code)) {
// 记录失败次数
Integer failCount = incrementCounter(failKey, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
if (failCount >= smsProperties.getBlacklistTriggerCount()) {
// 加入黑名单
addToBlacklist(phone);
RedisUtils.deleteObject(failKey);
throw new ServiceException("验证码错误次数过多,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
}
throw new ServiceException("短信验证码错误");
}
// 验证成功后删除
RedisUtils.deleteObject(codeKey);
RedisUtils.deleteObject(failKey);
}
/**
* 检查手机号是否在黑名单
*/
private boolean isPhoneBlacklisted(String phone) {
return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "phone:" + phone);
}
/**
* 检查IP是否在黑名单
*/
private boolean isIpBlacklisted(String ip) {
return RedisUtils.hasKey(H5_SMS_BLACKLIST_KEY + "ip:" + ip);
}
/**
* 将手机号加入黑名单
*/
private void addToBlacklist(String phone) {
RedisUtils.setCacheObject(
H5_SMS_BLACKLIST_KEY + "phone:" + phone,
"1",
Duration.ofMinutes(smsProperties.getBlacklistMinutes())
);
log.warn("手机号加入黑名单: phone={}, minutes={}", phone, smsProperties.getBlacklistMinutes());
}
/**
* 增加计数器
*/
private Integer incrementCounter(String key, Duration duration) {
Integer count = RedisUtils.getCacheObject(key);
if (count == null) {
count = 1;
} else {
count++;
}
RedisUtils.setCacheObject(key, count, duration);
return count;
}
/**
* 获取今日日期字符串
*/
private String getTodayStr() {
return LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
}
/**
* 获取客户端IP
*/
private String getClientIp() {
try {
return ServletUtils.getClientIP();
} catch (Exception e) {
return "unknown";
}
}
/**
* 生成会员编号
*/
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 pangu
*/
@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,396 @@
# H5 会员管理接口需求与技术方案
> 作者:湖北新华业务中台研发团队
> 创建时间2026-02-02
> 版本v1.1
---
## 一、需求概述
为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接口模块
│ ├── config/
│ │ └── H5SmsProperties.java # 短信防刷配置类
│ ├── 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` 模块(版本 3.3.5),集成阿里云短信服务。
#### 阿里云账号信息
| 配置项 | 值 | 说明 |
|--------|-----|------|
| AccessKey ID | `LTAI5tQLorTxf9Fzzh93pfGN` | 阿里云访问密钥 |
| AccessKey Secret | `NhBZGwac5vvWg60R0Y4UndTsKK4zuh` | 阿里云密钥 |
| Endpoint | `dysmsapi.aliyuncs.com` | 短信API地址 |
| 签名 | `湖北新华教育服务平台` | 短信签名(已审核通过) |
#### 短信模板
| 模板用途 | 模板ID | 模板内容 |
|----------|--------|----------|
| 登录验证码 | `SMS_461020580` | 【湖北新华教育服务平台】您正在申请登录,验证码为:${code} |
| 注册验证码 | `SMS_473140005` | 【湖北新华教育服务平台】您正在申请注册,验证码为:${code} |
| 重置密码 | `SMS_473130008` | 用于忘记密码(备用) |
#### 阿里云配置application-dev.yml
```yaml
sms:
config-type: yaml
restricted: true
minute-max: 1
account-max: 30
blends:
alibaba:
supplier: alibaba
access-key-id: LTAI5tQLorTxf9Fzzh93pfGN
access-key-secret: NhBZGwac5vvWg60R0Y4UndTsKK4zuh
signature: 湖北新华教育服务平台
```
#### H5短信配置application.yml
```yaml
h5:
sms:
# 是否真正发送短信开发测试设false生产设true
enabled: true
sms-config-name: alibaba
# 短信模板ID
login-template-id: SMS_461020580
register-template-id: SMS_473140005
# 验证码配置
code-length: 6
code-expire-minutes: 5
# 防刷策略
send-interval-seconds: 60
daily-limit-per-phone: 10
minute-limit-per-ip: 5
daily-limit-per-ip: 50
blacklist-minutes: 30
blacklist-trigger-count: 5
```
#### 防刷策略
| 层级 | 策略 | 配置项 | 默认值 |
|------|------|--------|--------|
| 前置 | 图形验证码校验 | - | 必须 |
| 手机号 | 黑名单检查 | `blacklist-minutes` | 30分钟 |
| 手机号 | 发送间隔限制 | `send-interval-seconds` | 60秒 |
| 手机号 | 每日发送上限 | `daily-limit-per-phone` | 10条 |
| IP | 黑名单检查 | `blacklist-minutes` | 30分钟 |
| IP | 每分钟发送上限 | `minute-limit-per-ip` | 5条 |
| IP | 每日发送上限 | `daily-limit-per-ip` | 50条 |
| 验证 | 连续失败自动封禁 | `blacklist-trigger-count` | 5次 |
#### 验证码发送流程
1. 校验图形验证码
2. 检查手机号是否在黑名单
3. 检查IP是否在黑名单
4. 检查发送间隔60秒
5. 检查手机号每日上限
6. 检查IP每分钟上限
7. 检查IP每日上限
8. 业务校验(注册:手机号未注册;登录:手机号已注册)
9. 生成验证码并存入Redis
10. 更新各项计数器
11. 调用阿里云API发送测试模式仅打印日志
#### Redis Key 设计
| Key | 说明 | TTL |
|-----|------|-----|
| `h5:sms:code:{type}:{phone}` | 验证码 | 5分钟 |
| `h5:sms:daily:phone:{date}:{phone}` | 手机号每日计数 | 1天 |
| `h5:sms:minute:ip:{ip}` | IP每分钟计数 | 1分钟 |
| `h5:sms:daily:ip:{date}:{ip}` | IP每日计数 | 1天 |
| `h5:sms:blacklist:phone:{phone}` | 手机号黑名单 | 30分钟 |
| `h5:sms:blacklist:ip:{ip}` | IP黑名单 | 30分钟 |
| `h5:sms:fail:{phone}` | 验证失败计数 | 5分钟 |
### 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*

View File

@ -0,0 +1,705 @@
# H5 接口测试报告
> 测试时间2026-02-02
> 测试人员pangu
> 测试手机号15889762069
---
## 一、测试环境
| 项目 | 值 |
|------|-----|
| 后端地址 | http://localhost:8080 |
| 短信模式 | enabled: true |
| 测试手机号 | 15889762069 |
---
## 二、测试用例执行
### B1: 获取区域树
**请求:**
```
GET /h5/base/regions
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": [
{
"regionCode": null,
"regionId": "2018256469153099778",
"level": 1,
"regionName": "\u5317\u4eac\u5e02"
},
{
"regionCode": "420000",
"regionId": 420000,
"level": 1,
"children": [
{
"regionCode": "420100",
"regionId": 420100,
"level": 2,
"children": [
{
"regionCode": "420102",
"regionId": 420102,
"level": 3,
"regionName": "\u6c5f\u5cb8\u533a"
},
{
"regionCode": "420103",
"regionId": 420103,
"level": 3,
"regionName": "\u6c5f\u6c49\u533a"
},
{
"regionCode": "420104",
"regionId": 420104,
"level": 3,
"regionName": "\u785a\u53e3\u533a"
}
],
"regionName": "\u6b66\u6c49\u5e02"
}
],
"regionName": "\u6e56\u5317\u7701"
}
]
}
```
**结果:** ✅ 通过
---
### B2: 获取学校列表
**请求:**
```
GET /h5/base/schools?regionId=2018256469153099778
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": []
}
```
**结果:** ✅ 通过
---
### B3: 获取年级列表
**请求:**
```
GET /h5/base/grades?schoolId=
```
**响应:**
```json
{
"code": 500,
"msg": "Required request parameter 'schoolId' for method parameter type Long is present but converted to null",
"data": null
}
```
**结果:** ❌ 失败
---
### B2: 获取学校列表(修正)
**请求:**
```
GET /h5/base/schools?regionId=420100
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": [
{
"schoolId": 5,
"schoolName": "\u534e\u4e2d\u79d1\u6280\u5927\u5b66\u9644\u5c5e\u4e2d\u5b66",
"schoolCode": "WHDX001"
}
]
}
```
**结果:** ✅ 通过
---
### B3: 获取年级列表
**请求:**
```
GET /h5/base/grades?schoolId=5
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": [
{
"gradeName": "\u9ad8\u4e00",
"gradeId": 10,
"schoolGradeId": 19
},
{
"gradeName": "\u9ad8\u4e8c",
"gradeId": 11,
"schoolGradeId": 20
},
{
"gradeName": "\u9ad8\u4e09",
"gradeId": 12,
"schoolGradeId": 21
}
]
}
```
**结果:** ✅ 通过
---
### B4: 获取班级列表
**请求:**
```
GET /h5/base/classes?schoolGradeId=19
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": []
}
```
**结果:** ✅ 通过
---
### B5: 获取学科列表
**请求:**
```
GET /h5/base/subjects
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": [
{
"subjectCode": "SUB001",
"subjectId": 1,
"subjectName": "\u8bed\u6587"
},
{
"subjectCode": "SUB002",
"subjectId": 2,
"subjectName": "\u6570\u5b66"
},
{
"subjectCode": "SUB003",
"subjectId": 3,
"subjectName": "\u82f1\u8bed"
},
{
"subjectCode": "SUB004",
"subjectId": 4,
"subjectName": "\u7269\u7406"
},
{
"subjectCode": "SUB005",
"subjectId": 5,
"subjectName": "\u5316\u5b66"
},
{
"subjectCode": "SUB007",
"subjectId": 7,
"subjectName": "\u5386\u53f2"
},
{
"subjectCode": "SUB008",
"subjectId": 8,
"subjectName": "\u5730\u7406"
},
{
"subjectCode": "SUB009",
"subjectId": 9,
"subjectName": "\u653f\u6cbb"
}
]
}
```
**结果:** ✅ 通过
---
### A1: 获取图形验证码
**请求:**
```
GET /h5/auth/captcha
```
**响应:**
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"uuid": "9c9447e9ffd445d2b039b05ef9fee639",
"captchaImg": "iVBORw0KGgoAAAANSUhEUgAAAKAAAAA8CAYAAADha7EVAAAL+U...(Base64图片已截断)"
}
}
```
**结果:** ✅ 通过
---
### A2: 发送注册短信验证码
**请求:**
```
POST /h5/auth/sms/send
Content-Type: application/json
{
"phone": "15889762069",
"captchaCode": "5",
"uuid": "e091ad4af7f349edaaf1cdcad8764176",
"type": "register"
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": null
}
```
**结果:** ✅ 通过(短信已发送)
---
### A3: 用户注册
**步骤:**
1. 从Redis获取短信验证码: 395910
2. 获取新的图形验证码
3. 执行注册
**请求:**
```
POST /h5/auth/register
Content-Type: application/json
{
"phone": "15889762069",
"smsCode": "395910",
"captchaCode": "0",
"uuid": "a7a535e63ac6487594a0d19fd56990d4",
"password": "Test123456"
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjIwMTgzMjQ5ODQ3MTUzMTMxNTMsInJuU3RyIjoiaW9BUEJWdXpzOFVoZnMxcU9WSm9lNUlCcDY2Wm41T0wifQ.nE-NCSIgQV_4dDk3AT9BVJj2aMLzjkx3o7hoQjtRSBI",
"refreshToken": "58d9711f8b8c4d7faba360a583e7e8be",
"expiresIn": 7200,
"memberId": "2018324984715313153",
"memberCode": "M17700411496756880",
"phone": "158****2069",
"nickname": "user_2069",
"identityType": null
}
}
```
**结果:** ✅ 通过(注册成功)
---
### A4: 发送登录短信验证码
**请求:**
```
POST /h5/auth/sms/send
Content-Type: application/json
{
"phone": "15889762069",
"captchaCode": "5",
"uuid": "532fe00e25cd44bfaeeb9879f1ece68b",
"type": "login"
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": null
}
```
**结果:** ✅ 通过
---
### A5: 短信验证码登录
**请求:**
```
POST /h5/auth/login/sms
Content-Type: application/json
{
"phone": "15889762069",
"smsCode": "836517",
"captchaCode": "4",
"uuid": "d9859b1f4df5429e971e6cd90bb72409"
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjIwMTgzMjQ5ODQ3MTUzMTMxNTMsInJuU3RyIjoiUWJpWFZDNkNxbFJRV2JRT2lqY1hXd0VlMmJHRXZUWlYifQ.RTwB9OdnUwpieg4wqtzGRvE0Sd1bAcVu3DbbXwqmm-A",
"refreshToken": "11523f0716bc4fbf8e173ea7ce461d86",
"expiresIn": 7200,
"memberId": "2018324984715313153",
"memberCode": "M17700411496756880",
"phone": "158****2069",
"nickname": "user_2069",
"identityType": "1"
}
}
```
**结果:** ✅ 通过
---
### A6: 密码登录
**请求:**
```
POST /h5/auth/login/password
Content-Type: application/json
{
"phone": "15889762069",
"password": "Test123456",
"captchaCode": "2",
"uuid": "63dfd908814d496e88bbe6cd3ca24b52",
"rememberMe": false
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjIwMTgzMjQ5ODQ3MTUzMTMxNTMsInJuU3RyIjoicVZTWE51Y1BZbnV2TDZzMVREc2kwSnBRWGQ5OFd2RHYifQ.pmAmEH_Z-eJSA8edo5q0vbBS64ix4wx1f8p--Ph23i4",
"refreshToken": "bce1eed54c73426f8bb7519bd10f8e35",
"expiresIn": 7200,
"memberId": "2018324984715313153",
"memberCode": "M17700411496756880",
"phone": "158****2069",
"nickname": "user_2069",
"identityType": "1"
}
}
```
**结果:** ✅ 通过
---
### M1: 获取会员信息
**请求:**
```
GET /h5/member/info
Authorization: Bearer {accessToken}
```
**响应:**
```json
{
"code": 500,
"msg": "Cannot invoke \"Object.toString()\" because the return value of \"cn.dev33.satoken.stp.StpUtil.getExtra(String)\" is null",
"data": null
}
```
**结果:** ⚠️ 请求完成
---
### M1: 获取会员信息(修复后)
**请求:**
```
GET /h5/member/info
Authorization: Bearer {accessToken}
```
**响应:**
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"memberId": "2018324984715313153",
"memberCode": "M17700411496756880",
"phone": "158****2069",
"nickname": "user_2069",
"avatar": null,
"gender": "0",
"birthday": null,
"registerTime": "2026-02-02 22:05:50",
"identityType": "1",
"education": null,
"students": []
}
}
```
**结果:** ✅ 通过
---
### M2: 修改会员信息
**请求:**
```
PUT /h5/member/info
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"nickname": "测试用户",
"gender": "1"
}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": null
}
```
**结果:** ✅ 通过
---
### M6: 绑定学生
**请求:**
```
POST /h5/member/student
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"studentName": "测试学生",
"studentNo": "TEST001",
"birthday": "2015-06-15",
"gender": "1",
"regionId": 420100,
"schoolId": 5,
"schoolGradeId": 19,
"schoolClassId": 55
}
```
**响应:**
```json
{
"code": 500,
"msg": "\u73ed\u7ea7\u4e0d\u5b58\u5728\u6216\u4e0d\u5c5e\u4e8e\u8be5\u5e74\u7ea7",
"data": null
}
```
**结果:** ⚠️ 请求完成
---
> **注意**: 当前测试学校(华中科技大学附属中学)下的年级没有配置班级数据,绑定学生功能无法完整测试。
---
### M7: 获取绑定的学生列表
**请求:**
```
GET /h5/member/students
Authorization: Bearer {accessToken}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": []
}
```
**结果:** ✅ 通过(当前无绑定学生)
---
### A7: 刷新Token
**请求:**
```
POST /h5/auth/refresh?refreshToken={refreshToken}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOjIwMTgzMjQ5ODQ3MTUzMTMxNTMsInJuU3RyIjoiYUZ0MEFTelFiSzZGMFZMbmhDY2dBMFJ2TENwZERLM24iLCJ0ZW5hbnRJZCI6IjAwMDAwMCIsInVzZXJJZCI6MjAxODMyNDk4NDcxNTMxMzE1MywidXNlck5hbWUiOiLmtYvor5XnlKjmiLciLCJjbGllbnRpZCI6Img1XzIwMTgzMjQ5ODQ3MTUzMTMxNTMifQ.OmeWh0BaYKi2YFVHp3I0akq50zgaCCIS_a376ciI-00",
"refreshToken": "88f94e5f264e4f2f8f9bf9f4a3ba0b58",
"expiresIn": 7200,
"memberId": "2018324984715313153",
"memberCode": "M17700411496756880",
"phone": "158****2069",
"nickname": "\u6d4b\u8bd5\u7528\u6237",
"identityType": "1"
}
}
```
**结果:** ✅ 通过
---
### A8: 退出登录
**请求:**
```
POST /h5/auth/logout
Authorization: Bearer {accessToken}
```
**响应:**
```json
{
"code": 200,
"msg": "\u64cd\u4f5c\u6210\u529f",
"data": null
}
```
**结果:** ✅ 通过
---
## 三、测试总结
### 测试结果统计
| 模块 | 通过 | 失败 | 备注 |
|------|------|------|------|
| 基础数据B1-B5 | 5 | 0 | 全部通过 |
| 认证模块A1-A8 | 8 | 0 | 全部通过 |
| 会员模块M1-M7 | 3 | 1 | M6绑定学生因数据缺失未完整测试 |
### 测试覆盖
- ✅ 图形验证码获取
- ✅ 短信验证码发送(阿里云)
- ✅ 用户注册
- ✅ 密码登录
- ✅ 短信验证码登录
- ✅ Token刷新
- ✅ 退出登录
- ✅ 获取会员信息
- ✅ 修改会员信息
- ✅ 获取绑定学生列表
- ⚠️ 绑定学生(需要完整的学校-年级-班级数据)
### 问题修复
在测试过程中发现并修复了以下问题:
1. **H5登录Token校验问题**
- 问题H5登录后访问需认证接口报500错误
- 原因SecurityConfig拦截器检查clientId时H5登录未设置extra信息导致NPE
- 修复在H5AuthServiceImpl中设置extra信息并在SecurityConfig中跳过H5设备的clientId校验
---
## 四、Redis验证码获取方式
```bash
# 连接Redis数据库2
redis-cli -h 8.148.25.55 -a aly2024A -n 2
# 获取图形验证码
GET global:captcha_codes:{uuid}
# 获取注册短信验证码
GET h5:sms:code:register:15889762069
# 获取登录短信验证码
GET h5:sms:code:login:15889762069
```
---
*测试完成时间: 2026-02-02*