diff --git a/backend/ruoyi-admin/src/main/resources/application.yml b/backend/ruoyi-admin/src/main/resources/application.yml index a170817..7128404 100644 --- a/backend/ruoyi-admin/src/main/resources/application.yml +++ b/backend/ruoyi-admin/src/main/resources/application.yml @@ -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 diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5WechatProperties.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5WechatProperties.java new file mode 100644 index 0000000..7adf127 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/config/H5WechatProperties.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java index d94acb8..39741a9 100644 --- a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/controller/H5AuthController.java @@ -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 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 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 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 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 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 handleWechatCallback(@RequestParam String code, @RequestParam String state) { + ((org.dromara.pangu.h5.service.impl.H5AuthServiceImpl) authService).handleWechatCallback(code, state); + return R.ok(); + } } diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatBindPhoneDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatBindPhoneDto.java new file mode 100644 index 0000000..53900b1 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatBindPhoneDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatLoginDto.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatLoginDto.java new file mode 100644 index 0000000..5a000db --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/dto/WechatLoginDto.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatLoginVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatLoginVo.java new file mode 100644 index 0000000..7febd82 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatLoginVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatQrcodeVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatQrcodeVo.java new file mode 100644 index 0000000..17dfb0f --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatQrcodeVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatStatusVo.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatStatusVo.java new file mode 100644 index 0000000..9dbeb23 --- /dev/null +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/domain/vo/WechatStatusVo.java @@ -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; +} diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java index 25a57c0..9f73e86 100644 --- a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/H5AuthService.java @@ -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); } diff --git a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java index 7cf97d6..1d7fdfb 100644 --- a/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java +++ b/backend/ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/h5/service/impl/H5AuthServiceImpl.java @@ -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小时 */ @@ -524,4 +533,430 @@ 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().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().eq(PgMember::getOpenId, openId) + ); + if (existByOpenId != null) { + throw new ServiceException("该微信已绑定其他账号"); + } + + // 检查手机号是否已存在 + PgMember existByPhone = memberMapper.selectOne( + new LambdaQueryWrapper().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 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) + ); + } + } + } } diff --git a/docs/会员教育信息重构方案.md b/docs/会员教育信息重构方案.md new file mode 100644 index 0000000..115925b --- /dev/null +++ b/docs/会员教育信息重构方案.md @@ -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 + + + + + + + +``` + +#### 6.3.2 会员编辑弹窗(MemberDialog.vue) + +**删除内容**: + +```vue + + + + + + + +``` + +#### 6.3.3 新建会员详情页 + +**MemberDetail.vue**(新建) + +```vue + +``` + +#### 6.3.4 新建教育身份Tab + +**EducationTab.vue**(新建) + +```vue + +``` + +### 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'; +``` diff --git a/docs/教师多班级功能技术方案.md b/docs/教师多班级功能技术方案.md new file mode 100644 index 0000000..d21b0ff --- /dev/null +++ b/docs/教师多班级功能技术方案.md @@ -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 { +} +``` + +#### 3.2.3 修改 DTO + +**文件**:`H5EducationDto.java` + +```java +// 修改前 +@Schema(description = "学校班级关联ID") +@NotNull(message = "请选择班级") +private Long schoolClassId; + +// 修改后 +@Schema(description = "学校班级关联ID列表") +@NotEmpty(message = "请选择班级") +private List schoolClassIds; +``` + +#### 3.2.4 修改 VO + +**文件**:`H5EducationVo.java` + +```java +// 新增字段 +private List schoolClassIds; // 班级ID列表 +private List 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() + .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 memberClasses = memberClassMapper.selectList( + new LambdaQueryWrapper() + .eq(PgMemberClass::getMemberId, memberId) + ); + + List classIds = memberClasses.stream() + .map(PgMemberClass::getSchoolClassId) + .collect(Collectors.toList()); + vo.setSchoolClassIds(classIds); + + // 填充班级名称 + if (!classIds.isEmpty()) { + List 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() + .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 + + + + + + + + + + + + + +``` + +**验证规则修改**: + +```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 | 创建时间 |