feat(hjc): 密码找回与修改密码(材料审核制 + 双因子)
密码找回(匿名接口,见 ADR 0008「密码重置由平台运维人工执行」) - POST /api/hjc/auth/password/apply:企业名称 + 纳税人识别号 + 新密码 + 授权委托书。 用双要素定位企业而不是只用企业名称——hjc_enterprise.name 没有唯一索引(hjc_init.sql 里只有 user_id 唯一),只用名称会把 A 企业的密码重置落到 B 账号上;加上同样有索引的 credit_code 后 歧义天然消失,命中多条时告警并拒绝。企业不存在与信息不匹配返回同一句话且 data 为 null, 避免企业注册状态被枚举;同企业 24h 内二次提交返回已存在那条。 - GET /api/hjc/auth/password/apply/status:同样双要素查进度。 - 状态 0 待审核 / 1 已通过(待重置) / 2 已驳回 / 3 已重置。「已通过」≠「已重置」是刻意的: 核心实例没有任何 hjc 可用的代改密码通道,最后由运维照单在核心实例后台手工重置,再回后台标记。 - 新表 hjc_password_apply:hjc_init.sql 加建表语句,另给幂等的 hjc_password_apply.sql 作发布步骤 (只增表,不动任何既有表,对共用本库的其他项目无影响)。新密码明文存储供运维照单重置, 是 ADR 0008 已显式接受的风险,列注释里写明。 修改密码(登录态,双因子:旧密码 + 账号绑定手机号的短信验证码) - POST /api/hjc/auth/password/sms 与 PUT /api/hjc/auth/password/change。短信发到**账号绑定手机号** (后端从登录态取),前端不能指定号码。 - HjcCoreAuthClient 加两条调用,且两条的认证姿态刻意相反,各自的「为什么与类注释相反」都写在注释里: verifyOldPassword 必须转发买家 Authorization(核心实例 PUT /auth/password 按登录态识别改谁), resetPassword 必须剥掉 Authorization 且带 userId + tenantId——不传 userId 时核心实例会按手机号 查出**所有租户**下的账号并逐个改密(其 SQL 无 tenant_id 条件),那会连带改掉同一手机号在其它 平台的账号密码。 访问控制 - SecurityConfig 只把匿名的 apply 加进 permitAll(status 走既有 GET /** 放行);sms 与 change **必须**保持登录态,故刻意不入名单。 后台端点(配套 hjc-vue 的审核页) - /api/hjc/password-apply 的 page / detail / audit / mark-reset 四个管理员端点。列表不带明文新密码, 只有详情给(运维要照单重置)。 未验:端到端需真实环境(核心实例授权、审核人、运维重置),见 .scratch/hjc-password/issues/06。
This commit is contained in:
@@ -93,7 +93,11 @@ public class SecurityConfig {
|
||||
"/api/hjc/ocr/recognize",
|
||||
// 小程序端登录:用 uni.login 的 code 换 openid,发生在业务请求之前,
|
||||
// 小程序侧此时还没有平台 token
|
||||
"/api/hjc/wechat/mp-login"
|
||||
"/api/hjc/wechat/mp-login",
|
||||
// 密码找回:企业未登录时提交材料申请(进度查询走
|
||||
// GET /api/hjc/auth/password/apply/status,已被上面的 GET /** 放行)。
|
||||
// 「修改密码」的两个端点必须保持需要登录态,故刻意不在此列。
|
||||
"/api/hjc/auth/password/apply"
|
||||
)
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
|
||||
@@ -46,6 +46,10 @@ public class HjcCoreAuthClient {
|
||||
static final String CAPTCHA_PATH = "/captcha";
|
||||
/** 核心实例:发送短信验证码 */
|
||||
static final String SMS_PATH = "/sendSmsCaptcha";
|
||||
/** 核心实例:修改自己的密码(需登录态 token + `sys:auth:password` 权限) */
|
||||
static final String PASSWORD_PATH = "/auth/password";
|
||||
/** 核心实例:重置密码(匿名,凭短信/邮箱验证码) */
|
||||
static final String RESET_PASSWORD_PATH = "/resetPassword";
|
||||
|
||||
static final int TIMEOUT_MS = 10000;
|
||||
|
||||
@@ -169,6 +173,54 @@ public class HjcCoreAuthClient {
|
||||
return post(SMS_PATH, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验旧密码(<b>唯一一条要转发买家 Authorization 的调用</b>)。
|
||||
*
|
||||
* <p><b>为什么这里与类注释第 2 条相反</b>:核心实例的 {@code PUT /auth/password} 按<b>登录态</b>
|
||||
* 识别要改谁的密码,不带 token 根本无从校验。它只碰密码,不碰短信验证码与图形验证码,
|
||||
* 因此不存在"Redis 键前缀随认证姿态变化"那个问题。</p>
|
||||
*
|
||||
* <p><b>为什么可以传「新密码 = 旧密码」</b>:该接口只校验 {@code oldPassword} 是否正确,
|
||||
* <b>不比较新旧密码是否相同</b>(见 core MainController#updatePassword),因此这次调用
|
||||
* 只做校验、不改变可登录的口令(代价是用同一明文重写了一次 BCrypt 哈希)。
|
||||
* 之所以需要它,是因为短信验证码只有核心实例的 {@code /resetPassword} 会校验,而那个接口
|
||||
* <b>同时就把密码改了</b>——双因子要各自独立成立,旧密码就必须先单独验掉。</p>
|
||||
*
|
||||
* <p><b>前置条件</b>:该账号必须拥有 {@code sys:auth:password}。hjc 买家角色(租户 10626 的
|
||||
* {@code user})当前是 0 菜单 0 权限,需由运维在核心实例后台授予,否则这里恒 403。</p>
|
||||
*
|
||||
* @param authorization 买家自己的 {@code Bearer xxx},原样转发
|
||||
*/
|
||||
public CoreResult verifyOldPassword(String authorization, String oldPassword) {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("oldPassword", oldPassword);
|
||||
body.put("password", oldPassword);
|
||||
return execute(HttpRequest.put(url(PASSWORD_PATH))
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
.header("Authorization", authorization)
|
||||
.body(body.toJSONString())
|
||||
.timeout(TIMEOUT_MS), PASSWORD_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码(匿名姿态,<b>必须剥掉 Authorization</b>)。
|
||||
*
|
||||
* <p><b>必须带 {@code userId} + {@code tenantId}</b>:不传 {@code userId} 时核心实例会按手机号
|
||||
* 查出<b>所有租户</b>下的账号并逐个改密(其 SQL 无 {@code tenant_id} 条件),那会连带改掉
|
||||
* 同一手机号在其它平台的账号密码。这里锁定单个账号。</p>
|
||||
*/
|
||||
public CoreResult resetPassword(Integer userId, Integer tenantId, String phone, String smsCode,
|
||||
String newPassword, String confirmPassword) {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("userId", String.valueOf(userId));
|
||||
body.put("tenantId", tenantId);
|
||||
body.put("phone", phone);
|
||||
body.put("smsCode", smsCode);
|
||||
body.put("newPassword", newPassword);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
return post(RESET_PASSWORD_PATH, body);
|
||||
}
|
||||
|
||||
private CoreResult post(String path, JSONObject body) {
|
||||
return execute(HttpRequest.post(url(path))
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package com.gxwebsoft.hjc.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.gxwebsoft.common.core.Constants;
|
||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.hjc.auth.HjcAuthProperties;
|
||||
import com.gxwebsoft.hjc.auth.HjcAuthResponses;
|
||||
import com.gxwebsoft.hjc.auth.HjcCoreAuthClient;
|
||||
import com.gxwebsoft.hjc.dto.HjcPasswordApplyRequest;
|
||||
import com.gxwebsoft.hjc.dto.HjcPasswordChangeRequest;
|
||||
import com.gxwebsoft.hjc.entity.HjcEnterprise;
|
||||
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
|
||||
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
|
||||
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
|
||||
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
|
||||
import com.gxwebsoft.hjc.service.HjcEnterpriseService;
|
||||
import com.gxwebsoft.hjc.service.HjcPasswordApplyService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 汇吉采 密码找回(材料审核制)与修改密码。
|
||||
*
|
||||
* <p><b>为什么找回密码不是一个自助改密流程</b>:账号凭据归核心实例所有(ADR-0006),而 hjc 对核心实例
|
||||
* 只有读的能力、没有任何合法的代改密码通道。因此:</p>
|
||||
* <ul>
|
||||
* <li><b>找回密码</b>=企业提交材料 → 平台人工审核 → <b>运维在核心实例管理后台照申请单执行重置</b>
|
||||
* → 回本后台标记「已重置」。故「审核通过」与「已重置」是两个状态,界面上不得混为一谈。</li>
|
||||
* <li><b>修改密码</b>=登录态下「旧密码 + 账号绑定手机号短信」双因子,两个因子都通过核心实例的接口完成。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>见 ADR-0008 与 {@code .scratch/hjc-password/spec.md}。</p>
|
||||
*/
|
||||
@Tag(name = "汇吉采-密码找回与修改密码")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/hjc")
|
||||
public class HjcPasswordController extends BaseController {
|
||||
|
||||
/**
|
||||
* 申请与进度查询的统一话术。
|
||||
*
|
||||
* <p><b>提交接口对「企业不存在 / 双要素不匹配 / 命中多条 / 已有待审核申请」一律返回这一句</b>,
|
||||
* 响应体完全一致(连 data 都为 null),使外部无法据此判断某个企业是否在本平台注册过
|
||||
* (见 spec 的「已定决策」第 2、8 条)。真正的失败原因只落服务端日志。</p>
|
||||
*/
|
||||
private static final String MSG_APPLY_ACCEPTED =
|
||||
"申请已提交,请等待平台审核。审核期间可用企业名称与纳税人识别号在本页查询进度;若长时间无进展,请核对所填信息是否与营业执照一致。";
|
||||
|
||||
/** 与核心实例 {@code resetPassword} 的后端校验保持一致(前端也按同一规则提示) */
|
||||
private static final Pattern PASSWORD_PATTERN =
|
||||
Pattern.compile("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{8,}$");
|
||||
|
||||
/** 旧密码校验失败时可直接透传的文案(原文来自核心实例,见 core MainController#updatePassword) */
|
||||
private static final List<String> PASSTHROUGH_OLD_PASSWORD_MESSAGES =
|
||||
Arrays.asList("原密码输入不正确", "请输入当前密码");
|
||||
|
||||
/** 同一 IP 每小时最多提交几次申请 */
|
||||
private static final int APPLY_IP_LIMIT_PER_HOUR = 5;
|
||||
/** 同一 IP 每小时最多查询几次进度 */
|
||||
private static final int QUERY_IP_LIMIT_PER_HOUR = 30;
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Resource
|
||||
private HjcPasswordApplyService passwordApplyService;
|
||||
@Resource
|
||||
private HjcEnterpriseService hjcEnterpriseService;
|
||||
@Resource
|
||||
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
|
||||
@Resource
|
||||
private HjcAuthProperties hjcAuthProperties;
|
||||
@Resource
|
||||
private HjcCoreAuthClient coreAuthClient;
|
||||
@Resource
|
||||
private HttpServletRequest request;
|
||||
|
||||
// ==================== C 端:密码找回(匿名) ====================
|
||||
|
||||
@Operation(summary = "密码找回-提交申请(匿名:企业名称+纳税人识别号+新密码+授权委托书)")
|
||||
@PostMapping("/auth/password/apply")
|
||||
public ApiResult<?> apply(@RequestBody HjcPasswordApplyRequest body) {
|
||||
if (body == null) {
|
||||
return fail("参数不能为空");
|
||||
}
|
||||
String enterpriseName = StrUtil.trimToNull(body.getEnterpriseName());
|
||||
String creditCode = StrUtil.trimToNull(body.getCreditCode());
|
||||
if (enterpriseName == null) {
|
||||
return fail("企业名称不能为空");
|
||||
}
|
||||
if (creditCode == null) {
|
||||
return fail("纳税人识别号不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(body.getNewPassword()) || StrUtil.isBlank(body.getConfirmPassword())) {
|
||||
return fail("请填写新密码并再次确认");
|
||||
}
|
||||
if (!body.getNewPassword().equals(body.getConfirmPassword())) {
|
||||
return fail("两次输入的新密码不一致");
|
||||
}
|
||||
if (!PASSWORD_PATTERN.matcher(body.getNewPassword()).matches()) {
|
||||
return fail("密码必须至少8位,且包含字母和数字");
|
||||
}
|
||||
if (StrUtil.isBlank(body.getHandbookUrl())) {
|
||||
return fail("请上传授权委托书");
|
||||
}
|
||||
// 校验通过后才占用配额:否则一个填错字段的请求也会消耗用户的提交次数
|
||||
if (!passwordApplyService.tryAcquireQuota("apply-ip", clientIp(), APPLY_IP_LIMIT_PER_HOUR, 3600L)) {
|
||||
// 这一条可以明确报错:它只说明「你这个地址提交太频繁」,不泄露任何企业的注册状态
|
||||
return fail("提交过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
Integer tenantId = hjcAuthProperties.getTenantId();
|
||||
List<HjcEnterprise> matched = hjcEnterpriseService.listByNameAndCreditCode(enterpriseName, creditCode, tenantId);
|
||||
if (matched.isEmpty()) {
|
||||
log.warn("HjcPassword: 找回申请的双要素未匹配到企业,已按统一话术响应 tenantId={}", tenantId);
|
||||
return success(MSG_APPLY_ACCEPTED, null);
|
||||
}
|
||||
if (matched.size() > 1) {
|
||||
// name 没有唯一索引,命中多条属脏数据。绝不能静默取一条——那会把 A 企业的密码重置落到 B 账号上。
|
||||
log.error("HjcPassword: 双要素命中多条企业档案,拒绝受理 tenantId={} 命中数={} ids={}",
|
||||
tenantId, matched.size(), idsOf(matched));
|
||||
return success(MSG_APPLY_ACCEPTED, null);
|
||||
}
|
||||
HjcEnterprise enterprise = matched.get(0);
|
||||
if (enterprise.getUserId() == null) {
|
||||
log.error("HjcPassword: 企业档案未关联核心实例账号,拒绝受理 enterpriseId={}", enterprise.getId());
|
||||
return success(MSG_APPLY_ACCEPTED, null);
|
||||
}
|
||||
|
||||
HjcPasswordApply pending = passwordApplyService.getPendingByEnterpriseId(enterprise.getId(), tenantId);
|
||||
if (pending != null) {
|
||||
// 重复提交:不新建,直接让它去查进度(不返回「已存在」以免泄露注册状态)
|
||||
log.info("HjcPassword: 该企业已有待审核申请,不重复新建 enterpriseId={} applyId={}",
|
||||
enterprise.getId(), pending.getId());
|
||||
return success(MSG_APPLY_ACCEPTED, null);
|
||||
}
|
||||
|
||||
try {
|
||||
passwordApplyService.submit(enterprise, body.getNewPassword(), body.getHandbookUrl(), tenantId);
|
||||
} catch (Exception e) {
|
||||
log.error("HjcPassword: 找回申请落库失败 enterpriseId={}", enterprise.getId(), e);
|
||||
return fail("提交失败,请稍后重试");
|
||||
}
|
||||
log.info("HjcPassword: 找回申请已提交 enterpriseId={} userId={}", enterprise.getId(), enterprise.getUserId());
|
||||
return success(MSG_APPLY_ACCEPTED, null);
|
||||
}
|
||||
|
||||
@Operation(summary = "密码找回-查询进度(匿名:企业名称+纳税人识别号)")
|
||||
@GetMapping("/auth/password/apply/status")
|
||||
public ApiResult<?> applyStatus(@RequestParam(value = "enterpriseName", required = false) String enterpriseName,
|
||||
@RequestParam(value = "creditCode", required = false) String creditCode) {
|
||||
String name = StrUtil.trimToNull(enterpriseName);
|
||||
String code = StrUtil.trimToNull(creditCode);
|
||||
if (name == null || code == null) {
|
||||
return fail("请填写企业名称与纳税人识别号");
|
||||
}
|
||||
if (!passwordApplyService.tryAcquireQuota("query-ip", clientIp(), QUERY_IP_LIMIT_PER_HOUR, 3600L)) {
|
||||
return fail("查询过于频繁,请稍后再试");
|
||||
}
|
||||
Integer tenantId = hjcAuthProperties.getTenantId();
|
||||
List<HjcEnterprise> matched = hjcEnterpriseService.listByNameAndCreditCode(name, code, tenantId);
|
||||
if (matched.size() != 1) {
|
||||
// 查不到与命中多条:一律回「无记录」,同样不区分企业是否存在。
|
||||
// 不用 success(null):BaseController 的 success 有多个重载,传 null 会编译歧义。
|
||||
return success("未查询到申请记录", null);
|
||||
}
|
||||
HjcPasswordApply apply = passwordApplyService.getLatestByEnterpriseId(matched.get(0).getId(), tenantId);
|
||||
return success(apply == null ? null : statusView(apply));
|
||||
}
|
||||
|
||||
// ==================== C 端:修改密码(登录态) ====================
|
||||
|
||||
@Operation(summary = "修改密码-发送短信验证码(登录态;发到账号绑定手机号,前端不可指定号码)")
|
||||
@PostMapping("/auth/password/sms")
|
||||
public ApiResult<?> changePasswordSms() {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getUserId() == null) {
|
||||
return HjcAuthResponses.unauthorized();
|
||||
}
|
||||
// 必须用 getPhone():User.getMobile() 是脱敏后的展示值(155****2748),拿它发短信必然失败
|
||||
String phone = StrUtil.trimToNull(loginUser.getPhone());
|
||||
if (phone == null) {
|
||||
return fail("账号未绑定手机号,请联系平台客服");
|
||||
}
|
||||
HjcCoreAuthClient.CoreResult result = coreAuthClient.sendSmsCaptcha(phone);
|
||||
if (!result.isOk()) {
|
||||
return fail(StrUtil.blankToDefault(result.getMessage(), "验证码发送失败"));
|
||||
}
|
||||
return success("验证码已发送", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码(旧密码 + 短信验证码)。
|
||||
*
|
||||
* <p><b>两步的顺序不能换</b>:短信验证码只有核心实例的 {@code /resetPassword} 会校验,而它
|
||||
* <b>同时就把密码改了</b>。若先改密再验短信,短信因子就等于不存在。因此:先用
|
||||
* {@code PUT /auth/password}(新旧密码传同一个值)把旧密码验掉,再让 {@code /resetPassword}
|
||||
* 以短信为闸门完成改密。</p>
|
||||
*/
|
||||
@Operation(summary = "修改密码(登录态;旧密码 + 账号绑定手机号短信验证码)")
|
||||
@PutMapping("/auth/password/change")
|
||||
public ApiResult<?> changePassword(@RequestBody HjcPasswordChangeRequest body) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null || loginUser.getUserId() == null) {
|
||||
return HjcAuthResponses.unauthorized();
|
||||
}
|
||||
if (body == null) {
|
||||
return fail("参数不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(body.getOldPassword()) || StrUtil.isBlank(body.getSmsCode())
|
||||
|| StrUtil.isBlank(body.getNewPassword()) || StrUtil.isBlank(body.getConfirmPassword())) {
|
||||
return fail("请填写旧密码、短信验证码与新密码");
|
||||
}
|
||||
if (!body.getNewPassword().equals(body.getConfirmPassword())) {
|
||||
return fail("两次输入的新密码不一致");
|
||||
}
|
||||
if (!PASSWORD_PATTERN.matcher(body.getNewPassword()).matches()) {
|
||||
return fail("密码必须至少8位,且包含字母和数字");
|
||||
}
|
||||
if (body.getNewPassword().equals(body.getOldPassword())) {
|
||||
return fail("新密码不能与旧密码相同");
|
||||
}
|
||||
String phone = StrUtil.trimToNull(loginUser.getPhone());
|
||||
if (phone == null) {
|
||||
return fail("账号未绑定手机号,请联系平台客服");
|
||||
}
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (StrUtil.isBlank(authorization)) {
|
||||
return HjcAuthResponses.unauthorized();
|
||||
}
|
||||
|
||||
// 第一步:校验旧密码。核心实例该接口按登录态识别用户,故必须带上买家自己的 token
|
||||
// (这是 hjc 唯一一条不剥 Authorization 的 core 调用;它不碰短信/图形码,无 Redis 前缀问题)。
|
||||
HjcCoreAuthClient.CoreResult oldCheck = coreAuthClient.verifyOldPassword(authorization, body.getOldPassword());
|
||||
if (!oldCheck.isOk()) {
|
||||
if (oldCheck.getCode() == Constants.UNAUTHORIZED_CODE) {
|
||||
// 核心实例的 403 = 买家账号没有 sys:auth:password 权限。这是本功能的已知前置条件
|
||||
// (运维需在核心实例后台给租户 10626 的 user 角色授权,见 ADR-0008),
|
||||
// 不是用户输错了密码——所以绝不能复用「原密码输入不正确」那套文案去误导用户。
|
||||
log.error("HjcPassword: 核心实例拒绝改密(403),买家账号缺少 sys:auth:password 权限 userId={}",
|
||||
loginUser.getUserId());
|
||||
return fail("修改密码暂不可用,请联系平台客服");
|
||||
}
|
||||
String message = oldCheck.getMessage();
|
||||
if (message == null || !PASSTHROUGH_OLD_PASSWORD_MESSAGES.contains(message)) {
|
||||
log.warn("HjcPassword: 旧密码校验失败已映射为通用文案,userId={} 原始 message={} error={} raw={}",
|
||||
loginUser.getUserId(), message, oldCheck.getError(), oldCheck.getRaw());
|
||||
message = "旧密码校验失败,请稍后重试";
|
||||
}
|
||||
return fail(message);
|
||||
}
|
||||
|
||||
// 第二步:短信校验与最终改密。必须带 userId + tenantId 锁定单个账号——
|
||||
// 不带 userId 时核心实例会按手机号跨租户批量重置,那会连带改掉同号在其它平台的账号。
|
||||
HjcCoreAuthClient.CoreResult reset = coreAuthClient.resetPassword(
|
||||
loginUser.getUserId(),
|
||||
hjcAuthProperties.getTenantId(),
|
||||
phone,
|
||||
StrUtil.trim(body.getSmsCode()),
|
||||
body.getNewPassword(),
|
||||
body.getConfirmPassword());
|
||||
if (!reset.isOk()) {
|
||||
log.warn("HjcPassword: 改密被核心实例拒绝 userId={} message={} error={} raw={}",
|
||||
loginUser.getUserId(), reset.getMessage(), reset.getError(), reset.getRaw());
|
||||
return fail(StrUtil.blankToDefault(reset.getMessage(), "修改密码失败,请稍后重试"));
|
||||
}
|
||||
return success("密码修改成功", null);
|
||||
}
|
||||
|
||||
// ==================== 后台:密码找回审核 ====================
|
||||
|
||||
@Operation(summary = "后台-密码找回申请分页(列表不含明文新密码)")
|
||||
@GetMapping("/password-apply/page")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<PageResult<HjcPasswordApply>> page(HjcPasswordApplyParam param) {
|
||||
PageResult<HjcPasswordApply> result = passwordApplyService.pageRel(param);
|
||||
if (result.getList() != null) {
|
||||
// 明文新密码只在详情里出现,且详情受同一个管理员守卫保护
|
||||
result.getList().forEach(item -> item.setNewPassword(null));
|
||||
}
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@Operation(summary = "后台-密码找回申请详情(含明文新密码与档案里旧的授权委托书地址)")
|
||||
@GetMapping("/password-apply/{id}")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> detail(@PathVariable("id") Integer id) {
|
||||
HjcPasswordApply apply = passwordApplyService.getById(id);
|
||||
if (apply == null) {
|
||||
return fail("申请不存在");
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>(4);
|
||||
data.put("apply", apply);
|
||||
// 并列展示资质档案里那份旧委托书,供审核人对比新旧授权(本次必须重新上传)
|
||||
data.put("archiveHandbookUrl", archiveHandbookUrl(apply.getEnterpriseId()));
|
||||
return success(data);
|
||||
}
|
||||
|
||||
@OperationLog(module = "汇吉采-密码找回", value = "审核密码找回申请")
|
||||
@Operation(summary = "后台-审核:通过(待重置)/ 驳回(附原因)")
|
||||
@PutMapping("/password-apply/audit")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> audit(@RequestBody HjcPasswordApply param) {
|
||||
if (param == null) {
|
||||
return fail("参数不能为空");
|
||||
}
|
||||
String error = passwordApplyService.audit(
|
||||
param.getId(), param.getStatus(), param.getRejectReason(), getLoginUserId());
|
||||
if (error != null) {
|
||||
return fail(error);
|
||||
}
|
||||
return success("审核完成。通过后请在核心实例管理后台执行密码重置,再回来标记「已重置」。");
|
||||
}
|
||||
|
||||
@OperationLog(module = "汇吉采-密码找回", value = "标记密码已重置")
|
||||
@Operation(summary = "后台-标记已重置(仅在运维于核心实例执行完毕后点)")
|
||||
@PutMapping("/password-apply/mark-reset")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> markReset(@RequestBody HjcPasswordApply param) {
|
||||
if (param == null || param.getId() == null) {
|
||||
return fail("参数不完整");
|
||||
}
|
||||
String error = passwordApplyService.markReset(param.getId(), getLoginUserId());
|
||||
if (error != null) {
|
||||
return fail(error);
|
||||
}
|
||||
return success("已标记为已重置", null);
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
/** 进度的对外视图:**不含明文新密码**,只给状态与时间 */
|
||||
private Map<String, Object> statusView(HjcPasswordApply apply) {
|
||||
Map<String, Object> view = new LinkedHashMap<>(8);
|
||||
view.put("status", apply.getStatus());
|
||||
view.put("statusText", statusText(apply.getStatus()));
|
||||
view.put("applyTime", format(apply.getCreateTime()));
|
||||
view.put("auditTime", format(apply.getAuditTime()));
|
||||
view.put("resetTime", format(apply.getResetTime()));
|
||||
view.put("rejectReason", apply.getRejectReason());
|
||||
return view;
|
||||
}
|
||||
|
||||
private String archiveHandbookUrl(Integer enterpriseId) {
|
||||
if (enterpriseId == null) {
|
||||
return null;
|
||||
}
|
||||
HjcEnterpriseMaterial material = hjcEnterpriseMaterialService.getOne(
|
||||
new LambdaQueryWrapper<HjcEnterpriseMaterial>()
|
||||
.eq(HjcEnterpriseMaterial::getEnterpriseId, enterpriseId)
|
||||
.eq(HjcEnterpriseMaterial::getMaterialType, "handbook")
|
||||
.orderByDesc(HjcEnterpriseMaterial::getId)
|
||||
.last("LIMIT 1"), false);
|
||||
return material == null ? null : material.getFileUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取客户端 IP(用于频次限制)。
|
||||
*
|
||||
* <p>优先 {@code X-Forwarded-For} 的第一段:mp-java 部署在 nginx/网关之后,{@code getRemoteAddr()}
|
||||
* 拿到的是反代地址,用它做限流会把所有人算成同一个主体。</p>
|
||||
*/
|
||||
private String clientIp() {
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
if (StrUtil.isNotBlank(forwarded) && !"unknown".equalsIgnoreCase(forwarded)) {
|
||||
int comma = forwarded.indexOf(',');
|
||||
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
|
||||
}
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StrUtil.isNotBlank(realIp) && !"unknown".equalsIgnoreCase(realIp)) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static String format(LocalDateTime time) {
|
||||
return time == null ? null : TIME_FORMATTER.format(time);
|
||||
}
|
||||
|
||||
private static String statusText(Integer status) {
|
||||
if (status == null) {
|
||||
return null;
|
||||
}
|
||||
switch (status) {
|
||||
case HjcPasswordApply.STATUS_PENDING:
|
||||
return "待审核";
|
||||
case HjcPasswordApply.STATUS_APPROVED:
|
||||
// 注意措辞:通过 ≠ 已重置,这里必须让用户知道还差平台侧一步
|
||||
return "已通过,等待平台执行重置";
|
||||
case HjcPasswordApply.STATUS_REJECTED:
|
||||
return "已驳回";
|
||||
case HjcPasswordApply.STATUS_RESET:
|
||||
return "已重置,可用新密码登录";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
private static String idsOf(List<HjcEnterprise> list) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (HjcEnterprise item : list) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(item.getId());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gxwebsoft.hjc.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 密码找回申请请求体(匿名)。
|
||||
*
|
||||
* <p>字段与需求一致:企业名称、纳税人识别号、新密码、授权委托书({@code handbookUrl} 由
|
||||
* 匿名上传接口 {@code POST /api/hjc/auth/upload} 先行取得,与注册链路同一做法)。</p>
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(name = "HjcPasswordApplyRequest对象", description = "密码找回申请")
|
||||
public class HjcPasswordApplyRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "企业名称(登录账号)")
|
||||
private String enterpriseName;
|
||||
|
||||
@Schema(description = "纳税人识别号/统一社会信用代码")
|
||||
private String creditCode;
|
||||
|
||||
@Schema(description = "新密码(至少8位且含字母和数字)")
|
||||
private String newPassword;
|
||||
|
||||
@Schema(description = "确认新密码")
|
||||
private String confirmPassword;
|
||||
|
||||
@Schema(description = "本次重新上传的授权委托书文件地址")
|
||||
private String handbookUrl;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.gxwebsoft.hjc.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 修改密码请求体(登录态)。
|
||||
*
|
||||
* <p>两个因子都必须提供:{@code oldPassword} 证明是持有人本人,{@code smsCode} 证明仍持有
|
||||
* 账号绑定手机号。两者的校验顺序与为什么不能换,见
|
||||
* {@code HjcPasswordController#changePassword}。</p>
|
||||
*/
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(name = "HjcPasswordChangeRequest对象", description = "修改密码")
|
||||
public class HjcPasswordChangeRequest implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "旧密码")
|
||||
private String oldPassword;
|
||||
|
||||
@Schema(description = "短信验证码(发到账号绑定手机号)")
|
||||
private String smsCode;
|
||||
|
||||
@Schema(description = "新密码(至少8位且含字母和数字)")
|
||||
private String newPassword;
|
||||
|
||||
@Schema(description = "确认新密码")
|
||||
private String confirmPassword;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.gxwebsoft.hjc.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 汇吉采密码找回申请(材料审核制)。
|
||||
*
|
||||
* <p><b>为什么不是一个"自助改密"流程</b>:账号凭据归核心实例所有(ADR-0006),而 hjc 对核心实例
|
||||
* 只有读的能力、也没有任何合法的代改密码通道,故「审核通过」与「密码已重置」必须是两个状态:
|
||||
* 审核通过后由运维在核心实例管理后台照本表照单手工重置,再回后台标记为「已重置」。
|
||||
* 见 ADR-0008。</p>
|
||||
*
|
||||
* <p><b>{@code newPassword} 是明文</b>:运维要照着它往核心实例里输。这是 ADR-0008 里
|
||||
* <b>显式接受的风险</b>(用户明确选择不做终态清空、不记查看审计),不是疏忽——
|
||||
* 改这一条之前请先改 ADR。</p>
|
||||
*
|
||||
* <p>不加 {@code @TableName}:与 hjc 既有实体一致,靠驼峰转下划线映射到 {@code hjc_password_apply}。</p>
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Schema(name = "HjcPasswordApply对象", description = "汇吉采密码找回申请")
|
||||
public class HjcPasswordApply implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 状态:待审核 */
|
||||
public static final int STATUS_PENDING = 0;
|
||||
/** 状态:已通过(待运维在核心实例执行重置) */
|
||||
public static final int STATUS_APPROVED = 1;
|
||||
/** 状态:已驳回 */
|
||||
public static final int STATUS_REJECTED = 2;
|
||||
/** 状态:已重置(运维已执行完毕) */
|
||||
public static final int STATUS_RESET = 3;
|
||||
|
||||
@Schema(description = "ID")
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
|
||||
@Schema(description = "企业ID")
|
||||
private Integer enterpriseId;
|
||||
|
||||
@Schema(description = "核心实例账号ID,供运维照单重置")
|
||||
private Integer userId;
|
||||
|
||||
@Schema(description = "企业名称(提交时快照)")
|
||||
private String enterpriseName;
|
||||
|
||||
@Schema(description = "纳税人识别号(提交时快照)")
|
||||
private String creditCode;
|
||||
|
||||
@Schema(description = "申请人填写的新密码(明文,仅供后台审核详情照单重置)")
|
||||
private String newPassword;
|
||||
|
||||
@Schema(description = "本次重新上传的授权委托书地址")
|
||||
private String handbookUrl;
|
||||
|
||||
@Schema(description = "状态:0待审核 1已通过(待重置) 2已驳回 3已重置")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "驳回原因")
|
||||
private String rejectReason;
|
||||
|
||||
@Schema(description = "审核人ID")
|
||||
private Integer auditUserId;
|
||||
|
||||
@Schema(description = "审核时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime auditTime;
|
||||
|
||||
@Schema(description = "标记已重置的操作人ID")
|
||||
private Integer resetUserId;
|
||||
|
||||
@Schema(description = "标记已重置的时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime resetTime;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private Integer tenantId;
|
||||
|
||||
@Schema(description = "是否删除, 0否, 1是")
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "修改时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -20,4 +20,21 @@ public interface HjcEnterpriseMapper extends BaseMapper<HjcEnterprise> {
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
HjcEnterprise getByUserId(@Param("userId") Integer userId);
|
||||
|
||||
/**
|
||||
* 按「企业名称 + 纳税人识别号」双要素取企业(忽略租户隔离,显式传 tenantId)。
|
||||
*
|
||||
* <p>用于<b>匿名</b>的密码找回申请与进度查询:只允许精确匹配,两者必须同时命中同一份档案。</p>
|
||||
*
|
||||
* <p>为什么是双要素:{@code hjc_enterprise.name} <b>没有唯一索引</b>({@code hjc_init.sql} 里只有
|
||||
* {@code user_id} 唯一),只用企业名称定位的话,同名企业会指向不确定的账号——那等于把 A 企业的
|
||||
* 密码重置落到 B 账号上。加上同样有索引的 {@code credit_code} 后歧义天然消失。</p>
|
||||
*
|
||||
* <p>为什么要忽略租户隔离:多租户插件的租户来源是请求头 {@code tenantId},匿名请求不带它时条件会被
|
||||
* 拼成 {@code tenant_id = NULL} → 恒不命中。故照 {@link #getByUserId} 的做法显式忽略并自带租户条件。</p>
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
List<HjcEnterprise> getByNameAndCreditCode(@Param("name") String name,
|
||||
@Param("creditCode") String creditCode,
|
||||
@Param("tenantId") Integer tenantId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.gxwebsoft.hjc.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
|
||||
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface HjcPasswordApplyMapper extends BaseMapper<HjcPasswordApply> {
|
||||
|
||||
List<HjcPasswordApply> selectPageRel(@Param("page") IPage<HjcPasswordApply> page, @Param("param") HjcPasswordApplyParam param);
|
||||
|
||||
List<HjcPasswordApply> selectListRel(@Param("param") HjcPasswordApplyParam param);
|
||||
|
||||
/**
|
||||
* 取某企业最新一条申请(忽略租户隔离,显式传 tenantId)。
|
||||
*
|
||||
* <p>为什么忽略:这条查询用在<b>匿名</b>的申请与进度查询链路上。多租户插件的租户来源是
|
||||
* 请求头 {@code tenantId}({@code MybatisPlusConfig} 的 tenantLineHandler),匿名请求不带它时
|
||||
* 条件会被拼成 {@code tenant_id = NULL} → 恒不命中。前端目前确实恒带该头,但把正确性押在
|
||||
* 一个可被省略的头上太脆,故这里照 {@code HjcEnterpriseMapper.getByUserId} 的做法
|
||||
* 显式忽略并自带租户条件。</p>
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
HjcPasswordApply getLatestByEnterpriseId(@Param("enterpriseId") Integer enterpriseId,
|
||||
@Param("tenantId") Integer tenantId);
|
||||
|
||||
/**
|
||||
* 取某企业最新一条待审核申请(用于「重复提交不新建」,语义与 {@link #getLatestByEnterpriseId} 同一处理)
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
HjcPasswordApply getPendingByEnterpriseId(@Param("enterpriseId") Integer enterpriseId,
|
||||
@Param("tenantId") Integer tenantId);
|
||||
}
|
||||
@@ -49,4 +49,15 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 密码找回:双要素精确匹配,租户条件显式写在 SQL 里(方法上有 @InterceptorIgnore)。
|
||||
不 LIMIT 1:命中多条属于脏数据,必须让调用方看见并拒绝,不能静默取一条。 -->
|
||||
<select id="getByNameAndCreditCode" resultType="com.gxwebsoft.hjc.entity.HjcEnterprise">
|
||||
SELECT a.*
|
||||
FROM hjc_enterprise a
|
||||
WHERE a.name = #{name}
|
||||
AND a.credit_code = #{creditCode}
|
||||
AND a.tenant_id = #{tenantId}
|
||||
ORDER BY a.id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.gxwebsoft.hjc.mapper.HjcPasswordApplyMapper">
|
||||
|
||||
<!-- 关联查询sql -->
|
||||
<sql id="selectSql">
|
||||
SELECT a.*
|
||||
FROM hjc_password_apply a
|
||||
<where>
|
||||
<if test="param.id != null">
|
||||
AND a.id = #{param.id}
|
||||
</if>
|
||||
<if test="param.enterpriseId != null">
|
||||
AND a.enterprise_id = #{param.enterpriseId}
|
||||
</if>
|
||||
<if test="param.enterpriseName != null">
|
||||
AND a.enterprise_name LIKE CONCAT('%', #{param.enterpriseName}, '%')
|
||||
</if>
|
||||
<if test="param.creditCode != null">
|
||||
AND a.credit_code = #{param.creditCode}
|
||||
</if>
|
||||
<if test="param.status != null">
|
||||
AND a.status = #{param.status}
|
||||
</if>
|
||||
<if test="param.createTimeStart != null">
|
||||
AND a.create_time >= #{param.createTimeStart}
|
||||
</if>
|
||||
<if test="param.createTimeEnd != null">
|
||||
AND a.create_time <= #{param.createTimeEnd}
|
||||
</if>
|
||||
</where>
|
||||
</sql>
|
||||
|
||||
<select id="selectPageRel" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
|
||||
<include refid="selectSql"></include>
|
||||
</select>
|
||||
|
||||
<select id="selectListRel" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
|
||||
<include refid="selectSql"></include>
|
||||
</select>
|
||||
|
||||
<!-- 最新一条申请:只用于匿名链路,租户条件显式写在 SQL 里(方法上有 @InterceptorIgnore) -->
|
||||
<select id="getLatestByEnterpriseId" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
|
||||
SELECT a.*
|
||||
FROM hjc_password_apply a
|
||||
WHERE a.enterprise_id = #{enterpriseId}
|
||||
AND a.tenant_id = #{tenantId}
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 最新一条待审核申请:命中即「重复提交」,直接返回它而不新建 -->
|
||||
<select id="getPendingByEnterpriseId" resultType="com.gxwebsoft.hjc.entity.HjcPasswordApply">
|
||||
SELECT a.*
|
||||
FROM hjc_password_apply a
|
||||
WHERE a.enterprise_id = #{enterpriseId}
|
||||
AND a.tenant_id = #{tenantId}
|
||||
AND a.status = 0
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.gxwebsoft.hjc.param;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.gxwebsoft.common.core.annotation.QueryField;
|
||||
import com.gxwebsoft.common.core.annotation.QueryType;
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 汇吉采密码找回申请查询参数(后台列表)
|
||||
*
|
||||
* @author WebSoft
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(name = "HjcPasswordApplyParam对象", description = "汇吉采密码找回申请查询参数")
|
||||
public class HjcPasswordApplyParam extends BaseParam {
|
||||
|
||||
@QueryField(type = QueryType.EQ)
|
||||
private Integer id;
|
||||
|
||||
@QueryField(type = QueryType.EQ)
|
||||
private Integer enterpriseId;
|
||||
|
||||
@QueryField(type = QueryType.LIKE)
|
||||
private String enterpriseName;
|
||||
|
||||
@QueryField(type = QueryType.EQ)
|
||||
private String creditCode;
|
||||
|
||||
@QueryField(type = QueryType.EQ)
|
||||
private Integer status;
|
||||
}
|
||||
@@ -16,6 +16,16 @@ public interface HjcEnterpriseService extends IService<HjcEnterprise> {
|
||||
|
||||
HjcEnterprise getByUserId(Integer userId);
|
||||
|
||||
/**
|
||||
* 按「企业名称 + 纳税人识别号」双要素<b>精确</b>匹配企业档案(密码找回的匿名链路用)。
|
||||
*
|
||||
* <p>返回列表而不是单条:{@code hjc_enterprise.name} 没有唯一索引,命中多条属于脏数据,
|
||||
* 调用方必须看见并拒绝(否则会把 A 企业的密码重置落到 B 账号上),不能在这里静默取一条。</p>
|
||||
*
|
||||
* @param tenantId 显式租户(匿名请求的租户不能依赖请求头,见 mapper 注释)
|
||||
*/
|
||||
List<HjcEnterprise> listByNameAndCreditCode(String name, String creditCode, Integer tenantId);
|
||||
|
||||
/**
|
||||
* 注册时写入企业档案与资质证件(同一事务)。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.gxwebsoft.hjc.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.hjc.entity.HjcEnterprise;
|
||||
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
|
||||
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
|
||||
|
||||
/**
|
||||
* 汇吉采密码找回申请(材料审核制,见 ADR-0008)。
|
||||
*/
|
||||
public interface HjcPasswordApplyService extends IService<HjcPasswordApply> {
|
||||
|
||||
PageResult<HjcPasswordApply> pageRel(HjcPasswordApplyParam param);
|
||||
|
||||
/** 某企业最新一条申请(匿名链路用,显式传租户) */
|
||||
HjcPasswordApply getLatestByEnterpriseId(Integer enterpriseId, Integer tenantId);
|
||||
|
||||
/** 某企业最新一条待审核申请;不为 null 即「重复提交」,直接返回它而不新建 */
|
||||
HjcPasswordApply getPendingByEnterpriseId(Integer enterpriseId, Integer tenantId);
|
||||
|
||||
/** 写入一条待审核申请 */
|
||||
HjcPasswordApply submit(HjcEnterprise enterprise, String newPassword, String handbookUrl, Integer tenantId);
|
||||
|
||||
/**
|
||||
* 审核:仅允许 {@code 0 → 1}(通过,待运维重置)或 {@code 0 → 2}(驳回)。
|
||||
*
|
||||
* <p>用「带状态条件的更新」而不是「先查后改」,避免两个管理员同时点通过/驳回时后者覆盖前者。</p>
|
||||
*
|
||||
* @return 错误文案;成功返回 null
|
||||
*/
|
||||
String audit(Integer id, Integer status, String rejectReason, Integer auditUserId);
|
||||
|
||||
/**
|
||||
* 标记已重置:仅允许 {@code 1 → 3}。
|
||||
*
|
||||
* <p><b>「审核通过」不等于「已重置」</b>:这一步表示运维已经在核心实例管理后台照申请单执行完毕。</p>
|
||||
*
|
||||
* @return 错误文案;成功返回 null
|
||||
*/
|
||||
String markReset(Integer id, Integer resetUserId);
|
||||
|
||||
/**
|
||||
* 频次配额(Redis 原子自增,键名以 {@code hjc:pwd:} 开头,与核心实例的键区分)。
|
||||
*
|
||||
* <p>用 {@code StringRedisTemplate} 而不是项目的 {@code RedisUtil}:后者只包了 set/get/delete,
|
||||
* 没有原子自增,而"先读再写"的计数在并发下会漏放。</p>
|
||||
*
|
||||
* @param bucket 配额桶名,例如 {@code apply-ip}
|
||||
* @param subject 配额主体,例如客户端 IP 或企业 ID
|
||||
* @return true = 未超限(已占用一次配额)
|
||||
*/
|
||||
boolean tryAcquireQuota(String bucket, String subject, int limit, long ttlSeconds);
|
||||
}
|
||||
@@ -40,6 +40,11 @@ public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, H
|
||||
return baseMapper.getByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HjcEnterprise> listByNameAndCreditCode(String name, String creditCode, Integer tenantId) {
|
||||
return baseMapper.getByNameAndCreditCode(name, creditCode, tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Integer saveRegistration(HjcEnterprise enterprise, List<HjcEnterpriseMaterial> materials) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.gxwebsoft.hjc.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.gxwebsoft.common.core.web.PageParam;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.hjc.entity.HjcEnterprise;
|
||||
import com.gxwebsoft.hjc.entity.HjcPasswordApply;
|
||||
import com.gxwebsoft.hjc.mapper.HjcPasswordApplyMapper;
|
||||
import com.gxwebsoft.hjc.param.HjcPasswordApplyParam;
|
||||
import com.gxwebsoft.hjc.service.HjcPasswordApplyService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class HjcPasswordApplyServiceImpl extends ServiceImpl<HjcPasswordApplyMapper, HjcPasswordApply>
|
||||
implements HjcPasswordApplyService {
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Override
|
||||
public PageResult<HjcPasswordApply> pageRel(HjcPasswordApplyParam param) {
|
||||
PageParam<HjcPasswordApply, HjcPasswordApplyParam> page = new PageParam<>(param);
|
||||
page.setDefaultOrder("id desc");
|
||||
List<HjcPasswordApply> list = baseMapper.selectPageRel(page, param);
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public HjcPasswordApply getLatestByEnterpriseId(Integer enterpriseId, Integer tenantId) {
|
||||
return baseMapper.getLatestByEnterpriseId(enterpriseId, tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HjcPasswordApply getPendingByEnterpriseId(Integer enterpriseId, Integer tenantId) {
|
||||
return baseMapper.getPendingByEnterpriseId(enterpriseId, tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HjcPasswordApply submit(HjcEnterprise enterprise, String newPassword, String handbookUrl, Integer tenantId) {
|
||||
HjcPasswordApply apply = new HjcPasswordApply();
|
||||
apply.setEnterpriseId(enterprise.getId());
|
||||
apply.setUserId(enterprise.getUserId());
|
||||
// 存提交时的快照:企业档案事后可能被改,申请单不随之漂移
|
||||
apply.setEnterpriseName(enterprise.getName());
|
||||
apply.setCreditCode(enterprise.getCreditCode());
|
||||
apply.setNewPassword(newPassword);
|
||||
apply.setHandbookUrl(handbookUrl);
|
||||
apply.setStatus(HjcPasswordApply.STATUS_PENDING);
|
||||
apply.setTenantId(tenantId);
|
||||
save(apply);
|
||||
return apply;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String audit(Integer id, Integer status, String rejectReason, Integer auditUserId) {
|
||||
if (id == null || status == null) {
|
||||
return "审核参数不完整";
|
||||
}
|
||||
if (status != HjcPasswordApply.STATUS_APPROVED && status != HjcPasswordApply.STATUS_REJECTED) {
|
||||
return "审核结果不合法";
|
||||
}
|
||||
if (status == HjcPasswordApply.STATUS_REJECTED && StrUtil.isBlank(rejectReason)) {
|
||||
return "驳回必须填写原因";
|
||||
}
|
||||
LambdaUpdateWrapper<HjcPasswordApply> wrapper = new LambdaUpdateWrapper<HjcPasswordApply>()
|
||||
.eq(HjcPasswordApply::getId, id)
|
||||
.eq(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_PENDING)
|
||||
.set(HjcPasswordApply::getStatus, status)
|
||||
.set(HjcPasswordApply::getAuditUserId, auditUserId)
|
||||
.set(HjcPasswordApply::getAuditTime, LocalDateTime.now());
|
||||
if (status == HjcPasswordApply.STATUS_REJECTED) {
|
||||
wrapper.set(HjcPasswordApply::getRejectReason, rejectReason);
|
||||
}
|
||||
if (!update(wrapper)) {
|
||||
// 条件更新影响 0 行:要么单不存在,要么已被别人处理过(不是待审核)
|
||||
HjcPasswordApply exist = getById(id);
|
||||
if (exist == null) {
|
||||
return "申请不存在";
|
||||
}
|
||||
return "该申请当前状态为「" + statusText(exist.getStatus()) + "」,不能重复审核";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String markReset(Integer id, Integer resetUserId) {
|
||||
if (id == null) {
|
||||
return "参数不完整";
|
||||
}
|
||||
boolean updated = update(new LambdaUpdateWrapper<HjcPasswordApply>()
|
||||
.eq(HjcPasswordApply::getId, id)
|
||||
.eq(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_APPROVED)
|
||||
.set(HjcPasswordApply::getStatus, HjcPasswordApply.STATUS_RESET)
|
||||
.set(HjcPasswordApply::getResetUserId, resetUserId)
|
||||
.set(HjcPasswordApply::getResetTime, LocalDateTime.now()));
|
||||
if (!updated) {
|
||||
HjcPasswordApply exist = getById(id);
|
||||
if (exist == null) {
|
||||
return "申请不存在";
|
||||
}
|
||||
if (Integer.valueOf(HjcPasswordApply.STATUS_RESET).equals(exist.getStatus())) {
|
||||
return "该申请已标记为已重置";
|
||||
}
|
||||
return "只有「已通过(待重置)」的申请才能标记为已重置,当前状态为「" + statusText(exist.getStatus()) + "」";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean tryAcquireQuota(String bucket, String subject, int limit, long ttlSeconds) {
|
||||
if (StrUtil.isBlank(subject)) {
|
||||
// 取不到主体(例如拿不到客户端 IP)时不拦:宁可少一道限制,也不要误伤正常用户
|
||||
return true;
|
||||
}
|
||||
String key = "hjc:pwd:" + bucket + ":" + subject;
|
||||
try {
|
||||
Long count = stringRedisTemplate.opsForValue().increment(key);
|
||||
if (count == null) {
|
||||
return true;
|
||||
}
|
||||
if (count == 1L) {
|
||||
stringRedisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||
} else {
|
||||
Long ttl = stringRedisTemplate.getExpire(key);
|
||||
if (ttl == null || ttl < 0) {
|
||||
// 兜底:极端情况下(自增成功但设置有效期失败)补一次 TTL,避免该主体被永久卡死
|
||||
stringRedisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
return count <= limit;
|
||||
} catch (Exception e) {
|
||||
log.error("HjcPassword: 频次限制读写失败,本次放行 key={}", key, e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static String statusText(Integer status) {
|
||||
if (status == null) {
|
||||
return "未知";
|
||||
}
|
||||
switch (status) {
|
||||
case HjcPasswordApply.STATUS_PENDING:
|
||||
return "待审核";
|
||||
case HjcPasswordApply.STATUS_APPROVED:
|
||||
return "已通过(待重置)";
|
||||
case HjcPasswordApply.STATUS_REJECTED:
|
||||
return "已驳回";
|
||||
case HjcPasswordApply.STATUS_RESET:
|
||||
return "已重置";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,33 @@ CREATE TABLE `hjc_order` (
|
||||
KEY `idx_hjc_order_tenant` (`tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇吉采标书订单';
|
||||
|
||||
-- ---------- 密码找回申请(材料审核制,见 ADR-0008) ----------
|
||||
-- 说明:「审核通过」与「密码已重置」是两个状态:核心实例没有任何 hjc 可用的代改密码通道,
|
||||
-- 最后由运维在核心实例管理后台照本表照单手工重置,再回后台标记为「已重置」。
|
||||
CREATE TABLE `hjc_password_apply` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`enterprise_id` int(11) NOT NULL COMMENT '企业ID(hjc_enterprise.id)',
|
||||
`user_id` int(11) NOT NULL COMMENT '核心实例账号ID,供运维照单重置',
|
||||
`enterprise_name` varchar(255) NOT NULL COMMENT '企业名称(提交时快照)',
|
||||
`credit_code` varchar(64) DEFAULT NULL COMMENT '纳税人识别号(提交时快照)',
|
||||
`new_password` varchar(128) DEFAULT NULL COMMENT '申请人填写的新密码,明文存储供运维照单重置(ADR-0008 已显式接受的风险)',
|
||||
`handbook_url` varchar(500) DEFAULT NULL COMMENT '本次重新上传的授权委托书地址(不复用资质档案里那份)',
|
||||
`status` tinyint(4) DEFAULT 0 COMMENT '状态:0待审核 1已通过(待重置) 2已驳回 3已重置',
|
||||
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
|
||||
`audit_user_id` int(11) DEFAULT NULL COMMENT '审核人ID',
|
||||
`audit_time` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`reset_user_id` int(11) DEFAULT NULL COMMENT '标记已重置的操作人ID',
|
||||
`reset_time` datetime DEFAULT NULL COMMENT '标记已重置的时间',
|
||||
`tenant_id` int(11) DEFAULT NULL COMMENT '租户ID',
|
||||
`deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除, 0否, 1是',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_hjc_pwa_ent` (`enterprise_id`),
|
||||
KEY `idx_hjc_pwa_status` (`status`),
|
||||
KEY `idx_hjc_pwa_tenant` (`tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇吉采密码找回申请';
|
||||
|
||||
-- ---------- 一站式出向推送日志(幂等+重试) ----------
|
||||
CREATE TABLE `hjc_order_push_log` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 汇吉采:密码找回申请表(材料审核制,见 docs/adr/0008-密码重置由平台运维人工执行.md)
|
||||
--
|
||||
-- 幂等:`CREATE TABLE IF NOT EXISTS` 本身幂等,重复执行不会报错,可安全地作为发布步骤对每个环境跑一遍。
|
||||
-- (对比 hjc_order_add_refund.sql:那个脚本要 ADD COLUMN,MySQL 8 无 `ADD COLUMN IF NOT EXISTS`,
|
||||
-- 故必须按 information_schema 判断;建表不需要这一套。)
|
||||
--
|
||||
-- 只增表,不删不改任何既有表,仅作用于 hjc 自己的新表,对共用本库的其他项目无影响。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `hjc_password_apply` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`enterprise_id` int(11) NOT NULL COMMENT '企业ID(hjc_enterprise.id)',
|
||||
`user_id` int(11) NOT NULL COMMENT '核心实例账号ID,供运维照单重置',
|
||||
`enterprise_name` varchar(255) NOT NULL COMMENT '企业名称(提交时快照)',
|
||||
`credit_code` varchar(64) DEFAULT NULL COMMENT '纳税人识别号(提交时快照)',
|
||||
`new_password` varchar(128) DEFAULT NULL COMMENT '申请人填写的新密码,明文存储供运维照单重置(ADR-0008 已显式接受的风险)',
|
||||
`handbook_url` varchar(500) DEFAULT NULL COMMENT '本次重新上传的授权委托书地址(不复用资质档案里那份)',
|
||||
`status` tinyint(4) DEFAULT 0 COMMENT '状态:0待审核 1已通过(待重置) 2已驳回 3已重置',
|
||||
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
|
||||
`audit_user_id` int(11) DEFAULT NULL COMMENT '审核人ID',
|
||||
`audit_time` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`reset_user_id` int(11) DEFAULT NULL COMMENT '标记已重置的操作人ID',
|
||||
`reset_time` datetime DEFAULT NULL COMMENT '标记已重置的时间',
|
||||
`tenant_id` int(11) DEFAULT NULL COMMENT '租户ID',
|
||||
`deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除, 0否, 1是',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_hjc_pwa_ent` (`enterprise_id`),
|
||||
KEY `idx_hjc_pwa_status` (`status`),
|
||||
KEY `idx_hjc_pwa_tenant` (`tenant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='汇吉采密码找回申请';
|
||||
Reference in New Issue
Block a user