1 parent
8ffeb59dd4
commit
51356c69a3
40 files changed
+2196
-29
No files matched your search
+24
-4
@@ -1,22 +1,42 @@
|
||||
# 本地环境变量模板(可复制到 .env 后填写,.env 已被 .gitignore 排除)
|
||||
# 说明:本文件只放占位符,禁止提交任何真实口令。
|
||||
# 公寓端本地运行环境变量模板(IT-0 / IT-1)
|
||||
# 用法:复制为 dev/.env(或 dev/scripts/.env)后填写真实值。
|
||||
# .env 已被 .gitignore 忽略,禁止提交;本模板不得写入任何真实口令。
|
||||
# 严禁把明文口令写入数据库配置文件、CI 脚本或提交记录。
|
||||
|
||||
# ---- MySQL(apt_rev01)----
|
||||
APT_DB_HOST=127.0.0.1
|
||||
APT_DB_PORT=3306
|
||||
APT_DB_NAME=apt_rev01
|
||||
APT_DB_USER=iotlocks
|
||||
APT_DB_PASSWORD=<从本机安全位置填入,勿提交>
|
||||
APT_DB_PASSWORD=<required: database account password>
|
||||
|
||||
# ---- Redis ----
|
||||
APT_REDIS_HOST=127.0.0.1
|
||||
APT_REDIS_PORT=6379
|
||||
APT_REDIS_DB=1
|
||||
APT_REDIS_PASSWORD=
|
||||
APT_REDIS_KEY_PREFIX=apt:rev01:
|
||||
|
||||
APT_TOKEN_SECRET=<32 位以上随机串,可用 openssl rand -hex 32 生成>
|
||||
# ---- JWT ----
|
||||
# TokenService.signWith(HS512, secret) requires a Base64-encoded key whose decoded
|
||||
# length is at least 64 bytes. Plain text / hex / placeholder text causes
|
||||
# ArrayIndexOutOfBoundsException on login.
|
||||
# PowerShell generation example:
|
||||
# $b = New-Object byte[] 64
|
||||
# (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($b)
|
||||
# [Convert]::ToBase64String($b)
|
||||
APT_TOKEN_SECRET=<required: Base64 HS512 key, decoded length >= 64 bytes>
|
||||
|
||||
# ---- Apartment identity (D5: one process, one gyid) ----
|
||||
APT_GY_ID=APT-REV01-001
|
||||
|
||||
# ---- Runtime ----
|
||||
APT_HTTP_PORT=8080
|
||||
APT_UPLOAD_PATH=./uploadPath
|
||||
APT_PROBE_ENABLED=true
|
||||
APT_PROBE_TIMEOUT_MS=2000
|
||||
|
||||
# ---- Druid console (disabled by default) ----
|
||||
APT_DRUID_CONSOLE=false
|
||||
APT_DRUID_USER=aptadmin
|
||||
APT_DRUID_PASSWORD=
|
||||
+12
@@ -87,6 +87,18 @@ public class AptConnectivityController
|
||||
return assemble(results, req.isStrict());
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /apt/ops/connectivity/error-probe —— 仅本机回环可用。
|
||||
* 故意抛出含内部细节的异常,用于验收表 7-3 M0「异常统一返回、无堆栈外泄」。
|
||||
*/
|
||||
@Anonymous
|
||||
@GetMapping("/error-probe")
|
||||
public AjaxResult errorProbe(HttpServletRequest request)
|
||||
{
|
||||
assertAllowed(request);
|
||||
throw new IllegalStateException("internal-detail-marker-do-not-leak");
|
||||
}
|
||||
|
||||
private AjaxResult assemble(List<ProbeResult> results, boolean strict)
|
||||
{
|
||||
boolean allOk = true;
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.apt.ops.controller;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import com.ruoyi.apt.master.mapper.AptRouteMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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;
|
||||
|
||||
/**
|
||||
* IT-1 / M0 业务表只读查询骨架:仅用于验证鉴权、gyid 隔离、分页上限与审计链路。
|
||||
* 按表 13-11「不含业务表 CRUD」,本类不提供新增 / 编辑 / 删除 / 状态命令接口。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/route", produces = "application/json;charset=UTF-8")
|
||||
public class AptRouteController extends AptBaseController
|
||||
{
|
||||
private final AptRouteMapper routeMapper;
|
||||
|
||||
@Autowired
|
||||
public AptRouteController(AptRouteMapper routeMapper)
|
||||
{
|
||||
this.routeMapper = routeMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /apt/route/list
|
||||
* 请求参数:cc、jlid、state、pageNum、pageSize、orderByColumn、isAsc
|
||||
* 返回:{code,msg,rows,total,pageSize,traceId};pageSize 上限 100。
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('apt:route:list')")
|
||||
@AptAudit(module = "ROUTE", action = "LIST")
|
||||
@GetMapping("/list")
|
||||
public AptTableDataInfo list(
|
||||
@RequestParam(required = false) String cc,
|
||||
@RequestParam(required = false) String jlid,
|
||||
@RequestParam(required = false) String state)
|
||||
{
|
||||
startAptPage();
|
||||
List<AptRoute> rows = routeMapper.selectRouteList(AptContext.getGyId(), cc, jlid, state);
|
||||
return getAptDataTable(rows);
|
||||
}
|
||||
}
|
||||
@@ -92,9 +92,18 @@ springdoc:
|
||||
tags-sorter: alpha
|
||||
group-configs:
|
||||
- group: 'apt'
|
||||
display-name: '公寓端基座'
|
||||
display-name: '公寓管理端 /apt'
|
||||
paths-to-match: '/apt/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
- group: 'duty'
|
||||
display-name: '值班台 /duty'
|
||||
paths-to-match: '/duty/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
- group: 'open'
|
||||
display-name: '设备侧 /open/v1'
|
||||
paths-to-match: '/open/v1/**'
|
||||
packages-to-scan: com.ruoyi.apt.**
|
||||
# /platform/** 属平台管理端,公寓端不提供,故不分组
|
||||
|
||||
referer:
|
||||
enabled: false
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.ruoyi.apt.common;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.page.AptPageSupport;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/** pageSize 上限 100(公寓端/04 §1.2、表 7-3 M1)。 */
|
||||
class AptPageSupportTest
|
||||
{
|
||||
@Test
|
||||
void pageSize1000IsCappedAt100()
|
||||
{
|
||||
assertEquals(100, AptPageSupport.normalize(1000, 100));
|
||||
assertEquals(100, AptPageSupport.normalize(101, 100));
|
||||
assertEquals(10, AptPageSupport.normalize(10, 100));
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredMaxCannotExceedHardLimit()
|
||||
{
|
||||
assertEquals(100, AptPageSupport.normalize(1000, 500));
|
||||
assertEquals(100, AptPageSupport.normalize(1000, 0));
|
||||
assertEquals(50, AptPageSupport.normalize(1000, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidPageSizeFallsBackToDefault()
|
||||
{
|
||||
assertEquals(10, AptPageSupport.normalize(0, 100));
|
||||
assertEquals(10, AptPageSupport.normalize(-5, 100));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startPageCapsOversizedRequestOnRealPageHelper()
|
||||
{
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("pageNum", "1");
|
||||
request.setParameter("pageSize", "1000");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
assertEquals(100, AptPageSupport.startPage(100));
|
||||
assertNotNull(PageHelper.getLocalPage());
|
||||
assertEquals(100, PageHelper.getLocalPage().getPageSize());
|
||||
assertEquals(100, AptContext.getPageSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startPageFallsBackToDefaultForZeroPageSize()
|
||||
{
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("pageSize", "0");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
assertEquals(10, AptPageSupport.startPage(100));
|
||||
assertEquals(10, PageHelper.getLocalPage().getPageSize());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearThreadLocals()
|
||||
{
|
||||
PageHelper.clearPage();
|
||||
AptContext.clear();
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.ruoyi.apt.common;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver.Prefix;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** 四前缀分区解析(IT-1 / M0)。 */
|
||||
class AptPrefixResolverTest
|
||||
{
|
||||
@Test
|
||||
void resolvesAllFourPrefixes()
|
||||
{
|
||||
assertEquals(Prefix.APT, AptPrefixResolver.resolve("/apt/route/list"));
|
||||
assertEquals(Prefix.DUTY, AptPrefixResolver.resolve("/duty/checkin/applications"));
|
||||
assertEquals(Prefix.OPEN, AptPrefixResolver.resolve("/open/v1/tablet/tasks"));
|
||||
assertEquals(Prefix.PLATFORM, AptPrefixResolver.resolve("/platform/v1/admin/stat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsAreNotManaged()
|
||||
{
|
||||
assertEquals(Prefix.OTHER, AptPrefixResolver.resolve("/system/user/list"));
|
||||
assertEquals(Prefix.OTHER, AptPrefixResolver.resolve("/login"));
|
||||
assertEquals(Prefix.OTHER, AptPrefixResolver.resolve(null));
|
||||
assertFalse(AptPrefixResolver.isAptManaged("/system/user/list"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionPrefixesAreLimitedToAptAndDuty()
|
||||
{
|
||||
assertTrue(AptPrefixResolver.requiresSession("/apt/route/list"));
|
||||
assertTrue(AptPrefixResolver.requiresSession("/duty/x"));
|
||||
assertFalse(AptPrefixResolver.requiresSession("/open/v1/x"));
|
||||
assertFalse(AptPrefixResolver.requiresSession("/platform/x"));
|
||||
assertTrue(AptPrefixResolver.isAptManaged("/platform/x"));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockingDetails;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import com.ruoyi.apt.framework.security.AptAccessDeniedHandler;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
/** 过滤器链 403:公寓端统一响应,非公寓端委托若依上游默认行为(IT-1 / M0)。 */
|
||||
class AptAccessDeniedHandlerTest
|
||||
{
|
||||
@Test
|
||||
void aptPrefixReturnsUnified403() throws Exception
|
||||
{
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
StringWriter body = new StringWriter();
|
||||
when(request.getRequestURI()).thenReturn("/apt/route/list");
|
||||
when(response.getWriter()).thenReturn(new PrintWriter(body));
|
||||
|
||||
new AptAccessDeniedHandler().handle(request, response, new AccessDeniedException("denied"));
|
||||
|
||||
verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
assertTrue(body.toString().contains("40301"));
|
||||
assertTrue(body.toString().contains("PERMISSION_DENIED"));
|
||||
assertTrue(body.toString().contains("traceId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAptPrefixDelegatesToUpstreamDefault() throws Exception
|
||||
{
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
when(request.getRequestURI()).thenReturn("/system/user/list");
|
||||
|
||||
new AptAccessDeniedHandler().handle(request, response, new AccessDeniedException("denied"));
|
||||
|
||||
verify(response, never()).getWriter();
|
||||
verify(response, never()).setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
boolean delegated = mockingDetails(response).getInvocations().stream()
|
||||
.anyMatch(invocation -> "sendError".equals(invocation.getMethod().getName()));
|
||||
assertTrue(delegated, "非公寓端 403 应委托 Spring Security 默认处理器");
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditAspect;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.ops.mapper.AptAuditMapper;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/** 审计切面落 duty_operation_log:字段完整、失败留痕、写库异常不影响业务(IT-1 / DV-1)。 */
|
||||
class AptAuditAspectTest
|
||||
{
|
||||
private AptAuditMapper mapper;
|
||||
private AptAuditAspect aspect;
|
||||
private ProceedingJoinPoint joinPoint;
|
||||
private AptAudit audit;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Throwable
|
||||
{
|
||||
mapper = mock(AptAuditMapper.class);
|
||||
aspect = new AptAuditAspect(mapper);
|
||||
joinPoint = mock(ProceedingJoinPoint.class);
|
||||
Signature signature = mock(Signature.class);
|
||||
when(signature.toShortString()).thenReturn("AptRouteController.list(..)");
|
||||
when(joinPoint.getSignature()).thenReturn(signature);
|
||||
audit = mock(AptAudit.class);
|
||||
when(audit.module()).thenReturn("ROUTE");
|
||||
when(audit.action()).thenReturn("LIST");
|
||||
when(audit.includeParams()).thenReturn(true);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
AptContext.initTraceId();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void successWritesOneCompleteAuditRow() throws Throwable
|
||||
{
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
assertEquals("ok", aspect.around(joinPoint, audit));
|
||||
|
||||
AptOperationLog row = capturedRow();
|
||||
assertEquals("APT-A", row.getGyId());
|
||||
assertEquals("ROUTE", row.getModule());
|
||||
assertEquals("LIST", row.getAction());
|
||||
assertEquals(AptContext.getTraceId(), row.getTraceId());
|
||||
assertNotNull(row.getLogId());
|
||||
assertNotNull(row.getOccurredAt());
|
||||
assertNotNull(row.getOperatorRef());
|
||||
assertTrue(row.getDetail().contains("SUCCESS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessFailureWritesFailedAuditAndRethrows() throws Throwable
|
||||
{
|
||||
IllegalStateException boom = new IllegalStateException("boom");
|
||||
when(joinPoint.proceed()).thenThrow(boom);
|
||||
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class,
|
||||
() -> aspect.around(joinPoint, audit));
|
||||
assertSame(boom, thrown);
|
||||
assertTrue(capturedRow().getDetail().contains("FAILED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditWriteFailureDoesNotAffectBusinessResult() throws Throwable
|
||||
{
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
doThrow(new RuntimeException("db down")).when(mapper)
|
||||
.insertOperationLog(any(AptOperationLog.class));
|
||||
assertEquals("ok", aspect.around(joinPoint, audit));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sensitiveParametersAreNotWrittenToDetail() throws Throwable
|
||||
{
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("cc", "G1");
|
||||
request.setParameter("password", "secret-value");
|
||||
request.setParameter("X-Token", "token-value");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
|
||||
aspect.around(joinPoint, audit);
|
||||
|
||||
String detail = capturedRow().getDetail();
|
||||
assertTrue(detail.contains("G1"));
|
||||
assertFalse(detail.contains("secret-value"));
|
||||
assertFalse(detail.contains("token-value"));
|
||||
}
|
||||
|
||||
private AptOperationLog capturedRow()
|
||||
{
|
||||
ArgumentCaptor<AptOperationLog> captor = ArgumentCaptor.forClass(AptOperationLog.class);
|
||||
verify(mapper).insertOperationLog(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.lang.reflect.Method;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.framework.web.AptContextInterceptor;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
/** 四前缀治理:会话、设备凭据、跨公寓拦截与平台前缀拒绝(表 7-3 M0)。 */
|
||||
class AptAuthInterceptorTest
|
||||
{
|
||||
private AptContextInterceptor interceptor;
|
||||
private HttpServletRequest request;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
AptProperties properties = new AptProperties();
|
||||
properties.setGyId("APT-A");
|
||||
interceptor = new AptContextInterceptor(properties);
|
||||
request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader("X-Gy-Id")).thenReturn(null);
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPrefixIsNotServed()
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/platform/v1/admin/stat");
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> interceptor.preHandle(request, null, null));
|
||||
assertEquals(40401, e.getErrorCode().getCode());
|
||||
assertEquals("PREFIX_NOT_SERVED", e.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openPrefixRequiresDeviceCredential()
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/open/v1/tablet/tasks");
|
||||
when(request.getHeader("X-Device-Code")).thenReturn(null);
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> interceptor.preHandle(request, null, null));
|
||||
assertEquals(40101, e.getErrorCode().getCode());
|
||||
assertEquals("DEVICE_CREDENTIAL_MISSING", e.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deviceGyidMismatchIsRejected()
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/open/v1/tablet/tasks");
|
||||
when(request.getHeader("X-Device-Code")).thenReturn("DEV-1");
|
||||
when(request.getParameter("gyid")).thenReturn("APT-B");
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> interceptor.preHandle(request, null, null));
|
||||
assertEquals(40303, e.getErrorCode().getCode());
|
||||
assertEquals("DEVICE_GYID_MISMATCH", e.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossApartmentReadIsRejectedWith40302() throws Exception
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/apt/route/list");
|
||||
when(request.getParameter("gyid")).thenReturn("APT-B");
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> interceptor.preHandle(request, null, anonymousHandler()));
|
||||
assertEquals(40302, e.getErrorCode().getCode());
|
||||
assertEquals("GY_ID_CROSS_SCOPE", e.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameApartmentPassesAndGyidComesFromConfigOnly() throws Exception
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/apt/route/list");
|
||||
when(request.getParameter("gyid")).thenReturn("APT-A");
|
||||
assertTrue(interceptor.preHandle(request, null, anonymousHandler()));
|
||||
assertEquals("APT-A", AptContext.getGyId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openPrefixWithDeviceCredentialPassesWithoutSession()
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/open/v1/tablet/tasks");
|
||||
when(request.getHeader("X-Device-Code")).thenReturn("DEV-1");
|
||||
assertTrue(interceptor.preHandle(request, null, null));
|
||||
assertEquals("APT-A", AptContext.getGyId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dutyPrefixRequiresSession()
|
||||
{
|
||||
when(request.getRequestURI()).thenReturn("/duty/checkin/applications");
|
||||
AptException e = assertThrows(AptException.class,
|
||||
() -> interceptor.preHandle(request, null, null));
|
||||
assertEquals(40101, e.getErrorCode().getCode());
|
||||
assertEquals("UNAUTHORIZED", e.getReason());
|
||||
}
|
||||
|
||||
private HandlerMethod anonymousHandler() throws Exception
|
||||
{
|
||||
AnonymousBean bean = new AnonymousBean();
|
||||
Method method = AnonymousBean.class.getMethod("probe");
|
||||
return new HandlerMethod(bean, method);
|
||||
}
|
||||
|
||||
public static class AnonymousBean
|
||||
{
|
||||
@Anonymous
|
||||
public void probe()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.framework.web.AptInfraExceptionHandler;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
/** 统一异常:40301 与 500 兜底,均带 traceId 且无堆栈 / 内部细节外泄(表 7-3 M0 第 4 项)。 */
|
||||
class AptErrorResponseTest
|
||||
{
|
||||
private final AptInfraExceptionHandler handler = new AptInfraExceptionHandler();
|
||||
|
||||
@Test
|
||||
void accessDeniedMapsTo40301()
|
||||
{
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getRequestURI()).thenReturn("/apt/route/list");
|
||||
AjaxResult result = handler.handleAccessDenied(new AccessDeniedException("denied"), request);
|
||||
assertError(result, 40301, "PERMISSION_DENIED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessDeniedOutsideAptPrefixKeepsRuoyiResponse()
|
||||
{
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getRequestURI()).thenReturn("/system/user/list");
|
||||
AjaxResult result = handler.handleAccessDenied(new AccessDeniedException("denied"), request);
|
||||
assertEquals(HttpStatus.FORBIDDEN, result.get(AjaxResult.CODE_TAG));
|
||||
assertEquals("没有权限,请联系管理员授权", result.get(AjaxResult.MSG_TAG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unexpectedExceptionDoesNotLeakInternalDetail()
|
||||
{
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getRequestURI()).thenReturn("/apt/route/list");
|
||||
AjaxResult result = handler.handleUnexpected(
|
||||
new IllegalStateException("internal-detail-marker-do-not-leak"), request);
|
||||
assertError(result, 500, "UNEXPECTED_ERROR");
|
||||
assertFalse(result.toString().contains("internal-detail-marker"),
|
||||
"异常响应不得回传内部异常消息");
|
||||
assertFalse(result.toString().contains("at com.ruoyi"), "异常响应不得回传堆栈");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void assertError(AjaxResult result, int code, String reason)
|
||||
{
|
||||
assertEquals(code, result.get(AjaxResult.CODE_TAG));
|
||||
Map<String, Object> data = (Map<String, Object>) result.get(AjaxResult.DATA_TAG);
|
||||
assertEquals(reason, data.get("reason"));
|
||||
assertNotNull(data.get("traceId"));
|
||||
}
|
||||
}
|
||||
+47
@@ -3,16 +3,23 @@ package com.ruoyi.apt.framework;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.framework.web.AptInfraExceptionHandler;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.QueryTimeoutException;
|
||||
import org.springframework.jdbc.CannotGetJdbcConnectionException;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/** 连接失败 / 超时 / 数据异常 -> 统一错误码映射的基线用例。 */
|
||||
class AptInfraExceptionHandlerTest
|
||||
@@ -50,6 +57,46 @@ class AptInfraExceptionHandlerTest
|
||||
assertError(result, 50304, "SCHEMA_MISMATCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aptExceptionSetsRealHttpStatusFromTable44()
|
||||
{
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse resp = new MockHttpServletResponse();
|
||||
req.setRequestURI("/apt/route/list");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(req, resp));
|
||||
|
||||
AjaxResult result = handler.handleAptException(
|
||||
new AptException(AptErrorCode.FORBIDDEN_GYID, "GY_ID_CROSS_SCOPE", "跨公寓访问被拒绝"), req);
|
||||
|
||||
assertEquals(40302, result.get(AjaxResult.CODE_TAG));
|
||||
assertEquals(403, resp.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceExceptionOutsideAptPrefixKeepsRuoyiCode()
|
||||
{
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getRequestURI()).thenReturn("/system/user");
|
||||
AjaxResult result = handler.handleServiceException(new ServiceException("业务校验失败", 602), req);
|
||||
assertEquals(602, result.get(AjaxResult.CODE_TAG));
|
||||
assertEquals("业务校验失败", result.get(AjaxResult.MSG_TAG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serviceExceptionInsideAptPrefixUsesUnifiedBody()
|
||||
{
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getRequestURI()).thenReturn("/apt/route/list");
|
||||
AjaxResult result = handler.handleServiceException(new ServiceException("内部细节", 602), req);
|
||||
assertError(result, 40001, "SERVICE_EXCEPTION");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearRequestAttributes()
|
||||
{
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void assertError(AjaxResult result, int code, String reason)
|
||||
{
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.apt.framework;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 架构测试(表 13-11 / DV-1):所有公寓端业务 Mapper 的 SQL 必须带 gyid,
|
||||
* 且只读列表必须按业务字段默认排序,禁止 ORDER BY id。
|
||||
*/
|
||||
class AptMapperGyidArchTest
|
||||
{
|
||||
private static final Pattern STATEMENT = Pattern.compile(
|
||||
"<(select|insert|update|delete)\\b[^>]*>([\\s\\S]*?)</\\1>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
@Test
|
||||
void everyAptMapperStatementCarriesGyid() throws IOException
|
||||
{
|
||||
Path dir = locateMapperDir();
|
||||
assertNotNull(dir, "未找到 ruoyi-system/src/main/resources/mapper/apt");
|
||||
List<Path> files;
|
||||
try (java.util.stream.Stream<Path> stream = Files.list(dir))
|
||||
{
|
||||
files = stream.filter(p -> p.toString().endsWith("Mapper.xml")).collect(Collectors.toList());
|
||||
}
|
||||
assertFalse(files.isEmpty(), "mapper/apt 下应有 Mapper XML");
|
||||
|
||||
int total = 0;
|
||||
for (Path file : files)
|
||||
{
|
||||
String xml = Files.readString(file, StandardCharsets.UTF_8);
|
||||
Matcher matcher = STATEMENT.matcher(xml);
|
||||
while (matcher.find())
|
||||
{
|
||||
String sql = matcher.group(2).toLowerCase();
|
||||
assertTrue(sql.contains("gyid"),
|
||||
file.getFileName() + " 的 " + matcher.group(1) + " 语句缺少 gyid 隔离条件");
|
||||
total++;
|
||||
}
|
||||
}
|
||||
assertTrue(total >= 2, "至少应扫描到只读列表与审计写入两条 SQL,实际 " + total);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeListDoesNotDefaultToIdOrdering() throws IOException
|
||||
{
|
||||
Path dir = locateMapperDir();
|
||||
assertNotNull(dir);
|
||||
String xml = Files.readString(dir.resolve("AptRouteMapper.xml"), StandardCharsets.UTF_8);
|
||||
assertTrue(xml.contains("ORDER BY state DESC, kcsj ASC, cc ASC"),
|
||||
"列表须按业务字段默认排序(公寓端/04 §1.2)");
|
||||
assertFalse(xml.toLowerCase().contains("order by id"),
|
||||
"禁止默认按 id 排序");
|
||||
assertTrue(xml.contains("deleted = 0"), "列表须过滤逻辑删除");
|
||||
}
|
||||
|
||||
private Path locateMapperDir()
|
||||
{
|
||||
Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
|
||||
int depth = 0;
|
||||
while (current != null && depth < 6)
|
||||
{
|
||||
Path candidate = current.resolve("ruoyi-system/src/main/resources/mapper/apt");
|
||||
if (Files.isDirectory(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
current = current.getParent();
|
||||
depth++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -71,7 +71,7 @@ class AptConnectivityControllerTest
|
||||
mockMvc.perform(post("/apt/ops/connectivity/probe")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"target\":\"UNKNOWN\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(status().is(400))
|
||||
.andExpect(jsonPath("$.code").value(40001))
|
||||
.andExpect(jsonPath("$.data.reason").value("INVALID_TARGET"));
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class AptConnectivityControllerTest
|
||||
mockMvc.perform(post("/apt/ops/connectivity/probe")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"target\":\"DB\",\"strict\":true}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(status().is(503))
|
||||
.andExpect(jsonPath("$.code").value(50301))
|
||||
.andExpect(jsonPath("$.data.reason").value("DB_CONNECT_FAILED"));
|
||||
}
|
||||
@@ -109,7 +109,7 @@ class AptConnectivityControllerTest
|
||||
{
|
||||
mockMvc.perform(get("/apt/ops/connectivity")
|
||||
.with(req -> { req.setRemoteAddr("10.0.0.9"); return req; }))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(status().is(403))
|
||||
.andExpect(jsonPath("$.code").value(40301))
|
||||
.andExpect(jsonPath("$.data.reason").value("NON_LOOPBACK_FORBIDDEN"));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public final class AptContext
|
||||
{
|
||||
private static final ThreadLocal<String> GY_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> PAGE_SIZE = new ThreadLocal<>();
|
||||
|
||||
private AptContext()
|
||||
{
|
||||
@@ -57,9 +58,21 @@ public final class AptContext
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 归一化后的每页条数(IT-1 分页上限) */
|
||||
public static void setPageSize(int pageSize)
|
||||
{
|
||||
PAGE_SIZE.set(pageSize);
|
||||
}
|
||||
|
||||
public static Integer getPageSize()
|
||||
{
|
||||
return PAGE_SIZE.get();
|
||||
}
|
||||
|
||||
public static void clear()
|
||||
{
|
||||
GY_ID.remove();
|
||||
TRACE_ID.remove();
|
||||
PAGE_SIZE.remove();
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -12,13 +12,30 @@ public enum AptErrorCode
|
||||
{
|
||||
SUCCESS(200, "success"),
|
||||
BAD_REQUEST(40001, "参数错误"),
|
||||
UNAUTHORIZED(40101, "未登录"),
|
||||
FORBIDDEN_PERM(40301, "缺少权限点"),
|
||||
FORBIDDEN_GYID(40302, "gyid 越界或缺失"),
|
||||
NOT_FOUND(40401, "资源不存在"),
|
||||
UNAUTHORIZED(40101, "未登录或令牌失效"),
|
||||
FORBIDDEN_PERM(40301, "无权限点"),
|
||||
FORBIDDEN_GYID(40302, "数据域不匹配"),
|
||||
FORBIDDEN_DEVICE_GYID(40303, "设备与公寓不匹配"),
|
||||
NOT_FOUND(40401, "对象不存在"),
|
||||
DUPLICATE_KEY(40901, "业务键重复"),
|
||||
STATE_ILLEGAL(40902, "状态不允许该操作"),
|
||||
SNAPSHOT_EXPIRED(40903, "快照版本过期"),
|
||||
SNAPSHOT_READONLY(40904, "快照只读,禁止本地改"),
|
||||
SYNC_PENDING(40905, "资源未同步完成"),
|
||||
EXTENSION_IMMUTABLE(40906, "分机号不可改"),
|
||||
COMMAND_DUPLICATE(40907, "命令重复提交"),
|
||||
CONF_BUSY(40908, "会议桥被占用"),
|
||||
OFFLINE_QUOTA_EXCEEDED(40913, "超出离线额度"),
|
||||
FACE_REG_PENDING(40914, "注册申请未获值班员确认"),
|
||||
TRANSFER_TIME_ILLEGAL(40915, "换乘时刻非法"),
|
||||
TABLET_TASK_EXPIRED(40916, "平板任务已派发或过期"),
|
||||
FEATURE_DISABLED(40940, "能力未启用"),
|
||||
RECORDING_PURGED(41001, "录音已清理"),
|
||||
PROTOCOL_NOT_CONFIRMED(50101, "现场协议未确认,暂不支持"),
|
||||
NOT_SUPPORTED(50102, "边界外能力"),
|
||||
DEPENDENCY_UNAVAILABLE(50301, "依赖不可用"),
|
||||
/** 数据异常:完整性约束、读写结果不符、序列化失败 */
|
||||
REPORT_RETRY_BUSY(50302, "同一续报批次进行中"),
|
||||
/** 数据异常:完整性约束、读写结果不符、序列化失败;IT-0 在 503xx 段内新增,待冻结评审登记 */
|
||||
DATA_ANOMALY(50304, "数据异常"),
|
||||
INTERNAL_ERROR(500, "内部错误");
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.apt.common;
|
||||
|
||||
/**
|
||||
* 四前缀分区解析器(公寓端/04 §1、CR-02 §15)。
|
||||
*
|
||||
* <p>同一个进程只服务一个 gyid(D5)。分区只用于决定「gyid 从哪来、
|
||||
* 是否需要会话、是否放行」,不用于路由到不同数据源。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public final class AptPrefixResolver
|
||||
{
|
||||
public enum Prefix
|
||||
{
|
||||
/** 公寓管理端:需登录 + 权限点 */
|
||||
APT,
|
||||
/** 值班台:需登录 + 权限点 */
|
||||
DUTY,
|
||||
/** 设备侧:匿名分区 + 设备凭据(HMAC 完整校验在 IT-3 落地) */
|
||||
OPEN,
|
||||
/** 平台管理端:本端不提供,显式拒绝 */
|
||||
PLATFORM,
|
||||
/** 若依自身 / 其他路径:不纳入公寓端治理 */
|
||||
OTHER
|
||||
}
|
||||
|
||||
private AptPrefixResolver()
|
||||
{
|
||||
}
|
||||
|
||||
public static Prefix resolve(String uri)
|
||||
{
|
||||
if (uri == null)
|
||||
{
|
||||
return Prefix.OTHER;
|
||||
}
|
||||
if (uri.startsWith("/open/v1/") || "/open/v1".equals(uri))
|
||||
{
|
||||
return Prefix.OPEN;
|
||||
}
|
||||
if (uri.startsWith("/platform/") || "/platform".equals(uri))
|
||||
{
|
||||
return Prefix.PLATFORM;
|
||||
}
|
||||
if (uri.startsWith("/duty/") || "/duty".equals(uri))
|
||||
{
|
||||
return Prefix.DUTY;
|
||||
}
|
||||
if (uri.startsWith("/apt/") || "/apt".equals(uri))
|
||||
{
|
||||
return Prefix.APT;
|
||||
}
|
||||
return Prefix.OTHER;
|
||||
}
|
||||
|
||||
public static boolean isAptManaged(String uri)
|
||||
{
|
||||
return resolve(uri) != Prefix.OTHER;
|
||||
}
|
||||
|
||||
/** 需要登录态的两个前缀 */
|
||||
public static boolean requiresSession(String uri)
|
||||
{
|
||||
Prefix prefix = resolve(uri);
|
||||
return prefix == Prefix.APT || prefix == Prefix.DUTY;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ruoyi.apt.common.audit;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 公寓端审计注解:落 duty_operation_log(公寓端/03 §5、表 7-3 M0/M1)。
|
||||
*
|
||||
* <p>与若依 @Log 落 sys_oper_log 并行,不修改上游日志表;
|
||||
* 本注解保留 gyid、traceId、detail JSON(Q-40 冻结前的兜底载体)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface AptAudit
|
||||
{
|
||||
/** 业务域,如 ROUTE / ROOM / CHECKIN */
|
||||
String module();
|
||||
|
||||
/** 动作,如 LIST / STATUS / REVIEW */
|
||||
String action();
|
||||
|
||||
/** 是否把请求参数写入 detail(写操作建议 true) */
|
||||
boolean includeParams() default true;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.ruoyi.apt.common.page;
|
||||
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.common.core.page.PageDomain;
|
||||
import com.ruoyi.common.core.page.TableSupport;
|
||||
import com.ruoyi.common.utils.sql.SqlUtil;
|
||||
|
||||
/**
|
||||
* 公寓端分页封装:强制 pageSize 上限 100(公寓端/04 §1.2、表 7-3 M1)。
|
||||
*
|
||||
* <p>若依原生 PageUtils.startPage() 不设上限,客户端可传 pageSize=1000
|
||||
* 拖垮数据库;本类在调用 PageHelper 之前先做归一化,并把结果写入
|
||||
* AptContext 供列表响应回显,便于验收复现。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public final class AptPageSupport
|
||||
{
|
||||
/** 设计锁定上限,配置值不得高于此值 */
|
||||
public static final int HARD_MAX_SIZE = 100;
|
||||
public static final int DEFAULT_PAGE_SIZE = 10;
|
||||
public static final int DEFAULT_PAGE_NUM = 1;
|
||||
|
||||
private AptPageSupport()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param configuredMax 配置上限(apt.page.max-size),会被 HARD_MAX_SIZE 再次压制
|
||||
* @return 归一化后实际生效的 pageSize
|
||||
*/
|
||||
public static int startPage(int configuredMax)
|
||||
{
|
||||
PageDomain domain = TableSupport.buildPageRequest();
|
||||
int pageNum = domain.getPageNum() == null ? DEFAULT_PAGE_NUM : domain.getPageNum();
|
||||
int pageSize = domain.getPageSize() == null ? DEFAULT_PAGE_SIZE : domain.getPageSize();
|
||||
if (pageNum <= 0)
|
||||
{
|
||||
pageNum = DEFAULT_PAGE_NUM;
|
||||
}
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
pageSize = DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
int cap = configuredMax <= 0 ? HARD_MAX_SIZE : Math.min(configuredMax, HARD_MAX_SIZE);
|
||||
pageSize = Math.min(pageSize, cap);
|
||||
String orderBy = SqlUtil.escapeOrderBySql(domain.getOrderBy());
|
||||
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(domain.getReasonable());
|
||||
AptContext.setPageSize(pageSize);
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
/** 不触发 PageHelper,仅做上限归一化,供单元测试与非列表场景复用 */
|
||||
public static int normalize(int requested, int configuredMax)
|
||||
{
|
||||
int cap = configuredMax <= 0 ? HARD_MAX_SIZE : Math.min(configuredMax, HARD_MAX_SIZE);
|
||||
if (requested <= 0)
|
||||
{
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(requested, cap);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.ruoyi.apt.common.page;
|
||||
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
|
||||
/**
|
||||
* 在若依 TableDataInfo 的 {code,msg,rows,total} 之上追加
|
||||
* 归一化 pageSize 与 traceId(追加字段,不破坏既有约定 公寓端/04 §1.2)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptTableDataInfo extends TableDataInfo
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Integer pageSize;
|
||||
private String traceId;
|
||||
|
||||
public Integer getPageSize()
|
||||
{
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
public void setPageSize(Integer pageSize)
|
||||
{
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
public String getTraceId()
|
||||
{
|
||||
return traceId;
|
||||
}
|
||||
|
||||
public void setTraceId(String traceId)
|
||||
{
|
||||
this.traceId = traceId;
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.ruoyi.apt.framework.audit;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.ops.mapper.AptAuditMapper;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/**
|
||||
* 审计切面:把带 @AptAudit 的操作落到 duty_operation_log。
|
||||
*
|
||||
* <p>与若依 @Log(落 sys_oper_log)并行,不改上游表;日志写入失败只记 WARN,
|
||||
* 绝不反向影响业务结果。detail 用 JSON 承载 Q-40 冻结前的扩展要素。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
public class AptAuditAspect
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptAuditAspect.class);
|
||||
|
||||
private final AptAuditMapper auditMapper;
|
||||
|
||||
public AptAuditAspect(AptAuditMapper auditMapper)
|
||||
{
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Around("@annotation(aptAudit)")
|
||||
public Object around(ProceedingJoinPoint joinPoint, AptAudit aptAudit) throws Throwable
|
||||
{
|
||||
long start = System.currentTimeMillis();
|
||||
String status = "SUCCESS";
|
||||
String error = null;
|
||||
try
|
||||
{
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
catch (Throwable t)
|
||||
{
|
||||
status = "FAILED";
|
||||
error = t.getClass().getSimpleName();
|
||||
throw t;
|
||||
}
|
||||
finally
|
||||
{
|
||||
writeQuietly(joinPoint, aptAudit, status, error, System.currentTimeMillis() - start);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeQuietly(ProceedingJoinPoint joinPoint, AptAudit aptAudit,
|
||||
String status, String error, long costMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("module", aptAudit.module());
|
||||
detail.put("action", aptAudit.action());
|
||||
detail.put("method", joinPoint.getSignature().toShortString());
|
||||
detail.put("status", status);
|
||||
detail.put("costMs", costMs);
|
||||
detail.put("traceId", AptContext.getTraceId());
|
||||
if (error != null)
|
||||
{
|
||||
detail.put("error", error);
|
||||
}
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes != null)
|
||||
{
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
detail.put("uri", request.getRequestURI());
|
||||
detail.put("httpMethod", request.getMethod());
|
||||
if (aptAudit.includeParams())
|
||||
{
|
||||
Map<String, Object> params = new LinkedHashMap<>();
|
||||
request.getParameterMap().forEach((key, values) -> {
|
||||
if (!isSensitive(key))
|
||||
{
|
||||
params.put(key, values.length == 1 ? values[0] : values);
|
||||
}
|
||||
});
|
||||
detail.put("params", params);
|
||||
}
|
||||
}
|
||||
|
||||
AptOperationLog entry = new AptOperationLog();
|
||||
entry.setLogId(UUID.randomUUID().toString().replace("-", ""));
|
||||
String gyId = AptContext.getGyIdOrNull();
|
||||
entry.setGyId(gyId == null ? "UNKNOWN" : gyId);
|
||||
entry.setOperatorRef(operatorRef());
|
||||
entry.setModule(aptAudit.module());
|
||||
entry.setAction(aptAudit.action());
|
||||
entry.setDetail(JSON.toJSONString(detail));
|
||||
entry.setTraceId(AptContext.getTraceId());
|
||||
entry.setOccurredAt(new Date());
|
||||
auditMapper.insertOperationLog(entry);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("apt audit write failed, traceId={}, cause={}",
|
||||
AptContext.getTraceId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSensitive(String key)
|
||||
{
|
||||
String lower = key.toLowerCase();
|
||||
return lower.contains("password") || lower.contains("secret")
|
||||
|| lower.contains("token") || lower.contains("credential");
|
||||
}
|
||||
|
||||
private String operatorRef()
|
||||
{
|
||||
try
|
||||
{
|
||||
return SecurityUtils.getLoginUser().getUsername();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return "ANONYMOUS";
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.ruoyi.apt.framework.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 过滤器链层的 403 处理:公寓端四前缀返回 HTTP 403 + code 40301 + reason + traceId。
|
||||
* 方法级 @PreAuthorize 抛出的 AccessDeniedException 由
|
||||
* AptInfraExceptionHandler#handleAccessDenied 统一映射,两处码值一致。
|
||||
*
|
||||
* <p>非公寓端路径(若依后台 /system/** 等)委托 Spring Security 默认处理器,
|
||||
* 与上游未注册 accessDeniedHandler 时的行为保持一致,不改变若依原有响应。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AptAccessDeniedHandler implements AccessDeniedHandler
|
||||
{
|
||||
/** 若依上游未注册 accessDeniedHandler,默认即 AccessDeniedHandlerImpl */
|
||||
private final AccessDeniedHandler delegate = new AccessDeniedHandlerImpl();
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException, ServletException
|
||||
{
|
||||
if (!AptPrefixResolver.isAptManaged(request.getRequestURI()))
|
||||
{
|
||||
delegate.handle(request, response, accessDeniedException);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("code", 40301);
|
||||
body.put("msg", "无权限点");
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reason", "PERMISSION_DENIED");
|
||||
data.put("traceId", UUID.randomUUID().toString().replace("-", ""));
|
||||
data.put("path", request.getRequestURI());
|
||||
body.put("data", data);
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(JSON.toJSONString(body));
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.ruoyi.apt.framework.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver;
|
||||
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 公寓端四前缀的 401 处理:返回真实 HTTP 401 + code 40101 + reason + traceId。
|
||||
* 若依默认 AuthenticationEntryPointImpl 经 ServletUtils.renderString 会把 HTTP 状态
|
||||
* 强制写成 200,无法满足表 7-3 M0「无令牌 /apt/route/list -> 401」;
|
||||
* 非公寓端路径仍委托给若依原实现,保持后台管理行为不变。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
public class AptAuthenticationEntryPoint implements AuthenticationEntryPoint
|
||||
{
|
||||
private final AuthenticationEntryPointImpl delegate;
|
||||
|
||||
public AptAuthenticationEntryPoint(AuthenticationEntryPointImpl delegate)
|
||||
{
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException
|
||||
{
|
||||
String uri = request.getRequestURI();
|
||||
if (!AptPrefixResolver.isAptManaged(uri))
|
||||
{
|
||||
delegate.commence(request, response, authException);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("code", 40101);
|
||||
body.put("msg", "未登录或令牌失效");
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reason", "UNAUTHORIZED");
|
||||
data.put("traceId", UUID.randomUUID().toString().replace("-", ""));
|
||||
data.put("path", uri);
|
||||
body.put("data", data);
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(JSON.toJSONString(body));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.apt.framework.web;
|
||||
|
||||
import java.util.List;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.page.AptPageSupport;
|
||||
import com.ruoyi.apt.common.page.AptTableDataInfo;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
/**
|
||||
* 公寓端 Controller 基类:统一分页上限与列表响应(IT-1 / DV-1)。
|
||||
* 后续每个业务域只写查询与 DTO,不再重复分页逻辑。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public abstract class AptBaseController extends BaseController
|
||||
{
|
||||
@Autowired
|
||||
protected AptProperties aptProperties;
|
||||
|
||||
/** 归一化后启动分页,返回实际生效的 pageSize */
|
||||
protected int startAptPage()
|
||||
{
|
||||
return AptPageSupport.startPage(aptProperties.getPage().getMaxSize());
|
||||
}
|
||||
|
||||
/** 组装 {code,msg,rows,total,pageSize,traceId} */
|
||||
protected AptTableDataInfo getAptDataTable(List<?> list)
|
||||
{
|
||||
AptTableDataInfo data = new AptTableDataInfo();
|
||||
data.setCode(200);
|
||||
data.setMsg("查询成功");
|
||||
data.setRows(list);
|
||||
data.setTotal(new PageInfo(list).getTotal());
|
||||
data.setPageSize(AptContext.getPageSize());
|
||||
data.setTraceId(AptContext.getTraceId());
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+101
-2
@@ -1,15 +1,29 @@
|
||||
package com.ruoyi.apt.framework.web;
|
||||
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver.Prefix;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.common.annotation.Anonymous;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
/**
|
||||
* 把部署环境注入的 gyid 与 traceId 放入线程上下文。
|
||||
* 仅作用于 /apt/**,且只从配置读取,绝不采信请求参数。
|
||||
* 四前缀治理拦截器(IT-1 / M0)。
|
||||
*
|
||||
* <p>D5「一进程一 gyid」:gyid 只从部署环境注入,绝不采信客户端参数;
|
||||
* 请求中一旦出现与上下文不一致的 gyid,立即拒绝,避免跨公寓数据泄露。
|
||||
* 分区策略:
|
||||
* /apt/**、/duty/** -> 需登录态(@Anonymous 例外)
|
||||
* /open/v1/** -> 设备凭据骨架(HMAC / 时间窗 / nonce 在 IT-3 落地)
|
||||
* /platform/** -> 本端不提供,显式 40401
|
||||
* 其余路径不在注册范围内。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@@ -27,7 +41,31 @@ public class AptContextInterceptor implements HandlerInterceptor
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
{
|
||||
AptContext.initTraceId();
|
||||
String uri = request.getRequestURI();
|
||||
Prefix prefix = AptPrefixResolver.resolve(uri);
|
||||
boolean anonymous = isAnonymous(handler);
|
||||
|
||||
switch (prefix)
|
||||
{
|
||||
case PLATFORM:
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "PREFIX_NOT_SERVED",
|
||||
"平台管理端接口不在公寓端提供");
|
||||
case OPEN:
|
||||
assertDeviceCredential(request);
|
||||
break;
|
||||
case APT:
|
||||
case DUTY:
|
||||
if (!anonymous)
|
||||
{
|
||||
assertSession();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
AptContext.setGyId(properties.getGyId());
|
||||
assertNoGyidOverride(request, prefix);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -37,4 +75,65 @@ public class AptContextInterceptor implements HandlerInterceptor
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
private void assertSession()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (SecurityUtils.getLoginUser() == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "UNAUTHORIZED", "未登录或令牌失效");
|
||||
}
|
||||
}
|
||||
catch (AptException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "UNAUTHORIZED", "未登录或令牌失效", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertDeviceCredential(HttpServletRequest request)
|
||||
{
|
||||
String deviceCode = request.getHeader("X-Device-Code");
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING",
|
||||
"缺少设备凭据 X-Device-Code");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNoGyidOverride(HttpServletRequest request, Prefix prefix)
|
||||
{
|
||||
String requested = request.getParameter("gyid");
|
||||
if (requested == null || requested.isBlank())
|
||||
{
|
||||
requested = request.getHeader("X-Gy-Id");
|
||||
}
|
||||
if (requested == null || requested.isBlank())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!requested.equals(AptContext.getGyId()))
|
||||
{
|
||||
boolean device = prefix == Prefix.OPEN;
|
||||
throw new AptException(
|
||||
device ? AptErrorCode.FORBIDDEN_DEVICE_GYID : AptErrorCode.FORBIDDEN_GYID,
|
||||
device ? "DEVICE_GYID_MISMATCH" : "GY_ID_CROSS_SCOPE",
|
||||
"跨公寓访问被拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAnonymous(Object handler)
|
||||
{
|
||||
if (!(handler instanceof HandlerMethod))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
HandlerMethod method = (HandlerMethod) handler;
|
||||
return method.getMethodAnnotation(Anonymous.class) != null
|
||||
|| method.getBeanType().getAnnotation(Anonymous.class) != null;
|
||||
}
|
||||
}
|
||||
+78
@@ -4,9 +4,13 @@ import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptPrefixResolver;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
@@ -19,8 +23,12 @@ import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import org.springframework.data.redis.RedisSystemException;
|
||||
import org.springframework.jdbc.BadSqlGrammarException;
|
||||
import org.springframework.jdbc.CannotGetJdbcConnectionException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
/**
|
||||
* 基础设施异常统一映射:连接失败 / 超时 / 数据异常 -> 约定错误码 + reason + traceId。
|
||||
@@ -92,6 +100,54 @@ public class AptInfraExceptionHandler
|
||||
return handle(AptErrorCode.DEPENDENCY_UNAVAILABLE, "DEPENDENCY_ERROR", "依赖访问异常", e, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方法级 @PreAuthorize 拒绝:公寓端四前缀统一 40301(与过滤器链 AptAccessDeniedHandler 一致);
|
||||
* 非公寓端路径保持若依 GlobalExceptionHandler#handleAccessDeniedException 的原口径。
|
||||
*/
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public AjaxResult handleAccessDenied(AccessDeniedException e, HttpServletRequest request)
|
||||
{
|
||||
if (!AptPrefixResolver.isAptManaged(request.getRequestURI()))
|
||||
{
|
||||
return AjaxResult.error(HttpStatus.FORBIDDEN, "没有权限,请联系管理员授权");
|
||||
}
|
||||
return handle(AptErrorCode.FORBIDDEN_PERM, "PERMISSION_DENIED", "无权限点", e, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 若依 ServiceException:非公寓端保留其自定义 code(GlobalExceptionHandler 口径),
|
||||
* 避免 @Order(HIGHEST_PRECEDENCE) 吞掉若依后台业务错误码;公寓端走统一响应体。
|
||||
*/
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public AjaxResult handleServiceException(ServiceException e, HttpServletRequest request)
|
||||
{
|
||||
if (!AptPrefixResolver.isAptManaged(request.getRequestURI()))
|
||||
{
|
||||
Integer code = e.getCode();
|
||||
return code != null ? AjaxResult.error(code, e.getMessage()) : AjaxResult.error(e.getMessage());
|
||||
}
|
||||
return handle(AptErrorCode.BAD_REQUEST, "SERVICE_EXCEPTION", "业务校验失败", e, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量兜底:公寓端四前缀内一律返回 {code,msg,data{reason,traceId,path}},
|
||||
* 不外泄 e.getMessage() 与堆栈;若依自身路径保持原有文案,仅补 traceId。
|
||||
*/
|
||||
@ExceptionHandler(Exception.class)
|
||||
public AjaxResult handleUnexpected(Exception e, HttpServletRequest request)
|
||||
{
|
||||
if (AptPrefixResolver.isAptManaged(request.getRequestURI()))
|
||||
{
|
||||
return handle(AptErrorCode.INTERNAL_ERROR, "UNEXPECTED_ERROR",
|
||||
"内部错误(已记录)", e, request);
|
||||
}
|
||||
log.error("unexpected error, uri={}, traceId={}",
|
||||
request.getRequestURI(), AptContext.getTraceId(), e);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("traceId", AptContext.getTraceId());
|
||||
return new AjaxResult(500, e.getMessage(), data);
|
||||
}
|
||||
|
||||
private AjaxResult handle(AptErrorCode code, String reason, String message,
|
||||
Exception e, HttpServletRequest request)
|
||||
{
|
||||
@@ -102,10 +158,32 @@ public class AptInfraExceptionHandler
|
||||
|
||||
private AjaxResult build(AptErrorCode code, String reason, String message, HttpServletRequest request)
|
||||
{
|
||||
applyHttpStatus(code);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reason", reason);
|
||||
data.put("traceId", AptContext.getTraceId());
|
||||
data.put("path", request.getRequestURI());
|
||||
return new AjaxResult(code.getCode(), message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表 4-4 的 HTTP 列:约定码前三位即 HTTP 状态(40001→400、40302→403、40401→404、
|
||||
* 40901→409、50304→503)。与过滤器链 AptAuthenticationEntryPoint / AptAccessDeniedHandler
|
||||
* 的真实 401/403 对齐,避免同一错误码在不同入口返回不同 HTTP 状态。
|
||||
*/
|
||||
private void applyHttpStatus(AptErrorCode code)
|
||||
{
|
||||
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
|
||||
if (!(attributes instanceof ServletRequestAttributes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
HttpServletResponse response = ((ServletRequestAttributes) attributes).getResponse();
|
||||
if (response == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int value = code.getCode();
|
||||
response.setStatus(value == 200 ? 200 : value >= 40000 ? value / 100 : 500);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -5,7 +5,8 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* /apt/** 前缀的基座拦截器注册(四前缀拦截器最小集,IT-1 再补 /duty/** 与 /open/**)。
|
||||
* 四前缀拦截器注册(IT-1 / M0)。IT-0 只注册 /apt/**,本轮补齐
|
||||
* /duty/**、/open/v1/**、/platform/** 三个分区。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@@ -22,6 +23,7 @@ public class AptWebMvcConfig implements WebMvcConfigurer
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry)
|
||||
{
|
||||
registry.addInterceptor(aptContextInterceptor).addPathPatterns("/apt/**");
|
||||
registry.addInterceptor(aptContextInterceptor)
|
||||
.addPathPatterns("/apt/**", "/duty/**", "/open/v1/**", "/platform/**");
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -14,25 +14,32 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
import com.ruoyi.apt.framework.security.AptAccessDeniedHandler;
|
||||
import com.ruoyi.apt.framework.security.AptAuthenticationEntryPoint;
|
||||
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
|
||||
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
|
||||
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
|
||||
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;
|
||||
|
||||
/**
|
||||
* spring security配置
|
||||
*
|
||||
*
|
||||
* <p>IT-1 / M0 改动:401 与 403 改由公寓端统一处理器承载,
|
||||
* 使 /apt/**、/duty/**、/open/v1/**、/platform/** 返回真实 HTTP 状态与
|
||||
* 约定错误码 + traceId;非公寓端路径仍委托若依原 AuthenticationEntryPointImpl。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
|
||||
@Configuration
|
||||
public class SecurityConfig
|
||||
{
|
||||
/**
|
||||
* 认证失败处理类
|
||||
*/
|
||||
/** 公寓端认证失败处理类(内部对非公寓端路径委托若依原实现) */
|
||||
@Autowired
|
||||
private AuthenticationEntryPointImpl unauthorizedHandler;
|
||||
private AptAuthenticationEntryPoint aptAuthenticationEntryPoint;
|
||||
|
||||
/** 公寓端权限不足处理类 */
|
||||
@Autowired
|
||||
private AptAccessDeniedHandler aptAccessDeniedHandler;
|
||||
|
||||
/**
|
||||
* 退出处理类
|
||||
@@ -45,7 +52,7 @@ public class SecurityConfig
|
||||
*/
|
||||
@Autowired
|
||||
private JwtAuthenticationTokenFilter authenticationTokenFilter;
|
||||
|
||||
|
||||
/**
|
||||
* 跨域过滤器
|
||||
*/
|
||||
@@ -62,7 +69,7 @@ public class SecurityConfig
|
||||
* 身份验证实现
|
||||
*/
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception
|
||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception
|
||||
{
|
||||
return authenticationConfiguration.getAuthenticationManager();
|
||||
}
|
||||
@@ -75,7 +82,7 @@ public class SecurityConfig
|
||||
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
|
||||
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
|
||||
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
|
||||
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
|
||||
* hasAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
|
||||
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
|
||||
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
|
||||
* permitAll | 用户可以任意访问
|
||||
@@ -92,8 +99,10 @@ public class SecurityConfig
|
||||
.headers((headersCustomizer) -> {
|
||||
headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
|
||||
})
|
||||
// 认证失败处理类
|
||||
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
|
||||
// 认证失败 / 权限不足处理类(IT-1)
|
||||
.exceptionHandling(exception -> exception
|
||||
.authenticationEntryPoint(aptAuthenticationEntryPoint)
|
||||
.accessDeniedHandler(aptAccessDeniedHandler))
|
||||
// 基于token,所以不需要session
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
// 注解标记允许匿名访问的url
|
||||
@@ -101,6 +110,10 @@ public class SecurityConfig
|
||||
permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
|
||||
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
|
||||
requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
|
||||
// 设备侧匿名分区:由 AptContextInterceptor 校验 X-Device-Code(IT-3 再补 HMAC /
|
||||
// 时间窗 / nonce)。此处若不放行,anyRequest().authenticated() 会提前返回 401,
|
||||
// 四前缀拦截器永远无法生效(IT-1 / M0)。
|
||||
.requestMatchers("/open/v1/**").permitAll()
|
||||
// 静态资源,可匿名访问
|
||||
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
|
||||
.requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.ruoyi.apt.master.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/** apt_master_route 只读列表视图(IT-1 业务表只读查询骨架,不做 CRUD)。 */
|
||||
public class AptRoute
|
||||
{
|
||||
private Long id;
|
||||
private String gyid;
|
||||
private String jlid;
|
||||
private String cc;
|
||||
private String routeName;
|
||||
private String startStation;
|
||||
private String endStation;
|
||||
private Date kcsj;
|
||||
private String state;
|
||||
private Long sourceVersion;
|
||||
private Date createTime;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getGyid() { return gyid; }
|
||||
public void setGyid(String gyid) { this.gyid = gyid; }
|
||||
public String getJlid() { return jlid; }
|
||||
public void setJlid(String jlid) { this.jlid = jlid; }
|
||||
public String getCc() { return cc; }
|
||||
public void setCc(String cc) { this.cc = cc; }
|
||||
public String getRouteName() { return routeName; }
|
||||
public void setRouteName(String routeName) { this.routeName = routeName; }
|
||||
public String getStartStation() { return startStation; }
|
||||
public void setStartStation(String startStation) { this.startStation = startStation; }
|
||||
public String getEndStation() { return endStation; }
|
||||
public void setEndStation(String endStation) { this.endStation = endStation; }
|
||||
public Date getKcsj() { return kcsj; }
|
||||
public void setKcsj(Date kcsj) { this.kcsj = kcsj; }
|
||||
public String getState() { return state; }
|
||||
public void setState(String state) { this.state = state; }
|
||||
public Long getSourceVersion() { return sourceVersion; }
|
||||
public void setSourceVersion(Long sourceVersion) { this.sourceVersion = sourceVersion; }
|
||||
public Date getCreateTime() { return createTime; }
|
||||
public void setCreateTime(Date createTime) { this.createTime = createTime; }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ruoyi.apt.master.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.master.domain.AptRoute;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/** 交路只读查询骨架(IT-1:不含任何 INSERT/UPDATE/DELETE)。 */
|
||||
public interface AptRouteMapper
|
||||
{
|
||||
List<AptRoute> selectRouteList(@Param("gyid") String gyid,
|
||||
@Param("cc") String cc,
|
||||
@Param("jlid") String jlid,
|
||||
@Param("state") String state);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.apt.ops.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/** duty_operation_log 写入对象(IT-1 审计切面)。 */
|
||||
public class AptOperationLog
|
||||
{
|
||||
private String logId;
|
||||
private String gyId;
|
||||
private String operatorRef;
|
||||
private String module;
|
||||
private String action;
|
||||
private String detail;
|
||||
private String traceId;
|
||||
private Date occurredAt;
|
||||
|
||||
public String getLogId() { return logId; }
|
||||
public void setLogId(String logId) { this.logId = logId; }
|
||||
public String getGyId() { return gyId; }
|
||||
public void setGyId(String gyId) { this.gyId = gyId; }
|
||||
public String getOperatorRef() { return operatorRef; }
|
||||
public void setOperatorRef(String operatorRef) { this.operatorRef = operatorRef; }
|
||||
public String getModule() { return module; }
|
||||
public void setModule(String module) { this.module = module; }
|
||||
public String getAction() { return action; }
|
||||
public void setAction(String action) { this.action = action; }
|
||||
public String getDetail() { return detail; }
|
||||
public void setDetail(String detail) { this.detail = detail; }
|
||||
public String getTraceId() { return traceId; }
|
||||
public void setTraceId(String traceId) { this.traceId = traceId; }
|
||||
public Date getOccurredAt() { return occurredAt; }
|
||||
public void setOccurredAt(Date occurredAt) { this.occurredAt = occurredAt; }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.apt.ops.mapper;
|
||||
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
|
||||
/** 公寓端审计 Mapper(落 duty_operation_log,SQL 必须带 gyid)。 */
|
||||
public interface AptAuditMapper
|
||||
{
|
||||
int insertOperationLog(AptOperationLog operationLog);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.ops.mapper.AptAuditMapper">
|
||||
|
||||
<!-- IT-1 审计写入:gyid 为强制隔离列,禁止省略 -->
|
||||
<insert id="insertOperationLog" parameterType="com.ruoyi.apt.ops.domain.AptOperationLog">
|
||||
INSERT INTO duty_operation_log
|
||||
(gyid, log_id, operator_ref, module, action, detail, trace_id, occurred_at)
|
||||
VALUES
|
||||
(#{gyId}, #{logId}, #{operatorRef}, #{module}, #{action},
|
||||
#{detail}, #{traceId}, #{occurredAt})
|
||||
</insert>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.master.mapper.AptRouteMapper">
|
||||
|
||||
<!--
|
||||
IT-1 业务表只读查询骨架。
|
||||
硬约束:WHERE 必须带 gyid = #{gyid},由 AptContext 注入,禁止客户端指定;
|
||||
默认排序按业务字段(state desc, kcsj asc, cc asc),不按 id 排序(公寓端/04 §1.2)。
|
||||
-->
|
||||
<select id="selectRouteList" resultType="com.ruoyi.apt.master.domain.AptRoute">
|
||||
SELECT id, gyid, jlid, cc, route_name, start_station, end_station,
|
||||
kcsj, state, source_version, create_time
|
||||
FROM apt_master_route
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
<if test="cc != null and cc != ''">
|
||||
AND cc = #{cc}
|
||||
</if>
|
||||
<if test="jlid != null and jlid != ''">
|
||||
AND jlid = #{jlid}
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND state = #{state}
|
||||
</if>
|
||||
ORDER BY state DESC, kcsj ASC, cc ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,248 @@
|
||||
<#
|
||||
IT-1 / M0 end-to-end verification (DV-1 "M0 test script").
|
||||
Maps to docs/apartment/07 table 7-3 M0 and docs/apartment/13 table 13-11.
|
||||
|
||||
Prerequisites:
|
||||
1) Copy dev/.env.example to dev/scripts/.env and set APT_DB_PASSWORD / APT_TOKEN_SECRET.
|
||||
2) MySQL apt_rev01 and Redis database=1 reachable; run dev/sql/apt_rev01/09_it1_permissions.sql.
|
||||
3) Run mvn -pl ruoyi-admin -am package to build ruoyi-admin/target/ruoyi-admin.jar.
|
||||
|
||||
Usage:
|
||||
powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it1-m0.ps1
|
||||
powershell -ExecutionPolicy Bypass -File dev/scripts/verify-it1-m0.ps1 -SkipStartup
|
||||
|
||||
Notes: captcha switch is NOT disabled and sys_config is NOT modified. The captcha
|
||||
answer is read (read-only) from Redis database=1 key captcha_codes:<uuid>.
|
||||
Login uses admin and the apt_guest account created by 09_it1_permissions.sql;
|
||||
both use the public RuoYi template password. Remove apt_guest before production.
|
||||
#>
|
||||
param(
|
||||
[switch]$SkipStartup,
|
||||
[string]$BaseUrl = 'http://127.0.0.1:8080',
|
||||
[string]$JavaPath = '',
|
||||
[string]$AdminPassword = 'admin123',
|
||||
[string]$GuestPassword = 'admin123',
|
||||
[int]$ReadyAttempts = 60
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$devDir = Split-Path $PSScriptRoot -Parent
|
||||
$backendDir = Join-Path $devDir 'RuoYi-Vue-master'
|
||||
$jar = Join-Path $backendDir 'ruoyi-admin/target/ruoyi-admin.jar'
|
||||
|
||||
# ---------- 1. Credentials ----------
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
if (-not [System.IO.File]::Exists($envFile)) { $envFile = Join-Path $devDir '.env' }
|
||||
if (-not [System.IO.File]::Exists($envFile)) {
|
||||
throw 'Missing credentials file: copy dev/.env.example to dev/scripts/.env and set APT_DB_PASSWORD / APT_TOKEN_SECRET'
|
||||
}
|
||||
Write-Host "Using credentials file: $envFile"
|
||||
Get-Content -LiteralPath $envFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line -eq '' -or $line.StartsWith('#')) { return }
|
||||
if ($line -match '^([A-Za-z0-9_]+)\s*=\s*(.*)$') {
|
||||
[Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process')
|
||||
}
|
||||
}
|
||||
foreach ($name in @('APT_DB_PASSWORD', 'APT_TOKEN_SECRET')) {
|
||||
if ([string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
throw "Missing required environment variable $name"
|
||||
}
|
||||
}
|
||||
|
||||
# jjwt signWith(HS512, secret) expects a Base64-encoded key of at least 64 bytes.
|
||||
# A placeholder text in .env causes an ArrayIndexOutOfBoundsException on login.
|
||||
# Normalize for this verification run only; the .env file is left untouched.
|
||||
$secretValue = [Environment]::GetEnvironmentVariable('APT_TOKEN_SECRET', 'Process')
|
||||
$secretValid = $false
|
||||
try {
|
||||
$decoded = [System.Convert]::FromBase64String($secretValue)
|
||||
if ($decoded.Length -ge 64) { $secretValid = $true }
|
||||
}
|
||||
catch { $secretValid = $false }
|
||||
if (-not $secretValid) {
|
||||
$rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
|
||||
$keyBytes = New-Object byte[] 64
|
||||
$rng.GetBytes($keyBytes)
|
||||
$rng.Dispose()
|
||||
[Environment]::SetEnvironmentVariable('APT_TOKEN_SECRET', [System.Convert]::ToBase64String($keyBytes), 'Process')
|
||||
Write-Host 'APT_TOKEN_SECRET was not a valid Base64 HS512 key; generated an ephemeral one for this run.'
|
||||
}
|
||||
|
||||
# ---------- 2. Redis RESP read-only helper (captcha answer) ----------
|
||||
function Send-RedisCommand {
|
||||
param([System.Net.Sockets.NetworkStream]$Stream, [string[]]$Parts)
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
[void]$sb.Append('*').Append($Parts.Count).Append("`r`n")
|
||||
foreach ($p in $Parts) {
|
||||
$n = [System.Text.Encoding]::UTF8.GetByteCount($p)
|
||||
[void]$sb.Append('$').Append($n).Append("`r`n").Append($p).Append("`r`n")
|
||||
}
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($sb.ToString())
|
||||
$Stream.Write($bytes, 0, $bytes.Length)
|
||||
$Stream.Flush()
|
||||
}
|
||||
|
||||
function Read-RedisLine {
|
||||
param([System.Net.Sockets.NetworkStream]$Stream)
|
||||
$sb = [System.Text.StringBuilder]::new()
|
||||
while ($true) {
|
||||
$b = $Stream.ReadByte()
|
||||
if ($b -lt 0) { throw 'Redis connection closed unexpectedly' }
|
||||
if ($b -eq 13) { [void]$Stream.ReadByte(); break }
|
||||
[void]$sb.Append([char]$b)
|
||||
}
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
function Read-RedisBulk {
|
||||
param([System.Net.Sockets.NetworkStream]$Stream)
|
||||
$line = Read-RedisLine -Stream $Stream
|
||||
if (-not $line.StartsWith('$')) { throw "Redis reply is not a bulk string: $line" }
|
||||
$len = [int]$line.Substring(1)
|
||||
if ($len -lt 0) { return $null }
|
||||
$buf = [byte[]]::new($len)
|
||||
$read = 0
|
||||
while ($read -lt $len) {
|
||||
$n = $Stream.Read($buf, $read, $len - $read)
|
||||
if ($n -le 0) { throw 'Incomplete Redis reply' }
|
||||
$read += $n
|
||||
}
|
||||
[void]$Stream.ReadByte()
|
||||
[void]$Stream.ReadByte()
|
||||
return [System.Text.Encoding]::UTF8.GetString($buf)
|
||||
}
|
||||
|
||||
function Get-CaptchaCode {
|
||||
param([string]$Uuid)
|
||||
$tcp = [System.Net.Sockets.TcpClient]::new('127.0.0.1', 6379)
|
||||
try {
|
||||
$stream = $tcp.GetStream()
|
||||
$stream.ReadTimeout = 3000
|
||||
Send-RedisCommand -Stream $stream -Parts @('SELECT', '1')
|
||||
[void](Read-RedisLine -Stream $stream)
|
||||
Send-RedisCommand -Stream $stream -Parts @('GET', ('captcha_codes:' + $Uuid))
|
||||
$value = Read-RedisBulk -Stream $stream
|
||||
if ($null -eq $value) { throw 'Captcha answer missing in Redis database=1' }
|
||||
return $value.Trim('"')
|
||||
}
|
||||
finally { $tcp.Dispose() }
|
||||
}
|
||||
|
||||
# ---------- 3. HTTP helpers ----------
|
||||
function Invoke-Api {
|
||||
param([string]$Path, [string]$Token = '')
|
||||
$headers = @{}
|
||||
if ($Token -ne '') { $headers['Authorization'] = 'Bearer ' + $Token }
|
||||
try {
|
||||
$res = Invoke-WebRequest -Uri ($BaseUrl + $Path) -Headers $headers -UseBasicParsing -TimeoutSec 15
|
||||
return @{ status = [int]$res.StatusCode; raw = $res.Content; body = ($res.Content | ConvertFrom-Json) }
|
||||
}
|
||||
catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($null -eq $response) { throw }
|
||||
# PowerShell 5.1 keeps the error body in ErrorDetails; GetResponseStream may be consumed.
|
||||
$content = $_.ErrorDetails.Message
|
||||
if ([string]::IsNullOrEmpty($content)) {
|
||||
$reader = [System.IO.StreamReader]::new($response.GetResponseStream())
|
||||
$content = $reader.ReadToEnd()
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($content)) { $content = '{}' }
|
||||
return @{ status = [int]$response.StatusCode; raw = $content; body = ($content | ConvertFrom-Json) }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Token {
|
||||
param([string]$User, [string]$Password)
|
||||
$captcha = Invoke-RestMethod -Uri ($BaseUrl + '/captchaImage') -Method Get -TimeoutSec 10
|
||||
$body = @{ username = $User; password = $Password }
|
||||
if ($captcha.captchaEnabled) {
|
||||
$body.code = Get-CaptchaCode -Uuid $captcha.uuid
|
||||
$body.uuid = $captcha.uuid
|
||||
}
|
||||
$resp = Invoke-RestMethod -Uri ($BaseUrl + '/login') -Method Post `
|
||||
-ContentType 'application/json;charset=UTF-8' -Body ($body | ConvertTo-Json -Compress) -TimeoutSec 15
|
||||
if ($resp.code -ne 200) { throw "Login failed [$User]: $($resp.msg)" }
|
||||
return $resp.token
|
||||
}
|
||||
|
||||
# ---------- 4. Startup and readiness ----------
|
||||
$proc = $null
|
||||
if (-not $SkipStartup) {
|
||||
if (-not (Test-Path -LiteralPath $jar)) { throw "Missing $jar - run mvn package first" }
|
||||
if ($JavaPath -eq '') {
|
||||
$javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME', 'Process')
|
||||
if ([string]::IsNullOrEmpty($javaHome)) { $JavaPath = 'java' }
|
||||
else { $JavaPath = Join-Path $javaHome 'bin/java.exe' }
|
||||
}
|
||||
$outLog = Join-Path $env:TEMP 'apt-it1-stdout.log'
|
||||
$errLog = Join-Path $env:TEMP 'apt-it1-stderr.log'
|
||||
$proc = Start-Process -FilePath $JavaPath -ArgumentList @('-jar', ('"' + $jar + '"')) `
|
||||
-PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog
|
||||
Write-Host "Started application PID=$($proc.Id), stderr log: $errLog"
|
||||
}
|
||||
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt $ReadyAttempts; $i++) {
|
||||
try {
|
||||
$probe = Invoke-WebRequest -Uri ($BaseUrl + '/apt/ops/connectivity') -UseBasicParsing -TimeoutSec 3
|
||||
if ([int]$probe.StatusCode -eq 200) { $ready = $true; break }
|
||||
}
|
||||
catch { Start-Sleep -Seconds 2 }
|
||||
}
|
||||
if (-not $ready) { throw 'Application not ready within 120s - check stderr log' }
|
||||
|
||||
# ---------- 5. M0 checks ----------
|
||||
$results = @()
|
||||
try {
|
||||
$adminToken = Get-Token -User 'admin' -Password $AdminPassword
|
||||
$guestToken = Get-Token -User 'apt_guest' -Password $GuestPassword
|
||||
|
||||
$r1 = Invoke-Api -Path '/apt/route/list'
|
||||
$results += [pscustomobject]@{
|
||||
Case = 'M0-1 no token'; Expected = 'HTTP 401 / 40101'
|
||||
Actual = "HTTP $($r1.status) / $($r1.body.code)"
|
||||
Pass = [bool]($r1.status -eq 401 -and $r1.body.code -eq 40101)
|
||||
}
|
||||
|
||||
$r2 = Invoke-Api -Path '/apt/route/list' -Token $guestToken
|
||||
$results += [pscustomobject]@{
|
||||
Case = 'M0-2 token without permission'; Expected = 'HTTP 403 / 40301'
|
||||
Actual = "HTTP $($r2.status) / $($r2.body.code)"
|
||||
Pass = [bool]($r2.status -eq 403 -and $r2.body.code -eq 40301)
|
||||
}
|
||||
|
||||
$r3 = Invoke-Api -Path '/apt/route/list?gyid=APT-B' -Token $adminToken
|
||||
$results += [pscustomobject]@{
|
||||
Case = 'M0-3 cross-apartment gyid'; Expected = 'HTTP 403 / 40302, no data'
|
||||
Actual = "HTTP $($r3.status) / $($r3.body.code)"
|
||||
Pass = [bool]($r3.status -eq 403 -and $r3.body.code -eq 40302 -and $r3.raw -notmatch '"rows"')
|
||||
}
|
||||
|
||||
$r4 = Invoke-Api -Path '/apt/ops/connectivity/error-probe'
|
||||
$noLeak = ($r4.raw -notmatch 'internal-detail-marker' -and $r4.raw -notmatch 'at com\.ruoyi')
|
||||
$results += [pscustomobject]@{
|
||||
Case = 'M0-4 no stack trace leak'; Expected = 'unified body with data.traceId'
|
||||
Actual = "HTTP $($r4.status) / $($r4.body.code) / leak=$(-not $noLeak)"
|
||||
Pass = [bool]($r4.body.data.traceId -and $noLeak)
|
||||
}
|
||||
|
||||
$r5 = Invoke-Api -Path '/apt/route/list?pageSize=1000' -Token $adminToken
|
||||
$results += [pscustomobject]@{
|
||||
Case = 'M0-5 page size cap'; Expected = 'HTTP 200 / pageSize=100'
|
||||
Actual = "HTTP $($r5.status) / pageSize=$($r5.body.pageSize)"
|
||||
Pass = [bool]($r5.status -eq 200 -and $r5.body.pageSize -eq 100)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $proc -and -not $proc.HasExited) {
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "Stopped application PID=$($proc.Id)"
|
||||
}
|
||||
}
|
||||
|
||||
$results | Format-Table -AutoSize
|
||||
$failed = @($results | Where-Object { -not $_.Pass })
|
||||
Write-Host "M0 checks passed: $($results.Count - $failed.Count)/$($results.Count)"
|
||||
if ($failed.Count -gt 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -0,0 +1,94 @@
|
||||
-- =============================================================================
|
||||
-- IT-1 / DV-1 权限点种子与 M0 验收账号(可重放,二次执行不产生重复数据)
|
||||
-- 依据:公寓端/04 §1 权限点命名、表 13-11 DV-1、表 7-3 M0 四项验收
|
||||
-- 说明:
|
||||
-- 1) 只写 sys_menu / sys_role / sys_user / sys_user_role / sys_role_menu 框架表,
|
||||
-- 不触碰 59 张业务表;DDL 与回填分离口径不变。
|
||||
-- 2) apt_guest 是「有令牌但缺权限点」的本地验收账号,口令为若依公开模板
|
||||
-- BCrypt 密文(对应 admin123);生产部署前必须删除,不进生产镜像。
|
||||
-- 3) 后续域权限点只登记、不关联任何角色(置灰),符合表 13-29 预埋不上线。
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. 公寓端父目录(M)
|
||||
INSERT INTO sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 3000, '公寓端', 0, 9, 'apt', NULL, '', '', 1, 0, 'M', '0', '0', '',
|
||||
'build', 'IT-1', NOW(), 'IT-1 / M0 权限点父目录'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 3000);
|
||||
|
||||
-- 2. 权限点(F):仅 3001 在本轮启用,其余为后续迭代预置且不关联角色
|
||||
INSERT INTO sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
(3001, '交路列表', 3000, 1, '', NULL, NULL, '', 1, 0, 'F', '0', '0',
|
||||
'apt:route:list', '#', 'IT-1', NOW(), 'IT-1 启用:M0 只读列表'),
|
||||
(3002, '连通性自检', 3000, 2, '', NULL, NULL, '', 1, 0, 'F', '0', '0',
|
||||
'apt:ops:connectivity', '#', 'IT-1', NOW(), 'IT-0 自检接口(@Anonymous,权限点仅登记)'),
|
||||
(3003, '房间列表', 3000, 3, '', NULL, NULL, '', 1, 0, 'F', '1', '1',
|
||||
'apt:room:list', '#', 'IT-1', NOW(), 'IT-2 预置,置灰不启用'),
|
||||
(3004, '叫乘列表', 3000, 4, '', NULL, NULL, '', 1, 0, 'F', '1', '1',
|
||||
'apt:jcxx:list', '#', 'IT-1', NOW(), 'IT-5 预置,置灰不启用'),
|
||||
(3005, '入住待审列表', 3000, 5, '', NULL, NULL, '', 1, 0, 'F', '1', '1',
|
||||
'duty:checkin:list', '#', 'IT-1', NOW(), 'IT-4 预置,置灰不启用'),
|
||||
(3006, '本地账务', 3000, 6, '', NULL, NULL, '', 1, 0, 'F', '1', '1',
|
||||
'apt:ledger:local', '#', 'IT-1', NOW(), 'IT-9~11 延后域预置,置灰不启用')
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), perms = VALUES(perms);
|
||||
|
||||
-- 3. 超管角色显式关联 apt:route:list(超管本身已有 *:*:*,此处为审计留痕)
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 1, 3001
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 1 AND menu_id = 3001);
|
||||
|
||||
-- 4. M0 验收角色:无任何公寓端权限点
|
||||
INSERT INTO sys_role
|
||||
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly,
|
||||
dept_check_strictly, status, del_flag, create_by, create_time, remark)
|
||||
SELECT 300, '公寓只读测试', 'apt_guest', 9, '1', 1, 1, '0', '0',
|
||||
'IT-1', NOW(), 'IT-1 M0 验收:有令牌但缺 apt:route:list;生产部署前删除'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_id = 300);
|
||||
|
||||
-- 5. M0 验收账号(BCrypt 密文取自若依公开模板,对应 admin123)
|
||||
INSERT INTO sys_user
|
||||
(user_id, dept_id, user_name, nick_name, user_type, email, phonenumber, sex,
|
||||
avatar, password, status, del_flag, login_ip, login_date, create_by, create_time, remark)
|
||||
SELECT 300, 103, 'apt_guest', '公寓只读测试', '00', '', '', '0', '',
|
||||
'$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2',
|
||||
'0', '0', '', NULL, 'IT-1', NOW(), 'IT-1 M0 验收账号;生产部署前删除'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_user WHERE user_id = 300);
|
||||
|
||||
INSERT INTO sys_user_role (user_id, role_id)
|
||||
SELECT 300, 300
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_user_role WHERE user_id = 300 AND role_id = 300);
|
||||
|
||||
-- 只给一个与公寓端无关的目录权限,保证 permissions 集合不含 apt:route:list / *:*:*
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 300, 1
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu WHERE role_id = 300 AND menu_id = 1);
|
||||
|
||||
-- 6. 校验
|
||||
SELECT '=== IT-1 权限点 ===' AS section;
|
||||
SELECT menu_id, perms, visible, status, remark FROM sys_menu WHERE menu_id BETWEEN 3000 AND 3006 ORDER BY menu_id;
|
||||
SELECT '=== M0 验收账号 ===' AS section;
|
||||
SELECT u.user_id, u.user_name, r.role_key, r.role_name
|
||||
FROM sys_user u JOIN sys_user_role ur ON ur.user_id = u.user_id
|
||||
JOIN sys_role r ON r.role_id = ur.role_id WHERE u.user_id = 300;
|
||||
SELECT '=== 验收角色是否被误授公寓端权限点(应为 0)===' AS section;
|
||||
SELECT COUNT(*) AS apt_permission_count
|
||||
FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = 300 AND (m.perms LIKE 'apt:%' OR m.perms LIKE 'duty:%' OR m.perms = '*:*:*');
|
||||
|
||||
-- 7. 种子幂等与「预埋不上线」校验
|
||||
SELECT '=== 权限点总数(应为 7,重复执行不增长)===' AS section;
|
||||
SELECT COUNT(*) AS menu_count FROM sys_menu WHERE menu_id BETWEEN 3000 AND 3006;
|
||||
SELECT '=== 置灰权限点是否被误关联角色(应为 0)===' AS section;
|
||||
SELECT COUNT(*) AS grey_permission_bound
|
||||
FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE m.menu_id BETWEEN 3003 AND 3006;
|
||||
SELECT '=== 超管角色只读列表权限点留痕(应为 1)===' AS section;
|
||||
SELECT COUNT(*) AS admin_route_perm
|
||||
FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = 1 AND m.perms = 'apt:route:list';
|
||||
SELECT '=== M0 验收账号状态(应 status=0 / del_flag=0)===' AS section;
|
||||
SELECT user_id, user_name, status, del_flag FROM sys_user WHERE user_id = 300;
|
||||
Reference in new issue
Block a user