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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 汇吉采 网站企业 注册/登录(企业名称+密码)
|
||||
* 汇吉采 注册 / 登录 / 退出 / 验证码(企业名称 + 密码)。
|
||||
*
|
||||
* <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 = "汇吉采-企业登录")
|
||||
@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。
|
||||
*
|
||||
* <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 =
|
||||
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)")
|
||||
@PostMapping("/upload")
|
||||
public ApiResult<Map<String, Object>> 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<String, Object> 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<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
|
||||
*/
|
||||
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<LoginResult> login(@RequestBody HjcAuthRequest request) {
|
||||
if (StrUtil.isBlank(request.getEnterpriseName()) || StrUtil.isBlank(request.getPassword())) {
|
||||
return fail("企业名称和密码不能为空", null);
|
||||
/**
|
||||
* 把核心实例的响应转成前端的登录结果,并<b>剔除敏感字段</b>。
|
||||
*
|
||||
* <p>核心实例的 {@code /login}、{@code /register} 会把整个 {@code User} 原样返回,
|
||||
* <b>其中含 BCrypt 密码哈希</b>(它不脱敏)。直接把该对象回传前端等于把密码哈希交给浏览器,
|
||||
* 故这里重新构造:只保留展示字段,清空密码、支付密码与权限集合。</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PageResult<HjcBidProject>> 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("删除成功");
|
||||
|
||||
@@ -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<PageResult<HjcEnterprise>> 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("审核参数不完整");
|
||||
|
||||
@@ -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<PageResult<HjcOrder>> page(HjcOrderParam param) {
|
||||
return success(hjcOrderService.pageRel(param));
|
||||
}
|
||||
|
||||
@Operation(summary = "退款(标记已退款,幂等),并推送 REFUNDED 状态")
|
||||
@PostMapping("/refund")
|
||||
@PreAuthorize("@hjcGuard.isAdmin()")
|
||||
public ApiResult<?> refund(@RequestBody Map<String, Object> body) {
|
||||
String orderNo = body.get("orderNo") == null ? null : String.valueOf(body.get("orderNo"));
|
||||
if (orderNo == null) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,16 @@ public class HjcAuthRequest {
|
||||
@Schema(description = "登录密码", required = true)
|
||||
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 = "纳税人识别号/统一社会信用代码")
|
||||
private String creditCode;
|
||||
|
||||
|
||||
@@ -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<HjcEnterprise> {
|
||||
List<HjcEnterprise> listRel(HjcEnterpriseParam param);
|
||||
|
||||
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;
|
||||
|
||||
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<HjcEnterpriseMapper, HjcEnterprise> implements HjcEnterpriseService {
|
||||
|
||||
@Resource
|
||||
private HjcEnterpriseMaterialService hjcEnterpriseMaterialService;
|
||||
|
||||
@Override
|
||||
public PageResult<HjcEnterprise> pageRel(HjcEnterpriseParam param) {
|
||||
PageParam<HjcEnterprise, HjcEnterpriseParam> page = new PageParam<>(param);
|
||||
@@ -31,4 +39,24 @@ public class HjcEnterpriseServiceImpl extends ServiceImpl<HjcEnterpriseMapper, H
|
||||
public HjcEnterprise getByUserId(Integer 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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user