第二次任务推进。
IT-0 verify / verify (push) Canceled after 0s

This commit is contained in:
zfjia committed 2026-11-10 16:44:01 +08:00
1 parent 8ffeb59dd4
commit 51356c69a3
40 files changed
+2196 -29

No files matched your search

+24 -4
View File
@@ -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=
@@ -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;
@@ -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
@@ -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();
}
}
@@ -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"));
}
}
@@ -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 默认处理器");
}
}
@@ -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();
}
}
@@ -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()
{
}
}
}
@@ -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"));
}
}
@@ -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)
{
@@ -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;
}
}
@@ -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();
}
}
@@ -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, "内部错误");
@@ -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;
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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";
}
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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/**");
}
}
@@ -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()
@@ -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; }
}
@@ -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);
}
@@ -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; }
}
@@ -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>
+248
View File
@@ -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
+94
View File
@@ -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;