feat: 添加微信登录接口和技术方案文档
This commit is contained in:
parent
f0d8a6c571
commit
acdade6725
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package org.dromara.pangu.h5.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H5微信登录配置属性
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "h5.wechat")
|
||||||
|
public class H5WechatProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用微信登录
|
||||||
|
*/
|
||||||
|
private boolean enabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信开放平台AppID(网站应用)
|
||||||
|
*/
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信开放平台AppSecret
|
||||||
|
*/
|
||||||
|
private String appSecret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二维码有效期(秒),默认5分钟
|
||||||
|
*/
|
||||||
|
private int qrcodeExpireSeconds = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定凭证有效期(分钟),默认10分钟
|
||||||
|
*/
|
||||||
|
private int bindTokenExpireMinutes = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次登录是否自动注册会员
|
||||||
|
*/
|
||||||
|
private boolean autoRegister = true;
|
||||||
|
}
|
||||||
|
|
@ -13,12 +13,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.dromara.pangu.h5.domain.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录绑定手机号请求DTO
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信登录绑定手机号请求参数")
|
||||||
|
public class WechatBindPhoneDto {
|
||||||
|
|
||||||
|
@Schema(description = "绑定凭证(从微信登录接口获取,needBindPhone=true时返回)", requiredMode = Schema.RequiredMode.REQUIRED, example = "bind_abc123")
|
||||||
|
@NotBlank(message = "绑定凭证不能为空")
|
||||||
|
private String bindToken;
|
||||||
|
|
||||||
|
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
|
||||||
|
@NotBlank(message = "手机号不能为空")
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "短信验证码(6位数字)", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||||
|
@NotBlank(message = "短信验证码不能为空")
|
||||||
|
private String smsCode;
|
||||||
|
|
||||||
|
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
|
||||||
|
@NotBlank(message = "图形验证码不能为空")
|
||||||
|
private String captchaCode;
|
||||||
|
|
||||||
|
@Schema(description = "图形验证码标识(从/captcha接口获取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
|
||||||
|
@NotBlank(message = "验证码标识不能为空")
|
||||||
|
private String uuid;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.dromara.pangu.h5.domain.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录请求DTO
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信扫码登录请求参数")
|
||||||
|
public class WechatLoginDto {
|
||||||
|
|
||||||
|
@Schema(description = "二维码凭证(从获取二维码接口获取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
|
||||||
|
@NotBlank(message = "二维码凭证不能为空")
|
||||||
|
private String ticket;
|
||||||
|
|
||||||
|
@Schema(description = "临时登录码(从查询状态接口获取,状态为confirmed时返回)", requiredMode = Schema.RequiredMode.REQUIRED, example = "xyz789abc")
|
||||||
|
@NotBlank(message = "临时登录码不能为空")
|
||||||
|
private String tempCode;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package org.dromara.pangu.h5.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信登录响应VO
|
||||||
|
* 登录成功时返回Token,需要绑定手机号时返回bindToken
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信登录响应")
|
||||||
|
public class WechatLoginVo {
|
||||||
|
|
||||||
|
@Schema(description = "是否需要绑定手机号", example = "false")
|
||||||
|
private Boolean needBindPhone;
|
||||||
|
|
||||||
|
@Schema(description = "绑定凭证,needBindPhone=true时返回,用于绑定手机号接口", example = "bind_abc123")
|
||||||
|
private String bindToken;
|
||||||
|
|
||||||
|
@Schema(description = "访问令牌,needBindPhone=false时返回", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
@Schema(description = "刷新令牌,needBindPhone=false时返回", example = "refresh_abc123def456")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
@Schema(description = "accessToken过期时间(秒)", example = "7200")
|
||||||
|
private Long expiresIn;
|
||||||
|
|
||||||
|
@Schema(description = "会员ID,needBindPhone=false时返回", example = "1234567890123456789")
|
||||||
|
private Long memberId;
|
||||||
|
|
||||||
|
@Schema(description = "会员编号,needBindPhone=false时返回", example = "M170900000012345678")
|
||||||
|
private String memberCode;
|
||||||
|
|
||||||
|
@Schema(description = "手机号(脱敏),needBindPhone=false时返回", example = "138****5678")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "昵称,needBindPhone=false时返回", example = "user_5678")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@Schema(description = "身份类型,needBindPhone=false时返回", example = "1")
|
||||||
|
private String identityType;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.dromara.pangu.h5.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码登录二维码响应VO
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信扫码登录二维码响应")
|
||||||
|
public class WechatQrcodeVo {
|
||||||
|
|
||||||
|
@Schema(description = "二维码凭证,用于后续查询扫码状态", example = "abc123def456")
|
||||||
|
private String ticket;
|
||||||
|
|
||||||
|
@Schema(description = "二维码图片URL", example = "https://open.weixin.qq.com/connect/qrcode/xxx")
|
||||||
|
private String qrcodeUrl;
|
||||||
|
|
||||||
|
@Schema(description = "二维码有效期(秒)", example = "300")
|
||||||
|
private Integer expireSeconds;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.dromara.pangu.h5.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信扫码状态响应VO
|
||||||
|
*
|
||||||
|
* @author 湖北新华业务中台研发团队
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "微信扫码状态响应")
|
||||||
|
public class WechatStatusVo {
|
||||||
|
|
||||||
|
@Schema(description = "扫码状态:waiting-等待扫码,scanned-已扫码待确认,confirmed-已确认,expired-已过期", example = "waiting")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "临时登录码,状态为confirmed时返回,用于后续登录接口", example = "xyz789abc")
|
||||||
|
private String tempCode;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
package org.dromara.pangu.h5.service;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,660 @@
|
||||||
|
# 会员教育信息重构方案
|
||||||
|
|
||||||
|
> 作者:湖北新华业务中台研发团队
|
||||||
|
> 创建时间:2026-02-03
|
||||||
|
> 状态:**已审核通过**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、需求变更说明
|
||||||
|
|
||||||
|
### 1.1 变更内容
|
||||||
|
|
||||||
|
1. **去掉身份类型**:会员不再区分"家长"和"教师"
|
||||||
|
2. **重构会员信息表**:会员与教育信息变为**一对多**关系,一个会员可以有多个教育身份
|
||||||
|
3. **pg_member 表移除字段**:
|
||||||
|
- `region_id` - 区域ID
|
||||||
|
- `school_id` - 学校ID
|
||||||
|
- `school_grade_id` - 年级ID
|
||||||
|
- `school_class_id` - 班级ID
|
||||||
|
- `identity_type` - 身份类型
|
||||||
|
- ~~`union_id`~~ - **保留**(微信多应用打通需要)
|
||||||
|
|
||||||
|
### 1.2 变更原因
|
||||||
|
|
||||||
|
- 业务场景:一个用户可能同时是多个学校/班级的教师
|
||||||
|
- 简化会员模型:会员表只存储基础信息,教育信息独立管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、当前架构分析
|
||||||
|
|
||||||
|
### 2.1 现有数据结构
|
||||||
|
|
||||||
|
**pg_member 表**(部分字段):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 变更 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| member_id | bigint | 主键 | 保留 |
|
||||||
|
| member_code | varchar(32) | 会员编号 | 保留 |
|
||||||
|
| phone | varchar(20) | 手机号 | 保留 |
|
||||||
|
| password | varchar(100) | 密码 | 保留 |
|
||||||
|
| nickname | varchar(50) | 昵称 | 保留 |
|
||||||
|
| avatar | varchar(500) | 头像 | 保留 |
|
||||||
|
| gender | char(1) | 性别 | 保留 |
|
||||||
|
| birthday | date | 生日 | 保留 |
|
||||||
|
| **identity_type** | char(1) | 身份类型 | **删除** |
|
||||||
|
| open_id | varchar(100) | 微信OpenID | 保留 |
|
||||||
|
| union_id | varchar(100) | 微信UnionID | 保留 |
|
||||||
|
| **region_id** | bigint | 区域ID | **删除** |
|
||||||
|
| **school_id** | bigint | 学校ID | **删除** |
|
||||||
|
| **school_grade_id** | bigint | 年级ID | **删除** |
|
||||||
|
| **school_class_id** | bigint | 班级ID | **删除** |
|
||||||
|
| register_source | char(1) | 注册来源 | 保留 |
|
||||||
|
| register_time | datetime | 注册时间 | 保留 |
|
||||||
|
| status | char(1) | 状态 | 保留 |
|
||||||
|
|
||||||
|
### 2.2 现有关联关系
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_member(会员)
|
||||||
|
├── 一对一:教育信息(存在会员表中) ← 需要改为一对多
|
||||||
|
└── 一对多:pg_student(学生) ← 保持不变
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目标架构设计
|
||||||
|
|
||||||
|
### 3.1 新数据结构
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_member(会员)- 只存基础信息
|
||||||
|
├── 一对多:pg_member_education(会员教育信息) ← 新建
|
||||||
|
└── 一对多:pg_student(学生) ← 保持不变
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 新建表:pg_member_education
|
||||||
|
|
||||||
|
**设计理念**:一个教育身份 = 一个班级的教学关系
|
||||||
|
|
||||||
|
```
|
||||||
|
张老师教3个班 → 3条教育身份记录
|
||||||
|
|
||||||
|
教育身份1: 二中 - 高二 - 1班 - 数学
|
||||||
|
教育身份2: 二中 - 高二 - 2班 - 数学
|
||||||
|
教育身份3: 二中 - 高二 - 3班 - 数学
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pg_member_education (
|
||||||
|
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
|
||||||
|
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||||
|
region_id BIGINT COMMENT '区域ID',
|
||||||
|
school_id BIGINT NOT NULL COMMENT '学校ID',
|
||||||
|
school_grade_id BIGINT NOT NULL COMMENT '年级ID(school_grade关联ID)',
|
||||||
|
school_class_id BIGINT NOT NULL COMMENT '班级ID(school_class关联ID)',
|
||||||
|
subject_id BIGINT COMMENT '学科ID',
|
||||||
|
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份(0否 1是)',
|
||||||
|
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||||
|
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
|
||||||
|
KEY idx_member_id (member_id),
|
||||||
|
KEY idx_school_id (school_id),
|
||||||
|
KEY idx_class_id (school_class_id)
|
||||||
|
) COMMENT '会员教育信息表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 最终关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
pg_member(会员基础信息)
|
||||||
|
│
|
||||||
|
├── 一对多 → pg_member_education(教育身份,每班一条)
|
||||||
|
│
|
||||||
|
└── 一对多 → pg_student(学生/孩子)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、影响范围分析
|
||||||
|
|
||||||
|
### 4.1 数据库层
|
||||||
|
|
||||||
|
| 操作 | 对象 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 修改 | pg_member | 删除5个字段 |
|
||||||
|
| 新建 | pg_member_education | 会员教育信息表(含班级字段) |
|
||||||
|
| 迁移 | 现有教育数据 | 迁移到新表 |
|
||||||
|
|
||||||
|
### 4.2 后端层
|
||||||
|
|
||||||
|
| 文件 | 改动类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `PgMember.java` | 修改 | 删除5个字段(保留unionId) |
|
||||||
|
| `PgMemberEducation.java` | 新建 | 教育身份实体 |
|
||||||
|
| `PgMemberEducationMapper.java` | 新建 | Mapper |
|
||||||
|
| `H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
|
||||||
|
| `H5EducationDto.java` | 修改 | 添加educationId |
|
||||||
|
| `H5EducationVo.java` | 修改 | 添加educationId等字段 |
|
||||||
|
| `H5MemberController.java` | 修改 | 接口调整 |
|
||||||
|
| `H5MemberInfoVo.java` | 修改 | 删除identityType |
|
||||||
|
| `PgMemberController.java` | 修改 | 删除身份类型相关 |
|
||||||
|
| `PgMemberServiceImpl.java` | 修改 | 删除教育信息处理 |
|
||||||
|
|
||||||
|
### 4.3 管理后台前端
|
||||||
|
|
||||||
|
| 文件 | 改动类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `business/member/index.vue` | 修改 | 删除身份类型筛选和列 |
|
||||||
|
| `business/member/components/MemberDialog.vue` | 重构 | 删除身份类型和教师信息区块 |
|
||||||
|
| `business/member/components/EducationTab.vue` | **新建** | 教育身份管理Tab |
|
||||||
|
| `business/member/components/MemberDetail.vue` | **新建** | 会员详情页(含教育身份Tab) |
|
||||||
|
| `api/pangu/member.js` | 修改 | 删除/新增接口 |
|
||||||
|
| `api/pangu/memberEducation.js` | **新建** | 教育身份接口 |
|
||||||
|
|
||||||
|
### 4.4 H5前端
|
||||||
|
|
||||||
|
| 文件 | 改动类型 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `stores/user.ts` | 修改 | 删除 identityType |
|
||||||
|
| `views/userCenter/index.vue` | 修改 | 教育身份列表支持多个 |
|
||||||
|
| `components/TeacherIdentityForm.vue` | 修改 | 新增/编辑某个教育身份 |
|
||||||
|
| `api/user.js` | 修改 | 接口调整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、接口变更
|
||||||
|
|
||||||
|
### 5.1 H5接口变更
|
||||||
|
|
||||||
|
#### 5.1.1 获取会员信息(修改)
|
||||||
|
|
||||||
|
**接口**:`GET /h5/member/info`
|
||||||
|
|
||||||
|
**响应变更**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 修改前
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"consumerId": 1,
|
||||||
|
"phone": "138****0000",
|
||||||
|
"nickname": "测试用户",
|
||||||
|
"identityType": "2", // ← 删除
|
||||||
|
"education": { ... }, // 单个
|
||||||
|
"students": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"consumerId": 1,
|
||||||
|
"phone": "138****0000",
|
||||||
|
"nickname": "测试用户",
|
||||||
|
"educations": [...], // 多个教育身份
|
||||||
|
"students": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.1.2 教育身份列表(修改)
|
||||||
|
|
||||||
|
**接口**:`GET /h5/member/educations`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"educationId": 1,
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolName": "武汉市第二中学",
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"gradeName": "高二",
|
||||||
|
"schoolClassId": 104,
|
||||||
|
"className": "1班",
|
||||||
|
"subjectId": 1,
|
||||||
|
"subjectName": "数学",
|
||||||
|
"isDefault": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"educationId": 2,
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolName": "武汉市第二中学",
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"gradeName": "高二",
|
||||||
|
"schoolClassId": 105,
|
||||||
|
"className": "2班",
|
||||||
|
"subjectId": 1,
|
||||||
|
"subjectName": "数学",
|
||||||
|
"isDefault": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.1.3 新增教育身份
|
||||||
|
|
||||||
|
**接口**:`POST /h5/member/educations`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"schoolClassId": 104,
|
||||||
|
"subjectId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.1.4 编辑教育身份
|
||||||
|
|
||||||
|
**接口**:`PUT /h5/member/educations/{educationId}`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"schoolClassId": 104,
|
||||||
|
"subjectId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.1.5 删除教育身份
|
||||||
|
|
||||||
|
**接口**:`DELETE /h5/member/educations/{educationId}`
|
||||||
|
|
||||||
|
#### 5.1.6 设置默认教育身份
|
||||||
|
|
||||||
|
**接口**:`PUT /h5/member/educations/{educationId}/default`
|
||||||
|
|
||||||
|
### 5.2 管理后台接口变更
|
||||||
|
|
||||||
|
#### 5.2.1 会员列表(修改)
|
||||||
|
|
||||||
|
- 删除 `identityType` 筛选参数
|
||||||
|
- 响应中删除 `identityType` 字段
|
||||||
|
|
||||||
|
#### 5.2.2 会员详情(修改)
|
||||||
|
|
||||||
|
- 删除教育信息字段
|
||||||
|
- 新增 `educations` 列表
|
||||||
|
|
||||||
|
#### 5.2.3 新增/编辑会员(修改)
|
||||||
|
|
||||||
|
- 删除 `identityType`、`regionId`、`schoolId`、`schoolGradeId`、`schoolClassId` 参数
|
||||||
|
|
||||||
|
#### 5.2.4 会员教育身份管理(新增)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/business/member/{memberId}/educations` | GET | 获取会员教育身份列表 |
|
||||||
|
| `/business/member/{memberId}/education` | POST | 添加教育身份 |
|
||||||
|
| `/business/member/{memberId}/education/{educationId}` | PUT | 修改教育身份 |
|
||||||
|
| `/business/member/{memberId}/education/{educationId}` | DELETE | 删除教育身份 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、详细技术方案
|
||||||
|
|
||||||
|
### 6.1 数据库改造
|
||||||
|
|
||||||
|
#### 6.1.1 DDL脚本
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 新建会员教育信息表(一个教育身份 = 一个班级的教学关系)
|
||||||
|
CREATE TABLE pg_member_education (
|
||||||
|
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
|
||||||
|
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||||
|
region_id BIGINT COMMENT '区域ID',
|
||||||
|
school_id BIGINT NOT NULL COMMENT '学校ID',
|
||||||
|
school_grade_id BIGINT NOT NULL COMMENT '年级ID',
|
||||||
|
school_class_id BIGINT NOT NULL COMMENT '班级ID',
|
||||||
|
subject_id BIGINT COMMENT '学科ID',
|
||||||
|
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份(0否 1是)',
|
||||||
|
status CHAR(1) DEFAULT '0' COMMENT '状态(0正常 1停用)',
|
||||||
|
tenant_id VARCHAR(20) DEFAULT '000000' COMMENT '租户编号',
|
||||||
|
create_dept BIGINT COMMENT '创建部门',
|
||||||
|
create_by BIGINT COMMENT '创建者',
|
||||||
|
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
update_by BIGINT COMMENT '更新者',
|
||||||
|
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
|
||||||
|
KEY idx_member_id (member_id),
|
||||||
|
KEY idx_school_id (school_id),
|
||||||
|
KEY idx_class_id (school_class_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='会员教育信息表';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.1.2 数据迁移脚本
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 迁移现有教育数据(identity_type = '2' 的教师数据)
|
||||||
|
INSERT INTO pg_member_education (
|
||||||
|
member_id, region_id, school_id, school_grade_id, school_class_id, subject_id, is_default, status, tenant_id, create_time
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
member_id, region_id, school_id, school_grade_id, school_class_id, NULL, '1', '0', tenant_id, NOW()
|
||||||
|
FROM pg_member
|
||||||
|
WHERE identity_type = '2'
|
||||||
|
AND school_id IS NOT NULL
|
||||||
|
AND school_class_id IS NOT NULL
|
||||||
|
AND del_flag = '0';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.1.3 删除字段(迁移完成后执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 删除 pg_member 表中的字段(建议先备份)
|
||||||
|
-- 注意:union_id 保留,不删除
|
||||||
|
ALTER TABLE pg_member
|
||||||
|
DROP COLUMN identity_type,
|
||||||
|
DROP COLUMN region_id,
|
||||||
|
DROP COLUMN school_id,
|
||||||
|
DROP COLUMN school_grade_id,
|
||||||
|
DROP COLUMN school_class_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 后端改造
|
||||||
|
|
||||||
|
#### 6.2.1 新建实体类
|
||||||
|
|
||||||
|
**PgMemberEducation.java**
|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.dromara.pangu.member.domain;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("pg_member_education")
|
||||||
|
public class PgMemberEducation extends BaseEntity {
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long educationId;
|
||||||
|
private Long memberId;
|
||||||
|
private Long regionId;
|
||||||
|
private Long schoolId;
|
||||||
|
private Long schoolGradeId;
|
||||||
|
private Long schoolClassId;
|
||||||
|
private Long subjectId;
|
||||||
|
private String isDefault;
|
||||||
|
private String status;
|
||||||
|
private String tenantId;
|
||||||
|
@TableLogic
|
||||||
|
private String delFlag;
|
||||||
|
|
||||||
|
// 非数据库字段(用于VO展示)
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String schoolName;
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String gradeName;
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String className;
|
||||||
|
@TableField(exist = false)
|
||||||
|
private String subjectName;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2.2 修改 PgMember.java
|
||||||
|
|
||||||
|
删除以下字段:
|
||||||
|
- identityType
|
||||||
|
- regionId
|
||||||
|
- regionIds(@TableField(exist = false))
|
||||||
|
- schoolId
|
||||||
|
- schoolGradeId
|
||||||
|
- schoolClassId
|
||||||
|
|
||||||
|
**保留字段**:
|
||||||
|
- unionId(微信多应用打通需要)
|
||||||
|
|
||||||
|
#### 6.2.3 新建 Mapper
|
||||||
|
|
||||||
|
- PgMemberEducationMapper.java
|
||||||
|
|
||||||
|
#### 6.2.4 修改 H5 Service
|
||||||
|
|
||||||
|
**H5MemberServiceImpl.java 主要改动**:
|
||||||
|
|
||||||
|
1. `getEducation()` → `getEducations()` 返回列表
|
||||||
|
2. `saveEducation()` 支持新增/编辑
|
||||||
|
3. `deleteEducation(Long educationId)` 按ID删除
|
||||||
|
4. 新增 `setDefaultEducation(Long educationId)` 设置默认
|
||||||
|
|
||||||
|
### 6.3 管理后台前端改造
|
||||||
|
|
||||||
|
#### 6.3.1 会员列表页(index.vue)
|
||||||
|
|
||||||
|
**删除内容**:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 删除:身份类型筛选 -->
|
||||||
|
<el-form-item label="身份类型">
|
||||||
|
<el-select v-model="queryParams.identityType" ... />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 删除:身份类型列 -->
|
||||||
|
<el-table-column prop="identityType" label="身份类型" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.2 会员编辑弹窗(MemberDialog.vue)
|
||||||
|
|
||||||
|
**删除内容**:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 删除:身份类型选择 -->
|
||||||
|
<el-form-item label="身份类型" prop="identityType">
|
||||||
|
<el-radio-group v-model="form.identityType" ... />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 删除:教师信息区块 -->
|
||||||
|
<template v-if="form.identityType === '2'">
|
||||||
|
<el-divider>学校信息(教师必填)</el-divider>
|
||||||
|
<!-- 区域、学校、年级、班级选择 -->
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.3 新建会员详情页
|
||||||
|
|
||||||
|
**MemberDetail.vue**(新建)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" title="会员详情" width="900px">
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="基本信息" name="basic">
|
||||||
|
<!-- 会员基本信息展示 -->
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="教育身份" name="education">
|
||||||
|
<EducationTab :member-id="memberId" />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="绑定学生" name="students">
|
||||||
|
<!-- 学生列表 -->
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.3.4 新建教育身份Tab
|
||||||
|
|
||||||
|
**EducationTab.vue**(新建)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="handleAdd">添加教育身份</el-button>
|
||||||
|
<el-table :data="educationList">
|
||||||
|
<el-table-column prop="schoolName" label="学校" />
|
||||||
|
<el-table-column prop="gradeName" label="年级" />
|
||||||
|
<el-table-column label="班级">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.classNames?.join('、') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="subjectName" label="学科" />
|
||||||
|
<el-table-column prop="isDefault" label="默认" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.isDefault === '1'" type="success">是</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button link @click="handleSetDefault(row)">设为默认</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<EducationDialog ref="dialogRef" @success="loadData" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 H5前端改造
|
||||||
|
|
||||||
|
#### 6.4.1 user.ts 修改
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 删除
|
||||||
|
identityType: string
|
||||||
|
|
||||||
|
// 修改
|
||||||
|
education: any // 单个 →
|
||||||
|
educations: any[] // 多个
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.4.2 userCenter/index.vue 修改
|
||||||
|
|
||||||
|
支持展示多个教育身份,而不是只展示一个。
|
||||||
|
|
||||||
|
#### 6.4.3 TeacherIdentityForm.vue 修改
|
||||||
|
|
||||||
|
新增 `educationId` 字段用于编辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、文件改动清单
|
||||||
|
|
||||||
|
### 7.1 数据库
|
||||||
|
|
||||||
|
| 文件/操作 | 类型 |
|
||||||
|
|-----------|------|
|
||||||
|
| DDL: pg_member_education | 新建表(含 school_class_id) |
|
||||||
|
| DML: 数据迁移脚本 | 执行 |
|
||||||
|
| DDL: pg_member 删除字段 | 修改表 |
|
||||||
|
|
||||||
|
### 7.2 后端(共 12+ 文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `member/domain/PgMember.java` | 修改 | 删除5个字段(保留unionId) |
|
||||||
|
| `member/domain/PgMemberEducation.java` | 新建 | |
|
||||||
|
| `member/mapper/PgMemberEducationMapper.java` | 新建 | |
|
||||||
|
| `member/service/PgMemberEducationService.java` | 新建 | |
|
||||||
|
| `member/service/impl/PgMemberEducationServiceImpl.java` | 新建 | |
|
||||||
|
| `member/controller/PgMemberController.java` | 修改 | 删除身份类型相关 |
|
||||||
|
| `member/service/impl/PgMemberServiceImpl.java` | 修改 | |
|
||||||
|
| `h5/domain/dto/H5EducationDto.java` | 修改 | 添加educationId |
|
||||||
|
| `h5/domain/vo/H5EducationVo.java` | 修改 | |
|
||||||
|
| `h5/domain/vo/H5MemberInfoVo.java` | 修改 | 删除identityType |
|
||||||
|
| `h5/controller/H5MemberController.java` | 修改 | 接口调整 |
|
||||||
|
| `h5/service/impl/H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
|
||||||
|
|
||||||
|
### 7.3 管理后台前端(共 8+ 文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `views/business/member/index.vue` | 修改 | 删除身份类型 |
|
||||||
|
| `views/business/member/components/MemberDialog.vue` | 重构 | 删除教师信息 |
|
||||||
|
| `views/business/member/components/MemberDetail.vue` | 新建 | 会员详情 |
|
||||||
|
| `views/business/member/components/EducationTab.vue` | 新建 | 教育身份Tab |
|
||||||
|
| `views/business/member/components/EducationDialog.vue` | 新建 | 教育身份编辑 |
|
||||||
|
| `api/pangu/member.js` | 修改 | |
|
||||||
|
| `api/pangu/memberEducation.js` | 新建 | |
|
||||||
|
|
||||||
|
### 7.4 H5前端(共 5+ 文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `stores/user.ts` | 修改 | 删除identityType |
|
||||||
|
| `views/userCenter/index.vue` | 修改 | 支持多教育身份 |
|
||||||
|
| `components/TeacherIdentityForm.vue` | 修改 | 添加educationId |
|
||||||
|
| `api/user.js` | 修改 | 接口调整 |
|
||||||
|
| `views/register/index.vue` | 可能修改 | 如有身份类型相关 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试要点
|
||||||
|
|
||||||
|
| 场景 | 测试内容 |
|
||||||
|
|------|----------|
|
||||||
|
| 数据迁移 | 现有教师数据正确迁移到新表 |
|
||||||
|
| H5-新增教育身份 | 首次添加、多次添加(每班一条记录) |
|
||||||
|
| H5-编辑教育身份 | 修改学校/年级/班级/学科 |
|
||||||
|
| H5-删除教育身份 | 删除非默认、删除默认 |
|
||||||
|
| H5-设置默认 | 切换默认身份 |
|
||||||
|
| H5-多教育身份 | 一个老师添加多条教育身份(教多个班) |
|
||||||
|
| 管理后台-会员列表 | 不显示身份类型 |
|
||||||
|
| 管理后台-会员详情 | 查看多个教育身份 |
|
||||||
|
| 管理后台-教育身份管理 | 增删改查 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、上线计划
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | 备份数据库 | 全量备份 pg_member |
|
||||||
|
| 2 | 执行建表DDL | 创建新表 |
|
||||||
|
| 3 | 执行数据迁移 | 迁移现有教育数据 |
|
||||||
|
| 4 | 部署后端 | 新代码 |
|
||||||
|
| 5 | 部署管理后台前端 | 新代码 |
|
||||||
|
| 6 | 部署H5前端 | 新代码 |
|
||||||
|
| 7 | 验证测试 | 回归测试 |
|
||||||
|
| 8 | 执行删除字段DDL | 确认无问题后 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、回滚方案
|
||||||
|
|
||||||
|
1. 后端代码回滚
|
||||||
|
2. 前端代码回滚
|
||||||
|
3. 数据库:
|
||||||
|
- 从新表恢复数据到 pg_member
|
||||||
|
- 删除新建的两张表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、风险评估
|
||||||
|
|
||||||
|
| 风险 | 等级 | 应对措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 数据迁移丢失 | 高 | 完整备份,测试环境验证 |
|
||||||
|
| 接口不兼容 | 高 | 前后端同步上线 |
|
||||||
|
| 性能问题 | 低 | 新表已加索引 |
|
||||||
|
| 业务中断 | 中 | 选择低峰期上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:现有数据统计(上线前执行)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 统计需要迁移的教育数据
|
||||||
|
SELECT COUNT(*) FROM pg_member
|
||||||
|
WHERE identity_type = '2' AND school_id IS NOT NULL AND del_flag = '0';
|
||||||
|
|
||||||
|
-- 统计有班级信息的数据
|
||||||
|
SELECT COUNT(*) FROM pg_member
|
||||||
|
WHERE identity_type = '2' AND school_class_id IS NOT NULL AND del_flag = '0';
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,533 @@
|
||||||
|
# 教师多班级功能技术方案
|
||||||
|
|
||||||
|
> 作者:湖北新华业务中台研发团队
|
||||||
|
> 创建时间:2026-02-03
|
||||||
|
> 状态:待审核
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、需求描述
|
||||||
|
|
||||||
|
### 1.1 业务场景
|
||||||
|
|
||||||
|
- 张老师是武汉二中高二年级的数学老师
|
||||||
|
- 他同时教 1班、2班、3班
|
||||||
|
- 系统应支持在**一个教育身份**中选择**多个班级**
|
||||||
|
|
||||||
|
### 1.2 预期效果
|
||||||
|
|
||||||
|
**添加/编辑教育身份时**:
|
||||||
|
|
||||||
|
```
|
||||||
|
所在地区: 湖北省 / 武汉市 / 硚口区
|
||||||
|
学校: 武汉市第二中学
|
||||||
|
年级: 高二
|
||||||
|
班级: [1班] [2班] [3班] ← 多选
|
||||||
|
学科: 数学
|
||||||
|
```
|
||||||
|
|
||||||
|
**个人中心展示**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────┐
|
||||||
|
│ 📘 数学 当前使用 │
|
||||||
|
│ 武汉市第二中学 │
|
||||||
|
│ 班级: 高二1班、高二2班、高二3班 │
|
||||||
|
│ 学科: 数学 │
|
||||||
|
│ 编辑 解除 切换 │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、当前架构分析
|
||||||
|
|
||||||
|
### 2.1 现有数据结构
|
||||||
|
|
||||||
|
| 表名 | 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `pg_member` | `school_class_id` | `bigint` | 单值,只能存一个班级 |
|
||||||
|
|
||||||
|
### 2.2 现有接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/h5/member/education` | GET | 返回单个教育身份 |
|
||||||
|
| `/h5/member/education` | POST | 保存单个教育身份(覆盖) |
|
||||||
|
| `/h5/member/education` | DELETE | 删除教育身份 |
|
||||||
|
|
||||||
|
### 2.3 问题
|
||||||
|
|
||||||
|
- 班级字段是单值,不支持多班级
|
||||||
|
- 接口设计为覆盖式更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、技术方案
|
||||||
|
|
||||||
|
### 3.1 数据库改造
|
||||||
|
|
||||||
|
#### 3.1.1 新建关联表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 会员-班级关联表(多对多)
|
||||||
|
CREATE TABLE pg_member_class (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
member_id BIGINT NOT NULL COMMENT '会员ID',
|
||||||
|
school_class_id BIGINT NOT NULL COMMENT '学校班级关联ID',
|
||||||
|
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_member_class (member_id, school_class_id),
|
||||||
|
KEY idx_member_id (member_id)
|
||||||
|
) COMMENT '会员班级关联表';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 数据迁移脚本
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 将现有数据迁移到关联表
|
||||||
|
INSERT INTO pg_member_class (member_id, school_class_id)
|
||||||
|
SELECT member_id, school_class_id
|
||||||
|
FROM pg_member
|
||||||
|
WHERE school_class_id IS NOT NULL AND identity_type = '2';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.3 pg_member 表调整
|
||||||
|
|
||||||
|
| 字段 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| `school_class_id` | 保留(向后兼容)或后续版本删除 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 后端改造
|
||||||
|
|
||||||
|
#### 3.2.1 新建实体类
|
||||||
|
|
||||||
|
**文件**:`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/domain/PgMemberClass.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.dromara.pangu.member.domain;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("pg_member_class")
|
||||||
|
public class PgMemberClass {
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private Long memberId;
|
||||||
|
private Long schoolClassId;
|
||||||
|
private Date createTime;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 新建 Mapper
|
||||||
|
|
||||||
|
**文件**:`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/mapper/PgMemberClassMapper.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package org.dromara.pangu.member.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.dromara.pangu.member.domain.PgMemberClass;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface PgMemberClassMapper extends BaseMapper<PgMemberClass> {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 修改 DTO
|
||||||
|
|
||||||
|
**文件**:`H5EducationDto.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 修改前
|
||||||
|
@Schema(description = "学校班级关联ID")
|
||||||
|
@NotNull(message = "请选择班级")
|
||||||
|
private Long schoolClassId;
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
@Schema(description = "学校班级关联ID列表")
|
||||||
|
@NotEmpty(message = "请选择班级")
|
||||||
|
private List<Long> schoolClassIds;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.4 修改 VO
|
||||||
|
|
||||||
|
**文件**:`H5EducationVo.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 新增字段
|
||||||
|
private List<Long> schoolClassIds; // 班级ID列表
|
||||||
|
private List<String> classNames; // 班级名称列表(用于前端展示)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.5 修改 Service
|
||||||
|
|
||||||
|
**文件**:`H5MemberServiceImpl.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Autowired
|
||||||
|
private PgMemberClassMapper memberClassMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void saveEducation(H5EducationDto dto) {
|
||||||
|
Long memberId = getCurrentMemberId();
|
||||||
|
PgMember member = memberMapper.selectById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ServiceException("会员不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 校验学校、年级存在
|
||||||
|
// ... 原有校验逻辑 ...
|
||||||
|
|
||||||
|
// 2. 校验所有班级是否存在且属于该年级
|
||||||
|
for (Long classId : dto.getSchoolClassIds()) {
|
||||||
|
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
|
||||||
|
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(dto.getSchoolGradeId())) {
|
||||||
|
throw new ServiceException("班级不存在或不属于该年级");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新会员基本信息
|
||||||
|
member.setSchoolId(dto.getSchoolId());
|
||||||
|
member.setSchoolGradeId(dto.getSchoolGradeId());
|
||||||
|
member.setSubjectId(dto.getSubjectId());
|
||||||
|
member.setIdentityType("2");
|
||||||
|
member.setRegionId(school.getRegionId());
|
||||||
|
memberMapper.updateById(member);
|
||||||
|
|
||||||
|
// 4. 删除旧的班级关联
|
||||||
|
memberClassMapper.delete(
|
||||||
|
new LambdaQueryWrapper<PgMemberClass>()
|
||||||
|
.eq(PgMemberClass::getMemberId, memberId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 插入新的班级关联
|
||||||
|
for (Long classId : dto.getSchoolClassIds()) {
|
||||||
|
PgMemberClass mc = new PgMemberClass();
|
||||||
|
mc.setMemberId(memberId);
|
||||||
|
mc.setSchoolClassId(classId);
|
||||||
|
memberClassMapper.insert(mc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public H5EducationVo getEducation() {
|
||||||
|
Long memberId = getCurrentMemberId();
|
||||||
|
PgMember member = memberMapper.selectById(memberId);
|
||||||
|
if (member == null) {
|
||||||
|
throw new ServiceException("会员不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!"2".equals(member.getIdentityType()) || member.getSchoolId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
H5EducationVo vo = buildEducationVo(member);
|
||||||
|
|
||||||
|
// 查询关联的班级列表
|
||||||
|
List<PgMemberClass> memberClasses = memberClassMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<PgMemberClass>()
|
||||||
|
.eq(PgMemberClass::getMemberId, memberId)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Long> classIds = memberClasses.stream()
|
||||||
|
.map(PgMemberClass::getSchoolClassId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
vo.setSchoolClassIds(classIds);
|
||||||
|
|
||||||
|
// 填充班级名称
|
||||||
|
if (!classIds.isEmpty()) {
|
||||||
|
List<String> classNames = new ArrayList<>();
|
||||||
|
for (Long classId : classIds) {
|
||||||
|
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
|
||||||
|
if (schoolClass != null) {
|
||||||
|
PgClass pgClass = classMapper.selectById(schoolClass.getClassId());
|
||||||
|
if (pgClass != null) {
|
||||||
|
classNames.add(pgClass.getClassName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vo.setClassNames(classNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void deleteEducation() {
|
||||||
|
Long memberId = getCurrentMemberId();
|
||||||
|
|
||||||
|
// 删除班级关联
|
||||||
|
memberClassMapper.delete(
|
||||||
|
new LambdaQueryWrapper<PgMemberClass>()
|
||||||
|
.eq(PgMemberClass::getMemberId, memberId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清空会员教育信息
|
||||||
|
// ... 原有逻辑 ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 H5 前端改造
|
||||||
|
|
||||||
|
#### 3.3.1 修改表单组件
|
||||||
|
|
||||||
|
**文件**:`user_authentication_center_front/user-front/src/components/TeacherIdentityForm.vue`
|
||||||
|
|
||||||
|
**表单数据修改**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 修改前
|
||||||
|
const teacherForm = reactive({
|
||||||
|
classId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
const teacherForm = reactive({
|
||||||
|
classIds: [],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**模板修改**:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- 修改前 -->
|
||||||
|
<el-form-item label="班级" prop="classId">
|
||||||
|
<el-select v-model="teacherForm.classId">
|
||||||
|
<!-- ... -->
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 修改后 -->
|
||||||
|
<el-form-item label="班级" prop="classIds">
|
||||||
|
<el-select
|
||||||
|
v-model="teacherForm.classIds"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="3"
|
||||||
|
placeholder="请选择班级(可多选)"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="classItem in classOptions"
|
||||||
|
:key="classItem.schoolClassId"
|
||||||
|
:label="classItem.className"
|
||||||
|
:value="classItem.schoolClassId"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证规则修改**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 修改前
|
||||||
|
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
classIds: [{
|
||||||
|
required: true,
|
||||||
|
type: 'array',
|
||||||
|
min: 1,
|
||||||
|
message: '请至少选择一个班级',
|
||||||
|
trigger: 'change'
|
||||||
|
}],
|
||||||
|
```
|
||||||
|
|
||||||
|
**提交逻辑修改**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const requestData = {
|
||||||
|
schoolId: teacherForm.schoolId,
|
||||||
|
schoolGradeId: teacherForm.gradeId,
|
||||||
|
schoolClassIds: teacherForm.classIds, // 数组
|
||||||
|
subjectId: teacherForm.subjectId,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**回显逻辑修改**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// setFormData 中
|
||||||
|
teacherForm.classIds = formData.schoolClassIds || []
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 修改列表展示
|
||||||
|
|
||||||
|
**文件**:`user_authentication_center_front/user-front/src/views/userCenter/index.vue`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// getEducationIdentities 方法中
|
||||||
|
educationIdentities.value = [{
|
||||||
|
id: 1,
|
||||||
|
role: `${item.subjectName || '教师'}`,
|
||||||
|
school: item.schoolName || '',
|
||||||
|
icon: 'mdi:account-tie',
|
||||||
|
active: true,
|
||||||
|
details: [
|
||||||
|
{ label: '班级', value: item.classNames?.join('、') || '' }, // 多班级用顿号连接
|
||||||
|
{ label: '学科', value: item.subjectName || '' },
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、文件改动清单
|
||||||
|
|
||||||
|
| 层级 | 文件路径 | 改动类型 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| **数据库** | DDL 脚本 | 新建 |
|
||||||
|
| **后端** | `pangu-business/.../member/domain/PgMemberClass.java` | 新建 |
|
||||||
|
| **后端** | `pangu-business/.../member/mapper/PgMemberClassMapper.java` | 新建 |
|
||||||
|
| **后端** | `pangu-business/.../h5/domain/dto/H5EducationDto.java` | 修改 |
|
||||||
|
| **后端** | `pangu-business/.../h5/domain/vo/H5EducationVo.java` | 修改 |
|
||||||
|
| **后端** | `pangu-business/.../h5/service/impl/H5MemberServiceImpl.java` | 修改 |
|
||||||
|
| **H5前端** | `user-front/src/components/TeacherIdentityForm.vue` | 修改 |
|
||||||
|
| **H5前端** | `user-front/src/views/userCenter/index.vue` | 修改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、接口变更
|
||||||
|
|
||||||
|
### 5.1 POST /h5/member/education
|
||||||
|
|
||||||
|
**请求参数变更**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 修改前
|
||||||
|
{
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"schoolClassId": 104,
|
||||||
|
"subjectId": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
{
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"schoolClassIds": [104, 105, 106],
|
||||||
|
"subjectId": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 GET /h5/member/education
|
||||||
|
|
||||||
|
**响应参数变更**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 修改前
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolName": "武汉市第二中学",
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"gradeName": "高二",
|
||||||
|
"schoolClassId": 104,
|
||||||
|
"className": "1班",
|
||||||
|
"subjectId": 1,
|
||||||
|
"subjectName": "数学"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"schoolId": 1,
|
||||||
|
"schoolName": "武汉市第二中学",
|
||||||
|
"schoolGradeId": 5,
|
||||||
|
"gradeName": "高二",
|
||||||
|
"schoolClassIds": [104, 105, 106],
|
||||||
|
"classNames": ["1班", "2班", "3班"],
|
||||||
|
"subjectId": 1,
|
||||||
|
"subjectName": "数学"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、测试要点
|
||||||
|
|
||||||
|
| 场景 | 测试内容 | 预期结果 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 新增 | 选择多个班级保存 | 保存成功,关联表有多条记录 |
|
||||||
|
| 编辑 | 回显多个班级 | 多选框显示已选班级 |
|
||||||
|
| 编辑 | 增加/减少班级 | 关联表正确更新 |
|
||||||
|
| 删除 | 删除教育身份 | 关联表数据同步删除 |
|
||||||
|
| 展示 | 列表显示多班级 | 显示"1班、2班、3班" |
|
||||||
|
| 边界 | 不选班级提交 | 校验失败,提示选择班级 |
|
||||||
|
| 边界 | 选择不同年级的班级 | 校验失败,提示班级不属于该年级 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、上线计划
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 负责人 | 备注 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 1 | 执行数据库 DDL | DBA | 建表 |
|
||||||
|
| 2 | 执行数据迁移脚本 | DBA | 迁移现有数据 |
|
||||||
|
| 3 | 部署后端服务 | 运维 | - |
|
||||||
|
| 4 | 部署 H5 前端 | 运维 | 需同步上线 |
|
||||||
|
| 5 | 验证测试 | 测试 | 回归测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、回滚方案
|
||||||
|
|
||||||
|
1. 后端代码回滚到上一版本
|
||||||
|
2. 前端代码回滚到上一版本
|
||||||
|
3. 数据库:
|
||||||
|
- 从 `pg_member_class` 恢复 `pg_member.school_class_id`(取第一条)
|
||||||
|
- 删除 `pg_member_class` 表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、风险评估
|
||||||
|
|
||||||
|
| 风险 | 等级 | 应对措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 数据迁移失败 | 中 | 先在测试环境验证,生产环境备份 |
|
||||||
|
| 前后端不同步 | 高 | 同步上线,灰度发布 |
|
||||||
|
| 性能问题 | 低 | 关联表已加索引 |
|
||||||
|
| 兼容性问题 | 中 | 管理后台如有相关页面需同步修改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:相关表结构
|
||||||
|
|
||||||
|
### pg_member(会员表,现有)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| member_id | bigint | 主键 |
|
||||||
|
| consumer_id | bigint | 用户ID |
|
||||||
|
| school_id | bigint | 学校ID |
|
||||||
|
| school_grade_id | bigint | 年级ID |
|
||||||
|
| school_class_id | bigint | 班级ID(单值,改造后可废弃) |
|
||||||
|
| subject_id | bigint | 学科ID |
|
||||||
|
| identity_type | varchar | 身份类型:1-家长,2-教师 |
|
||||||
|
|
||||||
|
### pg_member_class(会员班级关联表,新建)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | bigint | 主键 |
|
||||||
|
| member_id | bigint | 会员ID |
|
||||||
|
| school_class_id | bigint | 学校班级关联ID |
|
||||||
|
| create_time | datetime | 创建时间 |
|
||||||
Loading…
Reference in New Issue