From ce1d0a2a8a585305938eeda5bf7c245cb1e0557b Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 10 Sep 2026 23:44:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(hjc):=20=E7=99=BB=E5=BD=95=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=87=8D=E5=81=9A=E2=80=94=E2=80=94=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E6=A0=B8=E5=BF=83=E5=AE=9E=E4=BE=8B=EF=BC=8C?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E8=AF=AD=E4=B9=89=E8=87=AA=E6=8C=81=EF=BC=9B?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8E=A5=E5=8F=A3=E9=89=B4=E6=9D=83=E6=94=B6?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 汇吉采企业账号原由 hjc 自行写入 sys_user,注册与登录从未跑通:UserMapper 的 LEFT JOIN shop_user_oauth 既漏库名又是 core 2023 年的旧表名(打到不存在的 modules.shop_user_oauth),saveUser 因实体缺 schema 打到不存在的 modules.sys_user;即便修好,token 经共享过滤器远程回核心实例取用户时也必然 401。 本次按 ADR-0006(取代 ADR-0005)切分:账号归核心实例,企业归汇吉采。 - 账号本体是核心实例 sys_user 的一条零企业语义普通账号(type=0,不写 shop_name/audit_status/company_id);注册/登录/图形验证码/短信 全部代理核心实例 - 企业主体、资质、经办人、证件、授权仍只存 hjc_enterprise / hjc_enterprise_material - 新增 HjcCoreAuthClient:必带 TenantId 头(缺它 core 会拼 tenant_id=NULL,查重 静默失效、登录永不匹配);剥掉 Authorization(否则短信码 Redis 前缀错位); 不转发邮箱(core 会把明文密码邮件发给客户);登录不传 isSuperAdmin、发短信不传 scene - 新增 HjcAuthProperties(hjc.tenant-id) 与 HjcAdminGuard:按 principal 的角色 + 租户判定 hjc 管理员(core 权限串无 hjc 命名空间且本仓对 core 只有 SELECT) - 鉴权收口(此前 hjc 包 @PreAuthorize 0 处):后台接口补权限校验, enterprise/page 等 GET 此前匿名可读、审核/退款仅需登录不校验角色; 订单详情补归属校验,并先判身份再判存在(否则匿名可枚举订单 ID) - 修复:注册在 core 建号成功后,因反序列化 UserDetails 多余字段 (enabled/accountNonExpired/... 及 Role.sortNumber) 失败而被误报「注册失败」, 造成「账号已存在但用户被告知失败、重试撞手机号已存在」的孤儿账号。 改为忽略未知字段,且注册流程只取 userId、不再依赖整个 User 能否解析; 并加回归测试(用真实失败响应体,反射读控制器真实的 ObjectMapper) - 未改动核心实例代码,未触碰共用 mapper 的陈旧缺陷(仅 hjc 包与 SecurityConfig 的 hjc 白名单为改动面) --- .../common/core/security/SecurityConfig.java | 5 + .../com/gxwebsoft/hjc/auth/HjcAdminGuard.java | 115 ++++++ .../gxwebsoft/hjc/auth/HjcAuthProperties.java | 26 ++ .../gxwebsoft/hjc/auth/HjcCoreAuthClient.java | 201 +++++++++ .../hjc/controller/HjcAuthController.java | 388 ++++++++++++------ .../controller/HjcBidProjectController.java | 5 + .../controller/HjcEnterpriseController.java | 4 + .../hjc/controller/HjcOrderController.java | 21 + .../hjc/controller/HjcPushController.java | 2 + .../com/gxwebsoft/hjc/dto/HjcAuthRequest.java | 10 + .../hjc/service/HjcEnterpriseService.java | 13 + .../impl/HjcEnterpriseServiceImpl.java | 28 ++ src/main/resources/application.yml | 6 +- .../HjcAuthControllerUserParseTest.java | 114 +++++ 14 files changed, 815 insertions(+), 123 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/hjc/auth/HjcAdminGuard.java create mode 100644 src/main/java/com/gxwebsoft/hjc/auth/HjcAuthProperties.java create mode 100644 src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java create mode 100644 src/test/java/com/gxwebsoft/hjc/controller/HjcAuthControllerUserParseTest.java 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 43e235a..21da82e 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -81,8 +81,13 @@ public class SecurityConfig { "/api/qr-code/**", "/api/shop/order-delivery/notify", "/api/hjc/push/project", + // 汇吉采买家端:注册/登录/验证码/退出都由 mp-java 代理核心实例, + // 必须在登录前可访问;未登录的客户端本来也没有平台 token。 "/api/hjc/auth/register", "/api/hjc/auth/login", + "/api/hjc/auth/logout", + "/api/hjc/auth/captcha", + "/api/hjc/auth/sms", // 注册页专用:证件上传与 OCR 识别在登录前发生 "/api/hjc/auth/upload", "/api/hjc/ocr/recognize" diff --git a/src/main/java/com/gxwebsoft/hjc/auth/HjcAdminGuard.java b/src/main/java/com/gxwebsoft/hjc/auth/HjcAdminGuard.java new file mode 100644 index 0000000..3924c1e --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/auth/HjcAdminGuard.java @@ -0,0 +1,115 @@ +package com.gxwebsoft.hjc.auth; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.system.entity.Role; +import com.gxwebsoft.common.system.entity.User; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.Arrays; +import java.util.List; + +/** + * 汇吉采后台管理员守卫。 + * + *

为什么需要它:hjc 的后台接口(企业资质分页/详情/审核、订单分页/退款、项目增删改) + * 此前一个权限注解都没有(全仓其他模块有 1231 处 {@code @PreAuthorize},hjc 包 0 处), + * 叠加共享 {@code SecurityConfig} 里 {@code GET /**} 放行,导致这些接口匿名可读; + * 写接口也只要求「已登录」,任何租户的任何登录用户都能审核别家企业的资质。

+ * + *

为什么是「按角色判」而不是「新建一个 hjc 权限串」:核心实例的权限串规范是 + * {@code 模块:实体:动作},其中没有 hjc 命名空间;而本仓对核心实例的库只有 SELECT 权限 + * (无权新建菜单/权限),核心实例本身又只读。所以「hjc 管理员」只能从 core 已经给出的东西里读—— + * 即 principal 上的角色。这正是 ADR-0006「账号复用核心实例」的直接收益:角色是 core 的权威,我们读它。

+ * + *

两个条件同时满足才算管理员:① 角色是 {@code admin}/{@code superAdmin}(或 core 的 + * {@code is_admin} 标记位);② principal 的租户是汇吉采租户。第二条顺带堵住一个现存漏洞: + * 其他租户的管理员带着 {@code tenantId: 10626} 请求头就能读 hjc 数据——现在不行了,因为这里校验的是 + * principal 自己的 {@code tenantId}(来自 core),而不是请求头。

+ */ +@Slf4j +@Component("hjcGuard") +public class HjcAdminGuard { + + /** 视为管理员的角色码(与 core 的 role_code 比对,忽略大小写) */ + private static final List ADMIN_ROLE_CODES = Arrays.asList("admin", "superadmin"); + + @Resource + private HjcAuthProperties hjcAuthProperties; + + /** 当前登录者是否为 hjc 管理员。用于 {@code @PreAuthorize("@hjcGuard.isAdmin()")} */ + public boolean isAdmin() { + User user = currentUser(); + if (user == null) { + return false; + } + if (!isHjcTenant(user)) { + log.warn("HjcGuard: 拒绝非汇吉采租户的账号访问后台接口 userId={} tenantId={} hjcTenantId={}", + user.getUserId(), user.getTenantId(), hjcAuthProperties.getTenantId()); + return false; + } + if (hasAdminRole(user) || Boolean.TRUE.equals(user.getIsAdmin())) { + return true; + } + log.warn("HjcGuard: 拒绝无管理员角色的账号访问后台接口 userId={} username={} roles={}", + user.getUserId(), user.getUsername(), roleCodes(user)); + return false; + } + + /** + * 当前登录者是否为「已登录的 hjc 买家」。 + * + *

用于需要登录态、但不要求管理员的接口(例如订单详情要判归属)。

+ */ + public boolean isBuyer() { + User user = currentUser(); + return user != null && isHjcTenant(user); + } + + /** 当前登录账号;未登录/principal 不是 core 的 User 时为 null */ + public User currentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + return principal instanceof User ? (User) principal : null; + } + + /** principal 的租户是否就是汇吉采租户 */ + private boolean isHjcTenant(User user) { + Integer hjcTenantId = hjcAuthProperties.getTenantId(); + return hjcTenantId != null && hjcTenantId.equals(user.getTenantId()); + } + + private boolean hasAdminRole(User user) { + if (user.getRoles() == null) { + return false; + } + for (Role role : user.getRoles()) { + if (role == null || StrUtil.isBlank(role.getRoleCode())) { + continue; + } + if (ADMIN_ROLE_CODES.contains(role.getRoleCode().trim().toLowerCase())) { + return true; + } + } + return false; + } + + private String roleCodes(User user) { + if (user.getRoles() == null) { + return "[]"; + } + StringBuilder sb = new StringBuilder("["); + for (Role role : user.getRoles()) { + if (role != null) { + sb.append(role.getRoleCode()).append(' '); + } + } + return sb.append(']').toString(); + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/auth/HjcAuthProperties.java b/src/main/java/com/gxwebsoft/hjc/auth/HjcAuthProperties.java new file mode 100644 index 0000000..78b4530 --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/auth/HjcAuthProperties.java @@ -0,0 +1,26 @@ +package com.gxwebsoft.hjc.auth; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 汇吉采登录认证的配置。 + * + *

{@code hjc.tenant-id} 是汇吉采在平台里的租户 ID(官网租户)。它有两个用途,且都必须用 + * 配置值而不是请求里带来的值:

+ *
    + *
  1. 代理核心实例时作为 {@code TenantId} 头(核心实例的多租户插件缺该头会拼 + * {@code tenant_id = NULL},导致查重静默失效、登录永不匹配);
  2. + *
  3. 判定「hjc 管理员」时校验 principal 的租户归属。
  4. + *
+ *

用配置而非请求头,是为了避免「前端把 tenantId 改成别的租户就能让 hjc 去操作别的租户」。

+ */ +@Getter +@Component +public class HjcAuthProperties { + + /** 汇吉采租户 ID(默认 10626 = 汇吉采官网) */ + @Value("${hjc.tenant-id:10626}") + private Integer tenantId; +} diff --git a/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java b/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java new file mode 100644 index 0000000..e064d5e --- /dev/null +++ b/src/main/java/com/gxwebsoft/hjc/auth/HjcCoreAuthClient.java @@ -0,0 +1,201 @@ +package com.gxwebsoft.hjc.auth; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.common.core.config.ConfigProperties; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 汇吉采登录认证对核心实例的调用出口。 + * + *

为什么 hjc 的注册/登录要调核心实例:平台的身份体系只有一套(核心实例 + * `gxwebsoft_core.sys_user`),C 端用户与管理员同源;mp-java 是模块实例,本库没有 + * `sys_user/sys_role/sys_menu/sys_user_role` 这些表,且模块库账号对 `gxwebsoft_core` + * 只有 SELECT 权限(无法跨库建号)。因此建号与口令校验只能走核心实例的 HTTP 接口。 + * 见 ADR-0006。

+ * + *

三条硬性实现约束(否则功能必然坏,且坏得很难查):

+ *
    + *
  1. 必须带 {@code TenantId} 请求头:核心实例的 MyBatis-Plus 多租户插件在缺少该头时 + * 会把条件拼成 {@code tenant_id = NULL}(MySQL 恒不命中)→ 查重静默失效、登录查询永不匹配。 + * 这里统一用 {@link HjcAuthProperties} 的配置值注入,不采用请求头里的值。
  2. + *
  3. 必须剥掉 Authorization:核心实例存短信验证码的 Redis 键前缀取决于请求 + * 是否已认证(未认证 = {@code cache{手机号}},已认证 = {@code cache{租户}:{手机号}}), + * 而注册读的是未认证那个。若把 hjc 前端的旧 token 转发过去,发码与校验会落到不同的键, + * 表现为莫名其妙的「验证码不正确」。
  4. + *
  5. 不转发邮箱:核心实例注册时会把明文密码连同品牌域名(硬编码 + * {@code websoft.top})邮件发给用户。hjc 的企业邮箱/经办人邮箱只存 hjc 自己的表。
  6. + *
+ */ +@Slf4j +@Component +public class HjcCoreAuthClient { + + /** 核心实例:账号密码登录(带图形验证码) */ + static final String LOGIN_PATH = "/login"; + /** 核心实例:账号注册(带短信验证码) */ + static final String REGISTER_PATH = "/register"; + /** 核心实例:图形验证码 */ + static final String CAPTCHA_PATH = "/captcha"; + /** 核心实例:发送短信验证码 */ + static final String SMS_PATH = "/sendSmsCaptcha"; + + static final int TIMEOUT_MS = 10000; + + @Resource + private ConfigProperties configProperties; + @Resource + private HjcAuthProperties hjcAuthProperties; + + /** + * 核心实例的响应。核心实例的业务失败是 HTTP 200 + code != 0(不是 HTTP 4xx), + * 所以判定成功一律看 {@code code}。 + */ + @Data + public static class CoreResult { + /** code == 0 即成功 */ + private boolean ok; + private int code; + private String message; + private String error; + /** 成功时的 data(核心实例的 LoginResult / CaptchaResult) */ + private JSONObject data; + /** 原始响应体,仅用于日志与异常排查 */ + private String raw; + + static CoreResult of(String raw) { + CoreResult r = new CoreResult(); + r.raw = raw; + JSONObject json; + try { + json = JSON.parseObject(raw); + } catch (Exception e) { + r.code = -1; + r.message = "认证服务返回异常"; + r.error = "响应不是合法 JSON"; + return r; + } + if (json == null) { + r.code = -1; + r.message = "认证服务返回异常"; + r.error = "响应为空"; + return r; + } + r.code = json.getIntValue("code"); + r.message = json.getString("message"); + r.error = json.getString("error"); + r.data = json.getJSONObject("data"); + r.ok = r.code == 0; + return r; + } + } + + /** + * 账号密码登录。 + * + *

{@code captchaCode} 为图形验证码。刻意不传 {@code isSuperAdmin}:核心实例只要收到 + * 该字段非 null(连 {@code false} 都算)就整段跳过图形验证码校验,那就等于我们自己把这道门拆了。

+ */ + public CoreResult login(String username, String password, String captchaCode) { + JSONObject body = new JSONObject(); + body.put("username", username); + body.put("password", password); + body.put("code", captchaCode); + body.put("tenantId", hjcAuthProperties.getTenantId()); + return post(LOGIN_PATH, body); + } + + /** 账号注册。不传 email(见类注释第 3 条) */ + public CoreResult register(String username, String phone, String password, String smsCode) { + JSONObject body = new JSONObject(); + body.put("username", username); + body.put("phone", phone); + body.put("password", password); + body.put("code", smsCode); + // 必须显式 false:该字段为 true 的分支会连带创建租户,而我们只要一个普通注册用户 + body.put("isSuperAdmin", Boolean.FALSE); + return post(REGISTER_PATH, body); + } + + /** + * 取图形验证码。 + * + *

注意:核心实例的响应体里含明文答案 {@code text}(它把答案也返回给客户端)。 + * 调用方必须丢弃该字段,只把图片下发给前端,否则这道验证码等于不存在。

+ */ + public CoreResult captcha() { + return execute(HttpRequest.get(url(CAPTCHA_PATH)), CAPTCHA_PATH); + } + + /** + * 发送短信验证码。 + * + *

注册场景不要传 scene:核心实例在 {@code scene=login} 时会先校验该手机号已注册, + * 而注册恰恰是给未注册的手机号发码,传了会被拒(「该手机号码未注册!」)。

+ */ + public CoreResult sendSmsCaptcha(String phone) { + JSONObject body = new JSONObject(); + body.put("phone", phone); + return post(SMS_PATH, body); + } + + private CoreResult post(String path, JSONObject body) { + return execute(HttpRequest.post(url(path)) + .header("Content-Type", "application/json;charset=UTF-8") + .body(body.toJSONString()) + .timeout(TIMEOUT_MS), path); + } + + /** + * 唯一的 HTTP 出口(测试中可覆盖以注入打桩响应)。 + * + *

只带 {@code TenantId},不转发 Authorization(见类注释第 2 条)。

+ */ + protected String doExecute(HttpRequest request, Integer tenantId, String path) { + if (tenantId != null) { + request.header("TenantId", String.valueOf(tenantId)); + } + String body = request.execute().body(); + log.info("HjcCoreAuth: {} tenantId={} → {}", path, tenantId, brief(body)); + return body; + } + + private CoreResult execute(HttpRequest request, String path) { + Integer tenantId = hjcAuthProperties.getTenantId(); + try { + CoreResult r = CoreResult.of(doExecute(request, tenantId, path)); + if (!r.isOk()) { + log.warn("HjcCoreAuth: {} 失败 code={} message={} error={}", path, r.getCode(), r.getMessage(), r.getError()); + } + return r; + } catch (Exception e) { + log.error("HjcCoreAuth: {} 调用核心实例异常 tenantId={} serverUrl={}", path, tenantId, serverUrl(), e); + CoreResult r = new CoreResult(); + r.setCode(-1); + r.setMessage("认证服务暂不可用,请稍后重试"); + r.setError(e.toString()); + return r; + } + } + + private String url(String path) { + return serverUrl() + path; + } + + private String serverUrl() { + return StrUtil.removeSuffix(configProperties.getServerUrl(), "/"); + } + + private static String brief(String text) { + if (text == null) { + return null; + } + return text.length() <= 300 ? text : text.substring(0, 300) + "..."; + } +} diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java index e76119d..559a9af 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcAuthController.java @@ -1,29 +1,24 @@ package com.gxwebsoft.hjc.controller; import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.gxwebsoft.common.core.config.ConfigProperties; -import com.gxwebsoft.common.core.security.JwtSubject; -import com.gxwebsoft.common.core.security.JwtUtil; +import com.alibaba.fastjson.JSONObject; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; -import com.gxwebsoft.common.system.entity.Role; import com.gxwebsoft.common.system.entity.User; -import com.gxwebsoft.common.system.entity.UserRole; import com.gxwebsoft.common.system.result.LoginResult; -import com.gxwebsoft.common.system.service.RoleService; -import com.gxwebsoft.common.system.service.UserRoleService; -import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.hjc.auth.HjcAuthProperties; +import com.gxwebsoft.hjc.auth.HjcCoreAuthClient; import com.gxwebsoft.hjc.dto.HjcAuthRequest; import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial; -import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService; import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.util.HjcOssUploadUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -36,9 +31,19 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; /** - * 汇吉采 网站企业 注册/登录(企业名称+密码) + * 汇吉采 注册 / 登录 / 退出 / 验证码(企业名称 + 密码)。 + * + *

账号归核心实例,企业归汇吉采(ADR-0006):本控制器不写任何账号表, + * 注册与登录都代理给核心实例({@link HjcCoreAuthClient});汇吉采只写自己的企业档案 + * {@code hjc_enterprise} / {@code hjc_enterprise_material}。

+ * + *

为什么不再直接用 {@code userService}:mp-java 的 {@code common} 层是核心实例 2023 年的 + * 旧快照,其中身份表 SQL 漏库名({@code UserMapper.xml} 的坏 JOIN、{@code RoleMenuMapper.xml}、 + * {@code TenantMapper.xml}),且 {@code sys_user} 根本不在模块库。走它必然失败或读错库。 + * 故本控制器不调用 {@code getByUsername} 等任何身份 SQL,企业名重名交给核心实例报错。

*/ @Tag(name = "汇吉采-企业登录") @Slf4j @@ -47,24 +52,187 @@ import java.util.Map; public class HjcAuthController extends BaseController { @Resource - private UserService userService; + private HjcCoreAuthClient coreAuthClient; @Resource - private RoleService roleService; - @Resource - private UserRoleService userRoleService; - @Resource - private ConfigProperties configProperties; + private HjcAuthProperties hjcAuthProperties; @Resource private HjcEnterpriseService hjcEnterpriseService; @Resource - private HjcEnterpriseMaterialService hjcEnterpriseMaterialService; - @Resource private HjcOssUploadUtil hjcOssUploadUtil; + /** + * 解析核心实例返回的 user 时使用的 ObjectMapper。 + * + *

必须忽略未知字段:核心实例的 {@code User} 实现了 Spring Security 的 + * {@code UserDetails},因此会多序列化 {@code enabled}、{@code accountNonExpired}、 + * {@code accountNonLocked}、{@code credentialsNonExpired} 四个字段,而模块实例这份 + * 2023 年的旧 {@code User} 副本里没有它们。用默认配置反序列化会直接抛 + * {@code UnrecognizedPropertyException}。

+ * + *

这正是教训所在:这个异常在核心实例已经建号成功之后抛出,一度被当成「注册失败」 + * 返回给用户,于是账号已存在而用户被告知失败、重试又撞「手机号已存在」。故除了放宽解析, + * 注册流程也不再依赖整个 User 能否解析成功(见 {@link #extractUserId})。

+ */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + /** 注册必传的资质证件材料类型 */ private static final List REQUIRED_MATERIAL_TYPES = Arrays.asList("idcard_front", "idcard_back", "handbook", "license"); + /** 与核心实例的短信发送校验一致:中国大陆手机号 */ + private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); + + /** + * 登录失败时可直接透传给用户的文案白名单。 + * + *

核心实例登录失败一律返回 {@code code=1},message 各异;其中 {@code 操作失败} 是它的兜底文案 + * (既可能是「密码错误」路径上的空指针,也可能是别的意外错误),无法区分。登录接口不该把内部 + * 错误暴露给用户,故:白名单内原样透传,其余一律归为「企业名称或密码错误」,同时把原始响应 + * 记入日志以便运维发现异常。

+ */ + private static final List PASSTHROUGH_LOGIN_MESSAGES = Arrays.asList( + "账号不存在", "密码错误", "账号被冻结", "图形验证码不正确", "图形验证码不能为空", + "密码错误次数过多,请10分钟后重试"); + + @Operation(summary = "图形验证码(登录用;只返回图片,不下发答案)") + @GetMapping("/captcha") + public ApiResult> captcha() { + HjcCoreAuthClient.CoreResult result = coreAuthClient.captcha(); + if (!result.isOk() || result.getData() == null) { + return fail(StrUtil.blankToDefault(result.getMessage(), "验证码获取失败"), null); + } + String base64 = result.getData().getString("base64"); + if (StrUtil.isBlank(base64)) { + return fail("验证码获取失败", null); + } + // 核心实例的响应里带明文答案 text,这里必须丢弃:否则这道验证码等于不存在 + Map payload = new HashMap<>(2); + payload.put("image", base64.startsWith("data:") ? base64 : "data:image/png;base64," + base64); + return success(payload); + } + + @Operation(summary = "发送注册短信验证码(发到经办人手机号)") + @PostMapping("/sms") + public ApiResult sms(@RequestBody Map body) { + String phone = body == null ? null : StrUtil.trimToNull(body.get("phone")); + if (phone == null) { + return fail("手机号不能为空"); + } + if (!PHONE_PATTERN.matcher(phone).matches()) { + return fail("请输入正确的手机号"); + } + // 注册场景不传 scene:核心实例的 scene=login 会先要求该手机号「已注册」 + HjcCoreAuthClient.CoreResult result = coreAuthClient.sendSmsCaptcha(phone); + if (!result.isOk()) { + return fail(StrUtil.blankToDefault(result.getMessage(), "验证码发送失败")); + } + return success("验证码已发送", null); + } + + @Operation(summary = "企业登录(企业名称+密码+图形验证码)") + @PostMapping("/login") + public ApiResult login(@RequestBody HjcAuthRequest request) { + if (request == null || StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) { + return fail("企业名称和密码不能为空", null); + } + if (StrUtil.isBlank(request.getCode())) { + return fail("图形验证码不能为空", null); + } + HjcCoreAuthClient.CoreResult result = coreAuthClient.login( + StrUtil.trim(request.getEnterpriseName()), + request.getPassword(), + // 核心实例以「小写验证码」作为 Redis 键存储并比对,用户输入的大小写必须先归一 + StrUtil.trim(request.getCode()).toLowerCase()); + if (!result.isOk()) { + String message = result.getMessage(); + if (message == null || !PASSTHROUGH_LOGIN_MESSAGES.contains(message)) { + log.warn("HjcAuth: 登录失败已映射为通用文案,原始 message={} error={} raw={}", + message, result.getError(), result.getRaw()); + message = "企业名称或密码错误"; + } + return fail(message, null); + } + LoginResult loginResult = toLoginResult(result); + if (loginResult == null) { + return fail("登录失败,请稍后重试", null); + } + return success("登录成功", loginResult); + } + + @Operation(summary = "企业注册(一站式:核心实例建号 + 企业/经办人/授权/证件入库,提交后待审核)") + @PostMapping("/register") + public ApiResult register(@RequestBody HjcAuthRequest request) { + if (request == null || StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) { + return fail("企业名称和密码不能为空", null); + } + String invalid = validateRegister(request); + if (invalid != null) { + return fail(invalid, null); + } + String enterpriseName = StrUtil.trim(request.getEnterpriseName()); + // 账号手机号 = 经办人手机号(需求:经办人手机号需核验) + String phone = StrUtil.trim(request.getAgentPhone()); + + // 第一步:核心实例建号。这是「先建号、后写档案」的两步操作,之间没有事务, + // 因此凡是能在建号前判定的问题都已在 validateRegister 挡掉,让这一步成为最后一道关。 + HjcCoreAuthClient.CoreResult result = coreAuthClient.register( + enterpriseName, phone, request.getPassword(), StrUtil.trim(request.getCode())); + if (!result.isOk()) { + // 核心实例会把 BusinessException 的 message 原样返回(账号已存在/手机号已存在/验证码不正确…),直接透传 + log.warn("HjcAuth: 注册被核心实例拒绝 message={} error={} raw={}", + result.getMessage(), result.getError(), result.getRaw()); + return fail(StrUtil.blankToDefault(result.getMessage(), "注册失败"), null); + } + // 建号已成功,此后任何失败都不能再说「注册失败」——账号已经存在了。 + // 先用最小依赖的方式取 userId(只读一个 JSON 数字),不依赖整个 User 能否反序列化。 + Integer userId = extractUserId(result); + if (userId == null) { + log.error("HjcAuth: 核心实例注册成功但未返回 userId,raw={}", result.getRaw()); + return fail("账号已创建,但未取到账号信息。请用「" + enterpriseName + + "」直接登录,并联系管理员核对。", null); + } + + // 第二步:写汇吉采自己的企业档案(独立事务)。此处失败时账号已存在于核心实例, + // 故必须明确告知用户「账号已建、资质未存」,让其登录后用资质页补交,而不是抛一句「操作失败」。 + HjcEnterprise enterprise = new HjcEnterprise(); + enterprise.setUserId(userId); + enterprise.setName(enterpriseName); + enterprise.setCreditCode(request.getCreditCode()); + enterprise.setContactPhone(request.getContactPhone()); + enterprise.setContactEmail(request.getContactEmail()); + enterprise.setAddress(request.getAddress()); + enterprise.setAgentName(request.getAgentName()); + enterprise.setAgentEmail(request.getAgentEmail()); + enterprise.setAgentPhone(phone); + enterprise.setIdCardNo(request.getIdCardNo()); + enterprise.setAuthorizeExpire(request.getAuthorizeExpire()); + enterprise.setAuthStatus(0); + enterprise.setTenantId(hjcAuthProperties.getTenantId()); + try { + hjcEnterpriseService.saveRegistration(enterprise, request.getMaterials()); + } catch (Exception e) { + log.error("HjcAuth: 账号已创建但企业资质入库失败 userId={} enterpriseName={}", + userId, enterpriseName, e); + return fail("账号已创建,但资质信息保存失败。请用「" + enterpriseName + + "」登录后,在资质页重新提交。", null); + } + + LoginResult loginResult = toLoginResult(result); + if (loginResult == null) { + return fail("注册失败,请稍后重试", null); + } + return success("注册成功", loginResult); + } + + @Operation(summary = "退出登录(无服务端会话,仅前端清 token)") + @PostMapping("/logout") + public ApiResult logout() { + // 平台不做服务端登出,也不维护 token 黑名单(核心实例自身也禁用了 logout); + // token 是无状态 JWT,前端清除本地凭据即完成退出。 + return success("已退出登录", null); + } + @Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS)") @PostMapping("/upload") public ApiResult> upload(@RequestParam("file") MultipartFile file) { @@ -72,107 +240,38 @@ public class HjcAuthController extends BaseController { return fail("上传文件不能为空", null); } try { - Integer tenantId = getTenantId(); - // 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它会按租户取云存储配置、 + // 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它按租户取云存储配置、 // 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。 - String url = hjcOssUploadUtil.upload(file, tenantId == null ? null : tenantId.toString()); + String url = hjcOssUploadUtil.upload(file, String.valueOf(hjcAuthProperties.getTenantId())); Map data = new HashMap<>(4); data.put("url", url); data.put("name", file.getOriginalFilename()); return success(data); } catch (Exception e) { - log.warn("HjcUpload: 证件上传失败 tenantId={} name={} size={}", - getTenantId(), file.getOriginalFilename(), file.getSize(), e); + log.warn("HjcAuth: 证件上传失败 tenantId={} name={} size={}", + hjcAuthProperties.getTenantId(), file.getOriginalFilename(), file.getSize(), e); return fail("上传失败", null); } } - @Operation(summary = "企业注册(一站式:企业+经办人+授权+证件,提交后待审核)") - @PostMapping("/register") - @Transactional(rollbackFor = Exception.class) - public ApiResult register(@RequestBody HjcAuthRequest request) { - if (StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) { - return fail("企业名称和密码不能为空", null); - } - String invalid = validateRegister(request); - if (invalid != null) { - return fail(invalid, null); - } - Integer tenantId = getTenantId(); - if (tenantId == null) { - return fail("租户ID不能为空", null); - } - String username = request.getEnterpriseName().trim(); - User exist = userService.getByUsername(username, tenantId); - if (exist != null) { - return fail("该企业已注册", null); - } - - User user = new User(); - user.setUsername(username); - user.setNickname(username); - user.setPhone(request.getContactPhone()); - user.setStatus(0); - user.setPlatform("website"); - user.setGradeId(2); - user.setPassword(userService.encodePassword(request.getPassword())); - user.setTenantId(tenantId); - Role role = roleService.getOne(new QueryWrapper().eq("role_code", "user"), false); - if (role != null) { - user.setRoleId(role.getRoleId()); - } - if (!userService.saveUser(user)) { - return fail("注册失败", null); - } - if (role != null) { - UserRole userRole = new UserRole(); - userRole.setUserId(user.getUserId()); - userRole.setTenantId(tenantId); - userRole.setRoleId(role.getRoleId()); - userRoleService.save(userRole); - } - - HjcEnterprise enterprise = new HjcEnterprise(); - enterprise.setUserId(user.getUserId()); - enterprise.setName(username); - enterprise.setCreditCode(request.getCreditCode()); - enterprise.setContactPhone(request.getContactPhone()); - enterprise.setContactEmail(request.getContactEmail()); - enterprise.setAddress(request.getAddress()); - enterprise.setAgentName(request.getAgentName()); - enterprise.setAgentEmail(request.getAgentEmail()); - enterprise.setAgentPhone(request.getAgentPhone()); - enterprise.setIdCardNo(request.getIdCardNo()); - enterprise.setAuthorizeExpire(request.getAuthorizeExpire()); - enterprise.setAuthStatus(0); - enterprise.setTenantId(tenantId); - hjcEnterpriseService.save(enterprise); - - // 一站式注册:同事务保存资质证件材料 - List materials = request.getMaterials(); - if (materials != null) { - for (HjcEnterpriseMaterial m : materials) { - if (StrUtil.isBlank(m.getFileUrl())) { - continue; - } - m.setId(null); - m.setEnterpriseId(enterprise.getId()); - m.setTenantId(tenantId); - hjcEnterpriseMaterialService.save(m); - } - } - - String token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), - configProperties.getTokenExpireTime(), configProperties.getTokenKey()); - return success("注册成功", new LoginResult(token, user)); - } - /** - * 注册必填校验:企业联系电话、企业地址选填,其余必填;4 类证件必传 + * 注册必填校验:企业联系电话、企业地址选填,其余必填;4 类证件必传。 + * + *

这是「让核心实例调用成为最后一步」的关键:凡能在建号前判定的问题都必须在此挡掉, + * 否则会在核心实例里留下一个没有企业档案的孤儿账号。

* * @return 错误信息;校验通过返回 null */ private String validateRegister(HjcAuthRequest request) { + if (StrUtil.isBlank(request.getCode())) { + return "短信验证码不能为空"; + } + if (StrUtil.isBlank(request.getAgentPhone())) { + return "经办人手机号不能为空"; + } + if (!PHONE_PATTERN.matcher(StrUtil.trim(request.getAgentPhone())).matches()) { + return "请输入正确的经办人手机号"; + } if (StrUtil.isBlank(request.getCreditCode())) { return "纳税人识别号不能为空"; } @@ -185,9 +284,6 @@ public class HjcAuthController extends BaseController { if (StrUtil.isBlank(request.getAgentEmail())) { return "经办人邮箱不能为空"; } - if (StrUtil.isBlank(request.getAgentPhone())) { - return "经办人手机号不能为空"; - } if (StrUtil.isBlank(request.getIdCardNo())) { return "经办人身份证号不能为空"; } @@ -205,20 +301,68 @@ public class HjcAuthController extends BaseController { return null; } - @Operation(summary = "企业登录(企业名称+密码)") - @PostMapping("/login") - public ApiResult login(@RequestBody HjcAuthRequest request) { - if (StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) { - return fail("企业名称和密码不能为空", null); + /** + * 把核心实例的响应转成前端的登录结果,并剔除敏感字段。 + * + *

核心实例的 {@code /login}、{@code /register} 会把整个 {@code User} 原样返回, + * 其中含 BCrypt 密码哈希(它不脱敏)。直接把该对象回传前端等于把密码哈希交给浏览器, + * 故这里重新构造:只保留展示字段,清空密码、支付密码与权限集合。

+ */ + private LoginResult toLoginResult(HjcCoreAuthClient.CoreResult result) { + JSONObject data = result.getData(); + if (data == null) { + return null; } - Integer tenantId = getTenantId(); - String username = request.getEnterpriseName().trim(); - User user = userService.getByUsername(username, tenantId); - if (user == null || !userService.comparePassword(user.getPassword(), request.getPassword())) { - return fail("企业名称或密码错误", null); + String token = data.getString("access_token"); + if (StrUtil.isBlank(token)) { + log.error("HjcAuth: 核心实例未返回 access_token,raw={}", result.getRaw()); + return null; } - String token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), - configProperties.getTokenExpireTime(), configProperties.getTokenKey()); - return success("登录成功", new LoginResult(token, user)); + return new LoginResult(token, parseUser(result)); + } + + /** 解析核心实例返回的 user(保留展示字段,清空凭据与权限) */ + private User parseUser(HjcCoreAuthClient.CoreResult result) { + JSONObject data = result.getData(); + JSONObject userJson = data == null ? null : data.getJSONObject("user"); + if (userJson == null) { + return null; + } + User user; + try { + user = OBJECT_MAPPER.readValue(userJson.toJSONString(), User.class); + } catch (Exception e) { + // 放宽解析后仍失败(例如核心实例将来加了不兼容的字段类型):不阻断主流程, + // 退回只带展示字段的最小对象,至少让前端拿到本次登录/注册的账号是谁。 + log.warn("HjcAuth: 核心实例 user 完整解析失败,退回最小字段。原因={}", e.toString()); + user = minimalUser(userJson); + } + user.setPassword(null); + user.setPayPassword(null); + user.setRoles(null); + user.setAuthorities(null); + return user; + } + + /** + * 只从响应里取 userId。与整个 User 的反序列化解耦—— + * 注册成功后的关键信息只有 userId,若因为解析 User 失败就当作「注册失败」, + * 会留下「核心实例有账号、汇吉采无档案」的孤儿账号,且用户被误导去重试。 + */ + private Integer extractUserId(HjcCoreAuthClient.CoreResult result) { + JSONObject data = result.getData(); + JSONObject userJson = data == null ? null : data.getJSONObject("user"); + return userJson == null ? null : userJson.getInteger("userId"); + } + + /** 最小可用账号对象:仅在完整解析失败时兜底,字段与前端展示一致 */ + private User minimalUser(JSONObject userJson) { + User user = new User(); + user.setUserId(userJson.getInteger("userId")); + user.setUsername(userJson.getString("username")); + user.setNickname(userJson.getString("nickname")); + user.setPhone(userJson.getString("phone")); + user.setTenantId(userJson.getInteger("tenantId")); + return user; } } diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java index 6d6fcb3..c4f221c 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcBidProjectController.java @@ -8,6 +8,7 @@ import com.gxwebsoft.hjc.param.HjcBidProjectParam; import com.gxwebsoft.hjc.service.HjcBidProjectService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @@ -25,6 +26,7 @@ public class HjcBidProjectController extends BaseController { @Operation(summary = "分页查询(后台)") @GetMapping("/page") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult> page(HjcBidProjectParam param) { return success(hjcBidProjectService.pageRel(param)); } @@ -48,6 +50,7 @@ public class HjcBidProjectController extends BaseController { @Operation(summary = "新增(后台)") @PostMapping() + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult save(@RequestBody HjcBidProject project) { project.setId(null); if (project.getTenantId() == null) { @@ -68,6 +71,7 @@ public class HjcBidProjectController extends BaseController { @Operation(summary = "更新(后台)") @PutMapping() + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult update(@RequestBody HjcBidProject project) { if (project.getId() == null) { return fail("ID不能为空"); @@ -78,6 +82,7 @@ public class HjcBidProjectController extends BaseController { @Operation(summary = "删除/下架(后台)") @DeleteMapping("/{id}") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult delete(@PathVariable("id") Integer id) { hjcBidProjectService.removeById(id); return success("删除成功"); diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcEnterpriseController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcEnterpriseController.java index 8805d98..6f31114 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcEnterpriseController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcEnterpriseController.java @@ -12,6 +12,7 @@ import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService; import com.gxwebsoft.hjc.service.HjcEnterpriseService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -94,12 +95,14 @@ public class HjcEnterpriseController extends BaseController { @Operation(summary = "后台-企业资质分页") @GetMapping("/page") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult> page(HjcEnterpriseParam param) { return success(hjcEnterpriseService.pageRel(param)); } @Operation(summary = "后台-资质详情") @GetMapping("/{id}") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult detail(@PathVariable("id") Integer id) { HjcEnterprise enterprise = hjcEnterpriseService.getById(id); if (enterprise == null) { @@ -111,6 +114,7 @@ public class HjcEnterpriseController extends BaseController { @Operation(summary = "后台-审核:通过/驳回") @PutMapping("/auth") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult audit(@RequestBody HjcEnterprise param) { if (param.getId() == null || param.getAuthStatus() == null) { return fail("审核参数不完整"); diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java index 5a1134d..9caf49a 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcOrderController.java @@ -6,6 +6,8 @@ import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.hjc.dto.CreateOrderRequest; +import com.gxwebsoft.hjc.auth.HjcAdminGuard; +import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.hjc.entity.HjcBidProject; import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcOrder; @@ -20,6 +22,7 @@ import com.gxwebsoft.payment.enums.PaymentType; import com.gxwebsoft.payment.service.PaymentService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @@ -49,6 +52,8 @@ public class HjcOrderController extends BaseController { private PaymentService paymentService; @Resource private HjcBizService hjcBizService; + @Resource + private HjcAdminGuard hjcGuard; @Operation(summary = "下单(创建待支付订单)") @PostMapping("/create") @@ -191,22 +196,38 @@ public class HjcOrderController extends BaseController { @Operation(summary = "订单详情") @GetMapping("/{id}") public ApiResult detail(@PathVariable("id") Integer id) { + // 先判身份、再判存在:顺序反了会让匿名调用方靠「订单不存在 / 未登录」的差异 + // 枚举出哪些订单 ID 真实存在。 + User loginUser = hjcGuard.currentUser(); + if (loginUser == null) { + return fail("用户未登录"); + } HjcOrder order = hjcOrderService.getById(id); if (order == null) { return fail("订单不存在"); } + // 归属校验:订单详情只对「订单所属企业的买家」或 hjc 管理员开放。 + // 此前没有任何校验,且 GET 全放行,等于任何人(含匿名)都能按 id 遍历读取全部订单。 + if (!hjcGuard.isAdmin()) { + HjcEnterprise enterprise = hjcEnterpriseService.getByUserId(loginUser.getUserId()); + if (enterprise == null || !enterprise.getId().equals(order.getEnterpriseId())) { + return fail("无权查看该订单"); + } + } order.setProject(hjcBidProjectService.getById(order.getProjectId())); return success(order); } @Operation(summary = "后台-订单分页") @GetMapping("/page") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult> page(HjcOrderParam param) { return success(hjcOrderService.pageRel(param)); } @Operation(summary = "退款(标记已退款,幂等),并推送 REFUNDED 状态") @PostMapping("/refund") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult refund(@RequestBody Map body) { String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo")); if (orderNo == null) { diff --git a/src/main/java/com/gxwebsoft/hjc/controller/HjcPushController.java b/src/main/java/com/gxwebsoft/hjc/controller/HjcPushController.java index 9b5095f..2ad037f 100644 --- a/src/main/java/com/gxwebsoft/hjc/controller/HjcPushController.java +++ b/src/main/java/com/gxwebsoft/hjc/controller/HjcPushController.java @@ -9,6 +9,7 @@ import com.gxwebsoft.hjc.service.HjcOrderService; import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; @@ -50,6 +51,7 @@ public class HjcPushController extends BaseController { @Operation(summary = "出向:手动触发订单推送(createPurchaseDetails)") @PostMapping("/order/{orderNo}") + @PreAuthorize("@hjcGuard.isAdmin()") public ApiResult triggerPush(@PathVariable("orderNo") String orderNo) { HjcOrder order = hjcOrderService.getByOrderNo(orderNo); if (order == null) { diff --git a/src/main/java/com/gxwebsoft/hjc/dto/HjcAuthRequest.java b/src/main/java/com/gxwebsoft/hjc/dto/HjcAuthRequest.java index eaffafe..c464c09 100644 --- a/src/main/java/com/gxwebsoft/hjc/dto/HjcAuthRequest.java +++ b/src/main/java/com/gxwebsoft/hjc/dto/HjcAuthRequest.java @@ -21,6 +21,16 @@ public class HjcAuthRequest { @Schema(description = "登录密码", required = true) private String password; + /** + * 验证码。两个接口各自含义不同,与核心实例的字段命名保持一致: + *
    + *
  • 登录:图形验证码(来自 {@code GET /api/hjc/auth/captcha} 的图片)
  • + *
  • 注册:短信验证码(发给「经办人手机号」,即账号手机号)
  • + *
+ */ + @Schema(description = "验证码:登录时为图形验证码,注册时为短信验证码") + private String code; + @Schema(description = "纳税人识别号/统一社会信用代码") private String creditCode; diff --git a/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java b/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java index 5a4f70d..e68fd44 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java +++ b/src/main/java/com/gxwebsoft/hjc/service/HjcEnterpriseService.java @@ -3,6 +3,7 @@ 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.HjcEnterpriseMaterial; import com.gxwebsoft.hjc.param.HjcEnterpriseParam; import java.util.List; @@ -14,4 +15,16 @@ public interface HjcEnterpriseService extends IService { List listRel(HjcEnterpriseParam param); HjcEnterprise getByUserId(Integer userId); + + /** + * 注册时写入企业档案与资质证件(同一事务)。 + * + *

独立成一个事务方法,是因为注册是「先在核心实例建号、再写汇吉采档案」的两步操作, + * 两步之间没有事务。调用方({@code HjcAuthController.register})需要在第二步失败时 + * 捕获异常并告知用户「账号已建、资质未存」,而不是把它抛成一句「操作失败」。 + * 若该方法与调用方处在同一事务里,异常必然连带外层回滚,也没有机会返回友好文案。

+ * + * @return 企业档案 ID + */ + Integer saveRegistration(HjcEnterprise enterprise, List materials); } 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 4987f34..0ab99c4 100644 --- a/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java +++ b/src/main/java/com/gxwebsoft/hjc/service/impl/HjcEnterpriseServiceImpl.java @@ -1,19 +1,27 @@ package com.gxwebsoft.hjc.service.impl; +import cn.hutool.core.util.StrUtil; 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.HjcEnterpriseMaterial; import com.gxwebsoft.hjc.mapper.HjcEnterpriseMapper; import com.gxwebsoft.hjc.param.HjcEnterpriseParam; +import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService; import com.gxwebsoft.hjc.service.HjcEnterpriseService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import javax.annotation.Resource; import java.util.List; @Service public class HjcEnterpriseServiceImpl extends ServiceImpl implements HjcEnterpriseService { + @Resource + private HjcEnterpriseMaterialService hjcEnterpriseMaterialService; + @Override public PageResult pageRel(HjcEnterpriseParam param) { PageParam page = new PageParam<>(param); @@ -31,4 +39,24 @@ public class HjcEnterpriseServiceImpl extends ServiceImpl materials) { + baseMapper.insert(enterprise); + if (materials != null) { + for (HjcEnterpriseMaterial m : materials) { + if (StrUtil.isBlank(m.getFileUrl())) { + continue; + } + m.setId(null); + m.setEnterpriseId(enterprise.getId()); + if (m.getTenantId() == null) { + m.setTenantId(enterprise.getTenantId()); + } + hjcEnterpriseMaterialService.save(m); + } + } + return enterprise.getId(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e35e29..27cec07 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -192,8 +192,12 @@ house: timeout-ms: 20000 tool-cache-ttl-ms: 300000 -# 汇吉采一站式平台对接 +# 汇吉采 hjc: + # 汇吉采在平台里的租户 ID(汇吉采官网)。两处使用:代理核心实例时作为 TenantId 头 + # (核心实例多租户插件缺该头会拼 tenant_id = NULL,导致查重静默失效、登录永不匹配); + # 判定「hjc 管理员」时校验 principal 的租户归属。 + tenant-id: 10626 one-stop: # 一站式平台地址(+ path = 最终推送地址)。base-url 为空时推送不会发起,部署时需配置。 base-url: "" diff --git a/src/test/java/com/gxwebsoft/hjc/controller/HjcAuthControllerUserParseTest.java b/src/test/java/com/gxwebsoft/hjc/controller/HjcAuthControllerUserParseTest.java new file mode 100644 index 0000000..9f0c44d --- /dev/null +++ b/src/test/java/com/gxwebsoft/hjc/controller/HjcAuthControllerUserParseTest.java @@ -0,0 +1,114 @@ +package com.gxwebsoft.hjc.controller; + +import com.alibaba.fastjson.JSON; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.common.system.entity.User; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 回归测试:核心实例返回的 user 必须能被解析。 + * + *

为什么需要这个测试:2026-09-10 生产实测中,注册在核心实例**已经建号成功**之后, + * 模块实例因反序列化失败把它当成「注册失败」返回给用户——账号已存在、用户却被告知失败, + * 重试又撞「手机号已存在」,留下孤儿账号。

+ * + *

根因是核心实例的 {@code User} 实现了 Spring Security 的 {@code UserDetails}, + * 因此多序列化 {@code enabled} / {@code accountNonExpired} / {@code accountNonLocked} / + * {@code credentialsNonExpired} 四个字段,而模块实例这份旧 {@code User} 副本里没有它们, + * 默认配置遇未知字段即抛 {@code UnrecognizedPropertyException}。

+ * + *

下面的 JSON 取自那次真实失败的响应(密码哈希已替换为占位值)。

+ */ +class HjcAuthControllerUserParseTest { + + /** + * 直接取 {@link HjcAuthController} 里那个真实的 ObjectMapper(而不是在测试里另配一个)。 + * + *

这一点是刻意的:如果测试自己配一份宽容的 mapper,那么将来有人把控制器里的配置改回 + * 默认(严格)时,测试**依然会通过**——那就成了一个不会失败的守卫。用反射取真身, + * 才能保证「控制器一旦退回严格解析,这个测试就红」。

+ */ + private static ObjectMapper controllerMapper() throws Exception { + java.lang.reflect.Field f = HjcAuthController.class.getDeclaredField("OBJECT_MAPPER"); + f.setAccessible(true); + return (ObjectMapper) f.get(null); + } + + @Test + void 控制器里的ObjectMapper必须已关闭未知字段失败() throws Exception { + ObjectMapper mapper = controllerMapper(); + boolean strict = mapper.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + org.junit.jupiter.api.Assertions.assertFalse(strict, + "HjcAuthController 的 OBJECT_MAPPER 必须是 FAIL_ON_UNKNOWN_PROPERTIES=false," + + "否则核心实例多出的 UserDetails 字段会让注册在建号成功后误报失败"); + } + + /** 生产实测的响应体(结构原样保留,仅替换密码哈希) */ + private static final String CORE_REGISTER_RESPONSE = "{" + + "\"code\":0,\"message\":\"注册成功\",\"data\":{" + + "\"access_token\":\"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig\"," + + "\"user\":{" + + "\"userId\":36031,\"type\":null,\"userCode\":null,\"uid\":null," + + "\"username\":\"测试01\",\"password\":\"$2a$10$PLACEHOLDER\"," + + "\"nickname\":\"155****2748\",\"phone\":\"15577122748\"," + + "\"idCardNo\":\"\",\"gradeId\":2,\"platform\":\"WEB\"," + + "\"isAdmin\":false,\"recommend\":0,\"auditStatus\":1,\"status\":0," + + "\"tenantId\":10626,\"roleId\":2077," + + "\"roles\":[{\"roleId\":2077,\"roleCode\":\"user\",\"roleName\":\"注册用户\"," + + "\"sortNumber\":1,\"deleted\":0,\"tenantId\":10626}]," + + "\"authorities\":[],\"mobile\":\"155****2748\"," + // 以下是核心实例因实现 UserDetails 而多出来的字段:旧 User 副本里没有 + + "\"enabled\":true,\"accountNonExpired\":true," + + "\"accountNonLocked\":true,\"credentialsNonExpired\":true" + + "},\"tenants\":null}}"; + + @Test + void 核心实例含UserDetails字段的响应必须能解析出userId() throws Exception { + User user = parseUser(CORE_REGISTER_RESPONSE); + assertNotNull(user, "解析不应返回 null"); + // 这条断言正是当初失败的后果:userId 取不到就无法写企业档案 + assertEquals(36031, user.getUserId().intValue()); + } + + @Test + void 解析后必须清空密码哈希与权限集合() throws Exception { + User user = parseUser(CORE_REGISTER_RESPONSE); + assertNotNull(user); + // 核心实例不脱敏,会把 BCrypt 哈希原样返回;回传前端前必须清掉 + assertNull(user.getPassword(), "密码哈希不得回传前端"); + assertNull(user.getPayPassword()); + assertNull(user.getRoles()); + assertNull(user.getAuthorities()); + } + + @Test + void 展示字段应正确映射() throws Exception { + User user = parseUser(CORE_REGISTER_RESPONSE); + assertNotNull(user); + assertEquals("测试01", user.getUsername()); + assertEquals("15577122748", user.getPhone()); + assertEquals(10626, user.getTenantId().intValue()); + } + + /** 复刻 HjcAuthController.parseUser 的解析路径,但用控制器里那个真实的 mapper */ + private User parseUser(String raw) throws Exception { + com.alibaba.fastjson.JSONObject root = JSON.parseObject(raw); + com.alibaba.fastjson.JSONObject userJson = root.getJSONObject("data").getJSONObject("user"); + User user; + try { + user = controllerMapper().readValue(userJson.toJSONString(), User.class); + } catch (Exception e) { + throw new AssertionError("核心实例的 user 反序列化失败:" + e, e); + } + user.setPassword(null); + user.setPayPassword(null); + user.setRoles(null); + user.setAuthorities(null); + return user; + } +}