Compare commits
2 Commits
f0d8a6c571
...
729f2c71f1
| Author | SHA1 | Date |
|---|---|---|
|
|
729f2c71f1 | |
|
|
acdade6725 |
|
|
@ -314,3 +314,27 @@ h5:
|
|||
blacklist-minutes: 30
|
||||
# 连续验证失败多少次后加入黑名单
|
||||
blacklist-trigger-count: 5
|
||||
|
||||
# ============================================================
|
||||
# H5微信扫码登录配置
|
||||
# 微信开放平台: https://open.weixin.qq.com/
|
||||
# 需要创建"网站应用"并获取AppID和AppSecret
|
||||
# ============================================================
|
||||
wechat:
|
||||
# ---------- 基础配置 ----------
|
||||
# 是否启用微信登录
|
||||
enabled: false
|
||||
# 微信开放平台AppID(网站应用)
|
||||
app-id: ${H5_WECHAT_APP_ID:your-app-id}
|
||||
# 微信开放平台AppSecret
|
||||
app-secret: ${H5_WECHAT_APP_SECRET:your-app-secret}
|
||||
|
||||
# ---------- 二维码配置 ----------
|
||||
# 二维码有效期(秒),默认5分钟
|
||||
qrcode-expire-seconds: 300
|
||||
|
||||
# ---------- 绑定配置 ----------
|
||||
# 绑定凭证有效期(分钟),默认10分钟
|
||||
bind-token-expire-minutes: 10
|
||||
# 首次登录是否自动注册会员(需绑定手机号后注册)
|
||||
auto-register: true
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ public class PgRegionController extends BaseController {
|
|||
return R.ok(regionService.selectById(regionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域路径(从根到当前区域的ID列表)
|
||||
* 用于级联选择器回显
|
||||
*/
|
||||
@GetMapping("/{regionId}/path")
|
||||
public R<List<Long>> getRegionPath(@PathVariable Long regionId) {
|
||||
return R.ok(regionService.getRegionPath(regionId));
|
||||
}
|
||||
|
||||
@SaCheckPermission("business:region:add")
|
||||
@Log(title = "区域管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
package org.dromara.pangu.base.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 教育信息表(会员教育身份)
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("pg_education")
|
||||
public class PgEducation extends BaseEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long educationId;
|
||||
|
||||
private Long memberId;
|
||||
|
||||
private Long regionId;
|
||||
|
||||
private Long schoolId;
|
||||
|
||||
private Long schoolGradeId;
|
||||
|
||||
private Long schoolClassId;
|
||||
|
||||
private Long subjectId;
|
||||
|
||||
/**
|
||||
* 是否默认身份(0否 1是)
|
||||
*/
|
||||
private String isDefault;
|
||||
|
||||
/**
|
||||
* 状态(0正常 1停用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
private String tenantId;
|
||||
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
// 非数据库字段(用于VO展示)
|
||||
@TableField(exist = false)
|
||||
private String regionName;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String schoolName;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String gradeName;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String className;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String subjectName;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package org.dromara.pangu.base.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.pangu.base.domain.PgEducation;
|
||||
|
||||
/**
|
||||
* 教育信息 Mapper 接口
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
public interface PgEducationMapper extends BaseMapperPlus<PgEducation, PgEducation> {
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ public interface IPgRegionService {
|
|||
List<PgRegion> selectTree();
|
||||
PgRegion selectById(Long regionId);
|
||||
List<PgRegion> selectByParentId(Long parentId);
|
||||
List<Long> getRegionPath(Long regionId);
|
||||
int insert(PgRegion region);
|
||||
int update(PgRegion region);
|
||||
int deleteByIds(Long[] regionIds);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ public class PgRegionServiceImpl implements IPgRegionService {
|
|||
.orderByAsc(PgRegion::getOrderNum));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> getRegionPath(Long regionId) {
|
||||
if (regionId == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
PgRegion region = baseMapper.selectById(regionId);
|
||||
if (region == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Long> path = new ArrayList<>();
|
||||
// ancestors 格式: "0,42,4201" 表示从根到父的路径
|
||||
if (StrUtil.isNotBlank(region.getAncestors())) {
|
||||
String[] ancestorIds = region.getAncestors().split(",");
|
||||
for (String id : ancestorIds) {
|
||||
if (StrUtil.isNotBlank(id) && !"0".equals(id)) {
|
||||
path.add(Long.parseLong(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 最后加上当前区域ID
|
||||
path.add(regionId);
|
||||
return path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insert(PgRegion region) {
|
||||
return baseMapper.insert(region);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package org.dromara.pangu.h5.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* H5微信登录配置属性
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "h5.wechat")
|
||||
public class H5WechatProperties {
|
||||
|
||||
/**
|
||||
* 是否启用微信登录
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 微信开放平台AppID(网站应用)
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 微信开放平台AppSecret
|
||||
*/
|
||||
private String appSecret;
|
||||
|
||||
/**
|
||||
* 二维码有效期(秒),默认5分钟
|
||||
*/
|
||||
private int qrcodeExpireSeconds = 300;
|
||||
|
||||
/**
|
||||
* 绑定凭证有效期(分钟),默认10分钟
|
||||
*/
|
||||
private int bindTokenExpireMinutes = 10;
|
||||
|
||||
/**
|
||||
* 首次登录是否自动注册会员
|
||||
*/
|
||||
private boolean autoRegister = true;
|
||||
}
|
||||
|
|
@ -13,12 +13,8 @@ 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.domain.dto.*;
|
||||
import org.dromara.pangu.h5.domain.vo.*;
|
||||
import org.dromara.pangu.h5.service.H5AuthService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
|
@ -230,4 +226,165 @@ public class H5AuthController {
|
|||
authService.logout();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// ==================== 微信扫码登录接口 ====================
|
||||
|
||||
/**
|
||||
* 获取微信扫码登录二维码
|
||||
*/
|
||||
@Operation(
|
||||
summary = "获取微信扫码登录二维码",
|
||||
description = """
|
||||
生成微信扫码登录二维码。
|
||||
|
||||
**使用流程:**
|
||||
1. 调用本接口获取二维码URL和ticket
|
||||
2. 前端将qrcodeUrl展示为二维码(可使用iframe或img)
|
||||
3. 前端轮询 /wechat/status/{ticket} 查询扫码状态
|
||||
4. 状态为confirmed时,调用 /wechat/login 完成登录
|
||||
|
||||
**二维码有效期:** 默认5分钟
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "获取成功",
|
||||
content = @Content(schema = @Schema(implementation = WechatQrcodeVo.class))),
|
||||
@ApiResponse(responseCode = "500", description = "微信登录功能未启用")
|
||||
})
|
||||
@GetMapping("/wechat/qrcode")
|
||||
public R<WechatQrcodeVo> getWechatQrcode() {
|
||||
return R.ok(authService.getWechatQrcode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询微信扫码状态
|
||||
*/
|
||||
@Operation(
|
||||
summary = "查询微信扫码状态",
|
||||
description = """
|
||||
轮询查询用户扫码状态。
|
||||
|
||||
**状态说明:**
|
||||
- waiting: 等待扫码
|
||||
- scanned: 已扫码,等待用户确认
|
||||
- confirmed: 用户已确认,可以登录
|
||||
- expired: 二维码已过期
|
||||
|
||||
**轮询建议:** 每2秒查询一次
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "查询成功",
|
||||
content = @Content(schema = @Schema(implementation = WechatStatusVo.class)))
|
||||
})
|
||||
@Parameters({
|
||||
@Parameter(name = "ticket", description = "二维码凭证", required = true, in = ParameterIn.PATH)
|
||||
})
|
||||
@GetMapping("/wechat/status/{ticket}")
|
||||
public R<WechatStatusVo> getWechatStatus(@PathVariable String ticket) {
|
||||
return R.ok(authService.getWechatStatus(ticket));
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信扫码登录
|
||||
*/
|
||||
@Operation(
|
||||
summary = "微信扫码登录",
|
||||
description = """
|
||||
用户扫码确认后,调用此接口完成登录。
|
||||
|
||||
**返回说明:**
|
||||
- needBindPhone=false: 登录成功,返回Token
|
||||
- needBindPhone=true: 需要绑定手机号,返回bindToken
|
||||
|
||||
**绑定手机号流程:**
|
||||
1. 获取bindToken
|
||||
2. 调用 /captcha 获取图形验证码
|
||||
3. 调用 /wechat/sms/send 发送短信验证码
|
||||
4. 调用 /wechat/bindPhone 绑定手机号并登录
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "登录成功或需要绑定手机号",
|
||||
content = @Content(schema = @Schema(implementation = WechatLoginVo.class))),
|
||||
@ApiResponse(responseCode = "500", description = "登录失败")
|
||||
})
|
||||
@PostMapping("/wechat/login")
|
||||
public R<WechatLoginVo> loginByWechat(@Valid @RequestBody WechatLoginDto dto) {
|
||||
return R.ok(authService.loginByWechat(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信绑定手机号短信验证码
|
||||
*/
|
||||
@Operation(
|
||||
summary = "发送微信绑定手机号短信验证码",
|
||||
description = """
|
||||
微信首次登录时,需要绑定手机号。调用此接口发送短信验证码。
|
||||
|
||||
**使用流程:**
|
||||
1. 先调用 /captcha 获取图形验证码
|
||||
2. 调用本接口发送短信验证码
|
||||
3. 调用 /wechat/bindPhone 绑定手机号
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "发送成功"),
|
||||
@ApiResponse(responseCode = "500", description = "发送失败")
|
||||
})
|
||||
@PostMapping("/wechat/sms/send")
|
||||
public R<Void> sendSmsCodeForWechatBind(@Valid @RequestBody H5SmsSendDto dto) {
|
||||
authService.sendSmsCodeForWechatBind(dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信登录绑定手机号
|
||||
*/
|
||||
@Operation(
|
||||
summary = "微信登录绑定手机号",
|
||||
description = """
|
||||
微信首次登录时,绑定手机号并完成登录。
|
||||
|
||||
**说明:**
|
||||
- 如果手机号已存在,则将微信绑定到现有账号
|
||||
- 如果手机号不存在,则创建新会员
|
||||
- 绑定成功后自动登录
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "绑定成功并登录",
|
||||
content = @Content(schema = @Schema(implementation = WechatLoginVo.class))),
|
||||
@ApiResponse(responseCode = "500", description = "绑定失败")
|
||||
})
|
||||
@PostMapping("/wechat/bindPhone")
|
||||
public R<WechatLoginVo> bindPhoneForWechat(@Valid @RequestBody WechatBindPhoneDto dto) {
|
||||
return R.ok(authService.bindPhoneForWechat(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调处理(前端调用)
|
||||
*/
|
||||
@Operation(
|
||||
summary = "微信回调处理",
|
||||
description = """
|
||||
前端回调页面收到微信code后,调用此接口处理。
|
||||
|
||||
**说明:**
|
||||
此接口由前端微信回调页面调用,将微信返回的code和state传给后端处理。
|
||||
处理成功后,前端轮询状态接口会得到confirmed状态。
|
||||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "处理成功")
|
||||
})
|
||||
@Parameters({
|
||||
@Parameter(name = "code", description = "微信授权码", required = true, in = ParameterIn.QUERY),
|
||||
@Parameter(name = "state", description = "状态参数(即ticket)", required = true, in = ParameterIn.QUERY)
|
||||
})
|
||||
@GetMapping("/wechat/callback")
|
||||
public R<Void> handleWechatCallback(@RequestParam String code, @RequestParam String state) {
|
||||
((org.dromara.pangu.h5.service.impl.H5AuthServiceImpl) authService).handleWechatCallback(code, state);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,12 +122,12 @@ public class H5MemberController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 添加/修改教育身份
|
||||
* 添加教育身份
|
||||
*/
|
||||
@Operation(
|
||||
summary = "设置教育身份(教师)",
|
||||
summary = "添加教育身份",
|
||||
description = """
|
||||
设置或修改会员的教育身份信息,设置后身份类型变为"教师"。
|
||||
添加一个教育身份。一个会员可以有多个教育身份(每班一条记录)。
|
||||
|
||||
**必填信息:**
|
||||
- 学校
|
||||
|
|
@ -143,31 +143,52 @@ public class H5MemberController {
|
|||
"""
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "设置成功"),
|
||||
@ApiResponse(responseCode = "200", description = "添加成功"),
|
||||
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
|
||||
@ApiResponse(responseCode = "500", description = "设置失败,可能原因:学校/年级/班级/学科不存在")
|
||||
@ApiResponse(responseCode = "500", description = "添加失败,可能原因:学校/年级/班级/学科不存在")
|
||||
})
|
||||
@PostMapping("/education")
|
||||
public R<Void> saveEducation(@Valid @RequestBody H5EducationDto dto) {
|
||||
memberService.saveEducation(dto);
|
||||
@PostMapping("/educations")
|
||||
public R<Void> addEducation(@Valid @RequestBody H5EducationDto dto) {
|
||||
memberService.addEducation(dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教育身份
|
||||
* 修改教育身份
|
||||
*/
|
||||
@Operation(
|
||||
summary = "获取教育身份信息",
|
||||
description = "获取当前会员的教育身份信息(学校、年级、班级、学科)。如果未设置教育身份,返回null。"
|
||||
summary = "修改教育身份",
|
||||
description = "修改指定的教育身份信息。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "修改成功"),
|
||||
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
|
||||
@ApiResponse(responseCode = "500", description = "修改失败,可能原因:教育身份不存在或无权限")
|
||||
})
|
||||
@Parameters({
|
||||
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
|
||||
})
|
||||
@PutMapping("/educations/{educationId}")
|
||||
public R<Void> updateEducation(@PathVariable Long educationId, @Valid @RequestBody H5EducationDto dto) {
|
||||
memberService.updateEducation(educationId, dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教育身份列表
|
||||
*/
|
||||
@Operation(
|
||||
summary = "获取教育身份列表",
|
||||
description = "获取当前会员的所有教育身份信息(学校、年级、班级、学科)。"
|
||||
)
|
||||
@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());
|
||||
@GetMapping("/educations")
|
||||
public R<List<H5EducationVo>> getEducations() {
|
||||
return R.ok(memberService.getEducations());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -175,15 +196,40 @@ public class H5MemberController {
|
|||
*/
|
||||
@Operation(
|
||||
summary = "删除教育身份",
|
||||
description = "清除当前会员的教育身份信息,身份类型将变为空。"
|
||||
description = "删除指定的教育身份。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "删除成功"),
|
||||
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
|
||||
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
|
||||
@ApiResponse(responseCode = "500", description = "删除失败,可能原因:教育身份不存在或无权限")
|
||||
})
|
||||
@DeleteMapping("/education")
|
||||
public R<Void> deleteEducation() {
|
||||
memberService.deleteEducation();
|
||||
@Parameters({
|
||||
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
|
||||
})
|
||||
@DeleteMapping("/educations/{educationId}")
|
||||
public R<Void> deleteEducation(@PathVariable Long educationId) {
|
||||
memberService.deleteEducation(educationId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认教育身份
|
||||
*/
|
||||
@Operation(
|
||||
summary = "设置默认教育身份",
|
||||
description = "将指定教育身份设为默认。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "设置成功"),
|
||||
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
|
||||
@ApiResponse(responseCode = "500", description = "设置失败,可能原因:教育身份不存在或无权限")
|
||||
})
|
||||
@Parameters({
|
||||
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
|
||||
})
|
||||
@PutMapping("/educations/{educationId}/default")
|
||||
public R<Void> setDefaultEducation(@PathVariable Long educationId) {
|
||||
memberService.setDefaultEducation(educationId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import lombok.Data;
|
|||
@Schema(description = "教育身份设置请求参数(教师身份)")
|
||||
public class H5EducationDto {
|
||||
|
||||
@Schema(description = "教育身份ID(新增时不传,编辑时必传)", example = "1")
|
||||
private Long educationId;
|
||||
|
||||
@Schema(description = "学校ID(从/h5/base/schools获取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "请选择学校")
|
||||
private Long schoolId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
|
||||
/**
|
||||
* 微信登录绑定手机号请求DTO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信登录绑定手机号请求参数")
|
||||
public class WechatBindPhoneDto {
|
||||
|
||||
@Schema(description = "绑定凭证(从微信登录接口获取,needBindPhone=true时返回)", requiredMode = Schema.RequiredMode.REQUIRED, example = "bind_abc123")
|
||||
@NotBlank(message = "绑定凭证不能为空")
|
||||
private String bindToken;
|
||||
|
||||
@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,23 @@
|
|||
package org.dromara.pangu.h5.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信扫码登录请求DTO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信扫码登录请求参数")
|
||||
public class WechatLoginDto {
|
||||
|
||||
@Schema(description = "二维码凭证(从获取二维码接口获取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
|
||||
@NotBlank(message = "二维码凭证不能为空")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "临时登录码(从查询状态接口获取,状态为confirmed时返回)", requiredMode = Schema.RequiredMode.REQUIRED, example = "xyz789abc")
|
||||
@NotBlank(message = "临时登录码不能为空")
|
||||
private String tempCode;
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ import lombok.Data;
|
|||
@Schema(description = "教育身份信息(教师)")
|
||||
public class H5EducationVo {
|
||||
|
||||
@Schema(description = "教育身份ID", example = "1")
|
||||
private Long educationId;
|
||||
|
||||
@Schema(description = "省份ID", example = "420000")
|
||||
private Long provinceId;
|
||||
|
||||
|
|
@ -53,4 +56,7 @@ public class H5EducationVo {
|
|||
|
||||
@Schema(description = "学科名称", example = "语文")
|
||||
private String subjectName;
|
||||
|
||||
@Schema(description = "是否默认身份(0否 1是)", example = "1")
|
||||
private String isDefault;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,4 @@ public class H5LoginVo {
|
|||
|
||||
@Schema(description = "昵称", example = "user_5678")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "身份类型:1-家长,2-教师,null-未设置", example = "1")
|
||||
private String identityType;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,11 +39,8 @@ public class H5MemberInfoVo {
|
|||
@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<H5EducationVo> educations;
|
||||
|
||||
@Schema(description = "绑定的学生列表")
|
||||
private List<H5StudentVo> students;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package org.dromara.pangu.h5.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信登录响应VO
|
||||
* 登录成功时返回Token,需要绑定手机号时返回bindToken
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信登录响应")
|
||||
public class WechatLoginVo {
|
||||
|
||||
@Schema(description = "是否需要绑定手机号", example = "false")
|
||||
private Boolean needBindPhone;
|
||||
|
||||
@Schema(description = "绑定凭证,needBindPhone=true时返回,用于绑定手机号接口", example = "bind_abc123")
|
||||
private String bindToken;
|
||||
|
||||
@Schema(description = "访问令牌,needBindPhone=false时返回", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...")
|
||||
private String accessToken;
|
||||
|
||||
@Schema(description = "刷新令牌,needBindPhone=false时返回", example = "refresh_abc123def456")
|
||||
private String refreshToken;
|
||||
|
||||
@Schema(description = "accessToken过期时间(秒)", example = "7200")
|
||||
private Long expiresIn;
|
||||
|
||||
@Schema(description = "会员ID,needBindPhone=false时返回", example = "1234567890123456789")
|
||||
private Long memberId;
|
||||
|
||||
@Schema(description = "会员编号,needBindPhone=false时返回", example = "M170900000012345678")
|
||||
private String memberCode;
|
||||
|
||||
@Schema(description = "手机号(脱敏),needBindPhone=false时返回", example = "138****5678")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "昵称,needBindPhone=false时返回", example = "user_5678")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "身份类型,needBindPhone=false时返回", example = "1")
|
||||
private String identityType;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.dromara.pangu.h5.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信扫码登录二维码响应VO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信扫码登录二维码响应")
|
||||
public class WechatQrcodeVo {
|
||||
|
||||
@Schema(description = "二维码凭证,用于后续查询扫码状态", example = "abc123def456")
|
||||
private String ticket;
|
||||
|
||||
@Schema(description = "二维码图片URL", example = "https://open.weixin.qq.com/connect/qrcode/xxx")
|
||||
private String qrcodeUrl;
|
||||
|
||||
@Schema(description = "二维码有效期(秒)", example = "300")
|
||||
private Integer expireSeconds;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.dromara.pangu.h5.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 微信扫码状态响应VO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "微信扫码状态响应")
|
||||
public class WechatStatusVo {
|
||||
|
||||
@Schema(description = "扫码状态:waiting-等待扫码,scanned-已扫码待确认,confirmed-已确认,expired-已过期", example = "waiting")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "临时登录码,状态为confirmed时返回,用于后续登录接口", example = "xyz789abc")
|
||||
private String tempCode;
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
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;
|
||||
import org.dromara.pangu.h5.domain.dto.*;
|
||||
import org.dromara.pangu.h5.domain.vo.*;
|
||||
|
||||
/**
|
||||
* H5认证服务接口
|
||||
|
|
@ -48,4 +44,33 @@ public interface H5AuthService {
|
|||
* 退出登录
|
||||
*/
|
||||
void logout();
|
||||
|
||||
// ==================== 微信扫码登录 ====================
|
||||
|
||||
/**
|
||||
* 获取微信扫码登录二维码
|
||||
*/
|
||||
WechatQrcodeVo getWechatQrcode();
|
||||
|
||||
/**
|
||||
* 查询微信扫码状态
|
||||
*
|
||||
* @param ticket 二维码凭证
|
||||
*/
|
||||
WechatStatusVo getWechatStatus(String ticket);
|
||||
|
||||
/**
|
||||
* 微信扫码登录
|
||||
*/
|
||||
WechatLoginVo loginByWechat(WechatLoginDto dto);
|
||||
|
||||
/**
|
||||
* 微信登录绑定手机号
|
||||
*/
|
||||
WechatLoginVo bindPhoneForWechat(WechatBindPhoneDto dto);
|
||||
|
||||
/**
|
||||
* 发送微信绑定手机号的短信验证码
|
||||
*/
|
||||
void sendSmsCodeForWechatBind(H5SmsSendDto dto);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,19 +33,29 @@ public interface H5MemberService {
|
|||
void updatePassword(H5PasswordUpdateDto dto);
|
||||
|
||||
/**
|
||||
* 添加/修改教育身份
|
||||
* 添加教育身份
|
||||
*/
|
||||
void saveEducation(H5EducationDto dto);
|
||||
void addEducation(H5EducationDto dto);
|
||||
|
||||
/**
|
||||
* 获取教育身份
|
||||
* 修改教育身份
|
||||
*/
|
||||
H5EducationVo getEducation();
|
||||
void updateEducation(Long educationId, H5EducationDto dto);
|
||||
|
||||
/**
|
||||
* 获取教育身份列表
|
||||
*/
|
||||
List<H5EducationVo> getEducations();
|
||||
|
||||
/**
|
||||
* 删除教育身份
|
||||
*/
|
||||
void deleteEducation();
|
||||
void deleteEducation(Long educationId);
|
||||
|
||||
/**
|
||||
* 设置默认教育身份
|
||||
*/
|
||||
void setDefaultEducation(Long educationId);
|
||||
|
||||
/**
|
||||
* 绑定学生
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ 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 cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -17,12 +20,9 @@ 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.config.H5WechatProperties;
|
||||
import org.dromara.pangu.h5.domain.dto.*;
|
||||
import org.dromara.pangu.h5.domain.vo.*;
|
||||
import org.dromara.pangu.h5.service.H5AuthService;
|
||||
import org.dromara.pangu.member.domain.PgMember;
|
||||
import org.dromara.pangu.member.mapper.PgMemberMapper;
|
||||
|
|
@ -35,6 +35,8 @@ import org.springframework.expression.spel.standard.SpelExpressionParser;
|
|||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.awt.*;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
|
@ -53,6 +55,7 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
|
||||
private final PgMemberMapper memberMapper;
|
||||
private final H5SmsProperties smsProperties;
|
||||
private final H5WechatProperties wechatProperties;
|
||||
|
||||
/**
|
||||
* H5会员登录设备标识
|
||||
|
|
@ -70,6 +73,12 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
private static final String H5_SMS_FAIL_COUNT_KEY = "h5:sms:fail:";
|
||||
private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:";
|
||||
|
||||
/**
|
||||
* 微信扫码登录相关Redis Key前缀
|
||||
*/
|
||||
private static final String H5_WECHAT_QRCODE_KEY = "h5:wechat:qrcode:";
|
||||
private static final String H5_WECHAT_BIND_KEY = "h5:wechat:bind:";
|
||||
|
||||
/**
|
||||
* accessToken有效期(秒)- 2小时
|
||||
*/
|
||||
|
|
@ -400,7 +409,6 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
vo.setMemberCode(member.getMemberCode());
|
||||
vo.setPhone(maskPhone(member.getPhone()));
|
||||
vo.setNickname(member.getNickname());
|
||||
vo.setIdentityType(member.getIdentityType());
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
|
@ -524,4 +532,428 @@ public class H5AuthServiceImpl implements H5AuthService {
|
|||
}
|
||||
return phone.substring(0, 3) + "****" + phone.substring(7);
|
||||
}
|
||||
|
||||
// ==================== 微信扫码登录实现 ====================
|
||||
|
||||
@Override
|
||||
public WechatQrcodeVo getWechatQrcode() {
|
||||
if (!wechatProperties.isEnabled()) {
|
||||
throw new ServiceException("微信登录功能未启用");
|
||||
}
|
||||
|
||||
// 生成唯一ticket
|
||||
String ticket = IdUtil.fastSimpleUUID();
|
||||
int expireSeconds = wechatProperties.getQrcodeExpireSeconds();
|
||||
|
||||
// 构建微信扫码登录URL
|
||||
// 使用微信开放平台的网站应用扫码登录
|
||||
String state = ticket;
|
||||
String redirectUri = URLEncoder.encode(
|
||||
getWechatCallbackUrl(),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
String qrcodeUrl = String.format(
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect",
|
||||
wechatProperties.getAppId(),
|
||||
redirectUri,
|
||||
state
|
||||
);
|
||||
|
||||
// 存储ticket状态到Redis
|
||||
// 初始状态:waiting
|
||||
JSONObject qrcodeData = new JSONObject();
|
||||
qrcodeData.set("status", "waiting");
|
||||
qrcodeData.set("createTime", System.currentTimeMillis());
|
||||
RedisUtils.setCacheObject(
|
||||
H5_WECHAT_QRCODE_KEY + ticket,
|
||||
qrcodeData.toString(),
|
||||
Duration.ofSeconds(expireSeconds)
|
||||
);
|
||||
|
||||
WechatQrcodeVo vo = new WechatQrcodeVo();
|
||||
vo.setTicket(ticket);
|
||||
vo.setQrcodeUrl(qrcodeUrl);
|
||||
vo.setExpireSeconds(expireSeconds);
|
||||
|
||||
log.info("生成微信扫码登录二维码: ticket={}", ticket);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WechatStatusVo getWechatStatus(String ticket) {
|
||||
if (!wechatProperties.isEnabled()) {
|
||||
throw new ServiceException("微信登录功能未启用");
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(ticket)) {
|
||||
throw new ServiceException("ticket不能为空");
|
||||
}
|
||||
|
||||
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (StringUtils.isBlank(cacheData)) {
|
||||
// ticket不存在或已过期
|
||||
WechatStatusVo vo = new WechatStatusVo();
|
||||
vo.setStatus("expired");
|
||||
return vo;
|
||||
}
|
||||
|
||||
JSONObject data = JSONUtil.parseObj(cacheData);
|
||||
String status = data.getStr("status");
|
||||
|
||||
WechatStatusVo vo = new WechatStatusVo();
|
||||
vo.setStatus(status);
|
||||
|
||||
// 如果已确认,返回tempCode
|
||||
if ("confirmed".equals(status)) {
|
||||
vo.setTempCode(data.getStr("tempCode"));
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WechatLoginVo loginByWechat(WechatLoginDto dto) {
|
||||
if (!wechatProperties.isEnabled()) {
|
||||
throw new ServiceException("微信登录功能未启用");
|
||||
}
|
||||
|
||||
String ticket = dto.getTicket();
|
||||
String tempCode = dto.getTempCode();
|
||||
|
||||
// 验证ticket
|
||||
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (StringUtils.isBlank(cacheData)) {
|
||||
throw new ServiceException("二维码已过期,请刷新重试");
|
||||
}
|
||||
|
||||
JSONObject data = JSONUtil.parseObj(cacheData);
|
||||
String status = data.getStr("status");
|
||||
|
||||
if (!"confirmed".equals(status)) {
|
||||
throw new ServiceException("请先完成扫码确认");
|
||||
}
|
||||
|
||||
// 验证tempCode
|
||||
String cachedTempCode = data.getStr("tempCode");
|
||||
if (!tempCode.equals(cachedTempCode)) {
|
||||
throw new ServiceException("登录凭证无效");
|
||||
}
|
||||
|
||||
String openId = data.getStr("openId");
|
||||
String unionId = data.getStr("unionId");
|
||||
String nickname = data.getStr("nickname");
|
||||
String headImgUrl = data.getStr("headImgUrl");
|
||||
|
||||
// 删除ticket缓存
|
||||
RedisUtils.deleteObject(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
|
||||
// 根据openId查询会员
|
||||
PgMember member = memberMapper.selectOne(
|
||||
new LambdaQueryWrapper<PgMember>().eq(PgMember::getOpenId, openId)
|
||||
);
|
||||
|
||||
WechatLoginVo vo = new WechatLoginVo();
|
||||
|
||||
if (member != null) {
|
||||
// 已绑定,直接登录
|
||||
if ("1".equals(member.getStatus())) {
|
||||
throw new ServiceException("账号已被禁用");
|
||||
}
|
||||
|
||||
H5LoginVo loginVo = doLogin(member, false);
|
||||
vo.setNeedBindPhone(false);
|
||||
vo.setAccessToken(loginVo.getAccessToken());
|
||||
vo.setRefreshToken(loginVo.getRefreshToken());
|
||||
vo.setExpiresIn(loginVo.getExpiresIn());
|
||||
vo.setMemberId(loginVo.getMemberId());
|
||||
vo.setMemberCode(loginVo.getMemberCode());
|
||||
vo.setPhone(loginVo.getPhone());
|
||||
vo.setNickname(loginVo.getNickname());
|
||||
|
||||
log.info("微信登录成功: openId={}, memberId={}", openId, member.getMemberId());
|
||||
} else {
|
||||
// 未绑定,需要绑定手机号
|
||||
String bindToken = IdUtil.fastSimpleUUID();
|
||||
|
||||
// 存储绑定信息
|
||||
JSONObject bindData = new JSONObject();
|
||||
bindData.set("openId", openId);
|
||||
bindData.set("unionId", unionId);
|
||||
bindData.set("nickname", nickname);
|
||||
bindData.set("headImgUrl", headImgUrl);
|
||||
RedisUtils.setCacheObject(
|
||||
H5_WECHAT_BIND_KEY + bindToken,
|
||||
bindData.toString(),
|
||||
Duration.ofMinutes(wechatProperties.getBindTokenExpireMinutes())
|
||||
);
|
||||
|
||||
vo.setNeedBindPhone(true);
|
||||
vo.setBindToken(bindToken);
|
||||
|
||||
log.info("微信登录需要绑定手机号: openId={}, bindToken={}", openId, bindToken);
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WechatLoginVo bindPhoneForWechat(WechatBindPhoneDto dto) {
|
||||
if (!wechatProperties.isEnabled()) {
|
||||
throw new ServiceException("微信登录功能未启用");
|
||||
}
|
||||
|
||||
// 校验图形验证码
|
||||
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
|
||||
|
||||
// 校验短信验证码
|
||||
validateSmsCode(dto.getPhone(), dto.getSmsCode(), "wechat_bind");
|
||||
|
||||
// 获取绑定信息
|
||||
String bindData = RedisUtils.getCacheObject(H5_WECHAT_BIND_KEY + dto.getBindToken());
|
||||
if (StringUtils.isBlank(bindData)) {
|
||||
throw new ServiceException("绑定凭证已过期,请重新扫码");
|
||||
}
|
||||
|
||||
JSONObject data = JSONUtil.parseObj(bindData);
|
||||
String openId = data.getStr("openId");
|
||||
String unionId = data.getStr("unionId");
|
||||
String nickname = data.getStr("nickname");
|
||||
|
||||
// 删除绑定凭证
|
||||
RedisUtils.deleteObject(H5_WECHAT_BIND_KEY + dto.getBindToken());
|
||||
|
||||
// 检查openId是否已被绑定
|
||||
PgMember existByOpenId = memberMapper.selectOne(
|
||||
new LambdaQueryWrapper<PgMember>().eq(PgMember::getOpenId, openId)
|
||||
);
|
||||
if (existByOpenId != null) {
|
||||
throw new ServiceException("该微信已绑定其他账号");
|
||||
}
|
||||
|
||||
// 检查手机号是否已存在
|
||||
PgMember existByPhone = memberMapper.selectOne(
|
||||
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, dto.getPhone())
|
||||
);
|
||||
|
||||
PgMember member;
|
||||
if (existByPhone != null) {
|
||||
// 手机号已存在,绑定微信到现有账号
|
||||
if ("1".equals(existByPhone.getStatus())) {
|
||||
throw new ServiceException("该手机号账号已被禁用");
|
||||
}
|
||||
existByPhone.setOpenId(openId);
|
||||
existByPhone.setUnionId(unionId);
|
||||
memberMapper.updateById(existByPhone);
|
||||
member = existByPhone;
|
||||
log.info("微信绑定到现有账号: phone={}, openId={}, memberId={}", dto.getPhone(), openId, member.getMemberId());
|
||||
} else {
|
||||
// 手机号不存在,创建新会员
|
||||
member = new PgMember();
|
||||
member.setPhone(dto.getPhone());
|
||||
member.setOpenId(openId);
|
||||
member.setUnionId(unionId);
|
||||
member.setMemberCode(generateMemberCode());
|
||||
member.setNickname(StringUtils.isNotBlank(nickname) ? nickname : "user_" + dto.getPhone().substring(7));
|
||||
member.setRegisterSource("2"); // H5注册
|
||||
member.setRegisterTime(new Date());
|
||||
member.setStatus("0");
|
||||
member.setLoginCount(0);
|
||||
memberMapper.insert(member);
|
||||
log.info("微信登录创建新会员: phone={}, openId={}, memberId={}", dto.getPhone(), openId, member.getMemberId());
|
||||
}
|
||||
|
||||
// 执行登录
|
||||
H5LoginVo loginVo = doLogin(member, false);
|
||||
|
||||
WechatLoginVo vo = new WechatLoginVo();
|
||||
vo.setNeedBindPhone(false);
|
||||
vo.setAccessToken(loginVo.getAccessToken());
|
||||
vo.setRefreshToken(loginVo.getRefreshToken());
|
||||
vo.setExpiresIn(loginVo.getExpiresIn());
|
||||
vo.setMemberId(loginVo.getMemberId());
|
||||
vo.setMemberCode(loginVo.getMemberCode());
|
||||
vo.setPhone(loginVo.getPhone());
|
||||
vo.setNickname(loginVo.getNickname());
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendSmsCodeForWechatBind(H5SmsSendDto dto) {
|
||||
// 校验图形验证码
|
||||
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
|
||||
|
||||
String phone = dto.getPhone();
|
||||
String clientIp = getClientIp();
|
||||
|
||||
// ========== 防刷检查 ==========
|
||||
if (isPhoneBlacklisted(phone)) {
|
||||
throw new ServiceException("操作过于频繁,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
|
||||
}
|
||||
|
||||
if (isIpBlacklisted(clientIp)) {
|
||||
throw new ServiceException("操作过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
String codeKey = H5_SMS_CODE_KEY + "wechat_bind:" + 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() + "秒后再试");
|
||||
}
|
||||
}
|
||||
|
||||
String dailyPhoneKey = H5_SMS_DAILY_PHONE_KEY + getTodayStr() + ":" + phone;
|
||||
Integer phoneCount = RedisUtils.getCacheObject(dailyPhoneKey);
|
||||
if (phoneCount != null && phoneCount >= smsProperties.getDailyLimitPerPhone()) {
|
||||
throw new ServiceException("今日发送次数已达上限,请明天再试");
|
||||
}
|
||||
|
||||
// ========== 发送短信 ==========
|
||||
String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
|
||||
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
|
||||
incrementCounter(dailyPhoneKey, 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 = smsProperties.getLoginTemplateId();
|
||||
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={}, ip={}", phone, clientIp);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
|
||||
throw new ServiceException("短信发送失败,请稍后重试");
|
||||
}
|
||||
} else {
|
||||
log.info("【测试模式】微信绑定短信验证码: phone={}, code={}", phone, code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信回调URL(由前端页面处理回调)
|
||||
*/
|
||||
private String getWechatCallbackUrl() {
|
||||
// 返回前端回调页面URL,前端需要实现该页面处理微信回调
|
||||
// 实际使用时需要配置为真实的前端回调地址
|
||||
return "https://your-domain.com/wechat-callback";
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信回调处理(由前端调用,传入code和state)
|
||||
* 这个方法用于处理微信回调后,前端将code传给后端换取用户信息
|
||||
*/
|
||||
public void handleWechatCallback(String code, String state) {
|
||||
if (!wechatProperties.isEnabled()) {
|
||||
throw new ServiceException("微信登录功能未启用");
|
||||
}
|
||||
|
||||
String ticket = state;
|
||||
|
||||
// 验证ticket是否存在
|
||||
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (StringUtils.isBlank(cacheData)) {
|
||||
log.warn("微信回调ticket已过期: ticket={}", ticket);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 用code换取access_token和openid
|
||||
String tokenUrl = String.format(
|
||||
"https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
|
||||
wechatProperties.getAppId(),
|
||||
wechatProperties.getAppSecret(),
|
||||
code
|
||||
);
|
||||
|
||||
String tokenResult = HttpUtil.get(tokenUrl);
|
||||
JSONObject tokenJson = JSONUtil.parseObj(tokenResult);
|
||||
|
||||
if (tokenJson.containsKey("errcode")) {
|
||||
log.error("微信获取access_token失败: {}", tokenResult);
|
||||
return;
|
||||
}
|
||||
|
||||
String accessToken = tokenJson.getStr("access_token");
|
||||
String openId = tokenJson.getStr("openid");
|
||||
String unionId = tokenJson.getStr("unionid");
|
||||
|
||||
// 获取用户信息
|
||||
String userInfoUrl = String.format(
|
||||
"https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s",
|
||||
accessToken,
|
||||
openId
|
||||
);
|
||||
|
||||
String userInfoResult = HttpUtil.get(userInfoUrl);
|
||||
JSONObject userInfo = JSONUtil.parseObj(userInfoResult);
|
||||
|
||||
String nickname = userInfo.getStr("nickname");
|
||||
String headImgUrl = userInfo.getStr("headimgurl");
|
||||
|
||||
// 生成tempCode
|
||||
String tempCode = IdUtil.fastSimpleUUID();
|
||||
|
||||
// 更新ticket状态为confirmed
|
||||
JSONObject data = new JSONObject();
|
||||
data.set("status", "confirmed");
|
||||
data.set("tempCode", tempCode);
|
||||
data.set("openId", openId);
|
||||
data.set("unionId", unionId);
|
||||
data.set("nickname", nickname);
|
||||
data.set("headImgUrl", headImgUrl);
|
||||
|
||||
// 获取剩余过期时间
|
||||
Long ttl = RedisUtils.getTimeToLive(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (ttl != null && ttl > 0) {
|
||||
RedisUtils.setCacheObject(
|
||||
H5_WECHAT_QRCODE_KEY + ticket,
|
||||
data.toString(),
|
||||
Duration.ofSeconds(ttl)
|
||||
);
|
||||
}
|
||||
|
||||
log.info("微信扫码回调成功: ticket={}, openId={}", ticket, openId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("微信回调处理异常: ticket={}, error={}", ticket, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扫码状态为已扫码(由前端轮询时模拟,实际由微信事件推送)
|
||||
*/
|
||||
public void updateWechatScanStatus(String ticket) {
|
||||
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (StringUtils.isBlank(cacheData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
JSONObject data = JSONUtil.parseObj(cacheData);
|
||||
if ("waiting".equals(data.getStr("status"))) {
|
||||
data.set("status", "scanned");
|
||||
Long ttl = RedisUtils.getTimeToLive(H5_WECHAT_QRCODE_KEY + ticket);
|
||||
if (ttl != null && ttl > 0) {
|
||||
RedisUtils.setCacheObject(
|
||||
H5_WECHAT_QRCODE_KEY + ticket,
|
||||
data.toString(),
|
||||
Duration.ofSeconds(ttl)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ 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.PgEducation;
|
||||
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.PgEducationMapper;
|
||||
import org.dromara.pangu.base.mapper.PgGradeMapper;
|
||||
import org.dromara.pangu.base.mapper.PgRegionMapper;
|
||||
import org.dromara.pangu.base.mapper.PgSubjectMapper;
|
||||
|
|
@ -59,6 +61,7 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
private final PgClassMapper classMapper;
|
||||
private final PgSubjectMapper subjectMapper;
|
||||
private final PgRegionMapper regionMapper;
|
||||
private final PgEducationMapper educationMapper;
|
||||
|
||||
@Override
|
||||
public H5MemberInfoVo getMemberInfo() {
|
||||
|
|
@ -77,12 +80,9 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
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.setEducations(getEducations());
|
||||
|
||||
// 获取绑定的学生列表
|
||||
vo.setStudents(getStudents());
|
||||
|
|
@ -139,14 +139,125 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveEducation(H5EducationDto dto) {
|
||||
public void addEducation(H5EducationDto dto) {
|
||||
Long memberId = getCurrentMemberId();
|
||||
PgMember member = memberMapper.selectById(memberId);
|
||||
if (member == null) {
|
||||
throw new ServiceException("会员不存在");
|
||||
|
||||
// 校验学校、年级、班级、学科是否存在
|
||||
PgSchool school = validateAndGetSchool(dto);
|
||||
|
||||
// 创建教育身份记录
|
||||
PgEducation education = new PgEducation();
|
||||
education.setMemberId(memberId);
|
||||
education.setRegionId(school.getRegionId());
|
||||
education.setSchoolId(dto.getSchoolId());
|
||||
education.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
education.setSchoolClassId(dto.getSchoolClassId());
|
||||
education.setSubjectId(dto.getSubjectId());
|
||||
education.setStatus("0");
|
||||
|
||||
// 如果是第一个教育身份,设为默认
|
||||
long count = educationMapper.selectCount(
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getDelFlag, "0")
|
||||
);
|
||||
education.setIsDefault(count == 0 ? "1" : "0");
|
||||
|
||||
educationMapper.insert(education);
|
||||
log.info("H5添加教育身份: memberId={}, educationId={}", memberId, education.getEducationId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateEducation(Long educationId, H5EducationDto dto) {
|
||||
Long memberId = getCurrentMemberId();
|
||||
|
||||
// 查询并校验教育身份归属
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限修改");
|
||||
}
|
||||
|
||||
// 校验学校、年级、班级、学科是否存在
|
||||
PgSchool school = validateAndGetSchool(dto);
|
||||
|
||||
// 更新教育身份
|
||||
education.setRegionId(school.getRegionId());
|
||||
education.setSchoolId(dto.getSchoolId());
|
||||
education.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
education.setSchoolClassId(dto.getSchoolClassId());
|
||||
education.setSubjectId(dto.getSubjectId());
|
||||
educationMapper.updateById(education);
|
||||
|
||||
log.info("H5修改教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<H5EducationVo> getEducations() {
|
||||
Long memberId = getCurrentMemberId();
|
||||
|
||||
List<PgEducation> educations = educationMapper.selectList(
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getDelFlag, "0")
|
||||
.orderByDesc(PgEducation::getIsDefault)
|
||||
.orderByDesc(PgEducation::getCreateTime)
|
||||
);
|
||||
|
||||
List<H5EducationVo> voList = new ArrayList<>();
|
||||
for (PgEducation education : educations) {
|
||||
voList.add(buildEducationVo(education));
|
||||
}
|
||||
return voList;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteEducation(Long educationId) {
|
||||
Long memberId = getCurrentMemberId();
|
||||
|
||||
// 查询并校验教育身份归属
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限删除");
|
||||
}
|
||||
|
||||
// 逻辑删除
|
||||
educationMapper.deleteById(educationId);
|
||||
log.info("H5删除教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setDefaultEducation(Long educationId) {
|
||||
Long memberId = getCurrentMemberId();
|
||||
|
||||
// 查询并校验教育身份归属
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限操作");
|
||||
}
|
||||
|
||||
// 取消其他默认
|
||||
PgEducation updateEntity = new PgEducation();
|
||||
updateEntity.setIsDefault("0");
|
||||
educationMapper.update(updateEntity,
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getIsDefault, "1")
|
||||
);
|
||||
|
||||
// 设置当前为默认
|
||||
education.setIsDefault("1");
|
||||
educationMapper.updateById(education);
|
||||
|
||||
log.info("H5设置默认教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并获取学校信息
|
||||
*/
|
||||
private PgSchool validateAndGetSchool(H5EducationDto dto) {
|
||||
PgSchool school = schoolMapper.selectById(dto.getSchoolId());
|
||||
if (school == null) {
|
||||
throw new ServiceException("学校不存在");
|
||||
|
|
@ -167,68 +278,13 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
throw new ServiceException("学科不存在");
|
||||
}
|
||||
|
||||
// 更新会员教育信息
|
||||
member.setIdentityType("2"); // 教师
|
||||
member.setSchoolId(dto.getSchoolId());
|
||||
member.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
member.setSchoolClassId(dto.getSchoolClassId());
|
||||
// 从学校获取区域信息并保存
|
||||
if (school.getRegionId() != null) {
|
||||
member.setRegionId(school.getRegionId());
|
||||
}
|
||||
// 学科信息需要在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;
|
||||
}
|
||||
|
||||
H5EducationVo vo = buildEducationVo(member);
|
||||
log.info("H5获取教育身份: memberId={}, provinceId={}, cityId={}, districtId={}, schoolId={}",
|
||||
memberId, vo.getProvinceId(), vo.getCityId(), vo.getDistrictId(), vo.getSchoolId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@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);
|
||||
return school;
|
||||
}
|
||||
|
||||
@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());
|
||||
|
|
@ -247,12 +303,6 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
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());
|
||||
}
|
||||
|
||||
|
|
@ -348,14 +398,17 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
/**
|
||||
* 构建教育身份VO
|
||||
*/
|
||||
private H5EducationVo buildEducationVo(PgMember member) {
|
||||
private H5EducationVo buildEducationVo(PgEducation education) {
|
||||
H5EducationVo vo = new H5EducationVo();
|
||||
vo.setSchoolId(member.getSchoolId());
|
||||
vo.setSchoolGradeId(member.getSchoolGradeId());
|
||||
vo.setSchoolClassId(member.getSchoolClassId());
|
||||
vo.setEducationId(education.getEducationId());
|
||||
vo.setSchoolId(education.getSchoolId());
|
||||
vo.setSchoolGradeId(education.getSchoolGradeId());
|
||||
vo.setSchoolClassId(education.getSchoolClassId());
|
||||
vo.setSubjectId(education.getSubjectId());
|
||||
vo.setIsDefault(education.getIsDefault());
|
||||
|
||||
// 获取学校名称和区域信息
|
||||
PgSchool school = schoolMapper.selectById(member.getSchoolId());
|
||||
PgSchool school = schoolMapper.selectById(education.getSchoolId());
|
||||
if (school != null) {
|
||||
vo.setSchoolName(school.getSchoolName());
|
||||
|
||||
|
|
@ -366,12 +419,9 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
vo.setDistrictId(district.getRegionId());
|
||||
vo.setDistrictName(district.getRegionName());
|
||||
|
||||
// 根据ancestors获取省市信息,格式如 "0,42,4201"
|
||||
// 根据ancestors获取省市信息
|
||||
if (StringUtils.isNotBlank(district.getAncestors())) {
|
||||
String[] ancestorIds = district.getAncestors().split(",");
|
||||
// ancestorIds[0] = "0" (根节点)
|
||||
// ancestorIds[1] = 省份ID
|
||||
// ancestorIds[2] = 城市ID
|
||||
if (ancestorIds.length >= 2 && !"0".equals(ancestorIds[1])) {
|
||||
try {
|
||||
Long provinceId = Long.parseLong(ancestorIds[1].trim());
|
||||
|
|
@ -402,7 +452,7 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
}
|
||||
|
||||
// 获取年级名称
|
||||
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(member.getSchoolGradeId());
|
||||
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(education.getSchoolGradeId());
|
||||
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
|
||||
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
|
||||
if (grade != null) {
|
||||
|
|
@ -411,7 +461,7 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
}
|
||||
|
||||
// 获取班级名称
|
||||
PgSchoolClass schoolClass = schoolClassMapper.selectById(member.getSchoolClassId());
|
||||
PgSchoolClass schoolClass = schoolClassMapper.selectById(education.getSchoolClassId());
|
||||
if (schoolClass != null && schoolClass.getClassId() != null) {
|
||||
PgClass cls = classMapper.selectById(schoolClass.getClassId());
|
||||
if (cls != null) {
|
||||
|
|
@ -419,17 +469,11 @@ public class H5MemberServiceImpl implements H5MemberService {
|
|||
}
|
||||
}
|
||||
|
||||
// 获取学科信息(从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) {
|
||||
// 忽略解析错误
|
||||
// 获取学科名称
|
||||
if (education.getSubjectId() != null) {
|
||||
PgSubject subject = subjectMapper.selectById(education.getSubjectId());
|
||||
if (subject != null) {
|
||||
vo.setSubjectName(subject.getSubjectName());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import org.dromara.common.mybatis.core.page.PageQuery;
|
|||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.pangu.member.domain.PgMember;
|
||||
import org.dromara.pangu.member.domain.dto.EducationDto;
|
||||
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
|
||||
import org.dromara.pangu.member.domain.vo.EducationVo;
|
||||
import org.dromara.pangu.member.service.IPgMemberService;
|
||||
import org.dromara.pangu.member.service.IPgEducationService;
|
||||
import org.dromara.pangu.student.domain.vo.StudentVo;
|
||||
import org.dromara.pangu.student.service.IPgStudentService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
|
@ -32,6 +36,7 @@ public class PgMemberController extends BaseController {
|
|||
|
||||
private final IPgMemberService memberService;
|
||||
private final IPgStudentService studentService;
|
||||
private final IPgEducationService educationService;
|
||||
|
||||
/**
|
||||
* 查询会员列表
|
||||
|
|
@ -52,23 +57,25 @@ public class PgMemberController extends BaseController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 新增会员
|
||||
* 新增会员(支持同时保存教育身份)
|
||||
*/
|
||||
@SaCheckPermission("business:member:add")
|
||||
@Log(title = "会员管理", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public R<Void> add(@Validated @RequestBody PgMember member) {
|
||||
return toAjax(memberService.insert(member));
|
||||
public R<Void> add(@Validated @RequestBody MemberSaveDto dto) {
|
||||
memberService.insertWithEducations(dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改会员
|
||||
* 修改会员(支持同时保存教育身份)
|
||||
*/
|
||||
@SaCheckPermission("business:member:edit")
|
||||
@Log(title = "会员管理", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public R<Void> edit(@Validated @RequestBody PgMember member) {
|
||||
return toAjax(memberService.update(member));
|
||||
public R<Void> edit(@Validated @RequestBody MemberSaveDto dto) {
|
||||
memberService.updateWithEducations(dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -139,4 +146,59 @@ public class PgMemberController extends BaseController {
|
|||
public R<Void> unbindStudent(@PathVariable Long studentId) {
|
||||
return toAjax(studentService.unbindStudent(studentId));
|
||||
}
|
||||
|
||||
// ==================== 教育身份管理 ====================
|
||||
|
||||
/**
|
||||
* 获取会员教育身份列表
|
||||
*/
|
||||
@GetMapping("/{memberId}/educations")
|
||||
public R<List<EducationVo>> getEducations(@PathVariable Long memberId) {
|
||||
return R.ok(educationService.getEducationsByMemberId(memberId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加教育身份
|
||||
*/
|
||||
@SaCheckPermission("business:member:edit")
|
||||
@Log(title = "会员管理-添加教育身份", businessType = BusinessType.INSERT)
|
||||
@PostMapping("/{memberId}/educations")
|
||||
public R<Void> addEducation(@PathVariable Long memberId, @Validated @RequestBody EducationDto dto) {
|
||||
educationService.addEducation(memberId, dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改教育身份
|
||||
*/
|
||||
@SaCheckPermission("business:member:edit")
|
||||
@Log(title = "会员管理-修改教育身份", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{memberId}/educations/{educationId}")
|
||||
public R<Void> updateEducation(@PathVariable Long memberId, @PathVariable Long educationId,
|
||||
@Validated @RequestBody EducationDto dto) {
|
||||
educationService.updateEducation(memberId, educationId, dto);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除教育身份
|
||||
*/
|
||||
@SaCheckPermission("business:member:edit")
|
||||
@Log(title = "会员管理-删除教育身份", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{memberId}/educations/{educationId}")
|
||||
public R<Void> deleteEducation(@PathVariable Long memberId, @PathVariable Long educationId) {
|
||||
educationService.deleteEducation(memberId, educationId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认教育身份
|
||||
*/
|
||||
@SaCheckPermission("business:member:edit")
|
||||
@Log(title = "会员管理-设置默认身份", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/{memberId}/educations/{educationId}/default")
|
||||
public R<Void> setDefaultEducation(@PathVariable Long memberId, @PathVariable Long educationId) {
|
||||
educationService.setDefaultEducation(memberId, educationId);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package org.dromara.pangu.member.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
|
@ -10,7 +9,6 @@ import lombok.EqualsAndHashCode;
|
|||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会员表
|
||||
|
|
@ -42,29 +40,10 @@ public class PgMember extends BaseEntity {
|
|||
|
||||
private Date birthday;
|
||||
|
||||
/**
|
||||
* 身份类型(1家长 2教师)
|
||||
*/
|
||||
private String identityType;
|
||||
|
||||
private String openId;
|
||||
|
||||
private String unionId;
|
||||
|
||||
private Long regionId;
|
||||
|
||||
/**
|
||||
* 区域ID路径(非数据库字段,用于级联选择器回显)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private List<Long> regionIds;
|
||||
|
||||
private Long schoolId;
|
||||
|
||||
private Long schoolGradeId;
|
||||
|
||||
private Long schoolClassId;
|
||||
|
||||
/**
|
||||
* 注册来源(1小程序 2H5 3后台 4导入)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package org.dromara.pangu.member.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 教育身份请求DTO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "教育身份请求")
|
||||
public class EducationDto {
|
||||
|
||||
@Schema(description = "区域ID")
|
||||
private Long regionId;
|
||||
|
||||
@NotNull(message = "学校不能为空")
|
||||
@Schema(description = "学校ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long schoolId;
|
||||
|
||||
@NotNull(message = "年级不能为空")
|
||||
@Schema(description = "年级ID(school_grade关联ID)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long schoolGradeId;
|
||||
|
||||
@NotNull(message = "班级不能为空")
|
||||
@Schema(description = "班级ID(school_class关联ID)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long schoolClassId;
|
||||
|
||||
@Schema(description = "学科ID")
|
||||
private Long subjectId;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package org.dromara.pangu.member.domain.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会员保存请求DTO(支持同时保存教育身份)
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "会员保存请求")
|
||||
public class MemberSaveDto {
|
||||
|
||||
@Schema(description = "会员ID(新增时不传,编辑时必传)")
|
||||
private Long memberId;
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@Schema(description = "性别(0未知 1男 2女)")
|
||||
private String gender;
|
||||
|
||||
@Schema(description = "出生日期")
|
||||
private Date birthday;
|
||||
|
||||
@Schema(description = "状态(0正常 1停用)")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "教育身份列表")
|
||||
private List<EducationDto> educations;
|
||||
|
||||
@Schema(description = "绑定的学生ID列表(亲子关系)")
|
||||
private List<Long> studentIds;
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package org.dromara.pangu.member.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 教育身份响应VO
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "教育身份信息")
|
||||
public class EducationVo {
|
||||
|
||||
@Schema(description = "教育身份ID")
|
||||
private Long educationId;
|
||||
|
||||
@Schema(description = "区域ID")
|
||||
private Long regionId;
|
||||
|
||||
@Schema(description = "区域名称(省/市/区)")
|
||||
private String regionName;
|
||||
|
||||
@Schema(description = "学校ID")
|
||||
private Long schoolId;
|
||||
|
||||
@Schema(description = "学校名称")
|
||||
private String schoolName;
|
||||
|
||||
@Schema(description = "年级ID(school_grade关联ID)")
|
||||
private Long schoolGradeId;
|
||||
|
||||
@Schema(description = "年级名称")
|
||||
private String gradeName;
|
||||
|
||||
@Schema(description = "班级ID(school_class关联ID)")
|
||||
private Long schoolClassId;
|
||||
|
||||
@Schema(description = "班级名称")
|
||||
private String className;
|
||||
|
||||
@Schema(description = "学科ID")
|
||||
private Long subjectId;
|
||||
|
||||
@Schema(description = "学科名称")
|
||||
private String subjectName;
|
||||
|
||||
@Schema(description = "是否默认身份(0否 1是)")
|
||||
private String isDefault;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.dromara.pangu.member.service;
|
||||
|
||||
import org.dromara.pangu.member.domain.dto.EducationDto;
|
||||
import org.dromara.pangu.member.domain.vo.EducationVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教育身份管理 Service 接口
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
public interface IPgEducationService {
|
||||
|
||||
/**
|
||||
* 获取会员的教育身份列表
|
||||
*/
|
||||
List<EducationVo> getEducationsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 添加教育身份
|
||||
*/
|
||||
void addEducation(Long memberId, EducationDto dto);
|
||||
|
||||
/**
|
||||
* 修改教育身份
|
||||
*/
|
||||
void updateEducation(Long memberId, Long educationId, EducationDto dto);
|
||||
|
||||
/**
|
||||
* 删除教育身份
|
||||
*/
|
||||
void deleteEducation(Long memberId, Long educationId);
|
||||
|
||||
/**
|
||||
* 设置默认教育身份
|
||||
*/
|
||||
void setDefaultEducation(Long memberId, Long educationId);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package org.dromara.pangu.member.service;
|
|||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.pangu.member.domain.PgMember;
|
||||
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -33,6 +34,16 @@ public interface IPgMemberService {
|
|||
*/
|
||||
int insert(PgMember member);
|
||||
|
||||
/**
|
||||
* 新增会员(支持同时保存教育身份)
|
||||
*/
|
||||
void insertWithEducations(MemberSaveDto dto);
|
||||
|
||||
/**
|
||||
* 修改会员(支持同时保存教育身份)
|
||||
*/
|
||||
void updateWithEducations(MemberSaveDto dto);
|
||||
|
||||
/**
|
||||
* 修改会员
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
package org.dromara.pangu.member.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.pangu.base.domain.PgClass;
|
||||
import org.dromara.pangu.base.domain.PgEducation;
|
||||
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.PgEducationMapper;
|
||||
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.member.domain.dto.EducationDto;
|
||||
import org.dromara.pangu.member.domain.vo.EducationVo;
|
||||
import org.dromara.pangu.member.service.IPgEducationService;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教育身份管理 Service 实现
|
||||
*
|
||||
* @author 湖北新华业务中台研发团队
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PgEducationServiceImpl implements IPgEducationService {
|
||||
|
||||
private final PgEducationMapper educationMapper;
|
||||
private final PgSchoolMapper schoolMapper;
|
||||
private final PgSchoolGradeMapper schoolGradeMapper;
|
||||
private final PgSchoolClassMapper schoolClassMapper;
|
||||
private final PgGradeMapper gradeMapper;
|
||||
private final PgClassMapper classMapper;
|
||||
private final PgSubjectMapper subjectMapper;
|
||||
private final PgRegionMapper regionMapper;
|
||||
|
||||
@Override
|
||||
public List<EducationVo> getEducationsByMemberId(Long memberId) {
|
||||
List<PgEducation> educations = educationMapper.selectList(
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getDelFlag, "0")
|
||||
.orderByDesc(PgEducation::getIsDefault)
|
||||
.orderByDesc(PgEducation::getCreateTime)
|
||||
);
|
||||
|
||||
List<EducationVo> voList = new ArrayList<>();
|
||||
for (PgEducation education : educations) {
|
||||
voList.add(buildEducationVo(education));
|
||||
}
|
||||
return voList;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void addEducation(Long memberId, EducationDto dto) {
|
||||
// 校验学校信息
|
||||
PgSchool school = validateAndGetSchool(dto);
|
||||
|
||||
// 创建教育身份
|
||||
PgEducation education = new PgEducation();
|
||||
education.setMemberId(memberId);
|
||||
education.setRegionId(school.getRegionId());
|
||||
education.setSchoolId(dto.getSchoolId());
|
||||
education.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
education.setSchoolClassId(dto.getSchoolClassId());
|
||||
education.setSubjectId(dto.getSubjectId());
|
||||
education.setStatus("0");
|
||||
|
||||
// 如果是第一个教育身份,设为默认
|
||||
long count = educationMapper.selectCount(
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getDelFlag, "0")
|
||||
);
|
||||
education.setIsDefault(count == 0 ? "1" : "0");
|
||||
|
||||
educationMapper.insert(education);
|
||||
log.info("添加教育身份: memberId={}, educationId={}", memberId, education.getEducationId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateEducation(Long memberId, Long educationId, EducationDto dto) {
|
||||
// 查询并校验归属
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限修改");
|
||||
}
|
||||
|
||||
// 校验学校信息
|
||||
PgSchool school = validateAndGetSchool(dto);
|
||||
|
||||
// 更新
|
||||
education.setRegionId(school.getRegionId());
|
||||
education.setSchoolId(dto.getSchoolId());
|
||||
education.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
education.setSchoolClassId(dto.getSchoolClassId());
|
||||
education.setSubjectId(dto.getSubjectId());
|
||||
educationMapper.updateById(education);
|
||||
|
||||
log.info("修改教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteEducation(Long memberId, Long educationId) {
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限删除");
|
||||
}
|
||||
|
||||
educationMapper.deleteById(educationId);
|
||||
log.info("删除教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setDefaultEducation(Long memberId, Long educationId) {
|
||||
PgEducation education = educationMapper.selectById(educationId);
|
||||
if (education == null || !memberId.equals(education.getMemberId())) {
|
||||
throw new ServiceException("教育身份不存在或无权限操作");
|
||||
}
|
||||
|
||||
// 取消其他默认
|
||||
PgEducation updateEntity = new PgEducation();
|
||||
updateEntity.setIsDefault("0");
|
||||
educationMapper.update(updateEntity,
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getIsDefault, "1")
|
||||
);
|
||||
|
||||
// 设置当前为默认
|
||||
education.setIsDefault("1");
|
||||
educationMapper.updateById(education);
|
||||
|
||||
log.info("设置默认教育身份: memberId={}, educationId={}", memberId, educationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验学校信息
|
||||
*/
|
||||
private PgSchool validateAndGetSchool(EducationDto dto) {
|
||||
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("班级不存在或不属于该年级");
|
||||
}
|
||||
|
||||
if (dto.getSubjectId() != null) {
|
||||
PgSubject subject = subjectMapper.selectById(dto.getSubjectId());
|
||||
if (subject == null) {
|
||||
throw new ServiceException("学科不存在");
|
||||
}
|
||||
}
|
||||
|
||||
return school;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建教育身份VO
|
||||
*/
|
||||
private EducationVo buildEducationVo(PgEducation education) {
|
||||
EducationVo vo = new EducationVo();
|
||||
vo.setEducationId(education.getEducationId());
|
||||
vo.setSchoolId(education.getSchoolId());
|
||||
vo.setSchoolGradeId(education.getSchoolGradeId());
|
||||
vo.setSchoolClassId(education.getSchoolClassId());
|
||||
vo.setSubjectId(education.getSubjectId());
|
||||
vo.setIsDefault(education.getIsDefault());
|
||||
|
||||
// 获取学校和区域信息
|
||||
PgSchool school = schoolMapper.selectById(education.getSchoolId());
|
||||
if (school != null) {
|
||||
vo.setSchoolName(school.getSchoolName());
|
||||
vo.setRegionId(school.getRegionId());
|
||||
|
||||
// 构建区域全名
|
||||
if (school.getRegionId() != null) {
|
||||
vo.setRegionName(buildRegionFullName(school.getRegionId()));
|
||||
}
|
||||
}
|
||||
|
||||
// 获取年级名称
|
||||
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(education.getSchoolGradeId());
|
||||
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
|
||||
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
|
||||
if (grade != null) {
|
||||
vo.setGradeName(grade.getGradeName());
|
||||
}
|
||||
}
|
||||
|
||||
// 获取班级名称
|
||||
PgSchoolClass schoolClass = schoolClassMapper.selectById(education.getSchoolClassId());
|
||||
if (schoolClass != null && schoolClass.getClassId() != null) {
|
||||
PgClass cls = classMapper.selectById(schoolClass.getClassId());
|
||||
if (cls != null) {
|
||||
vo.setClassName(cls.getClassName());
|
||||
}
|
||||
}
|
||||
|
||||
// 获取学科名称
|
||||
if (education.getSubjectId() != null) {
|
||||
PgSubject subject = subjectMapper.selectById(education.getSubjectId());
|
||||
if (subject != null) {
|
||||
vo.setSubjectName(subject.getSubjectName());
|
||||
}
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建区域全名(省/市/区)
|
||||
*/
|
||||
private String buildRegionFullName(Long regionId) {
|
||||
PgRegion district = regionMapper.selectById(regionId);
|
||||
if (district == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (StringUtils.isNotBlank(district.getAncestors())) {
|
||||
String[] ancestorIds = district.getAncestors().split(",");
|
||||
for (String idStr : ancestorIds) {
|
||||
if (!"0".equals(idStr.trim())) {
|
||||
try {
|
||||
PgRegion ancestor = regionMapper.selectById(Long.parseLong(idStr.trim()));
|
||||
if (ancestor != null) {
|
||||
if (sb.length() > 0) sb.append("/");
|
||||
sb.append(ancestor.getRegionName());
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sb.length() > 0) sb.append("/");
|
||||
sb.append(district.getRegionName());
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,23 @@ import cn.hutool.core.util.StrUtil;
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.pangu.base.domain.PgRegion;
|
||||
import org.dromara.pangu.base.mapper.PgRegionMapper;
|
||||
import org.dromara.pangu.member.domain.PgMember;
|
||||
import org.dromara.pangu.member.domain.dto.EducationDto;
|
||||
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
|
||||
import org.dromara.pangu.member.mapper.PgMemberMapper;
|
||||
import org.dromara.pangu.member.service.IPgEducationService;
|
||||
import org.dromara.pangu.member.service.IPgMemberService;
|
||||
import org.dromara.pangu.student.mapper.PgStudentMapper;
|
||||
import org.dromara.pangu.student.service.IPgStudentService;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -29,13 +30,15 @@ import java.util.List;
|
|||
*
|
||||
* @author pangu
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class PgMemberServiceImpl implements IPgMemberService {
|
||||
|
||||
private final PgMemberMapper baseMapper;
|
||||
private final PgStudentMapper studentMapper;
|
||||
private final PgRegionMapper regionMapper;
|
||||
private final IPgEducationService educationService;
|
||||
private final IPgStudentService studentService;
|
||||
|
||||
private static final String DEFAULT_PASSWORD = "123456";
|
||||
|
||||
|
|
@ -53,29 +56,7 @@ public class PgMemberServiceImpl implements IPgMemberService {
|
|||
|
||||
@Override
|
||||
public PgMember selectById(Long memberId) {
|
||||
PgMember member = baseMapper.selectById(memberId);
|
||||
if (member != null && member.getRegionId() != null) {
|
||||
// 查询区域的完整路径用于级联选择器回显
|
||||
member.setRegionIds(getRegionPath(member.getRegionId()));
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域的完整路径(从根到当前节点的ID列表)
|
||||
*/
|
||||
private List<Long> getRegionPath(Long regionId) {
|
||||
List<Long> path = new ArrayList<>();
|
||||
Long currentId = regionId;
|
||||
while (currentId != null && currentId > 0) {
|
||||
path.add(0, currentId);
|
||||
PgRegion region = regionMapper.selectById(currentId);
|
||||
if (region == null) {
|
||||
break;
|
||||
}
|
||||
currentId = region.getParentId();
|
||||
}
|
||||
return path;
|
||||
return baseMapper.selectById(memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -112,12 +93,63 @@ public class PgMemberServiceImpl implements IPgMemberService {
|
|||
member.setStatus("0");
|
||||
}
|
||||
|
||||
// 教师身份校验必填字段
|
||||
validateTeacherInfo(member);
|
||||
|
||||
return baseMapper.insert(member);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void insertWithEducations(MemberSaveDto dto) {
|
||||
log.info("新增会员: phone={}, educations={}, studentIds={}",
|
||||
dto.getPhone(),
|
||||
dto.getEducations() != null ? dto.getEducations().size() : 0,
|
||||
dto.getStudentIds() != null ? dto.getStudentIds() : "null");
|
||||
|
||||
// 创建会员
|
||||
PgMember member = new PgMember();
|
||||
member.setPhone(dto.getPhone());
|
||||
member.setNickname(dto.getNickname());
|
||||
member.setGender(dto.getGender());
|
||||
member.setBirthday(dto.getBirthday());
|
||||
member.setStatus(dto.getStatus());
|
||||
insert(member);
|
||||
|
||||
Long memberId = member.getMemberId();
|
||||
|
||||
// 保存教育身份
|
||||
if (dto.getEducations() != null && !dto.getEducations().isEmpty()) {
|
||||
log.info("保存教育身份: memberId={}, count={}", memberId, dto.getEducations().size());
|
||||
for (EducationDto eduDto : dto.getEducations()) {
|
||||
educationService.addEducation(memberId, eduDto);
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定学生(亲子关系)
|
||||
if (dto.getStudentIds() != null && !dto.getStudentIds().isEmpty()) {
|
||||
log.info("绑定学生: memberId={}, studentIds={}", memberId, dto.getStudentIds());
|
||||
studentService.bindStudentsToMember(memberId, dto.getStudentIds());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateWithEducations(MemberSaveDto dto) {
|
||||
if (dto.getMemberId() == null) {
|
||||
throw new ServiceException("会员ID不能为空");
|
||||
}
|
||||
|
||||
// 更新会员基本信息
|
||||
PgMember member = new PgMember();
|
||||
member.setMemberId(dto.getMemberId());
|
||||
member.setPhone(dto.getPhone());
|
||||
member.setNickname(dto.getNickname());
|
||||
member.setGender(dto.getGender());
|
||||
member.setBirthday(dto.getBirthday());
|
||||
member.setStatus(dto.getStatus());
|
||||
update(member);
|
||||
|
||||
// 注意:编辑时教育身份通过单独的接口管理,这里不处理
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int update(PgMember member) {
|
||||
|
|
@ -126,17 +158,6 @@ public class PgMemberServiceImpl implements IPgMemberService {
|
|||
throw new ServiceException("手机号已存在");
|
||||
}
|
||||
|
||||
// 教师身份校验必填字段
|
||||
validateTeacherInfo(member);
|
||||
|
||||
// 家长身份清空学校信息
|
||||
if ("1".equals(member.getIdentityType())) {
|
||||
member.setRegionId(null);
|
||||
member.setSchoolId(null);
|
||||
member.setSchoolGradeId(null);
|
||||
member.setSchoolClassId(null);
|
||||
}
|
||||
|
||||
// 不更新密码(密码通过重置接口更新)
|
||||
member.setPassword(null);
|
||||
|
||||
|
|
@ -223,31 +244,10 @@ public class PgMemberServiceImpl implements IPgMemberService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验教师身份必填字段
|
||||
*/
|
||||
private void validateTeacherInfo(PgMember member) {
|
||||
if ("2".equals(member.getIdentityType())) {
|
||||
if (member.getRegionId() == null) {
|
||||
throw new ServiceException("教师身份必须选择所属区域");
|
||||
}
|
||||
if (member.getSchoolId() == null) {
|
||||
throw new ServiceException("教师身份必须选择所属学校");
|
||||
}
|
||||
if (member.getSchoolGradeId() == null) {
|
||||
throw new ServiceException("教师身份必须选择所属年级");
|
||||
}
|
||||
if (member.getSchoolClassId() == null) {
|
||||
throw new ServiceException("教师身份必须选择所属班级");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<PgMember> buildQueryWrapper(PgMember member) {
|
||||
LambdaQueryWrapper<PgMember> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.like(StrUtil.isNotBlank(member.getNickname()), PgMember::getNickname, member.getNickname());
|
||||
lqw.like(StrUtil.isNotBlank(member.getPhone()), PgMember::getPhone, member.getPhone());
|
||||
lqw.eq(StrUtil.isNotBlank(member.getIdentityType()), PgMember::getIdentityType, member.getIdentityType());
|
||||
lqw.eq(StrUtil.isNotBlank(member.getStatus()), PgMember::getStatus, member.getStatus());
|
||||
lqw.orderByDesc(PgMember::getCreateTime);
|
||||
return lqw;
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public class PgStudentController extends BaseController {
|
|||
* @param schoolId 学校ID(教师身份时传入,限制只能选本校学生)
|
||||
*/
|
||||
@GetMapping("/available")
|
||||
public TableDataInfo<PgStudent> availableStudents(
|
||||
public TableDataInfo<StudentVo> availableStudents(
|
||||
@RequestParam(required = false) String studentName,
|
||||
@RequestParam(required = false) String studentNo,
|
||||
@RequestParam(required = false) Long memberId,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public interface IPgStudentService {
|
|||
* @param schoolId 学校ID(教师身份时必传,限制只能选本校学生)
|
||||
* @param pageQuery 分页参数
|
||||
*/
|
||||
TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery);
|
||||
TableDataInfo<StudentVo> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 查询会员已绑定的学生列表(包含学校、年级、班级名称)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import org.dromara.pangu.base.domain.PgClass;
|
|||
import org.dromara.pangu.base.domain.PgGrade;
|
||||
import org.dromara.pangu.base.mapper.PgClassMapper;
|
||||
import org.dromara.pangu.base.mapper.PgGradeMapper;
|
||||
import org.dromara.pangu.base.domain.PgEducation;
|
||||
import org.dromara.pangu.base.mapper.PgEducationMapper;
|
||||
import org.dromara.pangu.member.domain.PgMember;
|
||||
import org.dromara.pangu.member.mapper.PgMemberMapper;
|
||||
import org.dromara.pangu.school.domain.PgSchool;
|
||||
|
|
@ -52,6 +54,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
private final PgGradeMapper gradeMapper;
|
||||
private final PgClassMapper classMapper;
|
||||
private final PgMemberMapper memberMapper;
|
||||
private final PgEducationMapper educationMapper;
|
||||
|
||||
@Override
|
||||
public TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery) {
|
||||
|
|
@ -211,7 +214,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
|
||||
public TableDataInfo<StudentVo> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.like(StrUtil.isNotBlank(studentName), PgStudent::getStudentName, studentName);
|
||||
lqw.like(StrUtil.isNotBlank(studentNo), PgStudent::getStudentNo, studentNo);
|
||||
|
|
@ -224,7 +227,9 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
lqw.orderByDesc(PgStudent::getCreateTime);
|
||||
|
||||
Page<PgStudent> page = baseMapper.selectPage(pageQuery.build(), lqw);
|
||||
return TableDataInfo.build(page);
|
||||
// 转换为 VO,填充学校、年级、班级名称
|
||||
List<StudentVo> voList = convertToVoList(page.getRecords());
|
||||
return new TableDataInfo<>(voList, page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -344,11 +349,18 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
PgMember member = findOrCreateMember(dto.getMemberPhone().trim());
|
||||
Long memberId = member.getMemberId();
|
||||
|
||||
// 6. 教师身份校验:教师的区域/学校/年级/班级必须与学生一致
|
||||
if ("2".equals(member.getIdentityType())) {
|
||||
String teacherError = validateTeacherStudent(member, school, schoolGrade, schoolClass);
|
||||
if (teacherError != null) {
|
||||
failList.add(createFailItem(rowNum, teacherError));
|
||||
// 6. 教师身份校验:检查会员是否有匹配的教育身份
|
||||
List<PgEducation> educations = educationMapper.selectList(
|
||||
new LambdaQueryWrapper<PgEducation>()
|
||||
.eq(PgEducation::getMemberId, memberId)
|
||||
.eq(PgEducation::getDelFlag, "0")
|
||||
);
|
||||
if (!educations.isEmpty()) {
|
||||
// 会员有教育身份,检查是否包含学生所在的班级
|
||||
boolean hasMatchingClass = educations.stream()
|
||||
.anyMatch(e -> schoolClass.getId().equals(e.getSchoolClassId()));
|
||||
if (!hasMatchingClass) {
|
||||
failList.add(createFailItem(rowNum, "该教师未管理学生所在班级"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -445,12 +457,11 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
return member;
|
||||
}
|
||||
|
||||
// 不存在则创建新会员(身份为家长,初始密码123456)
|
||||
// 不存在则创建新会员(初始密码123456)
|
||||
PgMember newMember = new PgMember();
|
||||
newMember.setMemberCode(generateMemberCode()); // 生成会员编码
|
||||
newMember.setPhone(phone);
|
||||
newMember.setNickname("家长" + phone.substring(7)); // 默认昵称
|
||||
newMember.setIdentityType("1"); // 家长
|
||||
newMember.setPassword(BCrypt.hashpw("123456")); // 初始密码
|
||||
newMember.setStatus("0"); // 正常
|
||||
newMember.setRegisterSource("4"); // 批量导入
|
||||
|
|
@ -460,44 +471,6 @@ public class PgStudentServiceImpl implements IPgStudentService {
|
|||
return newMember;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验教师与学生的归属关系
|
||||
* 教师的区域/学校/年级/班级必须与学生一致
|
||||
*/
|
||||
private String validateTeacherStudent(PgMember teacher, PgSchool studentSchool,
|
||||
PgSchoolGrade studentGrade, PgSchoolClass studentClass) {
|
||||
String teacherInfo = "教师\"" + (teacher.getNickname() != null ? teacher.getNickname() : "未知")
|
||||
+ "\"(" + teacher.getPhone() + ")";
|
||||
|
||||
// 检查教师是否设置了学校信息
|
||||
if (teacher.getSchoolId() == null || teacher.getSchoolGradeId() == null || teacher.getSchoolClassId() == null) {
|
||||
return teacherInfo + "未设置学校信息,无法绑定学生";
|
||||
}
|
||||
|
||||
// 校验区域(通过学校的区域ID间接校验)
|
||||
if (teacher.getRegionId() != null && studentSchool.getRegionId() != null) {
|
||||
if (!teacher.getRegionId().equals(studentSchool.getRegionId())) {
|
||||
return teacherInfo + "所属区域与学生不一致";
|
||||
}
|
||||
}
|
||||
|
||||
// 校验学校
|
||||
if (!teacher.getSchoolId().equals(studentSchool.getSchoolId())) {
|
||||
return teacherInfo + "所属学校与学生不一致";
|
||||
}
|
||||
|
||||
// 校验年级
|
||||
if (!teacher.getSchoolGradeId().equals(studentGrade.getId())) {
|
||||
return teacherInfo + "所属年级与学生不一致";
|
||||
}
|
||||
|
||||
// 校验班级
|
||||
if (!teacher.getSchoolClassId().equals(studentClass.getId())) {
|
||||
return teacherInfo + "所属班级与学生不一致";
|
||||
}
|
||||
|
||||
return null; // 校验通过
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成会员编码(M + 年月日时分秒毫秒 + 4位随机数)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
# 会员教育信息重构方案
|
||||
|
||||
> 作者:湖北新华业务中台研发团队
|
||||
> 创建时间:2026-02-03
|
||||
> 状态:**已审核通过**
|
||||
|
||||
---
|
||||
|
||||
## 一、需求变更说明
|
||||
|
||||
### 1.1 变更内容
|
||||
|
||||
1. **去掉身份类型**:会员不再区分"家长"和"教师"
|
||||
2. **重构会员信息表**:会员与教育信息变为**一对多**关系,一个会员可以有多个教育身份
|
||||
3. **pg_member 表移除字段**:
|
||||
- `region_id` - 区域ID
|
||||
- `school_id` - 学校ID
|
||||
- `school_grade_id` - 年级ID
|
||||
- `school_class_id` - 班级ID
|
||||
- `identity_type` - 身份类型
|
||||
- ~~`union_id`~~ - **保留**(微信多应用打通需要)
|
||||
|
||||
### 1.2 变更原因
|
||||
|
||||
- 业务场景:一个用户可能同时是多个学校/班级的教师
|
||||
- 简化会员模型:会员表只存储基础信息,教育信息独立管理
|
||||
|
||||
---
|
||||
|
||||
## 二、当前架构分析
|
||||
|
||||
### 2.1 现有数据结构
|
||||
|
||||
**pg_member 表**(部分字段):
|
||||
|
||||
| 字段 | 类型 | 说明 | 变更 |
|
||||
|------|------|------|------|
|
||||
| member_id | bigint | 主键 | 保留 |
|
||||
| member_code | varchar(32) | 会员编号 | 保留 |
|
||||
| phone | varchar(20) | 手机号 | 保留 |
|
||||
| password | varchar(100) | 密码 | 保留 |
|
||||
| nickname | varchar(50) | 昵称 | 保留 |
|
||||
| avatar | varchar(500) | 头像 | 保留 |
|
||||
| gender | char(1) | 性别 | 保留 |
|
||||
| birthday | date | 生日 | 保留 |
|
||||
| **identity_type** | char(1) | 身份类型 | **删除** |
|
||||
| open_id | varchar(100) | 微信OpenID | 保留 |
|
||||
| union_id | varchar(100) | 微信UnionID | 保留 |
|
||||
| **region_id** | bigint | 区域ID | **删除** |
|
||||
| **school_id** | bigint | 学校ID | **删除** |
|
||||
| **school_grade_id** | bigint | 年级ID | **删除** |
|
||||
| **school_class_id** | bigint | 班级ID | **删除** |
|
||||
| register_source | char(1) | 注册来源 | 保留 |
|
||||
| register_time | datetime | 注册时间 | 保留 |
|
||||
| status | char(1) | 状态 | 保留 |
|
||||
|
||||
### 2.2 现有关联关系
|
||||
|
||||
```
|
||||
pg_member(会员)
|
||||
├── 一对一:教育信息(存在会员表中) ← 需要改为一对多
|
||||
└── 一对多:pg_student(学生) ← 保持不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、目标架构设计
|
||||
|
||||
### 3.1 新数据结构
|
||||
|
||||
```
|
||||
pg_member(会员)- 只存基础信息
|
||||
├── 一对多:pg_member_education(会员教育信息) ← 新建
|
||||
└── 一对多:pg_student(学生) ← 保持不变
|
||||
```
|
||||
|
||||
### 3.2 新建表:pg_member_education
|
||||
|
||||
**设计理念**:一个教育身份 = 一个班级的教学关系
|
||||
|
||||
```
|
||||
张老师教3个班 → 3条教育身份记录
|
||||
|
||||
教育身份1: 二中 - 高二 - 1班 - 数学
|
||||
教育身份2: 二中 - 高二 - 2班 - 数学
|
||||
教育身份3: 二中 - 高二 - 3班 - 数学
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE TABLE pg_member_education (
|
||||
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
|
||||
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||
region_id BIGINT COMMENT '区域ID',
|
||||
school_id BIGINT NOT NULL COMMENT '学校ID',
|
||||
school_grade_id BIGINT NOT NULL COMMENT '年级ID(school_grade关联ID)',
|
||||
school_class_id BIGINT NOT NULL COMMENT '班级ID(school_class关联ID)',
|
||||
subject_id BIGINT COMMENT '学科ID',
|
||||
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份(0否 1是)',
|
||||
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
|
||||
KEY idx_member_id (member_id),
|
||||
KEY idx_school_id (school_id),
|
||||
KEY idx_class_id (school_class_id)
|
||||
) COMMENT '会员教育信息表';
|
||||
```
|
||||
|
||||
### 3.3 最终关系图
|
||||
|
||||
```
|
||||
pg_member(会员基础信息)
|
||||
│
|
||||
├── 一对多 → pg_member_education(教育身份,每班一条)
|
||||
│
|
||||
└── 一对多 → pg_student(学生/孩子)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、影响范围分析
|
||||
|
||||
### 4.1 数据库层
|
||||
|
||||
| 操作 | 对象 | 说明 |
|
||||
|------|------|------|
|
||||
| 修改 | pg_member | 删除5个字段 |
|
||||
| 新建 | pg_member_education | 会员教育信息表(含班级字段) |
|
||||
| 迁移 | 现有教育数据 | 迁移到新表 |
|
||||
|
||||
### 4.2 后端层
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `PgMember.java` | 修改 | 删除5个字段(保留unionId) |
|
||||
| `PgMemberEducation.java` | 新建 | 教育身份实体 |
|
||||
| `PgMemberEducationMapper.java` | 新建 | Mapper |
|
||||
| `H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
|
||||
| `H5EducationDto.java` | 修改 | 添加educationId |
|
||||
| `H5EducationVo.java` | 修改 | 添加educationId等字段 |
|
||||
| `H5MemberController.java` | 修改 | 接口调整 |
|
||||
| `H5MemberInfoVo.java` | 修改 | 删除identityType |
|
||||
| `PgMemberController.java` | 修改 | 删除身份类型相关 |
|
||||
| `PgMemberServiceImpl.java` | 修改 | 删除教育信息处理 |
|
||||
|
||||
### 4.3 管理后台前端
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `business/member/index.vue` | 修改 | 删除身份类型筛选和列 |
|
||||
| `business/member/components/MemberDialog.vue` | 重构 | 删除身份类型和教师信息区块 |
|
||||
| `business/member/components/EducationTab.vue` | **新建** | 教育身份管理Tab |
|
||||
| `business/member/components/MemberDetail.vue` | **新建** | 会员详情页(含教育身份Tab) |
|
||||
| `api/pangu/member.js` | 修改 | 删除/新增接口 |
|
||||
| `api/pangu/memberEducation.js` | **新建** | 教育身份接口 |
|
||||
|
||||
### 4.4 H5前端
|
||||
|
||||
| 文件 | 改动类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| `stores/user.ts` | 修改 | 删除 identityType |
|
||||
| `views/userCenter/index.vue` | 修改 | 教育身份列表支持多个 |
|
||||
| `components/TeacherIdentityForm.vue` | 修改 | 新增/编辑某个教育身份 |
|
||||
| `api/user.js` | 修改 | 接口调整 |
|
||||
|
||||
---
|
||||
|
||||
## 五、接口变更
|
||||
|
||||
### 5.1 H5接口变更
|
||||
|
||||
#### 5.1.1 获取会员信息(修改)
|
||||
|
||||
**接口**:`GET /h5/member/info`
|
||||
|
||||
**响应变更**:
|
||||
|
||||
```json
|
||||
// 修改前
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"consumerId": 1,
|
||||
"phone": "138****0000",
|
||||
"nickname": "测试用户",
|
||||
"identityType": "2", // ← 删除
|
||||
"education": { ... }, // 单个
|
||||
"students": [...]
|
||||
}
|
||||
}
|
||||
|
||||
// 修改后
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"consumerId": 1,
|
||||
"phone": "138****0000",
|
||||
"nickname": "测试用户",
|
||||
"educations": [...], // 多个教育身份
|
||||
"students": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.1.2 教育身份列表(修改)
|
||||
|
||||
**接口**:`GET /h5/member/educations`
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"educationId": 1,
|
||||
"schoolId": 1,
|
||||
"schoolName": "武汉市第二中学",
|
||||
"schoolGradeId": 5,
|
||||
"gradeName": "高二",
|
||||
"schoolClassId": 104,
|
||||
"className": "1班",
|
||||
"subjectId": 1,
|
||||
"subjectName": "数学",
|
||||
"isDefault": "1"
|
||||
},
|
||||
{
|
||||
"educationId": 2,
|
||||
"schoolId": 1,
|
||||
"schoolName": "武汉市第二中学",
|
||||
"schoolGradeId": 5,
|
||||
"gradeName": "高二",
|
||||
"schoolClassId": 105,
|
||||
"className": "2班",
|
||||
"subjectId": 1,
|
||||
"subjectName": "数学",
|
||||
"isDefault": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.1.3 新增教育身份
|
||||
|
||||
**接口**:`POST /h5/member/educations`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"schoolId": 1,
|
||||
"schoolGradeId": 5,
|
||||
"schoolClassId": 104,
|
||||
"subjectId": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.1.4 编辑教育身份
|
||||
|
||||
**接口**:`PUT /h5/member/educations/{educationId}`
|
||||
|
||||
**请求参数**:
|
||||
|
||||
```json
|
||||
{
|
||||
"schoolId": 1,
|
||||
"schoolGradeId": 5,
|
||||
"schoolClassId": 104,
|
||||
"subjectId": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.1.5 删除教育身份
|
||||
|
||||
**接口**:`DELETE /h5/member/educations/{educationId}`
|
||||
|
||||
#### 5.1.6 设置默认教育身份
|
||||
|
||||
**接口**:`PUT /h5/member/educations/{educationId}/default`
|
||||
|
||||
### 5.2 管理后台接口变更
|
||||
|
||||
#### 5.2.1 会员列表(修改)
|
||||
|
||||
- 删除 `identityType` 筛选参数
|
||||
- 响应中删除 `identityType` 字段
|
||||
|
||||
#### 5.2.2 会员详情(修改)
|
||||
|
||||
- 删除教育信息字段
|
||||
- 新增 `educations` 列表
|
||||
|
||||
#### 5.2.3 新增/编辑会员(修改)
|
||||
|
||||
- 删除 `identityType`、`regionId`、`schoolId`、`schoolGradeId`、`schoolClassId` 参数
|
||||
|
||||
#### 5.2.4 会员教育身份管理(新增)
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/business/member/{memberId}/educations` | GET | 获取会员教育身份列表 |
|
||||
| `/business/member/{memberId}/education` | POST | 添加教育身份 |
|
||||
| `/business/member/{memberId}/education/{educationId}` | PUT | 修改教育身份 |
|
||||
| `/business/member/{memberId}/education/{educationId}` | DELETE | 删除教育身份 |
|
||||
|
||||
---
|
||||
|
||||
## 六、详细技术方案
|
||||
|
||||
### 6.1 数据库改造
|
||||
|
||||
#### 6.1.1 DDL脚本
|
||||
|
||||
```sql
|
||||
-- 新建会员教育信息表(一个教育身份 = 一个班级的教学关系)
|
||||
CREATE TABLE pg_member_education (
|
||||
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
|
||||
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||
region_id BIGINT COMMENT '区域ID',
|
||||
school_id BIGINT NOT NULL COMMENT '学校ID',
|
||||
school_grade_id BIGINT NOT NULL COMMENT '年级ID',
|
||||
school_class_id BIGINT NOT NULL COMMENT '班级ID',
|
||||
subject_id BIGINT COMMENT '学科ID',
|
||||
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份(0否 1是)',
|
||||
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||
tenant_id VARCHAR(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
create_dept BIGINT COMMENT '创建部门',
|
||||
create_by BIGINT COMMENT '创建者',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_by BIGINT COMMENT '更新者',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
|
||||
KEY idx_member_id (member_id),
|
||||
KEY idx_school_id (school_id),
|
||||
KEY idx_class_id (school_class_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='会员教育信息表';
|
||||
```
|
||||
|
||||
#### 6.1.2 数据迁移脚本
|
||||
|
||||
```sql
|
||||
-- 迁移现有教育数据(identity_type = '2' 的教师数据)
|
||||
INSERT INTO pg_member_education (
|
||||
member_id, region_id, school_id, school_grade_id, school_class_id, subject_id, is_default, status, tenant_id, create_time
|
||||
)
|
||||
SELECT
|
||||
member_id, region_id, school_id, school_grade_id, school_class_id, NULL, '1', '0', tenant_id, NOW()
|
||||
FROM pg_member
|
||||
WHERE identity_type = '2'
|
||||
AND school_id IS NOT NULL
|
||||
AND school_class_id IS NOT NULL
|
||||
AND del_flag = '0';
|
||||
```
|
||||
|
||||
#### 6.1.3 删除字段(迁移完成后执行)
|
||||
|
||||
```sql
|
||||
-- 删除 pg_member 表中的字段(建议先备份)
|
||||
-- 注意:union_id 保留,不删除
|
||||
ALTER TABLE pg_member
|
||||
DROP COLUMN identity_type,
|
||||
DROP COLUMN region_id,
|
||||
DROP COLUMN school_id,
|
||||
DROP COLUMN school_grade_id,
|
||||
DROP COLUMN school_class_id;
|
||||
```
|
||||
|
||||
### 6.2 后端改造
|
||||
|
||||
#### 6.2.1 新建实体类
|
||||
|
||||
**PgMemberEducation.java**
|
||||
|
||||
```java
|
||||
package org.dromara.pangu.member.domain;
|
||||
|
||||
@Data
|
||||
@TableName("pg_member_education")
|
||||
public class PgMemberEducation extends BaseEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long educationId;
|
||||
private Long memberId;
|
||||
private Long regionId;
|
||||
private Long schoolId;
|
||||
private Long schoolGradeId;
|
||||
private Long schoolClassId;
|
||||
private Long subjectId;
|
||||
private String isDefault;
|
||||
private String status;
|
||||
private String tenantId;
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
// 非数据库字段(用于VO展示)
|
||||
@TableField(exist = false)
|
||||
private String schoolName;
|
||||
@TableField(exist = false)
|
||||
private String gradeName;
|
||||
@TableField(exist = false)
|
||||
private String className;
|
||||
@TableField(exist = false)
|
||||
private String subjectName;
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2.2 修改 PgMember.java
|
||||
|
||||
删除以下字段:
|
||||
- identityType
|
||||
- regionId
|
||||
- regionIds(@TableField(exist = false))
|
||||
- schoolId
|
||||
- schoolGradeId
|
||||
- schoolClassId
|
||||
|
||||
**保留字段**:
|
||||
- unionId(微信多应用打通需要)
|
||||
|
||||
#### 6.2.3 新建 Mapper
|
||||
|
||||
- PgMemberEducationMapper.java
|
||||
|
||||
#### 6.2.4 修改 H5 Service
|
||||
|
||||
**H5MemberServiceImpl.java 主要改动**:
|
||||
|
||||
1. `getEducation()` → `getEducations()` 返回列表
|
||||
2. `saveEducation()` 支持新增/编辑
|
||||
3. `deleteEducation(Long educationId)` 按ID删除
|
||||
4. 新增 `setDefaultEducation(Long educationId)` 设置默认
|
||||
|
||||
### 6.3 管理后台前端改造
|
||||
|
||||
#### 6.3.1 会员列表页(index.vue)
|
||||
|
||||
**删除内容**:
|
||||
|
||||
```vue
|
||||
<!-- 删除:身份类型筛选 -->
|
||||
<el-form-item label="身份类型">
|
||||
<el-select v-model="queryParams.identityType" ... />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 删除:身份类型列 -->
|
||||
<el-table-column prop="identityType" label="身份类型" ... />
|
||||
```
|
||||
|
||||
#### 6.3.2 会员编辑弹窗(MemberDialog.vue)
|
||||
|
||||
**删除内容**:
|
||||
|
||||
```vue
|
||||
<!-- 删除:身份类型选择 -->
|
||||
<el-form-item label="身份类型" prop="identityType">
|
||||
<el-radio-group v-model="form.identityType" ... />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 删除:教师信息区块 -->
|
||||
<template v-if="form.identityType === '2'">
|
||||
<el-divider>学校信息(教师必填)</el-divider>
|
||||
<!-- 区域、学校、年级、班级选择 -->
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 6.3.3 新建会员详情页
|
||||
|
||||
**MemberDetail.vue**(新建)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="会员详情" width="900px">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<!-- 会员基本信息展示 -->
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="教育身份" name="education">
|
||||
<EducationTab :member-id="memberId" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="绑定学生" name="students">
|
||||
<!-- 学生列表 -->
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 6.3.4 新建教育身份Tab
|
||||
|
||||
**EducationTab.vue**(新建)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<el-button type="primary" @click="handleAdd">添加教育身份</el-button>
|
||||
<el-table :data="educationList">
|
||||
<el-table-column prop="schoolName" label="学校" />
|
||||
<el-table-column prop="gradeName" label="年级" />
|
||||
<el-table-column label="班级">
|
||||
<template #default="{ row }">
|
||||
{{ row.classNames?.join('、') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="subjectName" label="学科" />
|
||||
<el-table-column prop="isDefault" label="默认" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDefault === '1'" type="success">是</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link @click="handleSetDefault(row)">设为默认</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EducationDialog ref="dialogRef" @success="loadData" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 6.4 H5前端改造
|
||||
|
||||
#### 6.4.1 user.ts 修改
|
||||
|
||||
```typescript
|
||||
// 删除
|
||||
identityType: string
|
||||
|
||||
// 修改
|
||||
education: any // 单个 →
|
||||
educations: any[] // 多个
|
||||
```
|
||||
|
||||
#### 6.4.2 userCenter/index.vue 修改
|
||||
|
||||
支持展示多个教育身份,而不是只展示一个。
|
||||
|
||||
#### 6.4.3 TeacherIdentityForm.vue 修改
|
||||
|
||||
新增 `educationId` 字段用于编辑。
|
||||
|
||||
---
|
||||
|
||||
## 七、文件改动清单
|
||||
|
||||
### 7.1 数据库
|
||||
|
||||
| 文件/操作 | 类型 |
|
||||
|-----------|------|
|
||||
| DDL: pg_member_education | 新建表(含 school_class_id) |
|
||||
| DML: 数据迁移脚本 | 执行 |
|
||||
| DDL: pg_member 删除字段 | 修改表 |
|
||||
|
||||
### 7.2 后端(共 12+ 文件)
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `member/domain/PgMember.java` | 修改 | 删除5个字段(保留unionId) |
|
||||
| `member/domain/PgMemberEducation.java` | 新建 | |
|
||||
| `member/mapper/PgMemberEducationMapper.java` | 新建 | |
|
||||
| `member/service/PgMemberEducationService.java` | 新建 | |
|
||||
| `member/service/impl/PgMemberEducationServiceImpl.java` | 新建 | |
|
||||
| `member/controller/PgMemberController.java` | 修改 | 删除身份类型相关 |
|
||||
| `member/service/impl/PgMemberServiceImpl.java` | 修改 | |
|
||||
| `h5/domain/dto/H5EducationDto.java` | 修改 | 添加educationId |
|
||||
| `h5/domain/vo/H5EducationVo.java` | 修改 | |
|
||||
| `h5/domain/vo/H5MemberInfoVo.java` | 修改 | 删除identityType |
|
||||
| `h5/controller/H5MemberController.java` | 修改 | 接口调整 |
|
||||
| `h5/service/impl/H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
|
||||
|
||||
### 7.3 管理后台前端(共 8+ 文件)
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `views/business/member/index.vue` | 修改 | 删除身份类型 |
|
||||
| `views/business/member/components/MemberDialog.vue` | 重构 | 删除教师信息 |
|
||||
| `views/business/member/components/MemberDetail.vue` | 新建 | 会员详情 |
|
||||
| `views/business/member/components/EducationTab.vue` | 新建 | 教育身份Tab |
|
||||
| `views/business/member/components/EducationDialog.vue` | 新建 | 教育身份编辑 |
|
||||
| `api/pangu/member.js` | 修改 | |
|
||||
| `api/pangu/memberEducation.js` | 新建 | |
|
||||
|
||||
### 7.4 H5前端(共 5+ 文件)
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `stores/user.ts` | 修改 | 删除identityType |
|
||||
| `views/userCenter/index.vue` | 修改 | 支持多教育身份 |
|
||||
| `components/TeacherIdentityForm.vue` | 修改 | 添加educationId |
|
||||
| `api/user.js` | 修改 | 接口调整 |
|
||||
| `views/register/index.vue` | 可能修改 | 如有身份类型相关 |
|
||||
|
||||
---
|
||||
|
||||
## 八、测试要点
|
||||
|
||||
| 场景 | 测试内容 |
|
||||
|------|----------|
|
||||
| 数据迁移 | 现有教师数据正确迁移到新表 |
|
||||
| H5-新增教育身份 | 首次添加、多次添加(每班一条记录) |
|
||||
| H5-编辑教育身份 | 修改学校/年级/班级/学科 |
|
||||
| H5-删除教育身份 | 删除非默认、删除默认 |
|
||||
| H5-设置默认 | 切换默认身份 |
|
||||
| H5-多教育身份 | 一个老师添加多条教育身份(教多个班) |
|
||||
| 管理后台-会员列表 | 不显示身份类型 |
|
||||
| 管理后台-会员详情 | 查看多个教育身份 |
|
||||
| 管理后台-教育身份管理 | 增删改查 |
|
||||
|
||||
---
|
||||
|
||||
## 九、上线计划
|
||||
|
||||
| 步骤 | 内容 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | 备份数据库 | 全量备份 pg_member |
|
||||
| 2 | 执行建表DDL | 创建新表 |
|
||||
| 3 | 执行数据迁移 | 迁移现有教育数据 |
|
||||
| 4 | 部署后端 | 新代码 |
|
||||
| 5 | 部署管理后台前端 | 新代码 |
|
||||
| 6 | 部署H5前端 | 新代码 |
|
||||
| 7 | 验证测试 | 回归测试 |
|
||||
| 8 | 执行删除字段DDL | 确认无问题后 |
|
||||
|
||||
---
|
||||
|
||||
## 十、回滚方案
|
||||
|
||||
1. 后端代码回滚
|
||||
2. 前端代码回滚
|
||||
3. 数据库:
|
||||
- 从新表恢复数据到 pg_member
|
||||
- 删除新建的两张表
|
||||
|
||||
---
|
||||
|
||||
## 十一、风险评估
|
||||
|
||||
| 风险 | 等级 | 应对措施 |
|
||||
|------|------|----------|
|
||||
| 数据迁移丢失 | 高 | 完整备份,测试环境验证 |
|
||||
| 接口不兼容 | 高 | 前后端同步上线 |
|
||||
| 性能问题 | 低 | 新表已加索引 |
|
||||
| 业务中断 | 中 | 选择低峰期上线 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:现有数据统计(上线前执行)
|
||||
|
||||
```sql
|
||||
-- 统计需要迁移的教育数据
|
||||
SELECT COUNT(*) FROM pg_member
|
||||
WHERE identity_type = '2' AND school_id IS NOT NULL AND del_flag = '0';
|
||||
|
||||
-- 统计有班级信息的数据
|
||||
SELECT COUNT(*) FROM pg_member
|
||||
WHERE identity_type = '2' AND school_class_id IS NOT NULL AND del_flag = '0';
|
||||
```
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
# 教师多班级功能技术方案
|
||||
|
||||
> 作者:湖北新华业务中台研发团队
|
||||
> 创建时间:2026-02-03
|
||||
> 状态:待审核
|
||||
|
||||
---
|
||||
|
||||
## 一、需求描述
|
||||
|
||||
### 1.1 业务场景
|
||||
|
||||
- 张老师是武汉二中高二年级的数学老师
|
||||
- 他同时教 1班、2班、3班
|
||||
- 系统应支持在**一个教育身份**中选择**多个班级**
|
||||
|
||||
### 1.2 预期效果
|
||||
|
||||
**添加/编辑教育身份时**:
|
||||
|
||||
```
|
||||
所在地区: 湖北省 / 武汉市 / 硚口区
|
||||
学校: 武汉市第二中学
|
||||
年级: 高二
|
||||
班级: [1班] [2班] [3班] ← 多选
|
||||
学科: 数学
|
||||
```
|
||||
|
||||
**个人中心展示**:
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ 📘 数学 当前使用 │
|
||||
│ 武汉市第二中学 │
|
||||
│ 班级: 高二1班、高二2班、高二3班 │
|
||||
│ 学科: 数学 │
|
||||
│ 编辑 解除 切换 │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、当前架构分析
|
||||
|
||||
### 2.1 现有数据结构
|
||||
|
||||
| 表名 | 字段 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pg_member` | `school_class_id` | `bigint` | 单值,只能存一个班级 |
|
||||
|
||||
### 2.2 现有接口
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/h5/member/education` | GET | 返回单个教育身份 |
|
||||
| `/h5/member/education` | POST | 保存单个教育身份(覆盖) |
|
||||
| `/h5/member/education` | DELETE | 删除教育身份 |
|
||||
|
||||
### 2.3 问题
|
||||
|
||||
- 班级字段是单值,不支持多班级
|
||||
- 接口设计为覆盖式更新
|
||||
|
||||
---
|
||||
|
||||
## 三、技术方案
|
||||
|
||||
### 3.1 数据库改造
|
||||
|
||||
#### 3.1.1 新建关联表
|
||||
|
||||
```sql
|
||||
-- 会员-班级关联表(多对多)
|
||||
CREATE TABLE pg_member_class (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||
school_class_id BIGINT NOT NULL COMMENT '学校班级关联ID',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_member_class (member_id, school_class_id),
|
||||
KEY idx_member_id (member_id)
|
||||
) COMMENT '会员班级关联表';
|
||||
```
|
||||
|
||||
#### 3.1.2 数据迁移脚本
|
||||
|
||||
```sql
|
||||
-- 将现有数据迁移到关联表
|
||||
INSERT INTO pg_member_class (member_id, school_class_id)
|
||||
SELECT member_id, school_class_id
|
||||
FROM pg_member
|
||||
WHERE school_class_id IS NOT NULL AND identity_type = '2';
|
||||
```
|
||||
|
||||
#### 3.1.3 pg_member 表调整
|
||||
|
||||
| 字段 | 处理方式 |
|
||||
|------|----------|
|
||||
| `school_class_id` | 保留(向后兼容)或后续版本删除 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 后端改造
|
||||
|
||||
#### 3.2.1 新建实体类
|
||||
|
||||
**文件**:`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/domain/PgMemberClass.java`
|
||||
|
||||
```java
|
||||
package org.dromara.pangu.member.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("pg_member_class")
|
||||
public class PgMemberClass {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Long schoolClassId;
|
||||
private Date createTime;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 新建 Mapper
|
||||
|
||||
**文件**:`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/mapper/PgMemberClassMapper.java`
|
||||
|
||||
```java
|
||||
package org.dromara.pangu.member.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.dromara.pangu.member.domain.PgMemberClass;
|
||||
|
||||
@Mapper
|
||||
public interface PgMemberClassMapper extends BaseMapper<PgMemberClass> {
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.3 修改 DTO
|
||||
|
||||
**文件**:`H5EducationDto.java`
|
||||
|
||||
```java
|
||||
// 修改前
|
||||
@Schema(description = "学校班级关联ID")
|
||||
@NotNull(message = "请选择班级")
|
||||
private Long schoolClassId;
|
||||
|
||||
// 修改后
|
||||
@Schema(description = "学校班级关联ID列表")
|
||||
@NotEmpty(message = "请选择班级")
|
||||
private List<Long> schoolClassIds;
|
||||
```
|
||||
|
||||
#### 3.2.4 修改 VO
|
||||
|
||||
**文件**:`H5EducationVo.java`
|
||||
|
||||
```java
|
||||
// 新增字段
|
||||
private List<Long> schoolClassIds; // 班级ID列表
|
||||
private List<String> classNames; // 班级名称列表(用于前端展示)
|
||||
```
|
||||
|
||||
#### 3.2.5 修改 Service
|
||||
|
||||
**文件**:`H5MemberServiceImpl.java`
|
||||
|
||||
```java
|
||||
@Autowired
|
||||
private PgMemberClassMapper memberClassMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveEducation(H5EducationDto dto) {
|
||||
Long memberId = getCurrentMemberId();
|
||||
PgMember member = memberMapper.selectById(memberId);
|
||||
if (member == null) {
|
||||
throw new ServiceException("会员不存在");
|
||||
}
|
||||
|
||||
// 1. 校验学校、年级存在
|
||||
// ... 原有校验逻辑 ...
|
||||
|
||||
// 2. 校验所有班级是否存在且属于该年级
|
||||
for (Long classId : dto.getSchoolClassIds()) {
|
||||
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
|
||||
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(dto.getSchoolGradeId())) {
|
||||
throw new ServiceException("班级不存在或不属于该年级");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新会员基本信息
|
||||
member.setSchoolId(dto.getSchoolId());
|
||||
member.setSchoolGradeId(dto.getSchoolGradeId());
|
||||
member.setSubjectId(dto.getSubjectId());
|
||||
member.setIdentityType("2");
|
||||
member.setRegionId(school.getRegionId());
|
||||
memberMapper.updateById(member);
|
||||
|
||||
// 4. 删除旧的班级关联
|
||||
memberClassMapper.delete(
|
||||
new LambdaQueryWrapper<PgMemberClass>()
|
||||
.eq(PgMemberClass::getMemberId, memberId)
|
||||
);
|
||||
|
||||
// 5. 插入新的班级关联
|
||||
for (Long classId : dto.getSchoolClassIds()) {
|
||||
PgMemberClass mc = new PgMemberClass();
|
||||
mc.setMemberId(memberId);
|
||||
mc.setSchoolClassId(classId);
|
||||
memberClassMapper.insert(mc);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
H5EducationVo vo = buildEducationVo(member);
|
||||
|
||||
// 查询关联的班级列表
|
||||
List<PgMemberClass> memberClasses = memberClassMapper.selectList(
|
||||
new LambdaQueryWrapper<PgMemberClass>()
|
||||
.eq(PgMemberClass::getMemberId, memberId)
|
||||
);
|
||||
|
||||
List<Long> classIds = memberClasses.stream()
|
||||
.map(PgMemberClass::getSchoolClassId)
|
||||
.collect(Collectors.toList());
|
||||
vo.setSchoolClassIds(classIds);
|
||||
|
||||
// 填充班级名称
|
||||
if (!classIds.isEmpty()) {
|
||||
List<String> classNames = new ArrayList<>();
|
||||
for (Long classId : classIds) {
|
||||
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
|
||||
if (schoolClass != null) {
|
||||
PgClass pgClass = classMapper.selectById(schoolClass.getClassId());
|
||||
if (pgClass != null) {
|
||||
classNames.add(pgClass.getClassName());
|
||||
}
|
||||
}
|
||||
}
|
||||
vo.setClassNames(classNames);
|
||||
}
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteEducation() {
|
||||
Long memberId = getCurrentMemberId();
|
||||
|
||||
// 删除班级关联
|
||||
memberClassMapper.delete(
|
||||
new LambdaQueryWrapper<PgMemberClass>()
|
||||
.eq(PgMemberClass::getMemberId, memberId)
|
||||
);
|
||||
|
||||
// 清空会员教育信息
|
||||
// ... 原有逻辑 ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 H5 前端改造
|
||||
|
||||
#### 3.3.1 修改表单组件
|
||||
|
||||
**文件**:`user_authentication_center_front/user-front/src/components/TeacherIdentityForm.vue`
|
||||
|
||||
**表单数据修改**:
|
||||
|
||||
```javascript
|
||||
// 修改前
|
||||
const teacherForm = reactive({
|
||||
classId: '',
|
||||
})
|
||||
|
||||
// 修改后
|
||||
const teacherForm = reactive({
|
||||
classIds: [],
|
||||
})
|
||||
```
|
||||
|
||||
**模板修改**:
|
||||
|
||||
```vue
|
||||
<!-- 修改前 -->
|
||||
<el-form-item label="班级" prop="classId">
|
||||
<el-select v-model="teacherForm.classId">
|
||||
<!-- ... -->
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 修改后 -->
|
||||
<el-form-item label="班级" prop="classIds">
|
||||
<el-select
|
||||
v-model="teacherForm.classIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="3"
|
||||
placeholder="请选择班级(可多选)"
|
||||
size="large"
|
||||
>
|
||||
<el-option
|
||||
v-for="classItem in classOptions"
|
||||
:key="classItem.schoolClassId"
|
||||
:label="classItem.className"
|
||||
:value="classItem.schoolClassId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
```
|
||||
|
||||
**验证规则修改**:
|
||||
|
||||
```javascript
|
||||
// 修改前
|
||||
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
|
||||
|
||||
// 修改后
|
||||
classIds: [{
|
||||
required: true,
|
||||
type: 'array',
|
||||
min: 1,
|
||||
message: '请至少选择一个班级',
|
||||
trigger: 'change'
|
||||
}],
|
||||
```
|
||||
|
||||
**提交逻辑修改**:
|
||||
|
||||
```javascript
|
||||
const requestData = {
|
||||
schoolId: teacherForm.schoolId,
|
||||
schoolGradeId: teacherForm.gradeId,
|
||||
schoolClassIds: teacherForm.classIds, // 数组
|
||||
subjectId: teacherForm.subjectId,
|
||||
}
|
||||
```
|
||||
|
||||
**回显逻辑修改**:
|
||||
|
||||
```javascript
|
||||
// setFormData 中
|
||||
teacherForm.classIds = formData.schoolClassIds || []
|
||||
```
|
||||
|
||||
#### 3.3.2 修改列表展示
|
||||
|
||||
**文件**:`user_authentication_center_front/user-front/src/views/userCenter/index.vue`
|
||||
|
||||
```javascript
|
||||
// getEducationIdentities 方法中
|
||||
educationIdentities.value = [{
|
||||
id: 1,
|
||||
role: `${item.subjectName || '教师'}`,
|
||||
school: item.schoolName || '',
|
||||
icon: 'mdi:account-tie',
|
||||
active: true,
|
||||
details: [
|
||||
{ label: '班级', value: item.classNames?.join('、') || '' }, // 多班级用顿号连接
|
||||
{ label: '学科', value: item.subjectName || '' },
|
||||
],
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、文件改动清单
|
||||
|
||||
| 层级 | 文件路径 | 改动类型 |
|
||||
|------|----------|----------|
|
||||
| **数据库** | DDL 脚本 | 新建 |
|
||||
| **后端** | `pangu-business/.../member/domain/PgMemberClass.java` | 新建 |
|
||||
| **后端** | `pangu-business/.../member/mapper/PgMemberClassMapper.java` | 新建 |
|
||||
| **后端** | `pangu-business/.../h5/domain/dto/H5EducationDto.java` | 修改 |
|
||||
| **后端** | `pangu-business/.../h5/domain/vo/H5EducationVo.java` | 修改 |
|
||||
| **后端** | `pangu-business/.../h5/service/impl/H5MemberServiceImpl.java` | 修改 |
|
||||
| **H5前端** | `user-front/src/components/TeacherIdentityForm.vue` | 修改 |
|
||||
| **H5前端** | `user-front/src/views/userCenter/index.vue` | 修改 |
|
||||
|
||||
---
|
||||
|
||||
## 五、接口变更
|
||||
|
||||
### 5.1 POST /h5/member/education
|
||||
|
||||
**请求参数变更**:
|
||||
|
||||
```json
|
||||
// 修改前
|
||||
{
|
||||
"schoolId": 1,
|
||||
"schoolGradeId": 5,
|
||||
"schoolClassId": 104,
|
||||
"subjectId": 1
|
||||
}
|
||||
|
||||
// 修改后
|
||||
{
|
||||
"schoolId": 1,
|
||||
"schoolGradeId": 5,
|
||||
"schoolClassIds": [104, 105, 106],
|
||||
"subjectId": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 GET /h5/member/education
|
||||
|
||||
**响应参数变更**:
|
||||
|
||||
```json
|
||||
// 修改前
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"schoolId": 1,
|
||||
"schoolName": "武汉市第二中学",
|
||||
"schoolGradeId": 5,
|
||||
"gradeName": "高二",
|
||||
"schoolClassId": 104,
|
||||
"className": "1班",
|
||||
"subjectId": 1,
|
||||
"subjectName": "数学"
|
||||
}
|
||||
}
|
||||
|
||||
// 修改后
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"schoolId": 1,
|
||||
"schoolName": "武汉市第二中学",
|
||||
"schoolGradeId": 5,
|
||||
"gradeName": "高二",
|
||||
"schoolClassIds": [104, 105, 106],
|
||||
"classNames": ["1班", "2班", "3班"],
|
||||
"subjectId": 1,
|
||||
"subjectName": "数学"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、测试要点
|
||||
|
||||
| 场景 | 测试内容 | 预期结果 |
|
||||
|------|----------|----------|
|
||||
| 新增 | 选择多个班级保存 | 保存成功,关联表有多条记录 |
|
||||
| 编辑 | 回显多个班级 | 多选框显示已选班级 |
|
||||
| 编辑 | 增加/减少班级 | 关联表正确更新 |
|
||||
| 删除 | 删除教育身份 | 关联表数据同步删除 |
|
||||
| 展示 | 列表显示多班级 | 显示"1班、2班、3班" |
|
||||
| 边界 | 不选班级提交 | 校验失败,提示选择班级 |
|
||||
| 边界 | 选择不同年级的班级 | 校验失败,提示班级不属于该年级 |
|
||||
|
||||
---
|
||||
|
||||
## 七、上线计划
|
||||
|
||||
| 步骤 | 内容 | 负责人 | 备注 |
|
||||
|------|------|--------|------|
|
||||
| 1 | 执行数据库 DDL | DBA | 建表 |
|
||||
| 2 | 执行数据迁移脚本 | DBA | 迁移现有数据 |
|
||||
| 3 | 部署后端服务 | 运维 | - |
|
||||
| 4 | 部署 H5 前端 | 运维 | 需同步上线 |
|
||||
| 5 | 验证测试 | 测试 | 回归测试 |
|
||||
|
||||
---
|
||||
|
||||
## 八、回滚方案
|
||||
|
||||
1. 后端代码回滚到上一版本
|
||||
2. 前端代码回滚到上一版本
|
||||
3. 数据库:
|
||||
- 从 `pg_member_class` 恢复 `pg_member.school_class_id`(取第一条)
|
||||
- 删除 `pg_member_class` 表
|
||||
|
||||
---
|
||||
|
||||
## 九、风险评估
|
||||
|
||||
| 风险 | 等级 | 应对措施 |
|
||||
|------|------|----------|
|
||||
| 数据迁移失败 | 中 | 先在测试环境验证,生产环境备份 |
|
||||
| 前后端不同步 | 高 | 同步上线,灰度发布 |
|
||||
| 性能问题 | 低 | 关联表已加索引 |
|
||||
| 兼容性问题 | 中 | 管理后台如有相关页面需同步修改 |
|
||||
|
||||
---
|
||||
|
||||
## 附录:相关表结构
|
||||
|
||||
### pg_member(会员表,现有)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| member_id | bigint | 主键 |
|
||||
| consumer_id | bigint | 用户ID |
|
||||
| school_id | bigint | 学校ID |
|
||||
| school_grade_id | bigint | 年级ID |
|
||||
| school_class_id | bigint | 班级ID(单值,改造后可废弃) |
|
||||
| subject_id | bigint | 学科ID |
|
||||
| identity_type | varchar | 身份类型:1-家长,2-教师 |
|
||||
|
||||
### pg_member_class(会员班级关联表,新建)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | bigint | 主键 |
|
||||
| member_id | bigint | 会员ID |
|
||||
| school_class_id | bigint | 学校班级关联ID |
|
||||
| create_time | datetime | 创建时间 |
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<!--
|
||||
教育身份编辑弹窗
|
||||
@author 湖北新华业务中台研发团队
|
||||
-->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑教育身份' : '添加教育身份'"
|
||||
width="550px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="80px"
|
||||
>
|
||||
<el-form-item label="区域" prop="regionId">
|
||||
<el-cascader
|
||||
v-model="regionIds"
|
||||
:options="regionTree"
|
||||
:props="{ value: 'regionId', label: 'regionName', checkStrictly: true }"
|
||||
placeholder="请选择区域"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="学校" prop="schoolId">
|
||||
<el-select v-model="form.schoolId" placeholder="请选择学校" clearable style="width: 100%" @change="handleSchoolChange">
|
||||
<el-option v-for="item in schoolList" :key="item.schoolId" :label="item.schoolName" :value="item.schoolId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="年级" prop="schoolGradeId">
|
||||
<el-select v-model="form.schoolGradeId" placeholder="请选择年级" clearable style="width: 100%" @change="handleGradeChange">
|
||||
<el-option v-for="item in gradeList" :key="item.id" :label="item.gradeName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="班级" prop="schoolClassId">
|
||||
<el-select v-model="form.schoolClassId" placeholder="请选择班级" clearable style="width: 100%">
|
||||
<el-option v-for="item in classList" :key="item.id" :label="item.className" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="学科" prop="subjectId">
|
||||
<el-select v-model="form.subjectId" placeholder="请选择学科(可选)" clearable style="width: 100%">
|
||||
<el-option v-for="item in subjectList" :key="item.subjectId" :label="item.subjectName" :value="item.subjectId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success', 'add', 'update'])
|
||||
|
||||
const visible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
const submitLoading = ref(false)
|
||||
|
||||
// 会员ID和教育身份ID(memberId 为 null 表示本地模式)
|
||||
const memberId = ref(null)
|
||||
const educationId = ref(null)
|
||||
// 本地模式下的临时索引
|
||||
const localIndex = ref(null)
|
||||
|
||||
// 区域ID数组(用于级联选择器)
|
||||
const regionIds = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
regionId: null,
|
||||
schoolId: null,
|
||||
schoolGradeId: null,
|
||||
schoolClassId: null,
|
||||
subjectId: null
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
schoolId: [{ required: true, message: '请选择学校', trigger: 'change' }],
|
||||
schoolGradeId: [{ required: true, message: '请选择年级', trigger: 'change' }],
|
||||
schoolClassId: [{ required: true, message: '请选择班级', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 下拉选项数据
|
||||
const regionTree = ref([])
|
||||
const schoolList = ref([])
|
||||
const gradeList = ref([])
|
||||
const classList = ref([])
|
||||
const subjectList = ref([])
|
||||
|
||||
/**
|
||||
* 打开弹窗
|
||||
* @param {Long} mId 会员ID(null 表示本地模式)
|
||||
* @param {Object} row 编辑时传入教育身份数据
|
||||
* @param {Number} index 本地模式下的数组索引
|
||||
*/
|
||||
const open = async (mId, row, index) => {
|
||||
resetForm()
|
||||
memberId.value = mId
|
||||
isEdit.value = !!row
|
||||
localIndex.value = index ?? null
|
||||
visible.value = true
|
||||
|
||||
// 加载基础数据
|
||||
await Promise.all([loadRegionTree(), loadSubjectList()])
|
||||
|
||||
// 编辑模式
|
||||
if (row) {
|
||||
educationId.value = row.educationId
|
||||
form.schoolId = row.schoolId
|
||||
form.schoolGradeId = row.schoolGradeId
|
||||
form.schoolClassId = row.schoolClassId
|
||||
form.subjectId = row.subjectId
|
||||
|
||||
// 加载关联数据
|
||||
if (row.regionId) {
|
||||
// 本地模式下 regionIds 可能已有值
|
||||
if (row.regionIds) {
|
||||
regionIds.value = row.regionIds
|
||||
} else {
|
||||
regionIds.value = await getRegionPath(row.regionId)
|
||||
}
|
||||
form.regionId = row.regionId
|
||||
await loadSchoolList(row.regionId)
|
||||
}
|
||||
if (row.schoolId) {
|
||||
await loadGradeList(row.schoolId)
|
||||
}
|
||||
if (row.schoolGradeId) {
|
||||
await loadClassList(row.schoolGradeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
const resetForm = () => {
|
||||
educationId.value = null
|
||||
regionIds.value = []
|
||||
form.regionId = null
|
||||
form.schoolId = null
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
form.subjectId = null
|
||||
schoolList.value = []
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载区域树
|
||||
*/
|
||||
const loadRegionTree = async () => {
|
||||
try {
|
||||
const res = await request.get('/business/region/tree')
|
||||
regionTree.value = res.data || []
|
||||
} catch (e) {
|
||||
regionTree.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载学科列表
|
||||
*/
|
||||
const loadSubjectList = async () => {
|
||||
try {
|
||||
const res = await request.get('/business/subject/listAll')
|
||||
subjectList.value = res.data || []
|
||||
} catch (e) {
|
||||
subjectList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载学校列表
|
||||
*/
|
||||
const loadSchoolList = async (regionId) => {
|
||||
try {
|
||||
const res = await request.get('/business/school/listAll', { params: { regionId } })
|
||||
schoolList.value = res.data || []
|
||||
} catch (e) {
|
||||
schoolList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载年级列表
|
||||
*/
|
||||
const loadGradeList = async (schoolId) => {
|
||||
try {
|
||||
const res = await request.get(`/business/school/${schoolId}/grades`)
|
||||
gradeList.value = res.data || []
|
||||
} catch (e) {
|
||||
gradeList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载班级列表
|
||||
*/
|
||||
const loadClassList = async (schoolGradeId) => {
|
||||
try {
|
||||
const res = await request.get(`/business/school/grade/${schoolGradeId}/classes`)
|
||||
classList.value = res.data || []
|
||||
} catch (e) {
|
||||
classList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域路径(用于回显)
|
||||
*/
|
||||
const getRegionPath = async (regionId) => {
|
||||
try {
|
||||
const res = await request.get(`/business/region/${regionId}/path`)
|
||||
return res.data || []
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域变更
|
||||
*/
|
||||
const handleRegionChange = (val) => {
|
||||
form.regionId = val && val.length ? val[val.length - 1] : null
|
||||
form.schoolId = null
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
schoolList.value = []
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
if (form.regionId) {
|
||||
loadSchoolList(form.regionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校变更
|
||||
*/
|
||||
const handleSchoolChange = () => {
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
if (form.schoolId) {
|
||||
loadGradeList(form.schoolId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级变更
|
||||
*/
|
||||
const handleGradeChange = () => {
|
||||
form.schoolClassId = null
|
||||
classList.value = []
|
||||
if (form.schoolGradeId) {
|
||||
loadClassList(form.schoolGradeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建数据(包含名称用于本地展示)
|
||||
const data = {
|
||||
regionId: form.regionId,
|
||||
regionIds: [...regionIds.value],
|
||||
schoolId: form.schoolId,
|
||||
schoolGradeId: form.schoolGradeId,
|
||||
schoolClassId: form.schoolClassId,
|
||||
subjectId: form.subjectId,
|
||||
// 添加名称用于列表展示
|
||||
schoolName: schoolList.value.find(s => s.schoolId === form.schoolId)?.schoolName || '',
|
||||
gradeName: gradeList.value.find(g => g.id === form.schoolGradeId)?.gradeName || '',
|
||||
className: classList.value.find(c => c.id === form.schoolClassId)?.className || '',
|
||||
subjectName: subjectList.value.find(s => s.subjectId === form.subjectId)?.subjectName || ''
|
||||
}
|
||||
|
||||
// 本地模式:返回数据给父组件
|
||||
if (!memberId.value) {
|
||||
visible.value = false
|
||||
if (isEdit.value) {
|
||||
emit('update', data, localIndex.value)
|
||||
} else {
|
||||
emit('add', data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 远程模式:调用 API
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const apiData = {
|
||||
regionId: form.regionId,
|
||||
schoolId: form.schoolId,
|
||||
schoolGradeId: form.schoolGradeId,
|
||||
schoolClassId: form.schoolClassId,
|
||||
subjectId: form.subjectId
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const res = await request.put(`/business/member/${memberId.value}/educations/${educationId.value}`, apiData)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('修改成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}
|
||||
} else {
|
||||
const res = await request.post(`/business/member/${memberId.value}/educations`, apiData)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('添加成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
|
@ -53,14 +53,6 @@
|
|||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="身份类型" prop="identityType">
|
||||
<el-radio-group v-model="form.identityType" @change="handleIdentityChange">
|
||||
<el-radio value="1">家长</el-radio>
|
||||
<el-radio value="2">教师</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
|
|
@ -74,65 +66,45 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 教师身份时显示学校信息 -->
|
||||
<template v-if="form.identityType === '2'">
|
||||
<el-divider content-position="left">学校信息(教师必填)</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="区域" prop="regionId" :rules="teacherRules.regionId">
|
||||
<el-cascader
|
||||
v-model="form.regionIds"
|
||||
:options="regionTree"
|
||||
:props="{ value: 'regionId', label: 'regionName', checkStrictly: true }"
|
||||
placeholder="请选择区域"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleRegionChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="学校" prop="schoolId" :rules="teacherRules.schoolId">
|
||||
<el-select v-model="form.schoolId" placeholder="请选择学校" clearable style="width: 100%" @change="handleSchoolChange">
|
||||
<el-option v-for="item in schoolList" :key="item.schoolId" :label="item.schoolName" :value="item.schoolId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="年级" prop="schoolGradeId" :rules="teacherRules.schoolGradeId">
|
||||
<el-select v-model="form.schoolGradeId" placeholder="请选择年级" clearable style="width: 100%" @change="handleGradeChange">
|
||||
<el-option v-for="item in gradeList" :key="item.id" :label="item.gradeName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="班级" prop="schoolClassId" :rules="teacherRules.schoolClassId">
|
||||
<el-select v-model="form.schoolClassId" placeholder="请选择班级" clearable style="width: 100%">
|
||||
<el-option v-for="item in classList" :key="item.id" :label="item.className" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<!-- 绑定学生 -->
|
||||
<el-divider content-position="left">绑定学生</el-divider>
|
||||
<el-alert
|
||||
v-if="form.identityType === '2'"
|
||||
title="教师只能绑定本校学生"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<!-- 教育身份 -->
|
||||
<el-divider content-position="left">教育身份</el-divider>
|
||||
<el-row style="margin-bottom: 12px;">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleAddStudent">添加学生</el-button>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleAddEducation">添加教育身份</el-button>
|
||||
</el-row>
|
||||
<el-table :data="form.students" border size="small" max-height="200">
|
||||
<el-table :data="educations" border size="small" max-height="180">
|
||||
<template #empty>
|
||||
<el-empty description="暂无绑定学生" :image-size="60" />
|
||||
<el-empty description="暂无教育身份" :image-size="60" />
|
||||
</template>
|
||||
<el-table-column prop="schoolName" label="学校" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="gradeName" label="年级" width="70" />
|
||||
<el-table-column prop="className" label="班级" width="60" />
|
||||
<el-table-column prop="subjectName" label="学科" width="60" />
|
||||
<el-table-column label="默认" width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDefault === '1'" type="success" size="small">默认</el-tag>
|
||||
<el-button v-else link type="primary" size="small" @click="handleSetDefault(row)">设为</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEditEducation(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定删除该教育身份?" @confirm="handleDeleteEducation(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 亲子关系 -->
|
||||
<el-divider content-position="left">亲子关系</el-divider>
|
||||
<el-row style="margin-bottom: 12px;">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleAddStudent">添加亲子关系</el-button>
|
||||
</el-row>
|
||||
<el-table :data="form.students" border size="small" max-height="180">
|
||||
<template #empty>
|
||||
<el-empty description="暂无亲子关系" :image-size="60" />
|
||||
</template>
|
||||
<el-table-column prop="studentName" label="姓名" min-width="80" />
|
||||
<el-table-column prop="studentNo" label="学号" width="120" />
|
||||
|
|
@ -141,9 +113,9 @@
|
|||
<el-table-column prop="className" label="班级" width="60" />
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm title="确定解绑该学生?" @confirm="handleRemoveStudent(row)">
|
||||
<el-popconfirm :title="isEdit ? '确定解绑该学生?' : '确定移除该学生?'" @confirm="handleRemoveStudent(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small">解绑</el-button>
|
||||
<el-button link type="danger" size="small">{{ isEdit ? '解绑' : '移除' }}</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
|
|
@ -156,7 +128,10 @@
|
|||
</template>
|
||||
|
||||
<!-- 学生选择器 -->
|
||||
<StudentSelectDialog ref="studentSelectRef" @success="loadBoundStudents" />
|
||||
<StudentSelectDialog ref="studentSelectRef" @success="loadBoundStudents" @add="handleLocalAddStudents" />
|
||||
|
||||
<!-- 教育身份编辑弹窗 -->
|
||||
<EducationDialog ref="educationDialogRef" @success="loadEducations" @add="handleLocalAddEducation" @update="handleLocalUpdateEducation" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
|
|
@ -166,6 +141,7 @@ import { Plus } from '@element-plus/icons-vue'
|
|||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import StudentSelectDialog from './StudentSelectDialog.vue'
|
||||
import EducationDialog from './EducationDialog.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
|
|
@ -174,6 +150,10 @@ const isEdit = ref(false)
|
|||
const formRef = ref(null)
|
||||
const submitLoading = ref(false)
|
||||
const studentSelectRef = ref(null)
|
||||
const educationDialogRef = ref(null)
|
||||
|
||||
// 教育身份列表
|
||||
const educations = ref([])
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
|
|
@ -182,13 +162,7 @@ const form = reactive({
|
|||
nickname: '',
|
||||
gender: '0',
|
||||
birthday: '',
|
||||
identityType: '1',
|
||||
status: '0',
|
||||
regionIds: [],
|
||||
regionId: null,
|
||||
schoolId: null,
|
||||
schoolGradeId: null,
|
||||
schoolClassId: null,
|
||||
students: []
|
||||
})
|
||||
|
||||
|
|
@ -197,26 +171,9 @@ const rules = {
|
|||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
identityType: [
|
||||
{ required: true, message: '请选择身份类型', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 教师身份额外校验规则
|
||||
const teacherRules = {
|
||||
regionId: [{ required: true, message: '请选择所属区域', trigger: 'change' }],
|
||||
schoolId: [{ required: true, message: '请选择所属学校', trigger: 'change' }],
|
||||
schoolGradeId: [{ required: true, message: '请选择所属年级', trigger: 'change' }],
|
||||
schoolClassId: [{ required: true, message: '请选择所属班级', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 下拉选项数据
|
||||
const regionTree = ref([])
|
||||
const schoolList = ref([])
|
||||
const gradeList = ref([])
|
||||
const classList = ref([])
|
||||
|
||||
/**
|
||||
* 打开弹窗
|
||||
*/
|
||||
|
|
@ -225,28 +182,14 @@ const open = async (row) => {
|
|||
isEdit.value = !!row
|
||||
visible.value = true
|
||||
|
||||
// 加载区域树
|
||||
await loadRegionTree()
|
||||
|
||||
// 编辑模式加载会员数据
|
||||
if (row && row.memberId) {
|
||||
try {
|
||||
const res = await request.get(`/business/member/${row.memberId}`)
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
// 后端已返回 regionIds 数组用于级联选择器回显
|
||||
// 加载学校信息的下拉选项
|
||||
if (form.regionId) {
|
||||
await loadSchoolList(form.regionId)
|
||||
}
|
||||
if (form.schoolId) {
|
||||
await loadGradeList(form.schoolId)
|
||||
}
|
||||
if (form.schoolGradeId) {
|
||||
await loadClassList(form.schoolGradeId)
|
||||
}
|
||||
// 加载已绑定的学生列表
|
||||
await loadBoundStudents()
|
||||
// 加载教育身份和学生列表
|
||||
await Promise.all([loadEducations(), loadBoundStudents()])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载会员数据失败', e)
|
||||
|
|
@ -254,6 +197,22 @@ const open = async (row) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载教育身份列表
|
||||
*/
|
||||
const loadEducations = async () => {
|
||||
if (!form.memberId) {
|
||||
educations.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await request.get(`/business/member/${form.memberId}/educations`)
|
||||
educations.value = res.data || []
|
||||
} catch (e) {
|
||||
educations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载已绑定的学生列表
|
||||
*/
|
||||
|
|
@ -279,143 +238,147 @@ const resetForm = () => {
|
|||
form.nickname = ''
|
||||
form.gender = '0'
|
||||
form.birthday = ''
|
||||
form.identityType = '1'
|
||||
form.status = '0'
|
||||
form.regionIds = []
|
||||
form.regionId = null
|
||||
form.schoolId = null
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
form.students = []
|
||||
schoolList.value = []
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
educations.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载区域树
|
||||
* 添加教育身份
|
||||
*/
|
||||
const loadRegionTree = async () => {
|
||||
try {
|
||||
const res = await request.get('/business/region/tree')
|
||||
regionTree.value = res.data || []
|
||||
} catch (e) {
|
||||
regionTree.value = []
|
||||
const handleAddEducation = () => {
|
||||
// 新增模式:本地模式(memberId 传 null)
|
||||
// 编辑模式:远程模式(传实际 memberId)
|
||||
educationDialogRef.value?.open(isEdit.value ? form.memberId : null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑教育身份
|
||||
*/
|
||||
const handleEditEducation = (row) => {
|
||||
const index = educations.value.indexOf(row)
|
||||
educationDialogRef.value?.open(isEdit.value ? form.memberId : null, row, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地添加教育身份(新增会员模式)
|
||||
*/
|
||||
const handleLocalAddEducation = (data) => {
|
||||
// 如果是第一条,设为默认
|
||||
if (educations.value.length === 0) {
|
||||
data.isDefault = '1'
|
||||
} else {
|
||||
data.isDefault = '0'
|
||||
}
|
||||
educations.value.push(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地更新教育身份(新增会员模式)
|
||||
*/
|
||||
const handleLocalUpdateEducation = (data, index) => {
|
||||
if (index !== null && index >= 0) {
|
||||
// 保留原来的默认状态
|
||||
data.isDefault = educations.value[index].isDefault
|
||||
educations.value[index] = data
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载学校列表
|
||||
* 删除教育身份
|
||||
*/
|
||||
const loadSchoolList = async (regionId) => {
|
||||
try {
|
||||
const res = await request.get('/business/school/listAll', { params: { regionId } })
|
||||
schoolList.value = res.data || []
|
||||
} catch (e) {
|
||||
schoolList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载年级列表
|
||||
*/
|
||||
const loadGradeList = async (schoolId) => {
|
||||
try {
|
||||
const res = await request.get(`/business/school/${schoolId}/grades`)
|
||||
gradeList.value = res.data || []
|
||||
} catch (e) {
|
||||
gradeList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载班级列表
|
||||
*/
|
||||
const loadClassList = async (schoolGradeId) => {
|
||||
try {
|
||||
const res = await request.get(`/business/school/grade/${schoolGradeId}/classes`)
|
||||
classList.value = res.data || []
|
||||
} catch (e) {
|
||||
classList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份类型变更
|
||||
*/
|
||||
const handleIdentityChange = () => {
|
||||
// 切换为家长时清空学校信息
|
||||
if (form.identityType === '1') {
|
||||
form.regionIds = []
|
||||
form.regionId = null
|
||||
form.schoolId = null
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
schoolList.value = []
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域变更
|
||||
*/
|
||||
const handleRegionChange = (val) => {
|
||||
form.regionId = val && val.length ? val[val.length - 1] : null
|
||||
form.schoolId = null
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
schoolList.value = []
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
if (form.regionId) {
|
||||
loadSchoolList(form.regionId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 学校变更
|
||||
*/
|
||||
const handleSchoolChange = () => {
|
||||
form.schoolGradeId = null
|
||||
form.schoolClassId = null
|
||||
gradeList.value = []
|
||||
classList.value = []
|
||||
if (form.schoolId) {
|
||||
loadGradeList(form.schoolId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级变更
|
||||
*/
|
||||
const handleGradeChange = () => {
|
||||
form.schoolClassId = null
|
||||
classList.value = []
|
||||
if (form.schoolGradeId) {
|
||||
loadClassList(form.schoolGradeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加学生
|
||||
*/
|
||||
const handleAddStudent = () => {
|
||||
if (!isEdit.value || !form.memberId) {
|
||||
ElMessage.warning('请先保存会员信息后再绑定学生')
|
||||
const handleDeleteEducation = async (row) => {
|
||||
// 新增模式:本地删除
|
||||
if (!isEdit.value) {
|
||||
const index = educations.value.indexOf(row)
|
||||
if (index > -1) {
|
||||
educations.value.splice(index, 1)
|
||||
// 如果删除的是默认项且还有其他项,将第一项设为默认
|
||||
if (row.isDefault === '1' && educations.value.length > 0) {
|
||||
educations.value[0].isDefault = '1'
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
studentSelectRef.value?.open({
|
||||
memberId: form.memberId,
|
||||
identityType: form.identityType,
|
||||
schoolId: form.schoolId
|
||||
})
|
||||
|
||||
// 编辑模式:远程删除
|
||||
try {
|
||||
const res = await request.delete(`/business/member/${form.memberId}/educations/${row.educationId}`)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
await loadEducations()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑学生
|
||||
* 设为默认教育身份
|
||||
*/
|
||||
const handleSetDefault = async (row) => {
|
||||
// 新增模式:本地设置
|
||||
if (!isEdit.value) {
|
||||
educations.value.forEach(e => e.isDefault = '0')
|
||||
row.isDefault = '1'
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式:远程设置
|
||||
try {
|
||||
const res = await request.put(`/business/member/${form.memberId}/educations/${row.educationId}/default`)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('设置成功')
|
||||
await loadEducations()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('设置失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加亲子关系
|
||||
*/
|
||||
const handleAddStudent = () => {
|
||||
// 新增模式:本地模式(传 excludeIds 排除已选)
|
||||
// 编辑模式:远程模式(传 memberId)
|
||||
if (isEdit.value) {
|
||||
studentSelectRef.value?.open({
|
||||
memberId: form.memberId
|
||||
})
|
||||
} else {
|
||||
studentSelectRef.value?.open({
|
||||
excludeIds: form.students.map(s => s.studentId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地添加学生(新增会员模式)
|
||||
*/
|
||||
const handleLocalAddStudents = (students) => {
|
||||
// 添加到本地列表(去重)
|
||||
for (const student of students) {
|
||||
if (!form.students.some(s => s.studentId === student.studentId)) {
|
||||
form.students.push(student)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑/移除学生
|
||||
*/
|
||||
const handleRemoveStudent = async (row) => {
|
||||
// 新增模式:本地移除
|
||||
if (!isEdit.value) {
|
||||
const index = form.students.findIndex(s => s.studentId === row.studentId)
|
||||
if (index > -1) {
|
||||
form.students.splice(index, 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式:远程解绑
|
||||
try {
|
||||
const res = await request.post(`/business/member/unbindStudent/${row.studentId}`)
|
||||
if (res.code === 200) {
|
||||
|
|
@ -439,10 +402,14 @@ const handleSubmit = async () => {
|
|||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const data = { ...form }
|
||||
// 移除不需要提交的字段
|
||||
delete data.regionIds
|
||||
delete data.students
|
||||
const data = {
|
||||
memberId: form.memberId,
|
||||
phone: form.phone,
|
||||
nickname: form.nickname,
|
||||
gender: form.gender,
|
||||
birthday: form.birthday,
|
||||
status: form.status
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
const res = await request.put('/business/member', data)
|
||||
|
|
@ -452,6 +419,15 @@ const handleSubmit = async () => {
|
|||
emit('success')
|
||||
}
|
||||
} else {
|
||||
// 新增时,将教育身份和亲子关系一起提交
|
||||
data.educations = educations.value.map(e => ({
|
||||
regionId: e.regionId,
|
||||
schoolId: e.schoolId,
|
||||
schoolGradeId: e.schoolGradeId,
|
||||
schoolClassId: e.schoolClassId,
|
||||
subjectId: e.subjectId
|
||||
}))
|
||||
data.studentIds = form.students.map(s => s.studentId)
|
||||
const res = await request.post('/business/member', data)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('新增成功')
|
||||
|
|
|
|||
|
|
@ -24,16 +24,6 @@
|
|||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 教师身份提示 -->
|
||||
<el-alert
|
||||
v-if="isTeacher"
|
||||
title="教师只能绑定本校学生"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
|
||||
<!-- 学生列表 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
|
|
@ -74,7 +64,7 @@
|
|||
<div class="dialog-footer">
|
||||
<span style="margin-right: 16px; color: #909399">已选择 {{ selectedStudents.length }} 名学生</span>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">确定绑定</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
|
@ -86,7 +76,7 @@ import { Refresh, Search } from '@element-plus/icons-vue'
|
|||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const emit = defineEmits(['success', 'add'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
|
|
@ -96,10 +86,12 @@ const total = ref(0)
|
|||
const selectedStudents = ref([])
|
||||
const tableRef = ref(null)
|
||||
|
||||
// 会员信息
|
||||
// 会员信息(memberId 为 null 表示本地模式)
|
||||
const memberId = ref(null)
|
||||
const isTeacher = ref(false)
|
||||
const schoolId = ref(null)
|
||||
// 本地模式:已选中的学生ID(用于排除)
|
||||
const excludeStudentIds = ref([])
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
|
|
@ -111,12 +103,13 @@ const queryParams = reactive({
|
|||
|
||||
/**
|
||||
* 打开弹窗
|
||||
* @param options { memberId: Long, identityType: String, schoolId: Long }
|
||||
* @param options { memberId: Long, excludeIds: Long[] } - memberId 为 null 表示本地模式
|
||||
*/
|
||||
const open = (options = {}) => {
|
||||
memberId.value = options.memberId
|
||||
isTeacher.value = options.identityType === '2'
|
||||
schoolId.value = options.schoolId
|
||||
memberId.value = options.memberId || null
|
||||
isTeacher.value = false
|
||||
schoolId.value = null
|
||||
excludeStudentIds.value = options.excludeIds || []
|
||||
|
||||
resetQuery()
|
||||
visible.value = true
|
||||
|
|
@ -134,11 +127,15 @@ const getList = async () => {
|
|||
pageSize: queryParams.pageSize,
|
||||
studentName: queryParams.studentName || undefined,
|
||||
studentNo: queryParams.studentNo || undefined,
|
||||
memberId: memberId.value,
|
||||
schoolId: isTeacher.value ? schoolId.value : undefined
|
||||
memberId: memberId.value || undefined
|
||||
}
|
||||
const res = await request.get('/business/student/available', { params })
|
||||
studentList.value = res.rows || []
|
||||
let list = res.rows || []
|
||||
// 本地模式:过滤掉已选中的学生
|
||||
if (!memberId.value && excludeStudentIds.value.length > 0) {
|
||||
list = list.filter(s => !excludeStudentIds.value.includes(s.studentId))
|
||||
}
|
||||
studentList.value = list
|
||||
total.value = res.total || 0
|
||||
} catch (e) {
|
||||
console.error('获取学生列表失败', e)
|
||||
|
|
@ -195,11 +192,18 @@ const handleSelectionChange = (selection) => {
|
|||
*/
|
||||
const handleConfirm = async () => {
|
||||
if (selectedStudents.value.length === 0) {
|
||||
ElMessage.warning('请选择要绑定的学生')
|
||||
ElMessage.warning('请选择要添加的学生')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出需要绑定的学生(排除已绑定当前会员的)
|
||||
// 本地模式:返回选中的学生数据
|
||||
if (!memberId.value) {
|
||||
visible.value = false
|
||||
emit('add', selectedStudents.value)
|
||||
return
|
||||
}
|
||||
|
||||
// 远程模式:调用 API 绑定
|
||||
const studentIds = selectedStudents.value
|
||||
.filter(s => s.memberId !== memberId.value)
|
||||
.map(s => s.studentId)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,6 @@
|
|||
<el-form-item label="昵称">
|
||||
<el-input v-model="queryParams.nickname" placeholder="请输入昵称" clearable style="width: 150px" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份类型">
|
||||
<el-select v-model="queryParams.identityType" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="家长" value="1" />
|
||||
<el-option label="教师" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 100px">
|
||||
<el-option label="正常" value="0" />
|
||||
|
|
@ -72,13 +66,6 @@
|
|||
{{ formatBirthday(row.birthday) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="identityType" label="身份类型" width="85" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.identityType === '1' ? 'success' : 'warning'">
|
||||
{{ row.identityType === '1' ? '家长' : '教师' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="registerTime" label="注册时间" width="165" />
|
||||
<el-table-column prop="registerSource" label="注册来源" width="85" align="center">
|
||||
<template #default="{ row }">
|
||||
|
|
@ -157,7 +144,6 @@ const queryParams = ref({
|
|||
pageSize: 10,
|
||||
phone: '',
|
||||
nickname: '',
|
||||
identityType: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
|
|
@ -235,7 +221,6 @@ const resetQuery = () => {
|
|||
pageSize: 10,
|
||||
phone: '',
|
||||
nickname: '',
|
||||
identityType: '',
|
||||
status: ''
|
||||
}
|
||||
dateRange.value = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue