feat: 添加微信登录接口和技术方案文档

This commit is contained in:
神码-方晓辉 2026-02-03 15:31:56 +08:00
parent f0d8a6c571
commit acdade6725
12 changed files with 2046 additions and 18 deletions

View File

@ -314,3 +314,27 @@ h5:
blacklist-minutes: 30 blacklist-minutes: 30
# 连续验证失败多少次后加入黑名单 # 连续验证失败多少次后加入黑名单
blacklist-trigger-count: 5 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

View File

@ -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;
}

View File

@ -13,12 +13,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto; import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto; import org.dromara.pangu.h5.domain.vo.*;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.service.H5AuthService; import org.dromara.pangu.h5.service.H5AuthService;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -230,4 +226,165 @@ public class H5AuthController {
authService.logout(); authService.logout();
return R.ok(); 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();
}
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 = "会员IDneedBindPhone=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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,11 +1,7 @@
package org.dromara.pangu.h5.service; package org.dromara.pangu.h5.service;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto; import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto; import org.dromara.pangu.h5.domain.vo.*;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
/** /**
* H5认证服务接口 * H5认证服务接口
@ -48,4 +44,33 @@ public interface H5AuthService {
* 退出登录 * 退出登录
*/ */
void logout(); void logout();
// ==================== 微信扫码登录 ====================
/**
* 获取微信扫码登录二维码
*/
WechatQrcodeVo getWechatQrcode();
/**
* 查询微信扫码状态
*
* @param ticket 二维码凭证
*/
WechatStatusVo getWechatStatus(String ticket);
/**
* 微信扫码登录
*/
WechatLoginVo loginByWechat(WechatLoginDto dto);
/**
* 微信登录绑定手机号
*/
WechatLoginVo bindPhoneForWechat(WechatBindPhoneDto dto);
/**
* 发送微信绑定手机号的短信验证码
*/
void sendSmsCodeForWechatBind(H5SmsSendDto dto);
} }

View File

@ -6,6 +6,9 @@ import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt; 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 com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.redis.utils.RedisUtils;
import org.dromara.common.web.core.WaveAndCircleCaptcha; import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.pangu.h5.config.H5SmsProperties; import org.dromara.pangu.h5.config.H5SmsProperties;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto; import org.dromara.pangu.h5.config.H5WechatProperties;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto; import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto; import org.dromara.pangu.h5.domain.vo.*;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.service.H5AuthService; import org.dromara.pangu.h5.service.H5AuthService;
import org.dromara.pangu.member.domain.PgMember; import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper; import org.dromara.pangu.member.mapper.PgMemberMapper;
@ -35,6 +35,8 @@ import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.awt.*; import java.awt.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -53,6 +55,7 @@ public class H5AuthServiceImpl implements H5AuthService {
private final PgMemberMapper memberMapper; private final PgMemberMapper memberMapper;
private final H5SmsProperties smsProperties; private final H5SmsProperties smsProperties;
private final H5WechatProperties wechatProperties;
/** /**
* H5会员登录设备标识 * 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_SMS_FAIL_COUNT_KEY = "h5:sms:fail:";
private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:"; 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小时 * accessToken有效期- 2小时
*/ */
@ -524,4 +533,430 @@ public class H5AuthServiceImpl implements H5AuthService {
} }
return phone.substring(0, 3) + "****" + phone.substring(7); 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());
vo.setIdentityType(loginVo.getIdentityType());
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());
vo.setIdentityType(loginVo.getIdentityType());
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)
);
}
}
}
} }

View File

@ -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 '年级IDschool_grade关联ID',
school_class_id BIGINT NOT NULL COMMENT '班级IDschool_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';
```

View File

@ -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 | 创建时间 |