feat(hjc): 登录认证重做——账号复用核心实例,企业语义自持;后台接口鉴权收口

汇吉采企业账号原由 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 白名单为改动面)
This commit is contained in:
2026-09-10 23:44:02 +08:00
parent 5d88684820
commit ce1d0a2a8a
14 changed files with 815 additions and 123 deletions
@@ -81,8 +81,13 @@ public class SecurityConfig {
"/api/qr-code/**", "/api/qr-code/**",
"/api/shop/order-delivery/notify", "/api/shop/order-delivery/notify",
"/api/hjc/push/project", "/api/hjc/push/project",
// 汇吉采买家端:注册/登录/验证码/退出都由 mp-java 代理核心实例,
// 必须在登录前可访问;未登录的客户端本来也没有平台 token。
"/api/hjc/auth/register", "/api/hjc/auth/register",
"/api/hjc/auth/login", "/api/hjc/auth/login",
"/api/hjc/auth/logout",
"/api/hjc/auth/captcha",
"/api/hjc/auth/sms",
// 注册页专用:证件上传与 OCR 识别在登录前发生 // 注册页专用:证件上传与 OCR 识别在登录前发生
"/api/hjc/auth/upload", "/api/hjc/auth/upload",
"/api/hjc/ocr/recognize" "/api/hjc/ocr/recognize"
@@ -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;
/**
* 汇吉采后台管理员守卫。
*
* <p><b>为什么需要它</b>:hjc 的后台接口(企业资质分页/详情/审核、订单分页/退款、项目增删改)
* 此前<b>一个权限注解都没有</b>(全仓其他模块有 1231 处 {@code @PreAuthorize}hjc 包 0 处),
* 叠加共享 {@code SecurityConfig} 里 {@code GET /**} 放行,导致这些接口<b>匿名可读</b>
* 写接口也只要求「已登录」,任何租户的任何登录用户都能审核别家企业的资质。</p>
*
* <p><b>为什么是「按角色判」而不是「新建一个 hjc 权限串」</b>:核心实例的权限串规范是
* {@code 模块:实体:动作},其中<b>没有 hjc 命名空间</b>;而本仓对核心实例的库只有 SELECT 权限
* (无权新建菜单/权限),核心实例本身又只读。所以「hjc 管理员」只能从 core 已经给出的东西里读——
* 即 principal 上的角色。这正是 ADR-0006「账号复用核心实例」的直接收益:角色是 core 的权威,我们读它。</p>
*
* <p><b>两个条件同时满足才算管理员</b>:① 角色是 {@code admin}/{@code superAdmin}(或 core 的
* {@code is_admin} 标记位);② <b>principal 的租户是汇吉采租户</b>。第二条顺带堵住一个现存漏洞:
* 其他租户的管理员带着 {@code tenantId: 10626} 请求头就能读 hjc 数据——现在不行了,因为这里校验的是
* principal 自己的 {@code tenantId}(来自 core),而不是请求头。</p>
*/
@Slf4j
@Component("hjcGuard")
public class HjcAdminGuard {
/** 视为管理员的角色码(与 core 的 role_code 比对,忽略大小写) */
private static final List<String> 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 买家」。
*
* <p>用于需要登录态、但不要求管理员的接口(例如订单详情要判归属)。</p>
*/
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();
}
}
@@ -0,0 +1,26 @@
package com.gxwebsoft.hjc.auth;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 汇吉采登录认证的配置。
*
* <p>{@code hjc.tenant-id} 是汇吉采在平台里的租户 ID(官网租户)。它有两个用途,且都必须用
* <b>配置值</b>而不是请求里带来的值:</p>
* <ol>
* <li>代理核心实例时作为 {@code TenantId} 头(核心实例的多租户插件缺该头会拼
* {@code tenant_id = NULL},导致查重静默失效、登录永不匹配);</li>
* <li>判定「hjc 管理员」时校验 principal 的租户归属。</li>
* </ol>
* <p>用配置而非请求头,是为了避免「前端把 tenantId 改成别的租户就能让 hjc 去操作别的租户」。</p>
*/
@Getter
@Component
public class HjcAuthProperties {
/** 汇吉采租户 ID(默认 10626 = 汇吉采官网) */
@Value("${hjc.tenant-id:10626}")
private Integer tenantId;
}
@@ -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;
/**
* 汇吉采登录认证对核心实例的调用出口。
*
* <p><b>为什么 hjc 的注册/登录要调核心实例</b>:平台的身份体系只有一套(核心实例
* `gxwebsoft_core.sys_user`),C 端用户与管理员同源;mp-java 是模块实例,<b>本库没有</b>
* `sys_user/sys_role/sys_menu/sys_user_role` 这些表,且模块库账号对 `gxwebsoft_core`
* <b>只有 SELECT 权限</b>(无法跨库建号)。因此建号与口令校验只能走核心实例的 HTTP 接口。
* 见 ADR-0006。</p>
*
* <p><b>三条硬性实现约束</b>(否则功能必然坏,且坏得很难查):</p>
* <ol>
* <li><b>必须带 {@code TenantId} 请求头</b>:核心实例的 MyBatis-Plus 多租户插件在缺少该头时
* 会把条件拼成 {@code tenant_id = NULL}MySQL 恒不命中)→ 查重静默失效、登录查询永不匹配。
* 这里统一用 {@link HjcAuthProperties} 的配置值注入,不采用请求头里的值。</li>
* <li><b>必须剥掉 Authorization</b>:核心实例存短信验证码的 Redis 键前缀取决于请求
* <i>是否已认证</i>(未认证 = {@code cache{手机号}},已认证 = {@code cache{租户}:{手机号}}),
* 而注册读的是<b>未认证</b>那个。若把 hjc 前端的旧 token 转发过去,发码与校验会落到不同的键,
* 表现为莫名其妙的「验证码不正确」。</li>
* <li><b>不转发邮箱</b>:核心实例注册时会把<b>明文密码</b>连同品牌域名(硬编码
* {@code websoft.top})邮件发给用户。hjc 的企业邮箱/经办人邮箱只存 hjc 自己的表。</li>
* </ol>
*/
@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;
/**
* 核心实例的响应。核心实例的业务失败是 <b>HTTP 200 + code != 0</b>(不是 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;
}
}
/**
* 账号密码登录。
*
* <p>{@code captchaCode} 为图形验证码。<b>刻意不传 {@code isSuperAdmin}</b>:核心实例只要收到
* 该字段非 null(连 {@code false} 都算)就整段跳过图形验证码校验,那就等于我们自己把这道门拆了。</p>
*/
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);
}
/**
* 取图形验证码。
*
* <p><b>注意</b>:核心实例的响应体里含明文答案 {@code text}(它把答案也返回给客户端)。
* 调用方<b>必须丢弃</b>该字段,只把图片下发给前端,否则这道验证码等于不存在。</p>
*/
public CoreResult captcha() {
return execute(HttpRequest.get(url(CAPTCHA_PATH)), CAPTCHA_PATH);
}
/**
* 发送短信验证码。
*
* <p><b>注册场景不要传 scene</b>:核心实例在 {@code scene=login} 时会先校验该手机号<b>已注册</b>
* 而注册恰恰是给未注册的手机号发码,传了会被拒(「该手机号码未注册!」)。</p>
*/
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 出口(测试中可覆盖以注入打桩响应)。
*
* <p>只带 {@code TenantId}<b>不转发 Authorization</b>(见类注释第 2 条)。</p>
*/
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) + "...";
}
}
@@ -1,29 +1,24 @@
package com.gxwebsoft.hjc.controller; package com.gxwebsoft.hjc.controller;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.alibaba.fastjson.JSONObject;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.fasterxml.jackson.databind.DeserializationFeature;
import com.gxwebsoft.common.core.security.JwtSubject; import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.common.core.security.JwtUtil;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; 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.User;
import com.gxwebsoft.common.system.entity.UserRole;
import com.gxwebsoft.common.system.result.LoginResult; import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.service.RoleService; import com.gxwebsoft.hjc.auth.HjcAuthProperties;
import com.gxwebsoft.common.system.service.UserRoleService; import com.gxwebsoft.hjc.auth.HjcCoreAuthClient;
import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.hjc.dto.HjcAuthRequest; import com.gxwebsoft.hjc.dto.HjcAuthRequest;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial; import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import com.gxwebsoft.hjc.util.HjcOssUploadUtil; import com.gxwebsoft.hjc.util.HjcOssUploadUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -36,9 +31,19 @@ import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* 汇吉采 网站企业 注册/登录(企业名称+密码) * 汇吉采 注册 / 登录 / 退出 / 验证码(企业名称 + 密码)
*
* <p><b>账号归核心实例,企业归汇吉采</b>ADR-0006):本控制器<b>不写任何账号表</b>
* 注册与登录都代理给核心实例({@link HjcCoreAuthClient});汇吉采只写自己的企业档案
* {@code hjc_enterprise} / {@code hjc_enterprise_material}。</p>
*
* <p><b>为什么不再直接用 {@code userService}</b>mp-java 的 {@code common} 层是核心实例 2023 年的
* 旧快照,其中身份表 SQL 漏库名({@code UserMapper.xml} 的坏 JOIN、{@code RoleMenuMapper.xml}、
* {@code TenantMapper.xml}),且 {@code sys_user} 根本不在模块库。走它必然失败或读错库。
* 故本控制器<b>不调用 {@code getByUsername} 等任何身份 SQL</b>,企业名重名交给核心实例报错。</p>
*/ */
@Tag(name = "汇吉采-企业登录") @Tag(name = "汇吉采-企业登录")
@Slf4j @Slf4j
@@ -47,24 +52,187 @@ import java.util.Map;
public class HjcAuthController extends BaseController { public class HjcAuthController extends BaseController {
@Resource @Resource
private UserService userService; private HjcCoreAuthClient coreAuthClient;
@Resource @Resource
private RoleService roleService; private HjcAuthProperties hjcAuthProperties;
@Resource
private UserRoleService userRoleService;
@Resource
private ConfigProperties configProperties;
@Resource @Resource
private HjcEnterpriseService hjcEnterpriseService; private HjcEnterpriseService hjcEnterpriseService;
@Resource @Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Resource
private HjcOssUploadUtil hjcOssUploadUtil; private HjcOssUploadUtil hjcOssUploadUtil;
/**
* 解析核心实例返回的 user 时使用的 ObjectMapper。
*
* <p><b>必须忽略未知字段</b>:核心实例的 {@code User} 实现了 Spring Security 的
* {@code UserDetails},因此会多序列化 {@code enabled}、{@code accountNonExpired}、
* {@code accountNonLocked}、{@code credentialsNonExpired} 四个字段,而模块实例这份
* 2023 年的旧 {@code User} 副本里没有它们。用默认配置反序列化会直接抛
* {@code UnrecognizedPropertyException}。</p>
*
* <p>这正是教训所在:这个异常在<b>核心实例已经建号成功之后</b>抛出,一度被当成「注册失败」
* 返回给用户,于是账号已存在而用户被告知失败、重试又撞「手机号已存在」。故除了放宽解析,
* 注册流程也不再依赖整个 User 能否解析成功(见 {@link #extractUserId})。</p>
*/
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/** 注册必传的资质证件材料类型 */ /** 注册必传的资质证件材料类型 */
private static final List<String> REQUIRED_MATERIAL_TYPES = private static final List<String> REQUIRED_MATERIAL_TYPES =
Arrays.asList("idcard_front", "idcard_back", "handbook", "license"); Arrays.asList("idcard_front", "idcard_back", "handbook", "license");
/** 与核心实例的短信发送校验一致:中国大陆手机号 */
private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$");
/**
* 登录失败时可直接透传给用户的文案白名单。
*
* <p>核心实例登录失败一律返回 {@code code=1}message 各异;其中 {@code 操作失败} 是它的兜底文案
* (既可能是「密码错误」路径上的空指针,也可能是别的意外错误),<b>无法区分</b>。登录接口不该把内部
* 错误暴露给用户,故:白名单内原样透传,其余一律归为「企业名称或密码错误」,同时把<b>原始响应</b>
* 记入日志以便运维发现异常。</p>
*/
private static final List<String> PASSTHROUGH_LOGIN_MESSAGES = Arrays.asList(
"账号不存在", "密码错误", "账号被冻结", "图形验证码不正确", "图形验证码不能为空",
"密码错误次数过多,请10分钟后重试");
@Operation(summary = "图形验证码(登录用;只返回图片,不下发答案)")
@GetMapping("/captcha")
public ApiResult<Map<String, Object>> 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<String, Object> 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<String, String> 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<LoginResult> 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<LoginResult> 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)") @Operation(summary = "注册证件上传(匿名,注册页与资质页通用;文件存入 OSS)")
@PostMapping("/upload") @PostMapping("/upload")
public ApiResult<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) { public ApiResult<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
@@ -72,107 +240,38 @@ public class HjcAuthController extends BaseController {
return fail("上传文件不能为空", null); return fail("上传文件不能为空", null);
} }
try { try {
Integer tenantId = getTenantId(); // 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它按租户取云存储配置、
// 委托统一 OSS 上传服务(server.websoft.top/api/oss/upload):它会按租户取云存储配置、
// 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。 // 把文件写进 OSS 并返回可访问地址。此前这里只写本地磁盘再拼 OSS 域名,文件从未进过 OSS。
String url = hjcOssUploadUtil.upload(file, tenantId == null ? null : tenantId.toString()); String url = hjcOssUploadUtil.upload(file, String.valueOf(hjcAuthProperties.getTenantId()));
Map<String, Object> data = new HashMap<>(4); Map<String, Object> data = new HashMap<>(4);
data.put("url", url); data.put("url", url);
data.put("name", file.getOriginalFilename()); data.put("name", file.getOriginalFilename());
return success(data); return success(data);
} catch (Exception e) { } catch (Exception e) {
log.warn("HjcUpload: 证件上传失败 tenantId={} name={} size={}", log.warn("HjcAuth: 证件上传失败 tenantId={} name={} size={}",
getTenantId(), file.getOriginalFilename(), file.getSize(), e); hjcAuthProperties.getTenantId(), file.getOriginalFilename(), file.getSize(), e);
return fail("上传失败", null); return fail("上传失败", null);
} }
} }
@Operation(summary = "企业注册(一站式:企业+经办人+授权+证件,提交后待审核)")
@PostMapping("/register")
@Transactional(rollbackFor = Exception.class)
public ApiResult<LoginResult> 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<Role>().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<HjcEnterpriseMaterial> 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 类证件必传
*
* <p>这是「让核心实例调用成为最后一步」的关键:凡能在建号前判定的问题都必须在此挡掉,
* 否则会在核心实例里留下一个没有企业档案的孤儿账号。</p>
* *
* @return 错误信息;校验通过返回 null * @return 错误信息;校验通过返回 null
*/ */
private String validateRegister(HjcAuthRequest request) { 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())) { if (StrUtil.isBlank(request.getCreditCode())) {
return "纳税人识别号不能为空"; return "纳税人识别号不能为空";
} }
@@ -185,9 +284,6 @@ public class HjcAuthController extends BaseController {
if (StrUtil.isBlank(request.getAgentEmail())) { if (StrUtil.isBlank(request.getAgentEmail())) {
return "经办人邮箱不能为空"; return "经办人邮箱不能为空";
} }
if (StrUtil.isBlank(request.getAgentPhone())) {
return "经办人手机号不能为空";
}
if (StrUtil.isBlank(request.getIdCardNo())) { if (StrUtil.isBlank(request.getIdCardNo())) {
return "经办人身份证号不能为空"; return "经办人身份证号不能为空";
} }
@@ -205,20 +301,68 @@ public class HjcAuthController extends BaseController {
return null; return null;
} }
@Operation(summary = "企业登录(企业名称+密码)") /**
@PostMapping("/login") * 把核心实例的响应转成前端的登录结果,并<b>剔除敏感字段</b>。
public ApiResult<LoginResult> login(@RequestBody HjcAuthRequest request) { *
if (StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) { * <p>核心实例的 {@code /login}、{@code /register} 会把整个 {@code User} 原样返回,
return fail("企业名称和密码不能为空", null); * <b>其中含 BCrypt 密码哈希</b>(它不脱敏)。直接把该对象回传前端等于把密码哈希交给浏览器,
* 故这里重新构造:只保留展示字段,清空密码、支付密码与权限集合。</p>
*/
private LoginResult toLoginResult(HjcCoreAuthClient.CoreResult result) {
JSONObject data = result.getData();
if (data == null) {
return null;
} }
Integer tenantId = getTenantId(); String token = data.getString("access_token");
String username = request.getEnterpriseName().trim(); if (StrUtil.isBlank(token)) {
User user = userService.getByUsername(username, tenantId); log.error("HjcAuth: 核心实例未返回 access_tokenraw={}", result.getRaw());
if (user == null || !userService.comparePassword(user.getPassword(), request.getPassword())) { return null;
return fail("企业名称或密码错误", null);
} }
String token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), return new LoginResult(token, parseUser(result));
configProperties.getTokenExpireTime(), configProperties.getTokenKey()); }
return success("登录成功", new LoginResult(token, user));
/** 解析核心实例返回的 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;
} }
} }
@@ -8,6 +8,7 @@ import com.gxwebsoft.hjc.param.HjcBidProjectParam;
import com.gxwebsoft.hjc.service.HjcBidProjectService; import com.gxwebsoft.hjc.service.HjcBidProjectService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -25,6 +26,7 @@ public class HjcBidProjectController extends BaseController {
@Operation(summary = "分页查询(后台)") @Operation(summary = "分页查询(后台)")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) { public ApiResult<PageResult<HjcBidProject>> page(HjcBidProjectParam param) {
return success(hjcBidProjectService.pageRel(param)); return success(hjcBidProjectService.pageRel(param));
} }
@@ -48,6 +50,7 @@ public class HjcBidProjectController extends BaseController {
@Operation(summary = "新增(后台)") @Operation(summary = "新增(后台)")
@PostMapping() @PostMapping()
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> save(@RequestBody HjcBidProject project) { public ApiResult<?> save(@RequestBody HjcBidProject project) {
project.setId(null); project.setId(null);
if (project.getTenantId() == null) { if (project.getTenantId() == null) {
@@ -68,6 +71,7 @@ public class HjcBidProjectController extends BaseController {
@Operation(summary = "更新(后台)") @Operation(summary = "更新(后台)")
@PutMapping() @PutMapping()
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> update(@RequestBody HjcBidProject project) { public ApiResult<?> update(@RequestBody HjcBidProject project) {
if (project.getId() == null) { if (project.getId() == null) {
return fail("ID不能为空"); return fail("ID不能为空");
@@ -78,6 +82,7 @@ public class HjcBidProjectController extends BaseController {
@Operation(summary = "删除/下架(后台)") @Operation(summary = "删除/下架(后台)")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> delete(@PathVariable("id") Integer id) { public ApiResult<?> delete(@PathVariable("id") Integer id) {
hjcBidProjectService.removeById(id); hjcBidProjectService.removeById(id);
return success("删除成功"); return success("删除成功");
@@ -12,6 +12,7 @@ import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -94,12 +95,14 @@ public class HjcEnterpriseController extends BaseController {
@Operation(summary = "后台-企业资质分页") @Operation(summary = "后台-企业资质分页")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcEnterprise>> page(HjcEnterpriseParam param) { public ApiResult<PageResult<HjcEnterprise>> page(HjcEnterpriseParam param) {
return success(hjcEnterpriseService.pageRel(param)); return success(hjcEnterpriseService.pageRel(param));
} }
@Operation(summary = "后台-资质详情") @Operation(summary = "后台-资质详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> detail(@PathVariable("id") Integer id) { public ApiResult<?> detail(@PathVariable("id") Integer id) {
HjcEnterprise enterprise = hjcEnterpriseService.getById(id); HjcEnterprise enterprise = hjcEnterpriseService.getById(id);
if (enterprise == null) { if (enterprise == null) {
@@ -111,6 +114,7 @@ public class HjcEnterpriseController extends BaseController {
@Operation(summary = "后台-审核:通过/驳回") @Operation(summary = "后台-审核:通过/驳回")
@PutMapping("/auth") @PutMapping("/auth")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> audit(@RequestBody HjcEnterprise param) { public ApiResult<?> audit(@RequestBody HjcEnterprise param) {
if (param.getId() == null || param.getAuthStatus() == null) { if (param.getId() == null || param.getAuthStatus() == null) {
return fail("审核参数不完整"); return fail("审核参数不完整");
@@ -6,6 +6,8 @@ import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.dto.CreateOrderRequest; 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.HjcBidProject;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcOrder; import com.gxwebsoft.hjc.entity.HjcOrder;
@@ -20,6 +22,7 @@ import com.gxwebsoft.payment.enums.PaymentType;
import com.gxwebsoft.payment.service.PaymentService; import com.gxwebsoft.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -49,6 +52,8 @@ public class HjcOrderController extends BaseController {
private PaymentService paymentService; private PaymentService paymentService;
@Resource @Resource
private HjcBizService hjcBizService; private HjcBizService hjcBizService;
@Resource
private HjcAdminGuard hjcGuard;
@Operation(summary = "下单(创建待支付订单)") @Operation(summary = "下单(创建待支付订单)")
@PostMapping("/create") @PostMapping("/create")
@@ -191,22 +196,38 @@ public class HjcOrderController extends BaseController {
@Operation(summary = "订单详情") @Operation(summary = "订单详情")
@GetMapping("/{id}") @GetMapping("/{id}")
public ApiResult<?> detail(@PathVariable("id") Integer id) { public ApiResult<?> detail(@PathVariable("id") Integer id) {
// 先判身份、再判存在:顺序反了会让匿名调用方靠「订单不存在 / 未登录」的差异
// 枚举出哪些订单 ID 真实存在。
User loginUser = hjcGuard.currentUser();
if (loginUser == null) {
return fail("用户未登录");
}
HjcOrder order = hjcOrderService.getById(id); HjcOrder order = hjcOrderService.getById(id);
if (order == null) { if (order == null) {
return fail("订单不存在"); 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())); order.setProject(hjcBidProjectService.getById(order.getProjectId()));
return success(order); return success(order);
} }
@Operation(summary = "后台-订单分页") @Operation(summary = "后台-订单分页")
@GetMapping("/page") @GetMapping("/page")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<PageResult<HjcOrder>> page(HjcOrderParam param) { public ApiResult<PageResult<HjcOrder>> page(HjcOrderParam param) {
return success(hjcOrderService.pageRel(param)); return success(hjcOrderService.pageRel(param));
} }
@Operation(summary = "退款(标记已退款,幂等),并推送 REFUNDED 状态") @Operation(summary = "退款(标记已退款,幂等),并推送 REFUNDED 状态")
@PostMapping("/refund") @PostMapping("/refund")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> refund(@RequestBody Map<String, Object> body) { public ApiResult<?> refund(@RequestBody Map<String, Object> body) {
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo")); String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
if (orderNo == null) { if (orderNo == null) {
@@ -9,6 +9,7 @@ import com.gxwebsoft.hjc.service.HjcOrderService;
import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil; import com.gxwebsoft.hjc.util.HjcOneStopAuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource; import javax.annotation.Resource;
@@ -50,6 +51,7 @@ public class HjcPushController extends BaseController {
@Operation(summary = "出向:手动触发订单推送(createPurchaseDetails)") @Operation(summary = "出向:手动触发订单推送(createPurchaseDetails)")
@PostMapping("/order/{orderNo}") @PostMapping("/order/{orderNo}")
@PreAuthorize("@hjcGuard.isAdmin()")
public ApiResult<?> triggerPush(@PathVariable("orderNo") String orderNo) { public ApiResult<?> triggerPush(@PathVariable("orderNo") String orderNo) {
HjcOrder order = hjcOrderService.getByOrderNo(orderNo); HjcOrder order = hjcOrderService.getByOrderNo(orderNo);
if (order == null) { if (order == null) {
@@ -21,6 +21,16 @@ public class HjcAuthRequest {
@Schema(description = "登录密码", required = true) @Schema(description = "登录密码", required = true)
private String password; private String password;
/**
* 验证码。两个接口各自含义不同,与核心实例的字段命名保持一致:
* <ul>
* <li>登录:<b>图形验证码</b>(来自 {@code GET /api/hjc/auth/captcha} 的图片)</li>
* <li>注册:<b>短信验证码</b>(发给「经办人手机号」,即账号手机号)</li>
* </ul>
*/
@Schema(description = "验证码:登录时为图形验证码,注册时为短信验证码")
private String code;
@Schema(description = "纳税人识别号/统一社会信用代码") @Schema(description = "纳税人识别号/统一社会信用代码")
private String creditCode; private String creditCode;
@@ -3,6 +3,7 @@ package com.gxwebsoft.hjc.service;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam; import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import java.util.List; import java.util.List;
@@ -14,4 +15,16 @@ public interface HjcEnterpriseService extends IService<HjcEnterprise> {
List<HjcEnterprise> listRel(HjcEnterpriseParam param); List<HjcEnterprise> listRel(HjcEnterpriseParam param);
HjcEnterprise getByUserId(Integer userId); HjcEnterprise getByUserId(Integer userId);
/**
* 注册时写入企业档案与资质证件(同一事务)。
*
* <p>独立成一个事务方法,是因为注册是「先在核心实例建号、再写汇吉采档案」的两步操作,
* 两步之间没有事务。调用方({@code HjcAuthController.register})需要在第二步失败时
* <b>捕获异常并告知用户「账号已建、资质未存」</b>,而不是把它抛成一句「操作失败」。
* 若该方法与调用方处在同一事务里,异常必然连带外层回滚,也没有机会返回友好文案。</p>
*
* @return 企业档案 ID
*/
Integer saveRegistration(HjcEnterprise enterprise, List<HjcEnterpriseMaterial> materials);
} }
@@ -1,19 +1,27 @@
package com.gxwebsoft.hjc.service.impl; package com.gxwebsoft.hjc.service.impl;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.hjc.entity.HjcEnterprise; import com.gxwebsoft.hjc.entity.HjcEnterprise;
import com.gxwebsoft.hjc.entity.HjcEnterpriseMaterial;
import com.gxwebsoft.hjc.mapper.HjcEnterpriseMapper; import com.gxwebsoft.hjc.mapper.HjcEnterpriseMapper;
import com.gxwebsoft.hjc.param.HjcEnterpriseParam; import com.gxwebsoft.hjc.param.HjcEnterpriseParam;
import com.gxwebsoft.hjc.service.HjcEnterpriseMaterialService;
import com.gxwebsoft.hjc.service.HjcEnterpriseService; import com.gxwebsoft.hjc.service.HjcEnterpriseService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.List; import java.util.List;
@Service @Service
public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, HjcEnterprise> implements HjcEnterpriseService { public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, HjcEnterprise> implements HjcEnterpriseService {
@Resource
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
@Override @Override
public PageResult<HjcEnterprise> pageRel(HjcEnterpriseParam param) { public PageResult<HjcEnterprise> pageRel(HjcEnterpriseParam param) {
PageParam<HjcEnterprise, HjcEnterpriseParam> page = new PageParam<>(param); PageParam<HjcEnterprise, HjcEnterpriseParam> page = new PageParam<>(param);
@@ -31,4 +39,24 @@ public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, H
public HjcEnterprise getByUserId(Integer userId) { public HjcEnterprise getByUserId(Integer userId) {
return baseMapper.getByUserId(userId); return baseMapper.getByUserId(userId);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Integer saveRegistration(HjcEnterprise enterprise, List<HjcEnterpriseMaterial> 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();
}
} }
+5 -1
View File
@@ -192,8 +192,12 @@ house:
timeout-ms: 20000 timeout-ms: 20000
tool-cache-ttl-ms: 300000 tool-cache-ttl-ms: 300000
# 汇吉采一站式平台对接 # 汇吉采
hjc: hjc:
# 汇吉采在平台里的租户 ID(汇吉采官网)。两处使用:代理核心实例时作为 TenantId 头
# (核心实例多租户插件缺该头会拼 tenant_id = NULL,导致查重静默失效、登录永不匹配);
# 判定「hjc 管理员」时校验 principal 的租户归属。
tenant-id: 10626
one-stop: one-stop:
# 一站式平台地址(+ path = 最终推送地址)。base-url 为空时推送不会发起,部署时需配置。 # 一站式平台地址(+ path = 最终推送地址)。base-url 为空时推送不会发起,部署时需配置。
base-url: "" base-url: ""
@@ -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 必须能被解析。
*
* <p><b>为什么需要这个测试</b>2026-09-10 生产实测中,注册在核心实例**已经建号成功**之后,
* 模块实例因反序列化失败把它当成「注册失败」返回给用户——账号已存在、用户却被告知失败,
* 重试又撞「手机号已存在」,留下孤儿账号。</p>
*
* <p>根因是核心实例的 {@code User} 实现了 Spring Security 的 {@code UserDetails}
* 因此多序列化 {@code enabled} / {@code accountNonExpired} / {@code accountNonLocked} /
* {@code credentialsNonExpired} 四个字段,而模块实例这份旧 {@code User} 副本里没有它们,
* 默认配置遇未知字段即抛 {@code UnrecognizedPropertyException}。</p>
*
* <p>下面的 JSON 取自那次真实失败的响应(密码哈希已替换为占位值)。</p>
*/
class HjcAuthControllerUserParseTest {
/**
* 直接取 {@link HjcAuthController} 里那个真实的 ObjectMapper(而不是在测试里另配一个)。
*
* <p>这一点是刻意的:如果测试自己配一份宽容的 mapper,那么将来有人把控制器里的配置改回
* 默认(严格)时,测试**依然会通过**——那就成了一个不会失败的守卫。用反射取真身,
* 才能保证「控制器一旦退回严格解析,这个测试就红」。</p>
*/
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;
}
}