Compare commits
8 Commits
e8c4f3f568
...
dcadd41e2b
| Author | SHA1 | Date |
|---|---|---|
|
|
dcadd41e2b | |
|
|
1ae754e56c | |
|
|
e98fe86124 | |
|
|
e13c9a7de3 | |
|
|
0b35da7f85 | |
|
|
a47536315b | |
|
|
5fe9d1117f | |
|
|
905e263ca8 |
|
|
@ -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: 湖北新华教育服务平台
|
||||
|
||||
|
||||
--- # 三方授权
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
// 有效率影响 用于临时测试
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = "刷新令牌(用于刷新accessToken,accessToken过期时使用)", 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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、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. 校验手机号未注册<br>2. 校验验证码<br>3. 创建会员账号<br>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. 创建学生记录<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 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接口模块
|
||||
│ ├── 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*
|
||||
|
|
@ -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*
|
||||
Loading…
Reference in New Issue