Merge branch 'dev' into test

This commit is contained in:
chenbowen
2025-10-31 09:30:15 +08:00
43 changed files with 2454 additions and 65 deletions

View File

@@ -205,8 +205,13 @@
<name>中铜 ZStack 私服</name>
<url>http://172.16.46.63:30708/repository/test/</url>
<releases>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
<checksumPolicy>warn</checksumPolicy>
</releases>
<snapshots>
<enabled>true</enabled>
<updatePolicy>always</updatePolicy>
</snapshots>
</repository>
</repositories>

View File

@@ -0,0 +1,33 @@
-- =============================================
-- 数据总线 API 版本历史菜单权限(备忘录模式)
-- 功能说明:
-- 1. 查看版本历史
-- 2. 查看版本详情
-- 3. 版本回滚
-- 4. 版本对比
-- =============================================
-- 删除旧的版本管理菜单权限
DELETE FROM system_menu WHERE id IN (650107, 650108, 650109, 650110);
-- 插入新的版本历史管理权限
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
VALUES
-- 查询版本历史列表
(650107, 'API版本历史', 'databus:gateway:version:query', 3, 7, 6501, '', '', '', '',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
-- 查看版本详情
(650108, 'API版本详情', 'databus:gateway:version:detail', 3, 8, 6501, '', '', '', '',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
-- 版本回滚
(650109, 'API版本回滚', 'databus:gateway:version:rollback', 3, 9, 6501, '', '', '', '',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0'),
-- 版本对比
(650110, 'API版本对比', 'databus:gateway:version:compare', 3, 10, 6501, '', '', '', '',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0');
-- 说明
-- 1. 不再需要"创建版本"权限,因为系统自动创建
-- 2. 不再需要"删除版本"权限,版本历史不可删除
-- 3. 保留查询、详情、回滚、对比四个核心功能

View File

@@ -0,0 +1,52 @@
-- =============================================
-- 数据总线 API 版本历史表(备忘录模式)
-- 功能说明:
-- 1. 每次保存 API 配置时自动创建版本记录
-- 2. 版本号自动递增v1, v2, v3...
-- 3. 保留完整历史链,不可删除
-- 4. 支持一键回滚到任意历史版本
-- 5. 支持版本对比功能
-- =============================================
-- 如果表已存在则删除
DROP TABLE IF EXISTS databus_api_version;
-- 创建版本历史表DM8 语法)
CREATE TABLE databus_api_version (
id BIGINT NOT NULL,
api_id BIGINT NOT NULL,
version_number INTEGER NOT NULL,
snapshot_data CLOB NOT NULL,
description VARCHAR(500),
is_current NUMBER(1) DEFAULT 0 NOT NULL,
operator VARCHAR(64),
creator VARCHAR(64) DEFAULT '' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updater VARCHAR(64) DEFAULT '' NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
deleted NUMBER(1) DEFAULT 0 NOT NULL,
tenant_id BIGINT DEFAULT 0 NOT NULL,
CONSTRAINT pk_databus_api_version PRIMARY KEY (id)
);
-- 创建索引
CREATE INDEX idx_databus_api_version_api_id ON databus_api_version (api_id);
CREATE INDEX idx_databus_api_version_version_number ON databus_api_version (api_id, version_number);
CREATE INDEX idx_databus_api_version_is_current ON databus_api_version (api_id, is_current);
CREATE INDEX idx_databus_api_version_create_time ON databus_api_version (create_time);
CREATE INDEX idx_databus_api_version_operator ON databus_api_version (operator);
COMMENT ON TABLE databus_api_version IS '数据总线API版本历史表采用备忘录模式每次保存API时自动创建版本快照支持完整的版本历史追溯和回滚';
COMMENT ON COLUMN databus_api_version.id IS '主键ID';
COMMENT ON COLUMN databus_api_version.api_id IS 'API定义ID关联databus_api_definition表';
COMMENT ON COLUMN databus_api_version.version_number IS '版本号同一API下自动递增1,2,3...';
COMMENT ON COLUMN databus_api_version.snapshot_data IS 'API完整配置快照JSON格式包含definition、steps、transforms等所有信息';
COMMENT ON COLUMN databus_api_version.description IS '变更说明,记录本次修改的内容';
COMMENT ON COLUMN databus_api_version.is_current IS '是否为当前版本1=是0=否同一API只有一个当前版本';
COMMENT ON COLUMN databus_api_version.operator IS '操作人,记录谁创建了这个版本';
COMMENT ON COLUMN databus_api_version.creator IS '创建者';
COMMENT ON COLUMN databus_api_version.create_time IS '创建时间(版本创建时间)';
COMMENT ON COLUMN databus_api_version.updater IS '更新者';
COMMENT ON COLUMN databus_api_version.update_time IS '更新时间';
COMMENT ON COLUMN databus_api_version.deleted IS '是否删除(逻辑删除,实际不删除版本历史)';
COMMENT ON COLUMN databus_api_version.tenant_id IS '租户ID';

View File

@@ -0,0 +1,13 @@
-- 数据总线 API 访问日志菜单权限初始化DM8
-- 创建访问日志页面及查询按钮权限。如已存在将先行移除再新增。
DELETE FROM system_menu WHERE id IN (6504, 650401);
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
VALUES (6504, '访问日志', 'databus:gateway:access-log:query', 2, 40, 6500, 'access-log', 'ep:document', 'databus/accesslog/index', 'DatabusAccessLog',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0');
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name,
status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted)
VALUES (650401, '访问日志查询', 'databus:gateway:access-log:query', 3, 1, 6504, '', '', '', '',
0, '1', '1', '1', 'admin', CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '0');

View File

@@ -0,0 +1,52 @@
-- 权限监督按钮及接口权限DM8 专用)
-- 执行前请确认未占用 1068 主键
DELETE FROM system_menu WHERE id = 1068;
INSERT INTO system_menu (
id,
name,
permission,
type,
sort,
parent_id,
path,
icon,
component,
component_name,
status,
visible,
keep_alive,
always_show,
creator,
create_time,
updater,
update_time,
deleted
)
SELECT
1068,
'权限监督',
'system:permission:user-permission-supervision',
3,
9,
101,
'',
'',
'',
NULL,
0,
'1',
'1',
'1',
'admin',
'2025-10-29 00:00:00',
'',
'2025-10-29 00:00:00',
'0'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM system_menu
WHERE id = 1068
);

View File

@@ -31,6 +31,11 @@
<artifactId>zt-spring-boot-starter-biz-data-permission</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.zt.plat</groupId>
<artifactId>zt-spring-boot-starter-biz-tenant</artifactId>
<version>${revision}</version>
</dependency>
<!-- Test 测试相关 -->
<dependency>
<groupId>com.zt.plat</groupId>

View File

@@ -4,11 +4,15 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.zt.plat.framework.common.pojo.CompanyDeptInfo;
import com.zt.plat.framework.security.core.LoginUser;
import com.zt.plat.framework.tenant.core.context.CompanyContextHolder;
import com.zt.plat.framework.web.core.util.WebFrameworkUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@@ -22,43 +26,85 @@ import static com.zt.plat.framework.security.core.util.SecurityFrameworkUtils.ge
public class BusinessDeptHandleUtil {
public static Set<CompanyDeptInfo> getBelongCompanyAndDept(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType("application/json;charset=UTF-8");
String companyId = request.getHeader("visit-company-id");
String deptId = request.getHeader("visit-dept-id");
LoginUser loginUser = Optional.ofNullable(getLoginUser()).orElse(new LoginUser().setInfo(new HashMap<>()));
Set<CompanyDeptInfo> companyDeptSet = JSONUtil.parseArray(loginUser.getInfo().getOrDefault(LoginUser.INFO_KEY_COMPANY_DEPT_SET, "[]")).stream()
String companyIdHeader = request.getHeader(WebFrameworkUtils.HEADER_VISIT_COMPANY_ID);
String deptIdHeader = request.getHeader(WebFrameworkUtils.HEADER_VISIT_DEPT_ID);
LoginUser currentLoginUser = getLoginUser();
Map<String, String> extraInfo = Optional.ofNullable(currentLoginUser)
.map(LoginUser::getInfo)
.orElseGet(HashMap::new);
if (currentLoginUser != null && currentLoginUser.getInfo() == null) {
currentLoginUser.setInfo(extraInfo);
}
Set<CompanyDeptInfo> companyDeptSet = JSONUtil.parseArray(extraInfo.getOrDefault(LoginUser.INFO_KEY_COMPANY_DEPT_SET, "[]")).stream()
.map(obj -> JSONUtil.toBean((JSONObject) obj, CompanyDeptInfo.class))
.collect(Collectors.toSet());
// 1. 有 companyId
if (companyId != null && !companyId.isBlank()) {
if (companyIdHeader != null && !companyIdHeader.isBlank()) {
// 根据请求头中的公司 ID 过滤出当前用户的公司部门信息
Set<CompanyDeptInfo> companyDeptSetByCompanyId = companyDeptSet.stream().filter(companyDeptInfo -> companyDeptInfo.getCompanyId().toString().equals(companyId)).collect(Collectors.toSet());
Set<CompanyDeptInfo> companyDeptSetByCompanyId = companyDeptSet.stream()
.filter(companyDeptInfo -> companyDeptInfo.getCompanyId().toString().equals(companyIdHeader))
.collect(Collectors.toSet());
if (companyDeptSetByCompanyId.isEmpty()) {
// 当前公司下没有部门
CompanyDeptInfo data = new CompanyDeptInfo();
data.setCompanyId(Long.valueOf(companyId));
data.setCompanyId(Long.valueOf(companyIdHeader));
data.setDeptId(0L);
return new HashSet<>(singleton(data));
}
// 如果有 deptId校验其是否属于该 companyId
if (deptId != null) {
boolean valid = companyDeptSetByCompanyId.stream().anyMatch(info -> String.valueOf(info.getDeptId()).equals(deptId));
if (deptIdHeader != null) {
boolean valid = companyDeptSetByCompanyId.stream().anyMatch(info -> String.valueOf(info.getDeptId()).equals(deptIdHeader));
if (!valid) {
return null;
}else{
} else {
// 部门存在,放行
return new HashSet<>();
}
}
if (companyDeptSetByCompanyId.size() == 1) {
CompanyDeptInfo singleCompanyDept = companyDeptSetByCompanyId.iterator().next();
if (applyAutoSelection(currentLoginUser, request, singleCompanyDept)) {
return Collections.emptySet();
}
}
return companyDeptSetByCompanyId;
}
// 2. 没有公司信息,尝试唯一性自动推断
// 如果当前用户下只有一个公司和部门的对于关系
if (companyDeptSet.size() == 1) {
CompanyDeptInfo companyDeptInfo = companyDeptSet.iterator().next();
if (applyAutoSelection(currentLoginUser, request, companyDeptInfo)) {
return Collections.emptySet();
}
return new HashSet<>(singleton(companyDeptInfo));
} else {
return companyDeptSet;
}
return companyDeptSet;
}
private static boolean applyAutoSelection(LoginUser loginUser, HttpServletRequest request, CompanyDeptInfo info) {
if (info == null || info.getCompanyId() == null || info.getCompanyId() <= 0
|| info.getDeptId() == null || info.getDeptId() <= 0) {
return false;
}
if (loginUser != null) {
loginUser.setVisitCompanyId(info.getCompanyId());
loginUser.setVisitCompanyName(info.getCompanyName());
loginUser.setVisitDeptId(info.getDeptId());
loginUser.setVisitDeptName(info.getDeptName());
}
request.setAttribute(WebFrameworkUtils.HEADER_VISIT_COMPANY_ID, info.getCompanyId());
if (info.getCompanyName() != null) {
request.setAttribute(WebFrameworkUtils.HEADER_VISIT_COMPANY_NAME, info.getCompanyName());
}
request.setAttribute(WebFrameworkUtils.HEADER_VISIT_DEPT_ID, info.getDeptId());
if (info.getDeptName() != null) {
request.setAttribute(WebFrameworkUtils.HEADER_VISIT_DEPT_NAME, info.getDeptName());
}
CompanyContextHolder.setIgnore(false);
CompanyContextHolder.setCompanyId(info.getCompanyId());
return true;
}
}

View File

@@ -20,30 +20,68 @@ public class CompanyVisitContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 解析 header 并设置 visitCompanyId
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
Long companyId = WebFrameworkUtils.getCompanyId(request);
// 优先使用请求头上的公司信息,若缺失则回退到请求属性或当前登录用户已缓存的访问公司
if (companyId == null || companyId <= 0L) {
Long attrCompanyId = resolveLong(request.getAttribute(WebFrameworkUtils.HEADER_VISIT_COMPANY_ID));
if (attrCompanyId != null && attrCompanyId > 0L) {
companyId = attrCompanyId;
} else if (loginUser != null && loginUser.getVisitCompanyId() != null && loginUser.getVisitCompanyId() > 0L) {
companyId = loginUser.getVisitCompanyId();
}
}
String companyName = WebFrameworkUtils.getCompanyName(request);
if (companyId <= 0L) {
// 如果没有设置 companyId则忽略
if (companyName == null || companyName.isEmpty()) {
Object attrCompanyName = request.getAttribute(WebFrameworkUtils.HEADER_VISIT_COMPANY_NAME);
if (attrCompanyName instanceof String) {
companyName = (String) attrCompanyName;
} else if (loginUser != null) {
companyName = loginUser.getVisitCompanyName();
}
}
Long deptId = WebFrameworkUtils.getDeptId(request);
// 部门信息同样遵循“请求头 -> 请求属性 -> 登录缓存”的回退顺序
if (deptId == null || deptId <= 0L) {
Long attrDeptId = resolveLong(request.getAttribute(WebFrameworkUtils.HEADER_VISIT_DEPT_ID));
if (attrDeptId != null && attrDeptId > 0L) {
deptId = attrDeptId;
} else if (loginUser != null && loginUser.getVisitDeptId() != null && loginUser.getVisitDeptId() > 0L) {
deptId = loginUser.getVisitDeptId();
}
}
String deptName = WebFrameworkUtils.getDeptName(request);
if (deptName == null || deptName.isEmpty()) {
Object attrDeptName = request.getAttribute(WebFrameworkUtils.HEADER_VISIT_DEPT_NAME);
if (attrDeptName instanceof String) {
deptName = (String) attrDeptName;
} else if (loginUser != null) {
deptName = loginUser.getVisitDeptName();
}
}
if (companyId == null || companyId <= 0L) {
CompanyContextHolder.setIgnore(true);
return true;
}
Long deptId = WebFrameworkUtils.getDeptId(request);
String deptName = WebFrameworkUtils.getDeptName(request);
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
CompanyContextHolder.setIgnore(false);
CompanyContextHolder.setCompanyId(companyId);
if (loginUser == null) {
return true;
}
if (deptId > 0L) {
// 同步最新的访问公司/部门到登录用户对象,供后续数据权限及上下文读取
loginUser.setVisitCompanyId(companyId);
loginUser.setVisitCompanyName(companyName);
if (deptId != null && deptId > 0L) {
loginUser.setVisitDeptId(deptId);
loginUser.setVisitDeptName(deptName);
}
// if (!securityFrameworkService.hasAnyPermissions(PERMISSION)) {
// throw exception0(GlobalErrorCodeConstants.FORBIDDEN.getCode(), "您无权切换部门");
// }
loginUser.setVisitCompanyId(companyId);
loginUser.setVisitCompanyName(companyName);
CompanyContextHolder.setCompanyId(companyId);
return true;
}
@@ -55,4 +93,18 @@ public class CompanyVisitContextInterceptor implements HandlerInterceptor {
loginUser.setVisitCompanyId(0L);
}
}
private Long resolveLong(Object value) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
if (value instanceof String) {
try {
return Long.parseLong(((String) value).trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,52 @@
package com.zt.plat.module.databus.controller.admin.gateway;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiAccessLogConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
/**
* Databus API 访问日志控制器。
*/
@Tag(name = "管理后台 - Databus API 访问日志")
@RestController
@RequestMapping("/databus/gateway/access-log")
@Validated
public class ApiAccessLogController {
@Resource
private ApiAccessLogService apiAccessLogService;
@GetMapping("/get")
@Operation(summary = "获取访问日志详情")
@Parameter(name = "id", description = "日志编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('databus:gateway:access-log:query')")
public CommonResult<ApiAccessLogRespVO> get(@RequestParam("id") Long id) {
ApiAccessLogDO logDO = apiAccessLogService.get(id);
return success(ApiAccessLogConvert.INSTANCE.convert(logDO));
}
@GetMapping("/page")
@Operation(summary = "分页查询访问日志")
@PreAuthorize("@ss.hasPermission('databus:gateway:access-log:query')")
public CommonResult<PageResult<ApiAccessLogRespVO>> page(@Valid ApiAccessLogPageReqVO pageReqVO) {
PageResult<ApiAccessLogDO> pageResult = apiAccessLogService.getPage(pageReqVO);
return success(ApiAccessLogConvert.INSTANCE.convertPage(pageResult));
}
}

View File

@@ -0,0 +1,97 @@
package com.zt.plat.module.databus.controller.admin.gateway;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiVersionConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionCompareRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionDetailRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionRollbackReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiVersionDO;
import com.zt.plat.module.databus.service.gateway.ApiVersionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
/**
* API 版本历史控制器。
*/
@Tag(name = "管理后台 - API 版本历史")
@RestController
@RequestMapping("/databus/gateway/version")
@Validated
@Slf4j
public class ApiVersionController {
@Resource
private ApiVersionService apiVersionService;
@Resource
private ObjectMapper objectMapper;
@GetMapping("/get")
@Operation(summary = "获取 API 版本详情")
@Parameter(name = "id", description = "版本编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('databus:gateway:version:query')")
public CommonResult<ApiVersionDetailRespVO> getVersion(@RequestParam("id") Long id) {
ApiVersionDO versionDO = apiVersionService.getVersion(id);
ApiVersionDetailRespVO respVO = ApiVersionConvert.INSTANCE.convertDetail(versionDO);
// 反序列化快照数据
if (versionDO.getSnapshotData() != null) {
try {
ApiDefinitionSaveReqVO snapshot = objectMapper.readValue(versionDO.getSnapshotData(), ApiDefinitionSaveReqVO.class);
respVO.setSnapshotData(snapshot);
} catch (JsonProcessingException ex) {
log.error("反序列化版本快照失败, versionId={}", id, ex);
}
}
return success(respVO);
}
@GetMapping("/page")
@Operation(summary = "分页查询 API 版本列表")
@PreAuthorize("@ss.hasPermission('databus:gateway:version:query')")
public CommonResult<PageResult<ApiVersionRespVO>> getVersionPage(@Valid ApiVersionPageReqVO pageReqVO) {
PageResult<ApiVersionDO> pageResult = apiVersionService.getVersionPage(pageReqVO);
return success(ApiVersionConvert.INSTANCE.convertPage(pageResult));
}
@GetMapping("/list")
@Operation(summary = "查询指定 API 的全部版本")
@PreAuthorize("@ss.hasPermission('databus:gateway:version:query')")
public CommonResult<java.util.List<ApiVersionRespVO>> getVersionList(@RequestParam("apiId") Long apiId) {
return success(ApiVersionConvert.INSTANCE.convertList(apiVersionService.getVersionListByApiId(apiId)));
}
@PutMapping("/rollback")
@Operation(summary = "回滚到指定版本")
@PreAuthorize("@ss.hasPermission('databus:gateway:version:rollback')")
public CommonResult<Boolean> rollbackToVersion(@Valid @RequestBody ApiVersionRollbackReqVO reqVO) {
apiVersionService.rollbackToVersion(reqVO.getId(), reqVO.getRemark());
return success(true);
}
@GetMapping("/compare")
@Operation(summary = "对比两个版本差异")
@PreAuthorize("@ss.hasPermission('databus:gateway:version:query')")
public CommonResult<ApiVersionCompareRespVO> compareVersions(
@RequestParam("sourceId") Long sourceId,
@RequestParam("targetId") Long targetId) {
return success(apiVersionService.compareVersions(sourceId, targetId));
}
}

View File

@@ -0,0 +1,29 @@
package com.zt.plat.module.databus.controller.admin.gateway.convert;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper
public interface ApiAccessLogConvert {
ApiAccessLogConvert INSTANCE = Mappers.getMapper(ApiAccessLogConvert.class);
ApiAccessLogRespVO convert(ApiAccessLogDO bean);
List<ApiAccessLogRespVO> convertList(List<ApiAccessLogDO> list);
default PageResult<ApiAccessLogRespVO> convertPage(PageResult<ApiAccessLogDO> page) {
if (page == null) {
return PageResult.empty();
}
PageResult<ApiAccessLogRespVO> result = new PageResult<>();
result.setList(convertList(page.getList()));
result.setTotal(page.getTotal());
return result;
}
}

View File

@@ -3,12 +3,10 @@ package com.zt.plat.module.databus.controller.admin.gateway.convert;
import cn.hutool.core.collection.CollUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.util.object.BeanUtils;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionDetailRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionPublicationRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionStepRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSummaryRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.*;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiFlowPublication;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
@@ -101,4 +99,29 @@ public interface ApiDefinitionConvert {
return publication == null ? null : BeanUtils.toBean(publication, ApiDefinitionPublicationRespVO.class);
}
/**
* 转换步骤列表DO -> SaveReqVO
*/
default List<ApiDefinitionStepSaveReqVO> convertStepList(List<ApiStepDO> steps) {
if (CollUtil.isEmpty(steps)) {
return new ArrayList<>();
}
return steps.stream()
.sorted(Comparator.comparing(step -> step.getStepOrder() == null ? Integer.MAX_VALUE : step.getStepOrder()))
.map(step -> BeanUtils.toBean(step, ApiDefinitionStepSaveReqVO.class))
.collect(Collectors.toList());
}
/**
* 转换变换列表DO -> SaveReqVO
*/
default List<ApiDefinitionTransformSaveReqVO> convertTransformList(List<ApiTransformDO> transforms) {
if (CollUtil.isEmpty(transforms)) {
return new ArrayList<>();
}
return transforms.stream()
.map(transform -> BeanUtils.toBean(transform, ApiDefinitionTransformSaveReqVO.class))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,30 @@
package com.zt.plat.module.databus.controller.admin.gateway.convert;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionDetailRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionRespVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiVersionDO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
import java.util.List;
/**
* API 版本历史 Convert。
*/
@Mapper
public interface ApiVersionConvert {
ApiVersionConvert INSTANCE = Mappers.getMapper(ApiVersionConvert.class);
ApiVersionRespVO convert(ApiVersionDO bean);
PageResult<ApiVersionRespVO> convertPage(PageResult<ApiVersionDO> page);
List<ApiVersionRespVO> convertList(List<ApiVersionDO> list);
@Mapping(target = "snapshotData", ignore = true)
ApiVersionDetailRespVO convertDetail(ApiVersionDO bean);
}

View File

@@ -0,0 +1,53 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog;
import com.zt.plat.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.zt.plat.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* Databus API 访问日志分页查询 VO。
*/
@Schema(description = "管理后台 - Databus API 访问日志分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class ApiAccessLogPageReqVO extends PageParam {
@Schema(description = "追踪 ID", example = "c8a3d52f-42c8-4b5d-9e26-8c2cc89f6bb5")
private String traceId;
@Schema(description = "API 编码", example = "user.query")
private String apiCode;
@Schema(description = "API 版本", example = "v1")
private String apiVersion;
@Schema(description = "HTTP 方法", example = "POST")
private String requestMethod;
@Schema(description = "响应 HTTP 状态", example = "200")
private Integer responseStatus;
@Schema(description = "访问状态", example = "0")
private Integer status;
@Schema(description = "客户端 IP", example = "192.168.0.10")
private String clientIp;
@Schema(description = "租户编号", example = "1")
private Long tenantId;
@Schema(description = "请求路径", example = "/gateway/api/user/query")
private String requestPath;
@Schema(description = "请求时间区间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] requestTime;
}

View File

@@ -0,0 +1,89 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Databus API 访问日志 Response VO。
*/
@Schema(description = "管理后台 - Databus API 访问日志 Response VO")
@Data
public class ApiAccessLogRespVO {
@Schema(description = "日志编号", example = "1024")
private Long id;
@Schema(description = "追踪 ID", example = "c8a3d52f-42c8-4b5d-9e26-8c2cc89f6bb5")
private String traceId;
@Schema(description = "API 编码", example = "user.query")
private String apiCode;
@Schema(description = "API 版本", example = "v1")
private String apiVersion;
@Schema(description = "HTTP 方法", example = "POST")
private String requestMethod;
@Schema(description = "请求路径", example = "/gateway/api/user/query")
private String requestPath;
@Schema(description = "查询参数(JSON)")
private String requestQuery;
@Schema(description = "请求头(JSON)")
private String requestHeaders;
@Schema(description = "请求体(JSON)")
private String requestBody;
@Schema(description = "响应 HTTP 状态", example = "200")
private Integer responseStatus;
@Schema(description = "响应提示", example = "OK")
private String responseMessage;
@Schema(description = "响应体(JSON)")
private String responseBody;
@Schema(description = "访问状态", example = "0")
private Integer status;
@Schema(description = "错误码", example = "DAT-001")
private String errorCode;
@Schema(description = "错误信息", example = "API 调用失败")
private String errorMessage;
@Schema(description = "异常堆栈")
private String exceptionStack;
@Schema(description = "客户端 IP", example = "192.168.0.10")
private String clientIp;
@Schema(description = "User-Agent")
private String userAgent;
@Schema(description = "租户编号", example = "1")
private Long tenantId;
@Schema(description = "请求耗时(毫秒)", example = "123")
private Long duration;
@Schema(description = "请求时间")
private LocalDateTime requestTime;
@Schema(description = "响应时间")
private LocalDateTime responseTime;
@Schema(description = "执行步骤(JSON)")
private String stepResults;
@Schema(description = "额外调试信息(JSON)")
private String extra;
@Schema(description = "创建时间")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,72 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* API 版本对比 Response VO。
*/
@Schema(description = "管理后台 - API 版本对比 Response VO")
@Data
public class ApiVersionCompareRespVO {
@Schema(description = "源版本 ID", example = "1001")
private Long sourceVersionId;
@Schema(description = "源版本号", example = "2")
private Integer sourceVersionNumber;
@Schema(description = "源版本描述")
private String sourceDescription;
@Schema(description = "源版本操作人")
private String sourceOperator;
@Schema(description = "源版本创建时间")
private LocalDateTime sourceCreateTime;
@Schema(description = "目标版本 ID", example = "1002")
private Long targetVersionId;
@Schema(description = "目标版本号", example = "3")
private Integer targetVersionNumber;
@Schema(description = "目标版本描述")
private String targetDescription;
@Schema(description = "目标版本操作人")
private String targetOperator;
@Schema(description = "目标版本创建时间")
private LocalDateTime targetCreateTime;
@Schema(description = "源版本快照")
private ApiDefinitionSaveReqVO sourceSnapshot;
@Schema(description = "目标版本快照")
private ApiDefinitionSaveReqVO targetSnapshot;
@Schema(description = "两者是否完全一致")
private Boolean same;
@Schema(description = "字段差异列表")
private List<FieldDiff> differences;
@Data
public static class FieldDiff {
@Schema(description = "差异字段路径", example = "/steps[0]/targetEndpoint")
private String path;
@Schema(description = "源版本值")
private String sourceValue;
@Schema(description = "目标版本值")
private String targetValue;
}
}

View File

@@ -0,0 +1,26 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* API 版本历史创建 Request VO。
*/
@Schema(description = "管理后台 - API 版本历史创建 Request VO")
@Data
public class ApiVersionCreateReqVO {
@Schema(description = "API 定义 ID", required = true, example = "1024")
@NotNull(message = "API 定义 ID 不能为空")
private Long apiId;
@Schema(description = "版本号", required = true, example = "v1.0.0")
@NotBlank(message = "版本号不能为空")
private String versionNumber;
@Schema(description = "版本描述", example = "初始版本")
private String description;
}

View File

@@ -0,0 +1,47 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSaveReqVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* API 版本详情 Response VO。
* 包含完整的 API 定义快照数据。
*/
@Schema(description = "管理后台 - API 版本详情 Response VO")
@Data
public class ApiVersionDetailRespVO {
@Schema(description = "主键", example = "1024")
private Long id;
@Schema(description = "API 定义 ID", example = "1001")
private Long apiId;
@Schema(description = "版本号", example = "1")
private Integer versionNumber;
@Schema(description = "版本描述", example = "初始版本")
private String description;
@Schema(description = "是否为当前版本", example = "true")
private Boolean isCurrent;
@Schema(description = "操作人", example = "admin")
private String operator;
@Schema(description = "API 定义快照数据")
private ApiDefinitionSaveReqVO snapshotData;
@Schema(description = "创建者", example = "admin")
private String creator;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "租户编号", example = "1")
private Long tenantId;
}

View File

@@ -0,0 +1,33 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import com.zt.plat.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.zt.plat.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* API 版本历史分页查询 VO。
*/
@Schema(description = "管理后台 - API 版本历史分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class ApiVersionPageReqVO extends PageParam {
@Schema(description = "API 定义 ID", required = true, example = "1024")
private Long apiId;
@Schema(description = "版本号", example = "1")
private Integer versionNumber;
@Schema(description = "创建时间区间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}

View File

@@ -0,0 +1,42 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* API 版本历史 Response VO。
*/
@Schema(description = "管理后台 - API 版本历史 Response VO")
@Data
public class ApiVersionRespVO {
@Schema(description = "主键", example = "1024")
private Long id;
@Schema(description = "API 定义 ID", example = "1001")
private Long apiId;
@Schema(description = "版本号", example = "1")
private Integer versionNumber;
@Schema(description = "版本描述", example = "初始版本")
private String description;
@Schema(description = "是否为当前版本", example = "true")
private Boolean isCurrent;
@Schema(description = "操作人", example = "admin")
private String operator;
@Schema(description = "创建者", example = "admin")
private String creator;
@Schema(description = "创建时间")
private LocalDateTime createTime;
@Schema(description = "租户编号", example = "1")
private Long tenantId;
}

View File

@@ -0,0 +1,21 @@
package com.zt.plat.module.databus.controller.admin.gateway.vo.version;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* API 版本回滚 Request VO。
*/
@Schema(description = "管理后台 - API 版本回滚 Request VO")
@Data
public class ApiVersionRollbackReqVO {
@Schema(description = "待回滚的版本 ID", required = true, example = "1024")
@NotNull(message = "版本 ID 不能为空")
private Long id;
@Schema(description = "回滚备注", example = "回滚到版本 v5")
private String remark;
}

View File

@@ -0,0 +1,140 @@
package com.zt.plat.module.databus.dal.dataobject.gateway;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* Databus API 访问日志数据对象。
*
* <p>用于记录 API 编排网关的请求与响应详情,便于审计与问题排查。</p>
*/
@TableName("databus_api_access_log")
@KeySequence("databus_api_access_log_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiAccessLogDO extends TenantBaseDO {
/**
* 主键
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* 请求追踪标识,对应 {@link com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext#getRequestId()}
*/
private String traceId;
/**
* API 编码
*/
private String apiCode;
/**
* API 版本
*/
private String apiVersion;
/**
* HTTP 方法
*/
private String requestMethod;
/**
* 请求路径
*/
private String requestPath;
/**
* 查询参数JSON 字符串)
*/
private String requestQuery;
/**
* 请求头信息JSON 字符串)
*/
private String requestHeaders;
/**
* 请求体JSON 字符串)
*/
private String requestBody;
/**
* 响应 HTTP 状态码
*/
private Integer responseStatus;
/**
* 响应提示信息
*/
private String responseMessage;
/**
* 响应体JSON 字符串)
*/
private String responseBody;
/**
* 访问状态0-成功 1-客户端错误 2-服务端错误 3-未知
*/
private Integer status;
/**
* 业务错误码
*/
private String errorCode;
/**
* 错误信息
*/
private String errorMessage;
/**
* 异常堆栈
*/
private String exceptionStack;
/**
* 客户端 IP
*/
private String clientIp;
/**
* User-Agent
*/
private String userAgent;
/**
* 请求耗时(毫秒)
*/
private Long duration;
/**
* 请求时间
*/
private LocalDateTime requestTime;
/**
* 响应时间
*/
private LocalDateTime responseTime;
/**
* 执行步骤结果JSON 字符串)
*/
private String stepResults;
/**
* 额外调试信息JSON 字符串)
*/
private String extra;
}

View File

@@ -0,0 +1,59 @@
package com.zt.plat.module.databus.dal.dataobject.gateway;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* API 版本历史数据对象
*
* <p>每次修改 API 配置时自动创建新版本记录,支持完整的版本历史追溯和回滚。</p>
* <p>版本号自动递增,不可删除,保留完整的历史记录链。</p>
*/
@TableName("databus_api_version")
@KeySequence("databus_api_version_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class ApiVersionDO extends TenantBaseDO {
/**
* 主键
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/**
* API 定义 ID
*/
private Long apiId;
/**
* 版本号(自动递增,从 1 开始)
*/
private Integer versionNumber;
/**
* API 完整定义快照JSON 格式,包含 definition、steps、transforms 等)
*/
private String snapshotData;
/**
* 版本描述/变更说明
*/
private String description;
/**
* 是否为当前版本(最新使用的版本)
*/
private Boolean isCurrent;
/**
* 操作人
*/
private String operator;
}

View File

@@ -0,0 +1,78 @@
package com.zt.plat.module.databus.dal.mapper.gateway;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiVersionDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* API 版本历史 Mapper
*/
@Mapper
public interface ApiVersionMapper extends BaseMapperX<ApiVersionDO> {
/**
* 分页查询版本历史
*/
default PageResult<ApiVersionDO> selectPage(ApiVersionPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<ApiVersionDO>()
.eqIfPresent(ApiVersionDO::getApiId, reqVO.getApiId())
.eqIfPresent(ApiVersionDO::getVersionNumber, reqVO.getVersionNumber())
.betweenIfPresent(ApiVersionDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(ApiVersionDO::getVersionNumber));
}
/**
* 查询指定 API 的所有版本历史
*/
default List<ApiVersionDO> selectListByApiId(Long apiId) {
return selectList(new LambdaQueryWrapperX<ApiVersionDO>()
.eq(ApiVersionDO::getApiId, apiId)
.orderByDesc(ApiVersionDO::getVersionNumber));
}
/**
* 查询指定 API 的当前版本
*/
default ApiVersionDO selectCurrentByApiId(Long apiId) {
return selectOne(new LambdaQueryWrapperX<ApiVersionDO>()
.eq(ApiVersionDO::getApiId, apiId)
.eq(ApiVersionDO::getIsCurrent, true));
}
/**
* 查询指定 API 的最大版本号
*/
default Integer selectMaxVersionNumber(Long apiId) {
ApiVersionDO maxVersion = selectOne(new LambdaQueryWrapperX<ApiVersionDO>()
.eq(ApiVersionDO::getApiId, apiId)
.orderByDesc(ApiVersionDO::getVersionNumber)
.last("LIMIT 1"));
return maxVersion != null ? maxVersion.getVersionNumber() : 0;
}
/**
* 将所有版本标记为非当前版本
*/
default void markAllAsNotCurrent(Long apiId) {
UpdateWrapper<ApiVersionDO> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("api_id", apiId)
.set("is_current", false);
update(null, updateWrapper);
}
/**
* 查询指定版本
*/
default ApiVersionDO selectByApiIdAndVersionNumber(Long apiId, Integer versionNumber) {
return selectOne(new LambdaQueryWrapperX<ApiVersionDO>()
.eq(ApiVersionDO::getApiId, apiId)
.eq(ApiVersionDO::getVersionNumber, versionNumber));
}
}

View File

@@ -0,0 +1,31 @@
package com.zt.plat.module.databus.dal.mysql.gateway;
import cn.hutool.core.util.ArrayUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.mybatis.core.mapper.BaseMapperX;
import com.zt.plat.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ApiAccessLogMapper extends BaseMapperX<ApiAccessLogDO> {
default PageResult<ApiAccessLogDO> selectPage(ApiAccessLogPageReqVO reqVO) {
LambdaQueryWrapperX<ApiAccessLogDO> query = new LambdaQueryWrapperX<ApiAccessLogDO>()
.likeIfPresent(ApiAccessLogDO::getTraceId, reqVO.getTraceId())
.eqIfPresent(ApiAccessLogDO::getApiCode, reqVO.getApiCode())
.eqIfPresent(ApiAccessLogDO::getApiVersion, reqVO.getApiVersion())
.eqIfPresent(ApiAccessLogDO::getRequestMethod, reqVO.getRequestMethod())
.eqIfPresent(ApiAccessLogDO::getResponseStatus, reqVO.getResponseStatus())
.eqIfPresent(ApiAccessLogDO::getStatus, reqVO.getStatus())
.likeIfPresent(ApiAccessLogDO::getClientIp, reqVO.getClientIp())
.eqIfPresent(ApiAccessLogDO::getTenantId, reqVO.getTenantId())
.likeIfPresent(ApiAccessLogDO::getRequestPath, reqVO.getRequestPath());
if (ArrayUtil.isNotEmpty(reqVO.getRequestTime()) && reqVO.getRequestTime().length == 2) {
query.between(ApiAccessLogDO::getRequestTime, reqVO.getRequestTime()[0], reqVO.getRequestTime()[1]);
}
return selectPage(reqVO, query.orderByDesc(ApiAccessLogDO::getRequestTime)
.orderByDesc(ApiAccessLogDO::getId));
}
}

View File

@@ -0,0 +1,255 @@
package com.zt.plat.module.databus.framework.integration.gateway.core;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
import com.zt.plat.module.databus.framework.integration.gateway.model.ApiInvocationContext;
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
/**
* 将 API 调用上下文持久化为访问日志。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ApiGatewayAccessLogger {
public static final String ATTR_LOG_ID = "ApiAccessLogId";
public static final String ATTR_EXCEPTION_STACK = "ApiAccessLogExceptionStack";
private static final int MAX_TEXT_LENGTH = 4000;
private final ApiAccessLogService apiAccessLogService;
private final ObjectMapper objectMapper;
/**
* 在分发前记录请求信息。
*/
public void onRequest(ApiInvocationContext context) {
try {
ApiAccessLogDO logDO = new ApiAccessLogDO();
logDO.setTraceId(context.getRequestId());
logDO.setApiCode(context.getApiCode());
logDO.setApiVersion(context.getApiVersion());
logDO.setRequestMethod(context.getHttpMethod());
logDO.setRequestPath(context.getRequestPath());
logDO.setRequestQuery(toJson(context.getRequestQueryParams()));
logDO.setRequestHeaders(toJson(context.getRequestHeaders()));
logDO.setRequestBody(toJson(context.getRequestBody()));
logDO.setClientIp(firstNonBlank(context.getClientIp(),
GatewayHeaderUtils.findFirstHeaderValue(context.getRequestHeaders(), "X-Forwarded-For")));
logDO.setUserAgent(GatewayHeaderUtils.findFirstHeaderValue(context.getRequestHeaders(), HttpHeaders.USER_AGENT));
logDO.setStatus(3); // 默认未知
logDO.setRequestTime(toLocalDateTime(context.getRequestTime()));
logDO.setTenantId(parseTenantId(context.getTenantId()));
Long logId = apiAccessLogService.create(logDO);
context.getAttributes().put(ATTR_LOG_ID, logId);
} catch (Exception ex) {
log.warn("记录 API 访问日志开始阶段失败, traceId={}", context.getRequestId(), ex);
}
}
/**
* 记录异常堆栈,便于后续写入日志。
*/
public void onException(ApiInvocationContext context, Throwable throwable) {
if (throwable == null) {
return;
}
context.getAttributes().put(ATTR_EXCEPTION_STACK, buildStackTrace(throwable));
}
/**
* 在分发完成后补全日志信息。
*/
public void onResponse(ApiInvocationContext context) {
Long logId = getLogId(context);
if (logId == null) {
return;
}
try {
ApiAccessLogDO update = new ApiAccessLogDO();
update.setId(logId);
update.setResponseStatus(context.getResponseStatus());
update.setResponseMessage(context.getResponseMessage());
update.setResponseBody(toJson(context.getResponseBody()));
update.setStatus(resolveStatus(context.getResponseStatus()));
update.setErrorCode(extractErrorCode(context.getResponseBody()));
update.setErrorMessage(resolveErrorMessage(context));
update.setExceptionStack((String) context.getAttributes().get(ATTR_EXCEPTION_STACK));
update.setStepResults(toJson(context.getStepResults()));
update.setExtra(toJson(buildExtra(context)));
update.setResponseTime(LocalDateTime.now());
update.setDuration(calculateDuration(context));
apiAccessLogService.update(update);
} catch (Exception ex) {
log.warn("记录 API 访问日志结束阶段失败, traceId={}, logId={}", context.getRequestId(), logId, ex);
}
}
private Long getLogId(ApiInvocationContext context) {
Object value = context.getAttributes().get(ATTR_LOG_ID);
if (value instanceof Long) {
return (Long) value;
}
if (value instanceof Number number) {
return number.longValue();
}
return null;
}
private Long calculateDuration(ApiInvocationContext context) {
Instant start = context.getRequestTime();
if (start == null) {
return null;
}
return Duration.between(start, Instant.now()).toMillis();
}
private Integer resolveStatus(Integer httpStatus) {
if (httpStatus == null) {
return 3;
}
if (httpStatus >= 200 && httpStatus < 400) {
return 0;
}
if (httpStatus >= 400 && httpStatus < 500) {
return 1;
}
if (httpStatus >= 500) {
return 2;
}
return 3;
}
private String resolveErrorMessage(ApiInvocationContext context) {
if (StringUtils.hasText(context.getResponseMessage())) {
return truncate(context.getResponseMessage());
}
Object responseBody = context.getResponseBody();
if (responseBody instanceof Map<?, ?> map) {
Object message = firstNonNull(map.get("errorMessage"), map.get("message"));
if (message != null) {
return truncate(String.valueOf(message));
}
}
return null;
}
private String extractErrorCode(Object responseBody) {
if (responseBody instanceof Map<?, ?> map) {
Object errorCode = firstNonNull(map.get("errorCode"), map.get("code"));
return errorCode == null ? null : truncate(String.valueOf(errorCode));
}
return null;
}
private Map<String, Object> buildExtra(ApiInvocationContext context) {
Map<String, Object> extra = new HashMap<>();
if (!CollectionUtils.isEmpty(context.getVariables())) {
extra.put("variables", context.getVariables());
}
if (!CollectionUtils.isEmpty(context.getAttributes())) {
Map<String, Object> attributes = new HashMap<>(context.getAttributes());
attributes.remove(ATTR_LOG_ID);
attributes.remove(ATTR_EXCEPTION_STACK);
if (!attributes.isEmpty()) {
extra.put("attributes", attributes);
}
}
if (CollectionUtils.isEmpty(extra)) {
return null;
}
return extra;
}
private String toJson(Object value) {
if (value == null) {
return null;
}
if (value instanceof String str) {
return truncate(str);
}
try {
return truncate(objectMapper.writeValueAsString(value));
} catch (JsonProcessingException ex) {
return truncate(String.valueOf(value));
}
}
private String truncate(String text) {
if (!StringUtils.hasText(text)) {
return text;
}
if (text.length() <= MAX_TEXT_LENGTH) {
return text;
}
return text.substring(0, MAX_TEXT_LENGTH);
}
private Long parseTenantId(String tenantId) {
if (!StringUtils.hasText(tenantId)) {
return null;
}
try {
return Long.parseLong(tenantId.trim());
} catch (NumberFormatException ex) {
return null;
}
}
private LocalDateTime toLocalDateTime(Instant instant) {
if (instant == null) {
return null;
}
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
}
private String buildStackTrace(Throwable throwable) {
try (StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw)) {
throwable.printStackTrace(pw);
return truncate(sw.toString());
} catch (Exception ex) {
return throwable.getMessage();
}
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (StringUtils.hasText(value)) {
return value;
}
}
return null;
}
private Object firstNonNull(Object... values) {
if (values == null) {
return null;
}
for (Object value : values) {
if (value != null) {
return value;
}
}
return null;
}
}

View File

@@ -38,12 +38,15 @@ public class ApiGatewayExecutionService {
private static final String HEADER_REQUEST_URI = org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri";
private static final String HEADER_REQUEST_PARAMS = org.springframework.integration.http.HttpHeaders.PREFIX + "requestParams";
private static final String HEADER_QUERY_STRING = org.springframework.integration.http.HttpHeaders.PREFIX + "queryString";
private static final String HEADER_REMOTE_ADDRESS = org.springframework.integration.http.HttpHeaders.PREFIX + "remoteAddress";
private static final String LOCAL_DEBUG_REMOTE_ADDRESS = "127.0.0.1";
private final ApiGatewayRequestMapper requestMapper;
private final ApiFlowDispatcher apiFlowDispatcher;
private final ApiGatewayErrorProcessor errorProcessor;
private final ApiGatewayProperties properties;
private final ObjectMapper objectMapper;
private final ApiGatewayAccessLogger accessLogger;
/**
* Maps a raw HTTP message (as provided by Spring Integration) into a context message.
@@ -62,26 +65,34 @@ public class ApiGatewayExecutionService {
*/
public ApiInvocationContext dispatch(Message<ApiInvocationContext> message) {
ApiInvocationContext context = message.getPayload();
accessLogger.onRequest(context);
ApiInvocationContext responseContext;
try {
return apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context);
responseContext = apiFlowDispatcher.dispatch(context.getApiCode(), context.getApiVersion(), context);
} catch (ServiceException ex) {
errorProcessor.applyServiceException(context, ex);
accessLogger.onException(context, ex);
log.warn("[API-PORTAL] 分发 apiCode={} version={} 时出现 ServiceException: {}", context.getApiCode(), context.getApiVersion(), ex.getMessage());
return context;
responseContext = context;
} catch (Exception ex) {
ServiceException nestedServiceException = errorProcessor.resolveServiceException(ex);
if (nestedServiceException != null) {
errorProcessor.applyServiceException(context, nestedServiceException);
accessLogger.onException(context, nestedServiceException);
log.warn("[API-PORTAL] 分发 apiCode={} version={} 时出现 ServiceException(包装异常): {}", context.getApiCode(), context.getApiVersion(), nestedServiceException.getMessage());
if (log.isDebugEnabled()) {
log.debug("[API-PORTAL] 包装异常堆栈", ex);
}
} else {
errorProcessor.applyUnexpectedException(context, ex);
accessLogger.onException(context, ex);
log.error("[API-PORTAL] 分发 apiCode={} version={} 时出现未预期异常", context.getApiCode(), context.getApiVersion(), ex);
}
return context;
responseContext = context;
} finally {
accessLogger.onResponse(context);
}
return responseContext;
}
/**
@@ -115,7 +126,7 @@ public class ApiGatewayExecutionService {
"version", reqVO.getVersion()
);
builder.setHeader(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriVariables);
builder.setHeader(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD, HttpMethod.POST.name());
builder.setHeader(org.springframework.integration.http.HttpHeaders.REQUEST_METHOD, HttpMethod.POST.name());
String basePath = normalizeBasePath(properties.getBasePath());
String rawQuery = buildQueryString(reqVO.getQueryParams());
@@ -125,10 +136,11 @@ public class ApiGatewayExecutionService {
}
builder.setHeader(HEADER_REQUEST_URI, requestUri);
builder.setHeader(org.springframework.integration.http.HttpHeaders.REQUEST_URL, requestUri);
builder.setHeader(HEADER_REMOTE_ADDRESS, LOCAL_DEBUG_REMOTE_ADDRESS);
Map<String, Object> requestHeaders = new LinkedHashMap<>();
if (reqVO.getHeaders() != null) {
reqVO.getHeaders().forEach(requestHeaders::put);
requestHeaders.putAll(reqVO.getHeaders());
}
normalizeJwtHeaders(requestHeaders, reqVO.getQueryParams());
requestHeaders.putIfAbsent(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);

View File

@@ -33,6 +33,7 @@ public class ApiGatewayRequestMapper {
private static final String HEADER_REQUEST_HEADERS = org.springframework.integration.http.HttpHeaders.PREFIX + "requestHeaders";
private static final String HEADER_REQUEST_URI = org.springframework.integration.http.HttpHeaders.PREFIX + "requestUri";
private static final String HEADER_REMOTE_ADDRESS = org.springframework.integration.http.HttpHeaders.PREFIX + "remoteAddress";
@SuppressWarnings("unchecked")
public ApiInvocationContext map(Object payload, Map<String, Object> headers) {
@@ -87,6 +88,8 @@ public class ApiGatewayRequestMapper {
context.getRequestHeaders().putIfAbsent(key, normalized);
}
});
context.setUserAgent(GatewayHeaderUtils.findFirstHeaderValue(context.getRequestHeaders(), HttpHeaders.USER_AGENT));
context.setClientIp(resolveClientIp(headers, context.getRequestHeaders()));
populateQueryParams(headers, context, originalRequestUri);
if (properties.isEnableTenantHeader()) {
Object tenantHeaderValue = context.getRequestHeaders().get(properties.getTenantHeader());
@@ -315,4 +318,26 @@ public class ApiGatewayRequestMapper {
}
return candidate;
}
private String resolveClientIp(Map<String, Object> headers, Map<String, Object> requestHeaders) {
String forwarded = GatewayHeaderUtils.findFirstHeaderValue(requestHeaders, "X-Forwarded-For");
if (StringUtils.hasText(forwarded)) {
int idx = forwarded.indexOf(',');
return idx >= 0 ? forwarded.substring(0, idx).trim() : forwarded;
}
String realIp = GatewayHeaderUtils.findFirstHeaderValue(requestHeaders, "X-Real-IP");
if (StringUtils.hasText(realIp)) {
return realIp;
}
Object remote = headers.get(HEADER_REMOTE_ADDRESS);
if (remote != null) {
String candidate = remote.toString();
int slash = candidate.indexOf('/') >= 0 ? candidate.indexOf('/') : candidate.indexOf(':');
if (slash > 0) {
candidate = candidate.substring(0, slash);
}
return candidate.trim();
}
return null;
}
}

View File

@@ -25,6 +25,10 @@ public class ApiInvocationContext {
private String tenantId;
private String clientIp;
private String userAgent;
private String httpMethod;
private String requestPath;
@@ -66,6 +70,8 @@ public class ApiInvocationContext {
copy.apiCode = this.apiCode;
copy.apiVersion = this.apiVersion;
copy.tenantId = this.tenantId;
copy.clientIp = this.clientIp;
copy.userAgent = this.userAgent;
copy.httpMethod = this.httpMethod;
copy.requestPath = this.requestPath;
copy.requestBody = this.requestBody;

View File

@@ -0,0 +1,42 @@
package com.zt.plat.module.databus.service.gateway;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
/**
* Databus API 访问日志 Service。
*/
public interface ApiAccessLogService {
/**
* 新增访问日志。
*
* @param logDO 日志信息
* @return 日志编号
*/
Long create(ApiAccessLogDO logDO);
/**
* 更新访问日志(仅更新非空字段)。
*
* @param logDO 日志信息
*/
void update(ApiAccessLogDO logDO);
/**
* 根据编号获取访问日志。
*
* @param id 日志编号
* @return 日志
*/
ApiAccessLogDO get(Long id);
/**
* 分页查询访问日志。
*
* @param pageReqVO 查询条件
* @return 日志分页
*/
PageResult<ApiAccessLogDO> getPage(ApiAccessLogPageReqVO pageReqVO);
}

View File

@@ -0,0 +1,65 @@
package com.zt.plat.module.databus.service.gateway;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionCompareRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiVersionDO;
import java.util.List;
/**
* API 版本历史 Service 接口(备忘录模式)。
*/
public interface ApiVersionService {
/**
* 捕获当前 API 配置快照并生成版本记录。
*
* @param apiId API ID
* @param description 版本描述(可为空)
* @param operator 操作人
* @return 版本 ID
*/
Long autoCreateVersion(Long apiId, String description, String operator);
/**
* 查询版本详情。
*
* @param id 版本 ID
* @return 版本信息
*/
ApiVersionDO getVersion(Long id);
/**
* 查询指定 API 的版本历史(分页)。
*
* @param pageReqVO 分页参数
* @return 版本列表
*/
PageResult<ApiVersionDO> getVersionPage(ApiVersionPageReqVO pageReqVO);
/**
* 查询指定 API 的全部版本历史(倒序)。
*
* @param apiId API ID
* @return 版本列表
*/
List<ApiVersionDO> getVersionListByApiId(Long apiId);
/**
* 回滚至指定版本。
*
* @param id 版本 ID
* @param remark 回滚说明(可为空)
*/
void rollbackToVersion(Long id, String remark);
/**
* 对比两个版本的差异。
*
* @param sourceVersionId 源版本 ID
* @param targetVersionId 目标版本 ID
* @return 差异结果
*/
ApiVersionCompareRespVO compareVersions(Long sourceVersionId, Long targetVersionId);
}

View File

@@ -0,0 +1,33 @@
package com.zt.plat.module.databus.service.gateway;
/**
* Thread-local context to control whether API definition updates should capture version snapshots.
*/
public final class ApiVersionSnapshotContextHolder {
private static final ThreadLocal<Boolean> SKIP_SNAPSHOT = new ThreadLocal<>();
private ApiVersionSnapshotContextHolder() {
}
/**
* Mark that the current thread should skip automatic version snapshot creation once.
*/
public static void markSkipOnce() {
SKIP_SNAPSHOT.set(Boolean.TRUE);
}
/**
* Determine whether the current thread requested to skip snapshot creation.
*/
public static boolean shouldSkip() {
return Boolean.TRUE.equals(SKIP_SNAPSHOT.get());
}
/**
* Clear the skip flag from the current thread.
*/
public static void clear() {
SKIP_SNAPSHOT.remove();
}
}

View File

@@ -0,0 +1,47 @@
package com.zt.plat.module.databus.service.gateway.impl;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.module.databus.controller.admin.gateway.vo.accesslog.ApiAccessLogPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiAccessLogDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiAccessLogMapper;
import com.zt.plat.module.databus.service.gateway.ApiAccessLogService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
/**
* Databus API 访问日志 Service 实现。
*/
@Service
@Validated
@Slf4j
public class ApiAccessLogServiceImpl implements ApiAccessLogService {
@Resource
private ApiAccessLogMapper apiAccessLogMapper;
@Override
public Long create(ApiAccessLogDO logDO) {
apiAccessLogMapper.insert(logDO);
return logDO.getId();
}
@Override
public void update(ApiAccessLogDO logDO) {
int rows = apiAccessLogMapper.updateById(logDO);
if (rows == 0 && log.isDebugEnabled()) {
log.debug("访问日志不存在无法更新。id={}", logDO.getId());
}
}
@Override
public ApiAccessLogDO get(Long id) {
return apiAccessLogMapper.selectById(id);
}
@Override
public PageResult<ApiAccessLogDO> getPage(ApiAccessLogPageReqVO pageReqVO) {
return apiAccessLogMapper.selectPage(pageReqVO);
}
}

View File

@@ -9,31 +9,27 @@ import com.github.benmanes.caffeine.cache.LoadingCache;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.common.util.object.BeanUtils;
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
import com.zt.plat.framework.tenant.core.context.TenantContextHolder;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionPageReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionStepSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionTransformSaveReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiFlowPublishDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiPolicyRateLimitDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiFlowPublishMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiPolicyRateLimitMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper;
import com.zt.plat.module.databus.dal.dataobject.gateway.*;
import com.zt.plat.module.databus.dal.mysql.gateway.*;
import com.zt.plat.module.databus.enums.gateway.ApiStatusEnum;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiDefinitionAggregate;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiFlowPublication;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiStepDefinition;
import com.zt.plat.module.databus.framework.integration.gateway.domain.ApiTransformDefinition;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import com.zt.plat.framework.tenant.core.db.TenantBaseDO;
import com.zt.plat.module.databus.service.gateway.ApiVersionService;
import com.zt.plat.module.databus.service.gateway.ApiVersionSnapshotContextHolder;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -61,6 +57,7 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
private final ApiFlowPublishMapper apiFlowPublishMapper;
private final ObjectMapper objectMapper;
private final StringRedisTemplate stringRedisTemplate;
private final ObjectProvider<ApiVersionService> apiVersionServiceProvider;
private LoadingCache<String, Optional<ApiDefinitionAggregate>> definitionCache;
@@ -133,6 +130,10 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
persistApiLevelTransforms(apiId, reqVO.getApiLevelTransforms());
persistSteps(apiId, reqVO.getSteps());
String operator = SecurityFrameworkUtils.getLoginUserNickname();
String description = String.format("创建 API (%s)", reqVO.getVersion());
apiVersionServiceProvider.getObject().autoCreateVersion(apiId, description, operator);
return apiId;
}
@@ -141,19 +142,35 @@ public class ApiDefinitionServiceImpl implements ApiDefinitionService {
public void update(ApiDefinitionSaveReqVO reqVO) {
ApiDefinitionDO existing = ensureExists(reqVO.getId());
validateDuplication(reqVO, existing.getId());
validateStructure(reqVO);
validatePolicies(reqVO);
boolean skipSnapshot = ApiVersionSnapshotContextHolder.shouldSkip();
ApiDefinitionDO updateObj = buildDefinitionDO(reqVO, existing);
apiDefinitionMapper.updateById(updateObj);
try {
validateDuplication(reqVO, existing.getId());
validateStructure(reqVO);
validatePolicies(reqVO);
invalidateCache(existing.getTenantId(), existing.getApiCode(), existing.getVersion());
apiTransformMapper.deleteByApiId(existing.getId());
apiStepMapper.deleteByApiId(existing.getId());
persistApiLevelTransforms(existing.getId(), reqVO.getApiLevelTransforms());
persistSteps(existing.getId(), reqVO.getSteps());
invalidateCache(updateObj.getTenantId(), updateObj.getApiCode(), updateObj.getVersion());
ApiDefinitionDO updateObj = buildDefinitionDO(reqVO, existing);
apiDefinitionMapper.updateById(updateObj);
invalidateCache(existing.getTenantId(), existing.getApiCode(), existing.getVersion());
apiTransformMapper.deleteByApiId(existing.getId());
apiStepMapper.deleteByApiId(existing.getId());
persistApiLevelTransforms(existing.getId(), reqVO.getApiLevelTransforms());
persistSteps(existing.getId(), reqVO.getSteps());
invalidateCache(updateObj.getTenantId(), updateObj.getApiCode(), updateObj.getVersion());
} finally {
if (skipSnapshot) {
ApiVersionSnapshotContextHolder.clear();
}
}
if (skipSnapshot) {
return;
}
String operator = SecurityFrameworkUtils.getLoginUserNickname();
String description = String.format("更新 API (%s)", reqVO.getVersion());
apiVersionServiceProvider.getObject().autoCreateVersion(existing.getId(), description, operator);
}
@Override

View File

@@ -0,0 +1,299 @@
package com.zt.plat.module.databus.service.gateway.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zt.plat.framework.common.exception.util.ServiceExceptionUtil;
import com.zt.plat.framework.common.pojo.PageResult;
import com.zt.plat.framework.security.core.util.SecurityFrameworkUtils;
import com.zt.plat.module.databus.controller.admin.gateway.convert.ApiDefinitionConvert;
import com.zt.plat.module.databus.controller.admin.gateway.vo.definition.ApiDefinitionSaveReqVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionCompareRespVO;
import com.zt.plat.module.databus.controller.admin.gateway.vo.version.ApiVersionPageReqVO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiDefinitionDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiStepDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiTransformDO;
import com.zt.plat.module.databus.dal.dataobject.gateway.ApiVersionDO;
import com.zt.plat.module.databus.dal.mapper.gateway.ApiVersionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiDefinitionMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiStepMapper;
import com.zt.plat.module.databus.dal.mysql.gateway.ApiTransformMapper;
import com.zt.plat.module.databus.service.gateway.ApiDefinitionService;
import com.zt.plat.module.databus.service.gateway.ApiVersionService;
import com.zt.plat.module.databus.service.gateway.ApiVersionSnapshotContextHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import static com.zt.plat.module.databus.service.gateway.impl.GatewayServiceErrorCodeConstants.*;
/**
* API 版本历史 Service 实现类(备忘录模式)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiVersionServiceImpl implements ApiVersionService {
private final ApiVersionMapper apiVersionMapper;
private final ApiDefinitionMapper apiDefinitionMapper;
private final ApiStepMapper apiStepMapper;
private final ApiTransformMapper apiTransformMapper;
private final ObjectMapper objectMapper;
private final ApiDefinitionService apiDefinitionService;
@Override
@Transactional(rollbackFor = Exception.class)
public Long autoCreateVersion(Long apiId, String description, String operator) {
ApiDefinitionDO definition = ensureApiExists(apiId);
ApiDefinitionSaveReqVO snapshot = buildApiSnapshot(definition);
String snapshotJson = serializeSnapshot(apiId, snapshot);
ApiVersionDO currentVersion = apiVersionMapper.selectCurrentByApiId(apiId);
if (currentVersion != null && snapshotEquals(currentVersion.getSnapshotData(), snapshotJson)) {
log.debug("[API-VERSION] Skip creating snapshot, apiId={} current version remains {}", apiId, currentVersion.getVersionNumber());
return currentVersion.getId();
}
Integer maxVersionNumber = apiVersionMapper.selectMaxVersionNumber(apiId);
int nextVersionNumber = (maxVersionNumber == null ? 0 : maxVersionNumber) + 1;
apiVersionMapper.markAllAsNotCurrent(apiId);
ApiVersionDO version = new ApiVersionDO();
version.setApiId(apiId);
version.setVersionNumber(nextVersionNumber);
version.setDescription(StringUtils.hasText(description) ? description : defaultDescription(nextVersionNumber));
version.setIsCurrent(Boolean.TRUE);
version.setOperator(StringUtils.hasText(operator) ? operator : "system");
version.setSnapshotData(snapshotJson);
apiVersionMapper.insert(version);
log.info("[API-VERSION] Created snapshot apiId={} version=v{} versionId={}", apiId, nextVersionNumber, version.getId());
return version.getId();
}
@Override
public ApiVersionDO getVersion(Long id) {
ApiVersionDO version = apiVersionMapper.selectById(id);
if (version == null) {
throw ServiceExceptionUtil.exception(API_VERSION_NOT_FOUND);
}
return version;
}
@Override
public PageResult<ApiVersionDO> getVersionPage(ApiVersionPageReqVO pageReqVO) {
return apiVersionMapper.selectPage(pageReqVO);
}
@Override
public List<ApiVersionDO> getVersionListByApiId(Long apiId) {
ensureApiExists(apiId);
return apiVersionMapper.selectListByApiId(apiId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void rollbackToVersion(Long id, String remark) {
ApiVersionDO targetVersion = getVersion(id);
ApiDefinitionSaveReqVO snapshot = deserializeSnapshot(targetVersion);
ensureApiExists(targetVersion.getApiId());
ApiVersionSnapshotContextHolder.markSkipOnce();
try {
apiDefinitionService.update(snapshot);
} finally {
ApiVersionSnapshotContextHolder.clear();
}
String operator = SecurityFrameworkUtils.getLoginUserNickname();
String description = StringUtils.hasText(remark)
? remark
: String.format("回滚到 v%d", targetVersion.getVersionNumber());
autoCreateVersion(targetVersion.getApiId(), description, operator);
log.info("[API-VERSION] Rolled back apiId={} to version v{} and created new snapshot", targetVersion.getApiId(), targetVersion.getVersionNumber());
}
@Override
public ApiVersionCompareRespVO compareVersions(Long sourceVersionId, Long targetVersionId) {
ApiVersionDO source = getVersion(sourceVersionId);
ApiVersionDO target = getVersion(targetVersionId);
if (!Objects.equals(source.getApiId(), target.getApiId())) {
throw ServiceExceptionUtil.exception(API_VERSION_API_MISMATCH);
}
ApiDefinitionSaveReqVO sourceSnapshot = deserializeSnapshot(source);
ApiDefinitionSaveReqVO targetSnapshot = deserializeSnapshot(target);
JsonNode sourceNode = readTree(source.getSnapshotData());
JsonNode targetNode = readTree(target.getSnapshotData());
List<ApiVersionCompareRespVO.FieldDiff> differences = new ArrayList<>();
collectDifferences(sourceNode, targetNode, "", differences);
ApiVersionCompareRespVO respVO = new ApiVersionCompareRespVO();
respVO.setSourceVersionId(source.getId());
respVO.setSourceVersionNumber(source.getVersionNumber());
respVO.setSourceDescription(source.getDescription());
respVO.setSourceOperator(source.getOperator());
respVO.setSourceCreateTime(source.getCreateTime());
respVO.setTargetVersionId(target.getId());
respVO.setTargetVersionNumber(target.getVersionNumber());
respVO.setTargetDescription(target.getDescription());
respVO.setTargetOperator(target.getOperator());
respVO.setTargetCreateTime(target.getCreateTime());
respVO.setSourceSnapshot(sourceSnapshot);
respVO.setTargetSnapshot(targetSnapshot);
respVO.setSame(differences.isEmpty());
respVO.setDifferences(differences);
return respVO;
}
private ApiDefinitionDO ensureApiExists(Long apiId) {
ApiDefinitionDO definition = apiDefinitionMapper.selectById(apiId);
if (definition == null) {
throw ServiceExceptionUtil.exception(API_DEFINITION_NOT_FOUND);
}
return definition;
}
private ApiDefinitionSaveReqVO buildApiSnapshot(ApiDefinitionDO definition) {
Long apiId = definition.getId();
ApiDefinitionSaveReqVO snapshot = new ApiDefinitionSaveReqVO();
snapshot.setId(definition.getId());
snapshot.setApiCode(definition.getApiCode());
snapshot.setVersion(definition.getVersion());
snapshot.setHttpMethod(definition.getHttpMethod());
snapshot.setStatus(definition.getStatus());
snapshot.setDescription(definition.getDescription());
snapshot.setRateLimitId(definition.getRateLimitId());
snapshot.setResponseTemplate(definition.getResponseTemplate());
List<ApiStepDO> steps = apiStepMapper.selectByApiId(apiId);
if (steps != null && !steps.isEmpty()) {
snapshot.setSteps(ApiDefinitionConvert.INSTANCE.convertStepList(steps));
}
List<ApiTransformDO> apiTransforms = apiTransformMapper.selectApiLevelTransforms(apiId);
if (apiTransforms != null && !apiTransforms.isEmpty()) {
snapshot.setApiLevelTransforms(ApiDefinitionConvert.INSTANCE.convertTransformList(apiTransforms));
}
return snapshot;
}
private String serializeSnapshot(Long apiId, ApiDefinitionSaveReqVO snapshot) {
try {
return objectMapper.writeValueAsString(snapshot);
} catch (JsonProcessingException ex) {
log.error("[API-VERSION] Failed to serialize snapshot, apiId={}", apiId, ex);
throw ServiceExceptionUtil.exception(API_VERSION_SNAPSHOT_SERIALIZE_FAILED);
}
}
private ApiDefinitionSaveReqVO deserializeSnapshot(ApiVersionDO version) {
try {
return objectMapper.readValue(version.getSnapshotData(), ApiDefinitionSaveReqVO.class);
} catch (JsonProcessingException ex) {
log.error("[API-VERSION] Failed to deserialize snapshot, versionId={}", version.getId(), ex);
throw ServiceExceptionUtil.exception(API_VERSION_SNAPSHOT_DESERIALIZE_FAILED);
}
}
private boolean snapshotEquals(String left, String right) {
if (Objects.equals(left, right)) {
return true;
}
if (left == null || right == null) {
return false;
}
try {
JsonNode leftNode = objectMapper.readTree(left);
JsonNode rightNode = objectMapper.readTree(right);
return leftNode.equals(rightNode);
} catch (JsonProcessingException ex) {
log.warn("[API-VERSION] Snapshot comparison failed, treat as different", ex);
return false;
}
}
private JsonNode readTree(String content) {
try {
return objectMapper.readTree(content);
} catch (JsonProcessingException ex) {
log.error("[API-VERSION] Failed to parse snapshot content", ex);
throw ServiceExceptionUtil.exception(API_VERSION_SNAPSHOT_DESERIALIZE_FAILED);
}
}
private void collectDifferences(JsonNode source, JsonNode target, String path, List<ApiVersionCompareRespVO.FieldDiff> differences) {
if (Objects.equals(source, target)) {
return;
}
if (source == null || source.isNull()) {
addDifference(path, null, target, differences);
return;
}
if (target == null || target.isNull()) {
addDifference(path, source, null, differences);
return;
}
if (source.isValueNode() && target.isValueNode()) {
if (!Objects.equals(source, target)) {
addDifference(path, source, target, differences);
}
return;
}
if (source.isObject() && target.isObject()) {
Set<String> fieldNames = new LinkedHashSet<>();
source.fieldNames().forEachRemaining(fieldNames::add);
target.fieldNames().forEachRemaining(fieldNames::add);
for (String name : fieldNames) {
String childPath = path + "/" + name;
collectDifferences(source.get(name), target.get(name), childPath, differences);
}
return;
}
if (source.isArray() && target.isArray()) {
int max = Math.max(source.size(), target.size());
for (int i = 0; i < max; i++) {
String childPath = path + "[" + i + "]";
JsonNode leftNode = i < source.size() ? source.get(i) : null;
JsonNode rightNode = i < target.size() ? target.get(i) : null;
collectDifferences(leftNode, rightNode, childPath, differences);
}
return;
}
addDifference(path, source, target, differences);
}
private void addDifference(String path, JsonNode source, JsonNode target, List<ApiVersionCompareRespVO.FieldDiff> differences) {
ApiVersionCompareRespVO.FieldDiff diff = new ApiVersionCompareRespVO.FieldDiff();
diff.setPath(StringUtils.hasText(path) ? path : "/");
diff.setSourceValue(source == null || source.isNull() ? "null" : source.toString());
diff.setTargetValue(target == null || target.isNull() ? "null" : target.toString());
differences.add(diff);
}
private String defaultDescription(int versionNumber) {
return String.format("自动版本 v%d", versionNumber);
}
}

View File

@@ -55,5 +55,11 @@ public interface GatewayServiceErrorCodeConstants {
ErrorCode API_STEP_MAPPING_CONFIG_INVALID = new ErrorCode(1_010_000_046, "步骤映射配置 JSON 非法");
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_REQUIRED = new ErrorCode(1_010_000_047, "启用匿名访问时必须指定固定用户");
ErrorCode API_CREDENTIAL_ANONYMOUS_USER_INVALID = new ErrorCode(1_010_000_048, "匿名访问固定用户不存在或已被禁用");
ErrorCode API_VERSION_NOT_FOUND = new ErrorCode(1_010_000_049, "API 版本不存在");
ErrorCode API_VERSION_DUPLICATE = new ErrorCode(1_010_000_050, "API 版本号已存在");
ErrorCode API_VERSION_SNAPSHOT_SERIALIZE_FAILED = new ErrorCode(1_010_000_051, "API 版本快照序列化失败");
ErrorCode API_VERSION_SNAPSHOT_DESERIALIZE_FAILED = new ErrorCode(1_010_000_052, "API 版本快照反序列化失败");
ErrorCode API_VERSION_ACTIVE_CANNOT_DELETE = new ErrorCode(1_010_000_053, "当前激活版本不允许删除");
ErrorCode API_VERSION_API_MISMATCH = new ErrorCode(1_010_000_054, "两个版本不属于同一 API");
}

View File

@@ -28,14 +28,15 @@ import java.util.UUID;
public final class DatabusApiInvocationExample {
public static final String TIMESTAMP = Long.toString(System.currentTimeMillis());
private static final String APP_ID = "ztmy";
private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
// private static final String APP_ID = "ztmy";
// private static final String APP_SECRET = "zFre/nTRGi7LpoFjN7oQkKeOT09x1fWTyIswrc702QQ=";
// private static final String APP_ID = "test";
// private static final String APP_SECRET = "RSYtKXrXPLMy3oeh0cOro6QCioRUgqfnKCkDkNq78sI=";
// private static final String APP_ID = "testAnnoy";
// private static final String APP_SECRET = "jyGCymUjCFL2i3a4Tm3qBIkUrUl4ZgKPYvOU/47ZWcM=";
private static final String APP_ID = "testAnnoy";
private static final String APP_SECRET = "jyGCymUjCFL2i3a4Tm3qBIkUrUl4ZgKPYvOU/47ZWcM=";
private static final String ENCRYPTION_TYPE = CryptoSignatureUtils.ENCRYPT_TYPE_AES;
private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/lgstOpenApi/v1";
// private static final String TARGET_API = "http://172.16.46.63:30081/admin-api/databus/api/portal/lgstOpenApi/v1";
private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/test/1";
// private static final String TARGET_API = "http://127.0.0.1:48080/admin-api/databus/api/portal/lgstOpenApi/v1";
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))

View File

@@ -1,12 +1,23 @@
package com.zt.plat.module.system.controller.admin.permission;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.pojo.CommonResult;
import com.zt.plat.framework.common.util.collection.CollectionUtils;
import com.zt.plat.module.system.controller.admin.permission.vo.permission.PermissionAssignRoleDataScopeReqVO;
import com.zt.plat.module.system.controller.admin.permission.vo.permission.PermissionAssignRoleMenuReqVO;
import com.zt.plat.module.system.controller.admin.permission.vo.permission.PermissionAssignUserRoleReqVO;
import com.zt.plat.module.system.controller.admin.permission.vo.permission.PermissionUserSupervisionRespVO;
import com.zt.plat.module.system.dal.dataobject.permission.MenuDO;
import com.zt.plat.module.system.dal.dataobject.permission.RoleDO;
import com.zt.plat.module.system.dal.dataobject.user.AdminUserDO;
import com.zt.plat.module.system.enums.permission.MenuTypeEnum;
import com.zt.plat.module.system.service.permission.MenuService;
import com.zt.plat.module.system.service.permission.PermissionService;
import com.zt.plat.module.system.service.permission.RoleService;
import com.zt.plat.module.system.service.tenant.TenantService;
import com.zt.plat.module.system.service.user.AdminUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -16,7 +27,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import static com.zt.plat.framework.common.pojo.CommonResult.success;
@@ -34,6 +46,12 @@ public class PermissionController {
private PermissionService permissionService;
@Resource
private TenantService tenantService;
@Resource
private AdminUserService userService;
@Resource
private RoleService roleService;
@Resource
private MenuService menuService;
@Operation(summary = "获得角色拥有的菜单编号")
@Parameter(name = "roleId", description = "角色编号", required = true)
@@ -79,4 +97,285 @@ public class PermissionController {
return success(true);
}
@Operation(summary = "获得用户权限监督图")
@GetMapping("/user-permission-supervision")
@PreAuthorize("@ss.hasPermission('system:permission:user-permission-supervision')")
public CommonResult<PermissionUserSupervisionRespVO> getUserPermissionSupervision(@RequestParam("userId") Long userId) {
AdminUserDO user = userService.getUser(userId);
if (user == null) {
return success(null);
}
PermissionUserSupervisionRespVO respVO = new PermissionUserSupervisionRespVO();
PermissionUserSupervisionRespVO.Node root = new PermissionUserSupervisionRespVO.Node();
root.setId("user-" + user.getId());
root.setLabel(Optional.ofNullable(user.getNickname()).map(nickname -> nickname + "(" + user.getUsername() + ")").orElse(user.getUsername()));
root.setType("user");
root.setStatus(user.getStatus());
respVO.setRoot(root);
Set<Long> assignedRoleIds = permissionService.getUserRoleIdListByUserId(userId);
if (CollUtil.isEmpty(assignedRoleIds)) {
root.setTotalPermissionCount(0);
root.setIncrementalPermissionCount(0);
return success(respVO);
}
Set<Long> allRoleIds = roleService.getAllParentAndSelfRoleIds(assignedRoleIds);
List<RoleDO> roles = roleService.getRoleList(allRoleIds);
if (CollUtil.isEmpty(roles)) {
root.setTotalPermissionCount(0);
root.setIncrementalPermissionCount(0);
return success(respVO);
}
Map<Long, RoleDO> roleMap = CollectionUtils.convertMap(roles, RoleDO::getId);
List<RoleDO> rootRoles = roles.stream()
.filter(role -> role.getParentId() == null || role.getParentId() <= 0 || !roleMap.containsKey(role.getParentId()))
.sorted(getRoleComparator())
.collect(Collectors.toList());
Map<Long, List<RoleDO>> childrenMap = new HashMap<>();
for (RoleDO role : roles) {
Long parentId = role.getParentId();
if (parentId != null && parentId > 0 && roleMap.containsKey(parentId)) {
childrenMap.computeIfAbsent(parentId, id -> new ArrayList<>()).add(role);
}
}
childrenMap.values().forEach(list -> list.sort(getRoleComparator()));
Map<Long, Set<Long>> roleMenuMap = new HashMap<>();
Set<Long> aggregatedMenuIds = new LinkedHashSet<>();
for (RoleDO role : roles) {
Set<Long> menuIds = new LinkedHashSet<>(permissionService.getRoleMenuListByRoleId(Collections.singleton(role.getId())));
roleMenuMap.put(role.getId(), menuIds);
aggregatedMenuIds.addAll(menuIds);
}
List<MenuDO> menuList = menuService.getMenuList(aggregatedMenuIds);
menuList.removeIf(menu -> !CommonStatusEnum.ENABLE.getStatus().equals(menu.getStatus()));
Map<Long, MenuDO> menuMap = CollectionUtils.convertMap(menuList, MenuDO::getId);
List<PermissionUserSupervisionRespVO.Node> roleNodes = rootRoles.stream()
.map(role -> buildRoleNode(role, childrenMap, roleMenuMap, menuMap, assignedRoleIds, new LinkedHashSet<>()))
.collect(Collectors.toList());
root.setChildren(roleNodes);
Set<Long> aggregatedEffectiveIds = aggregatedMenuIds.stream()
.map(menuMap::get)
.filter(Objects::nonNull)
.filter(menu -> StrUtil.isNotEmpty(menu.getPermission()))
.map(MenuDO::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
root.setTotalPermissionCount(aggregatedEffectiveIds.size());
root.setIncrementalPermissionCount(aggregatedEffectiveIds.size());
return success(respVO);
}
private PermissionUserSupervisionRespVO.Node buildRoleNode(RoleDO role,
Map<Long, List<RoleDO>> childrenMap,
Map<Long, Set<Long>> roleMenuMap,
Map<Long, MenuDO> menuMap,
Set<Long> assignedRoleIds,
Set<Long> inheritedMenuIds) {
PermissionUserSupervisionRespVO.Node node = new PermissionUserSupervisionRespVO.Node();
node.setId("role-" + role.getId());
node.setLabel(role.getName());
node.setType("role");
node.setStatus(role.getStatus());
node.setCode(role.getCode());
node.setAssigned(assignedRoleIds.contains(role.getId()));
Set<Long> roleMenuIds = new LinkedHashSet<>(roleMenuMap.getOrDefault(role.getId(), Collections.emptySet()));
Set<Long> effectiveRoleMenuIds = roleMenuIds.stream()
.map(menuMap::get)
.filter(Objects::nonNull)
.filter(menu -> StrUtil.isNotEmpty(menu.getPermission()))
.map(MenuDO::getId)
.collect(Collectors.toCollection(LinkedHashSet::new));
node.setTotalPermissionCount(effectiveRoleMenuIds.size());
Set<Long> incrementalMenuIds = new LinkedHashSet<>(effectiveRoleMenuIds);
incrementalMenuIds.removeAll(inheritedMenuIds);
node.setIncrementalPermissionCount(incrementalMenuIds.size());
Set<Long> nextInheritedMenuIds = new LinkedHashSet<>(inheritedMenuIds);
nextInheritedMenuIds.addAll(effectiveRoleMenuIds);
List<RoleDO> childRoles = childrenMap.getOrDefault(role.getId(), Collections.emptyList());
for (RoleDO childRole : childRoles) {
node.getChildren().add(buildRoleNode(childRole, childrenMap, roleMenuMap, menuMap, assignedRoleIds,
new LinkedHashSet<>(nextInheritedMenuIds)));
}
List<MenuDO> orphanButtons = new ArrayList<>();
List<PermissionUserSupervisionRespVO.Node> menuNodes = buildMenuChildren(role, incrementalMenuIds, menuMap, orphanButtons);
node.getChildren().addAll(menuNodes);
if (!orphanButtons.isEmpty()) {
orphanButtons.stream()
.sorted(getMenuComparator())
.map(button -> buildPermissionNode(role, button))
.forEach(node.getChildren()::add);
}
return node;
}
private Comparator<RoleDO> getRoleComparator() {
return Comparator
.comparing((RoleDO role) -> role.getSort() == null ? Integer.MAX_VALUE : role.getSort())
.thenComparing(RoleDO::getId);
}
private Comparator<MenuDO> getMenuComparator() {
return Comparator
.comparing((MenuDO menu) -> menu.getSort() == null ? Integer.MAX_VALUE : menu.getSort())
.thenComparing(MenuDO::getId);
}
private List<PermissionUserSupervisionRespVO.Node> buildMenuChildren(RoleDO role,
Set<Long> incrementalButtonIds,
Map<Long, MenuDO> menuMap,
List<MenuDO> orphanButtons) {
if (CollUtil.isEmpty(incrementalButtonIds)) {
return Collections.emptyList();
}
Map<Long, MenuTreeNode> menuNodeCache = new HashMap<>();
Map<Long, MenuTreeNode> rootMenuNodes = new LinkedHashMap<>();
for (Long buttonId : incrementalButtonIds) {
MenuDO button = menuMap.get(buttonId);
if (button == null) {
continue;
}
MenuDO parentMenu = findNearestNonButtonMenu(button, menuMap);
if (parentMenu == null) {
orphanButtons.add(button);
continue;
}
MenuTreeNode parentNode = ensureMenuPath(parentMenu, menuMap, menuNodeCache, rootMenuNodes);
if (parentNode == null) {
orphanButtons.add(button);
continue;
}
parentNode.getButtons().add(button);
}
return rootMenuNodes.values().stream()
.sorted((left, right) -> getMenuComparator().compare(left.getMenu(), right.getMenu()))
.map(menuTreeNode -> convertMenuTreeNode(role, menuTreeNode))
.collect(Collectors.toList());
}
private MenuTreeNode ensureMenuPath(MenuDO menu,
Map<Long, MenuDO> menuMap,
Map<Long, MenuTreeNode> menuNodeCache,
Map<Long, MenuTreeNode> rootMenuNodes) {
if (menu == null || MenuTypeEnum.BUTTON.getType().equals(menu.getType())) {
return null;
}
MenuTreeNode existing = menuNodeCache.get(menu.getId());
if (existing != null) {
return existing;
}
MenuTreeNode current = new MenuTreeNode(menu);
menuNodeCache.put(menu.getId(), current);
Long parentId = menu.getParentId();
if (parentId == null || parentId <= 0) {
rootMenuNodes.putIfAbsent(menu.getId(), current);
return current;
}
MenuDO parentMenu = menuMap.get(parentId);
MenuTreeNode parentNode = ensureMenuPath(parentMenu, menuMap, menuNodeCache, rootMenuNodes);
if (parentNode != null) {
parentNode.getChildren().putIfAbsent(menu.getId(), current);
} else {
rootMenuNodes.putIfAbsent(menu.getId(), current);
}
return current;
}
private MenuDO findNearestNonButtonMenu(MenuDO menu, Map<Long, MenuDO> menuMap) {
MenuDO current = menu;
while (current != null && MenuTypeEnum.BUTTON.getType().equals(current.getType())) {
current = menuMap.get(current.getParentId());
}
return current;
}
private PermissionUserSupervisionRespVO.Node convertMenuTreeNode(RoleDO role, MenuTreeNode treeNode) {
PermissionUserSupervisionRespVO.Node menuNode = new PermissionUserSupervisionRespVO.Node();
MenuDO menu = treeNode.getMenu();
menuNode.setId("menu-" + role.getId() + '-' + menu.getId());
menuNode.setLabel(menu.getName());
menuNode.setType("menu");
menuNode.setStatus(menu.getStatus());
List<PermissionUserSupervisionRespVO.Node> childMenuNodes = treeNode.getChildren().values().stream()
.sorted((left, right) -> getMenuComparator().compare(left.getMenu(), right.getMenu()))
.map(child -> convertMenuTreeNode(role, child))
.collect(Collectors.toList());
menuNode.getChildren().addAll(childMenuNodes);
List<PermissionUserSupervisionRespVO.Node> buttonNodes = treeNode.getButtons().stream()
.sorted(getMenuComparator())
.map(button -> buildPermissionNode(role, button))
.collect(Collectors.toList());
menuNode.getButtonChildren().addAll(buttonNodes);
int totalButtonCount = treeNode.getTotalButtonCount();
menuNode.setTotalPermissionCount(totalButtonCount);
menuNode.setIncrementalPermissionCount(totalButtonCount);
return menuNode;
}
private PermissionUserSupervisionRespVO.Node buildPermissionNode(RoleDO role, MenuDO button) {
PermissionUserSupervisionRespVO.Node permissionNode = new PermissionUserSupervisionRespVO.Node();
permissionNode.setId("permission-" + role.getId() + '-' + button.getId());
permissionNode.setLabel(button.getName());
permissionNode.setType("permission");
permissionNode.setStatus(button.getStatus());
permissionNode.setPermission(button.getPermission());
permissionNode.setTotalPermissionCount(1);
permissionNode.setIncrementalPermissionCount(1);
return permissionNode;
}
private static final class MenuTreeNode {
private final MenuDO menu;
private final LinkedHashMap<Long, MenuTreeNode> children = new LinkedHashMap<>();
private final List<MenuDO> buttons = new ArrayList<>();
private MenuTreeNode(MenuDO menu) {
this.menu = menu;
}
private MenuDO getMenu() {
return menu;
}
private LinkedHashMap<Long, MenuTreeNode> getChildren() {
return children;
}
private List<MenuDO> getButtons() {
return buttons;
}
private int getTotalButtonCount() {
int total = buttons.size();
for (MenuTreeNode child : children.values()) {
total += child.getTotalButtonCount();
}
return total;
}
}
}

View File

@@ -0,0 +1,41 @@
package com.zt.plat.module.system.controller.admin.permission.vo.permission;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 用户权限监督图响应 VO
*/
@Data
public class PermissionUserSupervisionRespVO {
private Node root;
@Data
public static class Node {
private String id;
private String label;
private String type;
private Integer status;
private Boolean assigned;
private String code;
private String permission;
private Integer totalPermissionCount;
private Integer incrementalPermissionCount;
private List<Node> children = new ArrayList<>();
private List<Node> buttonChildren = new ArrayList<>();
}
}

View File

@@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import java.util.List;
import static com.zt.plat.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@@ -38,4 +39,7 @@ public class UserPageReqVO extends PageParam {
@Schema(description = "角色编号", example = "1024")
private Long roleId;
@Schema(description = "用户编号集合", example = "[1, 2, 3]")
private List<Long> ids;
}

View File

@@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.zt.plat.framework.common.enums.CommonStatusEnum;
import com.zt.plat.framework.common.util.object.BeanUtils;
import com.zt.plat.module.system.controller.admin.permission.vo.menu.MenuListReqVO;
@@ -24,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import static com.zt.plat.framework.common.exception.util.ServiceExceptionUtil.exception;
import static com.zt.plat.framework.common.util.collection.CollectionUtils.convertList;
import static com.zt.plat.framework.common.util.collection.CollectionUtils.convertMap;
@@ -66,11 +68,13 @@ public class MenuServiceImpl implements MenuService {
}
@Override
@Transactional(rollbackFor = Exception.class)
@CacheEvict(value = RedisKeyConstants.PERMISSION_MENU_ID_LIST,
allEntries = true) // allEntries 清空所有缓存,因为 permission 如果变更,涉及到新老两个 permission。直接清理简单有效
public void updateMenu(MenuSaveVO updateReqVO) {
// 校验更新的菜单是否存在
if (menuMapper.selectById(updateReqVO.getId()) == null) {
MenuDO oldMenu = menuMapper.selectById(updateReqVO.getId());
if (oldMenu == null) {
throw exception(MENU_NOT_EXISTS);
}
// 校验父菜单存在
@@ -83,6 +87,12 @@ public class MenuServiceImpl implements MenuService {
MenuDO updateObj = BeanUtils.toBean(updateReqVO, MenuDO.class);
initMenuProperty(updateObj);
menuMapper.updateById(updateObj);
// 如果本次更新禁用了当前菜单,则联动禁用所有子菜单
if (!CommonStatusEnum.isDisable(oldMenu.getStatus())
&& CommonStatusEnum.isDisable(updateObj.getStatus())) {
cascadeDisableChildMenus(updateObj.getId());
}
}
@Override
@@ -190,6 +200,41 @@ public class MenuServiceImpl implements MenuService {
return menuMapper.selectBatchIds(ids);
}
// 禁用父级菜单时需级联禁用所有子菜单
private void cascadeDisableChildMenus(Long parentId) {
List<MenuDO> allMenus = menuMapper.selectList();
if (CollUtil.isEmpty(allMenus)) {
return;
}
Map<Long, List<MenuDO>> childrenMap = new HashMap<>();
for (MenuDO menu : allMenus) {
childrenMap.computeIfAbsent(menu.getParentId(), key -> new ArrayList<>()).add(menu);
}
Deque<Long> queue = new ArrayDeque<>();
queue.add(parentId);
List<Long> toDisableIds = new ArrayList<>();
while (!queue.isEmpty()) {
Long current = queue.poll();
List<MenuDO> children = childrenMap.get(current);
if (CollUtil.isEmpty(children)) {
continue;
}
for (MenuDO child : children) {
if (!CommonStatusEnum.isDisable(child.getStatus())) {
toDisableIds.add(child.getId());
}
queue.add(child.getId());
}
}
if (CollUtil.isEmpty(toDisableIds)) {
return;
}
menuMapper.update(null, new LambdaUpdateWrapper<MenuDO>()
.in(MenuDO::getId, toDisableIds)
.set(MenuDO::getStatus, CommonStatusEnum.DISABLE.getStatus()));
}
/**
* 校验父菜单是否合法
* <p>

View File

@@ -338,6 +338,18 @@ public class AdminUserServiceImpl implements AdminUserService {
Set<Long> userIds = reqVO.getRoleId() != null ?
permissionService.getUserRoleIdListByRoleId(singleton(reqVO.getRoleId())) : null;
if (CollUtil.isNotEmpty(reqVO.getIds())) {
Set<Long> idFilter = new HashSet<>(reqVO.getIds());
if (userIds == null) {
userIds = idFilter;
} else {
userIds.retainAll(idFilter);
}
if (CollUtil.isEmpty(userIds)) {
return PageResult.empty();
}
}
// 分页查询
PageResult<AdminUserDO> pageResult = userMapper.selectPage(reqVO, getDeptCondition(reqVO.getDeptId()), userIds);
fillUserDeptInfo(pageResult.getList());