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:
2026-09-17 02:35:02 +08:00
parent 2cd60270b9
commit a3c54249dc
17 changed files with 1108 additions and 1 deletions
@@ -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 &gt;= #{param.createTimeStart}
</if>
<if test="param.createTimeEnd != null">
AND a.create_time &lt;= #{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 "未知";
}
}
}
+27
View File
@@ -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 COLUMNMySQL 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='汇吉采密码找回申请';