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(官网租户)。它有两个用途,且都必须用
+ * 配置值而不是请求里带来的值:
+ *
+ * - 代理核心实例时作为 {@code TenantId} 头(核心实例的多租户插件缺该头会拼
+ * {@code tenant_id = NULL},导致查重静默失效、登录永不匹配);
+ * - 判定「hjc 管理员」时校验 principal 的租户归属。
+ *
+ * 用配置而非请求头,是为了避免「前端把 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。
+ *
+ * 三条硬性实现约束(否则功能必然坏,且坏得很难查):
+ *
+ * - 必须带 {@code TenantId} 请求头:核心实例的 MyBatis-Plus 多租户插件在缺少该头时
+ * 会把条件拼成 {@code tenant_id = NULL}(MySQL 恒不命中)→ 查重静默失效、登录查询永不匹配。
+ * 这里统一用 {@link HjcAuthProperties} 的配置值注入,不采用请求头里的值。
+ * - 必须剥掉 Authorization:核心实例存短信验证码的 Redis 键前缀取决于请求
+ * 是否已认证(未认证 = {@code cache{手机号}},已认证 = {@code cache{租户}:{手机号}}),
+ * 而注册读的是未认证那个。若把 hjc 前端的旧 token 转发过去,发码与校验会落到不同的键,
+ * 表现为莫名其妙的「验证码不正确」。
+ * - 不转发邮箱:核心实例注册时会把明文密码连同品牌域名(硬编码
+ * {@code websoft.top})邮件发给用户。hjc 的企业邮箱/经办人邮箱只存 hjc 自己的表。
+ *
+ */
+@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