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='汇吉采密码找回申请';