diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
index de9f5ed..6545529 100644
--- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
+++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java
@@ -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()
diff --git a/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java b/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java
index 4f21ef3..c3f96d1 100644
--- a/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java
+++ b/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java
@@ -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);
}
+ /**
+ * 校验旧密码(唯一一条要转发买家 Authorization 的调用)。
+ *
+ *
为什么这里与类注释第 2 条相反:核心实例的 {@code PUT /auth/password} 按登录态
+ * 识别要改谁的密码,不带 token 根本无从校验。它只碰密码,不碰短信验证码与图形验证码,
+ * 因此不存在"Redis 键前缀随认证姿态变化"那个问题。
+ *
+ * 为什么可以传「新密码 = 旧密码」:该接口只校验 {@code oldPassword} 是否正确,
+ * 不比较新旧密码是否相同(见 core MainController#updatePassword),因此这次调用
+ * 只做校验、不改变可登录的口令(代价是用同一明文重写了一次 BCrypt 哈希)。
+ * 之所以需要它,是因为短信验证码只有核心实例的 {@code /resetPassword} 会校验,而那个接口
+ * 同时就把密码改了——双因子要各自独立成立,旧密码就必须先单独验掉。
+ *
+ * 前置条件:该账号必须拥有 {@code sys:auth:password}。hjc 买家角色(租户 10626 的
+ * {@code user})当前是 0 菜单 0 权限,需由运维在核心实例后台授予,否则这里恒 403。
+ *
+ * @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);
+ }
+
+ /**
+ * 重置密码(匿名姿态,必须剥掉 Authorization)。
+ *
+ * 必须带 {@code userId} + {@code tenantId}:不传 {@code userId} 时核心实例会按手机号
+ * 查出所有租户下的账号并逐个改密(其 SQL 无 {@code tenant_id} 条件),那会连带改掉
+ * 同一手机号在其它平台的账号密码。这里锁定单个账号。
+ */
+ 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")
diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcPasswordController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcPasswordController.java
new file mode 100644
index 0000000..7274674
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcPasswordController.java
@@ -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;
+
+/**
+ * 汇吉采 密码找回(材料审核制)与修改密码。
+ *
+ * 为什么找回密码不是一个自助改密流程:账号凭据归核心实例所有(ADR-0006),而 hjc 对核心实例
+ * 只有读的能力、没有任何合法的代改密码通道。因此:
+ *
+ * - 找回密码=企业提交材料 → 平台人工审核 → 运维在核心实例管理后台照申请单执行重置
+ * → 回本后台标记「已重置」。故「审核通过」与「已重置」是两个状态,界面上不得混为一谈。
+ * - 修改密码=登录态下「旧密码 + 账号绑定手机号短信」双因子,两个因子都通过核心实例的接口完成。
+ *
+ *
+ * 见 ADR-0008 与 {@code .scratch/hjc-password/spec.md}。
+ */
+@Tag(name = "汇吉采-密码找回与修改密码")
+@Slf4j
+@RestController
+@RequestMapping("/api/hjc")
+public class HjcPasswordController extends BaseController {
+
+ /**
+ * 申请与进度查询的统一话术。
+ *
+ * 提交接口对「企业不存在 / 双要素不匹配 / 命中多条 / 已有待审核申请」一律返回这一句,
+ * 响应体完全一致(连 data 都为 null),使外部无法据此判断某个企业是否在本平台注册过
+ * (见 spec 的「已定决策」第 2、8 条)。真正的失败原因只落服务端日志。
+ */
+ 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 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 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 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);
+ }
+
+ /**
+ * 修改密码(旧密码 + 短信验证码)。
+ *
+ * 两步的顺序不能换:短信验证码只有核心实例的 {@code /resetPassword} 会校验,而它
+ * 同时就把密码改了。若先改密再验短信,短信因子就等于不存在。因此:先用
+ * {@code PUT /auth/password}(新旧密码传同一个值)把旧密码验掉,再让 {@code /resetPassword}
+ * 以短信为闸门完成改密。
+ */
+ @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> page(HjcPasswordApplyParam param) {
+ PageResult 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 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 statusView(HjcPasswordApply apply) {
+ Map 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()
+ .eq(HjcEnterpriseMaterial::getEnterpriseId, enterpriseId)
+ .eq(HjcEnterpriseMaterial::getMaterialType, "handbook")
+ .orderByDesc(HjcEnterpriseMaterial::getId)
+ .last("LIMIT 1"), false);
+ return material == null ? null : material.getFileUrl();
+ }
+
+ /**
+ * 取客户端 IP(用于频次限制)。
+ *
+ * 优先 {@code X-Forwarded-For} 的第一段:mp-java 部署在 nginx/网关之后,{@code getRemoteAddr()}
+ * 拿到的是反代地址,用它做限流会把所有人算成同一个主体。
+ */
+ 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 list) {
+ StringBuilder sb = new StringBuilder();
+ for (HjcEnterprise item : list) {
+ if (sb.length() > 0) {
+ sb.append(',');
+ }
+ sb.append(item.getId());
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordApplyRequest.java b/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordApplyRequest.java
new file mode 100644
index 0000000..0156a2b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordApplyRequest.java
@@ -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;
+
+/**
+ * 密码找回申请请求体(匿名)。
+ *
+ * 字段与需求一致:企业名称、纳税人识别号、新密码、授权委托书({@code handbookUrl} 由
+ * 匿名上传接口 {@code POST /api/hjc/auth/upload} 先行取得,与注册链路同一做法)。
+ */
+@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;
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordChangeRequest.java b/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordChangeRequest.java
new file mode 100644
index 0000000..5dd0a30
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/dto/HjcPasswordChangeRequest.java
@@ -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;
+
+/**
+ * 修改密码请求体(登录态)。
+ *
+ * 两个因子都必须提供:{@code oldPassword} 证明是持有人本人,{@code smsCode} 证明仍持有
+ * 账号绑定手机号。两者的校验顺序与为什么不能换,见
+ * {@code HjcPasswordController#changePassword}。
+ */
+@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;
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/entity/HjcPasswordApply.java b/src/main/java/com/gxwebsoft/hjc/entity/HjcPasswordApply.java
new file mode 100644
index 0000000..38b7b24
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/entity/HjcPasswordApply.java
@@ -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;
+
+/**
+ * 汇吉采密码找回申请(材料审核制)。
+ *
+ * 为什么不是一个"自助改密"流程:账号凭据归核心实例所有(ADR-0006),而 hjc 对核心实例
+ * 只有读的能力、也没有任何合法的代改密码通道,故「审核通过」与「密码已重置」必须是两个状态:
+ * 审核通过后由运维在核心实例管理后台照本表照单手工重置,再回后台标记为「已重置」。
+ * 见 ADR-0008。
+ *
+ * {@code newPassword} 是明文:运维要照着它往核心实例里输。这是 ADR-0008 里
+ * 显式接受的风险(用户明确选择不做终态清空、不记查看审计),不是疏忽——
+ * 改这一条之前请先改 ADR。
+ *
+ * 不加 {@code @TableName}:与 hjc 既有实体一致,靠驼峰转下划线映射到 {@code hjc_password_apply}。
+ *
+ * @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;
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/HjcEnterpriseMapper.java b/src/main/java/com/gxwebsoft/hjc/mapper/HjcEnterpriseMapper.java
index d4ab75f..34caa4f 100644
--- a/src/main/java/com/gxwebsoft/hjc/mapper/HjcEnterpriseMapper.java
+++ b/src/main/java/com/gxwebsoft/hjc/mapper/HjcEnterpriseMapper.java
@@ -20,4 +20,21 @@ public interface HjcEnterpriseMapper extends BaseMapper {
*/
@InterceptorIgnore(tenantLine = "true")
HjcEnterprise getByUserId(@Param("userId") Integer userId);
+
+ /**
+ * 按「企业名称 + 纳税人识别号」双要素取企业(忽略租户隔离,显式传 tenantId)。
+ *
+ * 用于匿名的密码找回申请与进度查询:只允许精确匹配,两者必须同时命中同一份档案。
+ *
+ * 为什么是双要素:{@code hjc_enterprise.name} 没有唯一索引({@code hjc_init.sql} 里只有
+ * {@code user_id} 唯一),只用企业名称定位的话,同名企业会指向不确定的账号——那等于把 A 企业的
+ * 密码重置落到 B 账号上。加上同样有索引的 {@code credit_code} 后歧义天然消失。
+ *
+ * 为什么要忽略租户隔离:多租户插件的租户来源是请求头 {@code tenantId},匿名请求不带它时条件会被
+ * 拼成 {@code tenant_id = NULL} → 恒不命中。故照 {@link #getByUserId} 的做法显式忽略并自带租户条件。
+ */
+ @InterceptorIgnore(tenantLine = "true")
+ List getByNameAndCreditCode(@Param("name") String name,
+ @Param("creditCode") String creditCode,
+ @Param("tenantId") Integer tenantId);
}
diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/HjcPasswordApplyMapper.java b/src/main/java/com/gxwebsoft/hjc/mapper/HjcPasswordApplyMapper.java
new file mode 100644
index 0000000..46fde67
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/mapper/HjcPasswordApplyMapper.java
@@ -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 {
+
+ List selectPageRel(@Param("page") IPage page, @Param("param") HjcPasswordApplyParam param);
+
+ List selectListRel(@Param("param") HjcPasswordApplyParam param);
+
+ /**
+ * 取某企业最新一条申请(忽略租户隔离,显式传 tenantId)。
+ *
+ * 为什么忽略:这条查询用在匿名的申请与进度查询链路上。多租户插件的租户来源是
+ * 请求头 {@code tenantId}({@code MybatisPlusConfig} 的 tenantLineHandler),匿名请求不带它时
+ * 条件会被拼成 {@code tenant_id = NULL} → 恒不命中。前端目前确实恒带该头,但把正确性押在
+ * 一个可被省略的头上太脆,故这里照 {@code HjcEnterpriseMapper.getByUserId} 的做法
+ * 显式忽略并自带租户条件。
+ */
+ @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);
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcEnterpriseMapper.xml b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcEnterpriseMapper.xml
index 8b7fa3d..f9d2205 100644
--- a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcEnterpriseMapper.xml
+++ b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcEnterpriseMapper.xml
@@ -49,4 +49,15 @@
LIMIT 1
+
+
+
diff --git a/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcPasswordApplyMapper.xml b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcPasswordApplyMapper.xml
new file mode 100644
index 0000000..71be354
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/mapper/xml/HjcPasswordApplyMapper.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+ SELECT a.*
+ FROM hjc_password_apply a
+
+
+ AND a.id = #{param.id}
+
+
+ AND a.enterprise_id = #{param.enterpriseId}
+
+
+ AND a.enterprise_name LIKE CONCAT('%', #{param.enterpriseName}, '%')
+
+
+ AND a.credit_code = #{param.creditCode}
+
+
+ AND a.status = #{param.status}
+
+
+ AND a.create_time >= #{param.createTimeStart}
+
+
+ AND a.create_time <= #{param.createTimeEnd}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/hjc/param/HjcPasswordApplyParam.java b/src/main/java/com/gxwebsoft/hjc/param/HjcPasswordApplyParam.java
new file mode 100644
index 0000000..5a2a5e7
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/param/HjcPasswordApplyParam.java
@@ -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;
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java
index e68fd44..cfbbe07 100644
--- a/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java
+++ b/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java
@@ -16,6 +16,16 @@ public interface HjcEnterpriseService extends IService {
HjcEnterprise getByUserId(Integer userId);
+ /**
+ * 按「企业名称 + 纳税人识别号」双要素精确匹配企业档案(密码找回的匿名链路用)。
+ *
+ * 返回列表而不是单条:{@code hjc_enterprise.name} 没有唯一索引,命中多条属于脏数据,
+ * 调用方必须看见并拒绝(否则会把 A 企业的密码重置落到 B 账号上),不能在这里静默取一条。
+ *
+ * @param tenantId 显式租户(匿名请求的租户不能依赖请求头,见 mapper 注释)
+ */
+ List listByNameAndCreditCode(String name, String creditCode, Integer tenantId);
+
/**
* 注册时写入企业档案与资质证件(同一事务)。
*
diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcPasswordApplyService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcPasswordApplyService.java
new file mode 100644
index 0000000..13a4ff7
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/service/HjcPasswordApplyService.java
@@ -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 {
+
+ PageResult 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}(驳回)。
+ *
+ * 用「带状态条件的更新」而不是「先查后改」,避免两个管理员同时点通过/驳回时后者覆盖前者。
+ *
+ * @return 错误文案;成功返回 null
+ */
+ String audit(Integer id, Integer status, String rejectReason, Integer auditUserId);
+
+ /**
+ * 标记已重置:仅允许 {@code 1 → 3}。
+ *
+ * 「审核通过」不等于「已重置」:这一步表示运维已经在核心实例管理后台照申请单执行完毕。
+ *
+ * @return 错误文案;成功返回 null
+ */
+ String markReset(Integer id, Integer resetUserId);
+
+ /**
+ * 频次配额(Redis 原子自增,键名以 {@code hjc:pwd:} 开头,与核心实例的键区分)。
+ *
+ * 用 {@code StringRedisTemplate} 而不是项目的 {@code RedisUtil}:后者只包了 set/get/delete,
+ * 没有原子自增,而"先读再写"的计数在并发下会漏放。
+ *
+ * @param bucket 配额桶名,例如 {@code apply-ip}
+ * @param subject 配额主体,例如客户端 IP 或企业 ID
+ * @return true = 未超限(已占用一次配额)
+ */
+ boolean tryAcquireQuota(String bucket, String subject, int limit, long ttlSeconds);
+}
diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java
index 0ab99c4..3ea4882 100644
--- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java
@@ -40,6 +40,11 @@ public class HjcEnterpriseServiceImpl extends ServiceImpl listByNameAndCreditCode(String name, String creditCode, Integer tenantId) {
+ return baseMapper.getByNameAndCreditCode(name, creditCode, tenantId);
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public Integer saveRegistration(HjcEnterprise enterprise, List materials) {
diff --git a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcPasswordApplyServiceImpl.java b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcPasswordApplyServiceImpl.java
new file mode 100644
index 0000000..84f2655
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcPasswordApplyServiceImpl.java
@@ -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
+ implements HjcPasswordApplyService {
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ @Override
+ public PageResult pageRel(HjcPasswordApplyParam param) {
+ PageParam page = new PageParam<>(param);
+ page.setDefaultOrder("id desc");
+ List 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 wrapper = new LambdaUpdateWrapper()
+ .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()
+ .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 "未知";
+ }
+ }
+}
diff --git a/src/main/resources/sql/hjc_init.sql b/src/main/resources/sql/hjc_init.sql
index bb254ea..3c792a9 100644
--- a/src/main/resources/sql/hjc_init.sql
+++ b/src/main/resources/sql/hjc_init.sql
@@ -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',
diff --git a/src/main/resources/sql/hjc_password_apply.sql b/src/main/resources/sql/hjc_password_apply.sql
new file mode 100644
index 0000000..caf5f53
--- /dev/null
+++ b/src/main/resources/sql/hjc_password_apply.sql
@@ -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='汇吉采密码找回申请';