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;
|
||||
@@ -90,6 +90,16 @@
|
||||
|
||||
> 6 项 P0 排期前提(技术基线冲突、表数口径、前端入口、CI/测试、明文凭据、契约冻结)与 3 项口径偏离须经评审签字后方可执行,见 `公寓端/13` 表 13-4 / 表 13-6。
|
||||
|
||||
### 第二轮开发(IT-1)交付(2026-11-10)
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档 | [公寓端/15-第二轮开发(IT-1)交付说明.md](./公寓端/15-第二轮开发(IT-1)交付说明.md)(v1.0,**实现说明,非设计变更**) |
|
||||
| 范围 | 运行时基座 M0:四前缀治理与 `/apt/**` 鉴权、`gyid` 隔离、统一错误码与表 4-4 真实 HTTP 状态、401/403 处理器、审计切面落 `duty_operation_log`、分页上限 100、springdoc 分组、权限点种子、交路只读列表骨架(不含 CRUD) |
|
||||
| 验证 | `mvn package` 7 模块 `BUILD SUCCESS`;41 个单测全绿;`dev/scripts/verify-it1-m0.ps1` 真实进程端到端 M0 **5/5 通过**(401 / 40301 / 40302 / 异常无堆栈外泄 / `pageSize=1000→100`) |
|
||||
| 权威关系 | 阶段划分与验收仍以 `公寓端/07` 为准,业务规则 / 状态 / 接口仍以 CR-02 §11~§15 为准;`15` 只记实现、验证、口径偏离与影响范围 |
|
||||
| **变更点与影响范围** | 新增 1 篇交付说明与 1 个 M0 验收脚本;同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`docs/README.md` 与 CR-02 表 0-2 **v1.8**;不改动 CR-02 §12~§15 权威表述、不新增既有编号,跨端 20 编号、`v2-draft`、平台零回调、59 张表不变;**契约冻结签字与 `D5` 签字仍为 P0 待办** |
|
||||
|
||||
## 建议阅读路径
|
||||
|
||||
- **第一次接触项目**:01 → 02 → 03 → 04;再看前端 01 → 02 → 04。
|
||||
|
||||
@@ -385,7 +385,7 @@ graph TD
|
||||
|
||||
| 迭代 | 本轮提供的前置 | 下一轮要做 |
|
||||
| --- | --- | --- |
|
||||
| `IT-1` M0 运行时基座 | 错误码 / gyid 上下文 / 四前缀中 `/apt/**` 拦截器骨架 / 测试与 CI | 补齐 `/duty/**`、`/open/v1/**` 拦截器与权限点种子、`sys_menu` 权限点、审计切面 |
|
||||
| `IT-1` M0 运行时基座 | 错误码 / gyid 上下文 / 四前缀中 `/apt/**` 拦截器骨架 / 测试与 CI | **已完成**:四前缀拦截器与 `/open/v1/**` 放行、401/403 处理器、统一错误码与真实 HTTP 状态、审计切面、分页上限、权限点种子、只读列表骨架、41 个单测与端到端 M0 5/5;见 `公寓端/15` |
|
||||
| `IT-2`~`IT-3` | 59 张表结构、幂等补列脚本 | 主数据 / 房间 / 人脸最小闭环业务代码(落 `ruoyi-system`) |
|
||||
| `IT-4`~`IT-5` | `apt_local_task` / `gy_jcxx` 结构、单一状态写入口约束 | 入住 CI-01~CI-05、叫乘状态机 M3 |
|
||||
| `IT-9`~`IT-11` 延后域 | 账务 6 张表**已建结构但不开放读写**、错误码与权限点可预埋 | 阶段三退出 + `D1/D2/P-07/Q-41~Q-45` 决策后再开 `/apt/ledger/**` |
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
> **文档定位**:`IT-1 运行时基座 M0` 第二轮开发的交付说明,与 `docs/公寓端/13` 表 13-11 / `DV-1` 一一对应。
|
||||
> **版本**:v1.0 | 2026-11-10
|
||||
> **环境**:MySQL `apt_rev01` @ `127.0.0.1:3306`(凭据由 `dev/.env` 注入)、Redis 本机 `database=1`、JDK 24 / Maven 3.8.1 / Spring Boot 4.1.0
|
||||
> **上位依据**:`公寓端/07` 表 7-2 / 7-3 / 7-6 / 7-8、`公寓端/04` §1 / §2 表 4-4、`公寓端/03` §4 表 3-12、`公寓端/13` 表 13-11 / 13-21 / 13-22 / 13-30 / 13-32、`公寓端/14` 表 14-23
|
||||
> **权威关系**:本文件是**实现说明**,不改变任何设计口径;阶段划分与验收仍以 `07` 为准,业务规则 / 状态 / 接口仍以 CR-02 §11~§15 为准;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 59 张表均不变。
|
||||
|
||||
---
|
||||
|
||||
## 1. 交付概览与验收对照
|
||||
|
||||
表 15-1 第二轮交付物清单(对应 `DV-1`)
|
||||
|
||||
| 编号 | 交付物 | 落点 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| DV-1-1 | `AptContext` 增强(`gyid`/`traceId`/`pageSize`) | `ruoyi-common/.../apt/common/AptContext.java` | 已交付 |
|
||||
| DV-1-2 | 四前缀拦截器与解析器 | `AptPrefixResolver.java`、`AptContextInterceptor.java`、`AptWebMvcConfig.java`、`SecurityConfig.java` | 已交付,本轮补 `/open/v1/**` 放行 |
|
||||
| DV-1-3 | 统一错误码与真实 HTTP 状态 | `AptErrorCode.java`、`AptInfraExceptionHandler.java`、`AptAuthenticationEntryPoint.java`、`AptAccessDeniedHandler.java` | 已交付,本轮补表 4-4 HTTP 列映射 |
|
||||
| DV-1-4 | 审计切面落库 | `AptAudit.java`、`AptAuditAspect.java`、`AptOperationLog.java`、`AptAuditMapper.java/.xml` | 已交付,落 `duty_operation_log` |
|
||||
| DV-1-5 | 分页封装(上限 100) | `AptPageSupport.java`、`AptTableDataInfo.java`、`AptBaseController.java` | 已交付 |
|
||||
| DV-1-6 | 业务表只读查询骨架 | `AptRoute.java`、`AptRouteMapper.java/.xml`、`AptRouteController.java` | 已交付,**不含 CRUD** |
|
||||
| DV-1-7 | springdoc 分组 | `application.yml`(`apt`/`duty`/`open`,扫 `com.ruoyi.apt.**`) | 已交付 |
|
||||
| DV-1-8 | 权限点种子与 M0 验收账号 | `dev/sql/apt_rev01/09_it1_permissions.sql` | 已交付,二次执行幂等通过 |
|
||||
| DV-1-9 | M0 用例脚本 | `dev/scripts/verify-it1-m0.ps1` | 已交付,本轮端到端 5/5 通过 |
|
||||
| DV-1-10 | 回归测试 | `ruoyi-admin/src/test/java/com/ruoyi/apt/**` | 41 用例全绿 |
|
||||
| DV-1-11 | 本文档 | `docs/公寓端/15` | 已交付 |
|
||||
|
||||
表 15-2 表 13-11 验收标准逐条对照
|
||||
|
||||
| # | 验收标准 | 依据 | 本轮结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 无令牌 `/apt/route/list` → 401 | `07` 表 7-3 M0 | **通过**:HTTP 401 + `40101/UNAUTHORIZED` |
|
||||
| 2 | 有令牌缺权限点 → 40301 | `07` 表 7-3 M0 | **通过**:HTTP 403 + `40301/PERMISSION_DENIED`(`apt_guest` 角色) |
|
||||
| 3 | `gyid=A` 查 `gyid=B` → 40302 且无数据泄露 | `07` 表 7-3 M0、`03` 表 3-14 | **通过**:HTTP 403 + `40302/GY_ID_CROSS_SCOPE`,响应无 `rows` |
|
||||
| 4 | 异常统一返回、无堆栈外泄 | `07` 表 7-3 M0 | **通过**:`500` + `{code,msg,data{reason,traceId,path}}`,无内部消息与堆栈 |
|
||||
| 5 | `pageSize=1000` 截为 100 | `13` 表 13-11(`07` 表 7-3 归 M1,本轮提前落地) | **通过**:HTTP 200 + `pageSize=100` |
|
||||
| 6 | 架构测试断言所有业务 Mapper SQL 带 `gyid` | `13` 表 13-11 | **通过**:`AptMapperGyidArchTest` 扫描 `mapper/apt/**` |
|
||||
|
||||
表 15-3 前置条件与 Go-NoGo 状态
|
||||
|
||||
| 项 | 要求 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `IT-0` 契约冻结签字 | `13` 表 13-10:未冻结不进 `IT-1` | **未满足**:`14` 表 14-22 ① 记「部分达成」,6 项 P0 与 `IR-01` 待冻结会签字;本轮按用户显式指令「继续下一轮开发」带条件开工,须补签 |
|
||||
| `D5` 一进程一 `gyid` 签字 | `13` 表 13-24,拍板时点 `IT-0` 冻结会 | **未满足**:代码已按 `D5` 实现(`apt.gy-id` 唯一来源),签字待补 |
|
||||
| 数据库 / Redis | `apt_rev01` 59 表、Redis `database=1` | 已具备,`09_it1_permissions.sql` 已执行且幂等 |
|
||||
| 技术基线 | 设计锁定 JDK 1.8 / Boot 2.5.15,`dev` 实测 JDK 17 / Boot 4.1.0 | `IR-01` 仍为 P0,实编译与运行均通过,定稿待冻结会 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块职责边界与相互依赖关系
|
||||
|
||||
表 15-4 模块清单
|
||||
|
||||
| 层 | 包 / 文件 | 职责 | 明确不做 |
|
||||
| --- | --- | --- | --- |
|
||||
| 公共 | `ruoyi-common` `com.ruoyi.apt.common` | `gyid`/`traceId` 上下文、错误码、异常、四前缀解析、分页封装、审计注解 | 不访问数据源、不碰若依 `AjaxResult` 语义 |
|
||||
| 框架 | `ruoyi-framework` `com.ruoyi.apt.framework` | 拦截器注册、401/403 处理器、异常统一映射、审计切面、探针服务、`AptProperties` | 不写业务逻辑、不改若依 `GlobalExceptionHandler` 本体 |
|
||||
| 系统 | `ruoyi-system` `com.ruoyi.apt.master/ops` | 只读列表 Domain/Mapper/XML、审计写入对象 | 不提供新增/编辑/删除/状态命令 |
|
||||
| 启动 | `ruoyi-admin` `com.ruoyi.apt.ops.controller` | 自检接口(IT-0)+ 只读列表骨架(IT-1) | 不写入住/叫乘/账务业务 Controller |
|
||||
| 配置 | `SecurityConfig`、`application.yml`、`application-druid.yml` | 过滤器链、springdoc 分组、数据源与 Redis | 凭据一律外置,不落明文 |
|
||||
| SQL | `09_it1_permissions.sql` | 只写 `sys_*` 框架表的权限点种子 | 不触碰 59 张业务表 |
|
||||
|
||||
图 15-1 依赖方向
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["SecurityConfig / 过滤器链"] --> B["AptAuthenticationEntryPoint<br/>AptAccessDeniedHandler"]
|
||||
A --> C["AptContextInterceptor"]
|
||||
C --> D["AptPrefixResolver"]
|
||||
C --> E["AptContext"]
|
||||
C --> F["AptProperties (apt.gy-id)"]
|
||||
G["AptRouteController"] --> H["AptBaseController"]
|
||||
H --> I["AptPageSupport / AptTableDataInfo"]
|
||||
G --> J["AptRouteMapper (gyid 隔离)"]
|
||||
G --> K["@AptAudit → AptAuditAspect"]
|
||||
K --> L["AptAuditMapper → duty_operation_log"]
|
||||
M["AptInfraExceptionHandler"] --> E
|
||||
M --> N["AptErrorCode"]
|
||||
```
|
||||
|
||||
拦截器在 `preHandle` 写入 `gyid` 与 `traceId`,`afterCompletion` 清理;业务 Controller 只从 `AptContext` 取值,绝不采信请求参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 四前缀治理与鉴权
|
||||
|
||||
表 15-5 四前缀策略矩阵(`04` §1、`13` 表 13-11)
|
||||
|
||||
| 前缀 | 会话要求 | 附加校验 | 拒绝码 | 过滤器链 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/apt/**` | 需登录(`@Anonymous` 例外) | 参数/头 `gyid` 与配置一致性 | `40101` / `40302` | `anyRequest().authenticated()` |
|
||||
| `/duty/**` | 需登录(`@Anonymous` 例外) | 同上 | `40101` / `40302` | `anyRequest().authenticated()` |
|
||||
| `/open/v1/**` | 匿名分区 | 必填 `X-Device-Code`;`gyid` 不一致 → `40303` | `40101` / `40303` | **本轮补 `permitAll()`**,否则会被提前 401 |
|
||||
| `/platform/**` | 本端不提供 | 有令牌也显式拒绝 | `40401 PREFIX_NOT_SERVED` | `anyRequest().authenticated()` |
|
||||
|
||||
> 设备侧 HMAC / 时间窗 / nonce 完整校验在 `IT-3` 落地;本轮只做占位骨架,符合表 13-11「不含业务表 CRUD」。
|
||||
|
||||
表 15-6 权限点种子(`09_it1_permissions.sql`)
|
||||
|
||||
| menu_id | 权限点 | 本轮状态 |
|
||||
| --- | --- | --- |
|
||||
| 3000 | 公寓端父目录 | 启用 |
|
||||
| 3001 | `apt:route:list` | **启用**,关联超管角色 1(留痕) |
|
||||
| 3002 | `apt:ops:connectivity` | 仅登记(自检接口为 `@Anonymous`) |
|
||||
| 3003~3006 | `apt:room:list`、`apt:jcxx:list`、`duty:checkin:list`、`apt:ledger:local` | **置灰不关联角色**(`13` 表 13-29 预埋不上线) |
|
||||
| 角色 300 / 账号 300 | `apt_guest` | M0「有令牌缺权限点」验收账号,`apt_permission_count=0`;**生产部署前必须删除** |
|
||||
|
||||
表 15-7 统一错误响应
|
||||
|
||||
| 场景 | HTTP | body |
|
||||
| --- | --- | --- |
|
||||
| 未登录(过滤器链) | 401 | `{code:40101, msg, data:{reason:"UNAUTHORIZED", traceId, path}}` |
|
||||
| 权限不足(过滤器链 / `@PreAuthorize`) | 403 | `{code:40301, msg, data:{reason:"PERMISSION_DENIED", traceId, path}}` |
|
||||
| 跨公寓 / 前缀拒绝 / 参数非法 | 按表 4-4 | `{code, msg, data:{reason, traceId, path}}` |
|
||||
| 未知异常(公寓端四前缀) | 500 | `msg` 固定为「内部错误(已记录)」,**不回传 `e.getMessage()` 与堆栈** |
|
||||
| 非公寓端路径 | 保持若依原状 | 401 委托 `AuthenticationEntryPointImpl`;403 委托 `AccessDeniedHandlerImpl`;`ServiceException` 保留自定义 code |
|
||||
|
||||
---
|
||||
|
||||
## 4. 接口与错误码约定
|
||||
|
||||
表 15-8 本轮接口清单
|
||||
|
||||
| 方法 | 路径 | 权限点 | 请求参数 | 返回结构 | 错误码 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| GET | `/apt/route/list` | `apt:route:list` | `cc`、`jlid`、`state`、`pageNum/pageSize`、`orderByColumn/isAsc` | `{code,msg,rows,total,pageSize,traceId}` | `40101`、`40301`、`40302`、`500` |
|
||||
| GET | `/apt/ops/connectivity` | `@Anonymous` + 仅回环 | 无 | `{code,msg,data{overall,gyid,results[],traceId}}` | `40001`、`40301`、`50301/50304` |
|
||||
| POST | `/apt/ops/connectivity/probe` | `@Anonymous` + 仅回环 | `{target:"DB\|REDIS\|ALL", strict:bool}` | 同上 | `40001 INVALID_TARGET`、`50301`、`50304` |
|
||||
| GET | `/apt/ops/connectivity/error-probe` | `@Anonymous` + 仅回环 | 无 | 统一错误体 | `500 UNEXPECTED_ERROR` |
|
||||
| — | springdoc `/v3/api-docs?group=apt\|duty\|open` | 匿名 | — | 分组文档 | — |
|
||||
|
||||
> `/platform/**` 不分组(公寓端不提供);`/apt/route/list` 为**只读骨架**,表 13-11 明确本轮不含 CRUD。
|
||||
|
||||
表 15-9 `AptErrorCode` 与表 4-4 HTTP 列映射(本轮补齐)
|
||||
|
||||
| 段 | 码 | HTTP | 典型场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| 参数 | `40001` | 400 | 必填缺失、`target` 非法 |
|
||||
| 认证 | `40101` | 401 | 无令牌、令牌失效、设备凭据缺失 |
|
||||
| 授权 | `40301`/`40302`/`40303` | 403 | 缺权限点、跨公寓、设备跨公寓 |
|
||||
| 不存在 | `40401` | 404 | 对象不存在、`/platform/**` 前缀拒绝 |
|
||||
| 冲突 | `40901`~`40916`、`40940` | 409 | 重复键、状态非法、延后域预埋 |
|
||||
| 其他 | `41001`、`50101/50102`、`50301/50302/50304`、`500` | 410/501/503/500 | 录音清理、协议未定、依赖不可用、数据异常、兜底 |
|
||||
|
||||
> `50304`(数据异常)为 IT-0 在既有 `503xx` 段内新增,`40940` 为 CR-02 表 C-3 延后域预埋;二者须随 `50304` 一并纳入契约冻结登记,`40940` 本期不启用。
|
||||
|
||||
表 15-10 异常策略矩阵
|
||||
|
||||
| 异常 | 映射 | 对外可见信息 |
|
||||
| --- | --- | --- |
|
||||
| `AptException` | 自身 `AptErrorCode` + `reason` | 仅 `msg` / `reason` / `traceId` / `path` |
|
||||
| `AccessDeniedException`(公寓端) | `40301` | 同上 |
|
||||
| `ServiceException`(公寓端) | `40001 SERVICE_EXCEPTION` | 不外泄原始 `message` |
|
||||
| `ServiceException`(非公寓端) | 若依原 `code` / `message` | 保持上游行为 |
|
||||
| `DuplicateKeyException` / `DataIntegrityViolationException` | `40901` / `50304` | 无堆栈 |
|
||||
| `CannotGetJdbcConnectionException` / `QueryTimeoutException` / `Redis*` | `50301` | 无连接串、无凭据 |
|
||||
| 兜底 `Exception`(四前缀内) | `500 UNEXPECTED_ERROR` | 仅写日志,不回传 `e.getMessage()` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 审计与分页
|
||||
|
||||
表 15-11 审计落库字段(`duty_operation_log`)
|
||||
|
||||
| 字段 | 来源 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `gyid` | `AptContext.getGyIdOrNull()`,缺失写 `UNKNOWN` | 强制隔离列,`AptAuditMapper.xml` INSERT 必带 |
|
||||
| `log_id` | UUID(去横线) | 与 `gyid` 组成唯一键 |
|
||||
| `operator_ref` | `SecurityUtils.getLoginUser()`,异常时 `ANONYMOUS` | — |
|
||||
| `module` / `action` | `@AptAudit` 注解 | 如 `ROUTE` / `LIST` |
|
||||
| `detail` | JSON | `status`、`costMs`、`traceId`、`uri`、`httpMethod`、`params`(敏感键过滤) |
|
||||
| `trace_id` / `occurred_at` | `AptContext` / 当前时间 | 与错误响应可关联 |
|
||||
|
||||
> 与若依 `@Log`/`sys_oper_log` 并行,不改上游表;写库失败只 WARN,不影响业务返回。审计写入当前与业务同事务,`IT-2` 引入写操作后需评估 `REQUIRES_NEW`,避免事务回滚时丢留痕。
|
||||
|
||||
表 15-12 分页规则
|
||||
|
||||
| 项 | 规则 |
|
||||
| --- | --- |
|
||||
| 硬上限 | `AptPageSupport.HARD_MAX_SIZE = 100`,配置 `apt.page.max-size` 高于此值仍被压制 |
|
||||
| 默认值 | `pageNum=1`、`pageSize=10`;`pageSize<=0` 回落默认 |
|
||||
| 生效链路 | `AptBaseController.startAptPage()` → `AptPageSupport.startPage()` → `PageHelper.startPage()` |
|
||||
| 响应回显 | `AptTableDataInfo` 追加 `pageSize` 与 `traceId` |
|
||||
| 排序 | 只读列表固定 `ORDER BY state DESC, kcsj ASC, cc ASC`,禁止默认按 `id`(`04` §1.2) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证结果
|
||||
|
||||
表 15-13 本轮实际执行的验证
|
||||
|
||||
| # | 验证项 | 手段 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 全量编译与打包 | `mvn -B -ntp package`(JDK 24 / Maven 3.8.1) | 7 模块 `BUILD SUCCESS`,`ruoyi-admin.jar` 重打包 |
|
||||
| 2 | 全量单测 | Surefire 3.5.2 + JUnit5 | `Tests run: 41, Failures: 0, Errors: 0` |
|
||||
| 3 | 四前缀 / 鉴权 | `AptPrefixResolverTest`、`AptAuthInterceptorTest`、`AptAccessDeniedHandlerTest` | 通过 |
|
||||
| 4 | 错误码 / 异常 | `AptErrorResponseTest`、`AptInfraExceptionHandlerTest`、`AptConnectivityControllerTest` | 通过(含真实 HTTP 状态 400/403/503) |
|
||||
| 5 | 审计落库 | `AptAuditAspectTest`(成功/失败/写库降级/敏感过滤) | 通过 |
|
||||
| 6 | 分页 | `AptPageSupportTest`(`normalize` + 真实 `PageHelper` 链路) | 通过 |
|
||||
| 7 | Mapper 架构约束 | `AptMapperGyidArchTest`(`gyid` + 非 `id` 排序) | 通过 |
|
||||
| 8 | 权限点种子 | `09_it1_permissions.sql` 首次/二次执行 | 幂等通过,`apt_permission_count=0`;追加置灰/总数/账号状态校验 SELECT |
|
||||
| 9 | 静态检查 | `read_lints`(`com.ruoyi.apt.**` 与 SQL) | 0 错误 |
|
||||
| 10 | **真实进程端到端 M0** | `dev/scripts/verify-it1-m0.ps1`,启动 `ruoyi-admin.jar` + 真实 HTTP | **5/5 通过**(见表 15-14) |
|
||||
|
||||
表 15-14 端到端 M0 实测(`07` 表 7-3 M0 四项 + `13` 表 13-11 扩展两项)
|
||||
|
||||
| 用例 | 期望 | 实测 |
|
||||
| --- | --- | --- |
|
||||
| 无令牌 `/apt/route/list` | HTTP 401 / `40101` | HTTP 401 / `40101` |
|
||||
| `apt_guest` 有令牌缺权限点 | HTTP 403 / `40301` | HTTP 403 / `40301` |
|
||||
| 令牌 `gyid=A` 查 `gyid=B` | HTTP 403 / `40302`,无数据 | HTTP 403 / `40302`,响应无 `rows` |
|
||||
| `/apt/ops/connectivity/error-probe` | 统一错误体、无堆栈外泄 | `500` + `data.traceId`,无内部标记与堆栈 |
|
||||
| `pageSize=1000` | 截为 100 | HTTP 200 + `pageSize=100` |
|
||||
|
||||
> 端到端脚本从 `dev/.env` 加载凭据,经 `/captchaImage` + Redis `database=1` 的 `captcha_codes:<uuid>` 读取验证码答案完成登录,**未关闭验证码开关、未修改 `sys_config`**;结束后自动停止应用进程。
|
||||
> **环境发现**:`dev/.env` 的 `APT_TOKEN_SECRET` 仍是占位文本,而 `TokenService.signWith(HS512, secret)` 要求 Base64 编码且解码后 ≥64 字节,否则登录抛 `ArrayIndexOutOfBoundsException`。脚本已内置「本次运行生成临时密钥」兜底,`.env` 未改动;本轮修正了 `dev/.env.example` 中误导性的 `APT_TOKEN_SECRET` 提示(原文引导填 hex 文本),改为 Base64 要求与生成示例,并补齐从 `application*.yml` 提取的变量;本机 `.env` 仍须按模板填写。
|
||||
|
||||
---
|
||||
|
||||
## 7. 口径偏离与待评审项
|
||||
|
||||
表 15-15 口径偏离登记(含任务 1 核对结果与本轮处置)
|
||||
|
||||
| # | 偏离 | 本轮处置 | 待办 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 契约冻结签字未完成即开工 | 按用户显式指令带条件开工,交付说明显式登记 | **补冻结会签字**(P0) |
|
||||
| 2 | `gyid` 注入:表 13-11 写「只从会话/设备凭据解析」、`03` 表 3-12 写「令牌声明 → AptContext」,实现按 `D5` 只从 `apt.gy-id` 配置注入 | 请求参数/头仅作一致性校验并拒绝跨域 | 随 `D5` 签字确认口径 |
|
||||
| 3 | M0「令牌 `gyid=A` 查 `gyid=B`」在 D5 下无令牌态 `gyid` | 验收改为「参数/头 `gyid` 不一致 → 40302/40303」(`03` 表 3-14 用例 2 允许) | 验收脚本已固化,冻结会确认 |
|
||||
| 4 | 分页上限归 M1(`07` 表 7-3),表 13-11 提前并入 IT-1 | 已提前实现并验证 | 交付记录按 6 项 |
|
||||
| 5 | 表 4-4 外的错误码 `50304` / `40940` / `500` | `50304` 沿用 503xx 段、`40940` 属 CR-02 预埋、`500` 仅兜底 | `50304` 纳入冻结登记;`40940` 本期不启用 |
|
||||
| 6 | 审计切面实现为独立 `@AptAudit` 而非「扩展 `@Log`」 | 落 `duty_operation_log`,与若依 `@Log` 并行、不改上游表 | 登记即可 |
|
||||
| 7 | `AptAccessDeniedHandler` / `handleAccessDenied` 曾越界改变若依后台 403 | **本轮已修**:非公寓端分别委托 `AccessDeniedHandlerImpl` 与若依原 code | 已完成 |
|
||||
| 8 | `/open/v1/**` 曾被 `anyRequest().authenticated()` 提前 401 | **本轮已修**:`SecurityConfig` 放行,由拦截器校验 `X-Device-Code` | `IT-3` 补 HMAC |
|
||||
| 9 | `AptException` 曾一律 HTTP 200,与表 4-4 不符 | **本轮已修**:`applyHttpStatus` 按码前三位映射,并同步更新 IT-0 自检用例断言 | 已完成 |
|
||||
| 10 | `ServiceException` 被 `@Order(HIGHEST_PRECEDENCE)` 吞掉自定义 code | **本轮已修**:新增专门 handler,非公寓端保留若依口径 | `IT-2` 需补 `BindException` / `MethodArgumentNotValidException` 类型分派 |
|
||||
| 11 | 只读骨架字段与 `04` 表 4-5 不同(`state/route_name/...` vs `enabled/jch/qd/jcsj/...`) | `apt_master_route` 为 IT-0 `[推断]` 表,骨架按 DDL 实现 | **IT-2 交路 CRUD 前给出字段映射**,不改 59 张表设计口径 |
|
||||
| 12 | 只读骨架 Controller 暂落 `com.ruoyi.apt.ops.controller` | 表 13-21 要求交路域落 `com.ruoyi.apt.master.*` | `IT-2` 迁移 |
|
||||
| 13 | 表 13-11/13-22 引用「`03` §5 四级隔离」 | 实际四级隔离在 `03` §4 表 3-12,§5 为逻辑删除约定 | 文档交叉引用待勘误 |
|
||||
| 14 | `application.yml` 的 `token.secret` 与 `.env` 占位文本不兼容 | 验收脚本内置临时 Base64 兜底 | 修正本机 `.env`;`run-local.ps1` 未内置兜底 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 变更点与影响范围、后续衔接
|
||||
|
||||
表 15-16 变更点与影响范围
|
||||
|
||||
| 类别 | 变更 | 影响范围 |
|
||||
| --- | --- | --- |
|
||||
| 新增 | `AptPrefixResolver`、`AptAudit`+切面+Mapper、`AptPageSupport`/`AptTableDataInfo`、`AptBaseController`、401/403 处理器、只读列表骨架、`09_it1_permissions.sql`、`verify-it1-m0.ps1`、本文档 | `dev/RuoYi-Vue-master` 后端、`dev/sql/apt_rev01`、`dev/scripts`、文档索引 |
|
||||
| 修改 | `SecurityConfig`(`/open/v1/**` 放行 + 401/403 处理器)、`application.yml`(springdoc 三组)、`AptContext`(`pageSize`)、`AptErrorCode`(表 4-4 全量)、`AptInfraExceptionHandler`(HTTP 状态 + `ServiceException`)、`AptConnectivityController`(`error-probe`)、`dev/.env.example` | 若依启动链路与非公寓端路径行为已做前缀隔离,后台管理保持原行为 |
|
||||
| 测试 | 32 → 41 用例;IT-0 自检用例断言改为真实 HTTP 400/403/503 | `ruoyi-admin` 构建 |
|
||||
| **不变** | CR-02 §12~§15 权威表述、跨端 20 编号、`v2-draft`、平台零回调、公寓本地库 59 张表、`apt.duty.state` 单写入口、统计结果不回写、`F-A`~`F-Q`、Q-01~Q-50 | 无设计口径改动;未新增 `M7` / 阶段六 / `D-4` / `Q-*` / `R-*` / 施工单编号 |
|
||||
| 提交纪律 | 本轮**未执行** `git commit` / `git push` | 变更清单待评审 |
|
||||
|
||||
表 15-17 后续衔接(`IT-2` 主数据与房间)
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 前置 | 本轮 M0 六项全过;`D8'` / `D3` / `D7` 签字;表 15-15 #11 / #12 的字段映射与包迁移 |
|
||||
| 范围 | 交路 CRUD + 复算校验 + 启停原因、房间档案 + 房态 + `state_version`、#1~#4 主数据下行 Mock(`source='LOCAL'`) |
|
||||
| 需补 | `BindException` / `MethodArgumentNotValidException` 的非公寓端类型分派;审计写入独立事务;只读骨架 Controller 迁入 `com.ruoyi.apt.master.*` |
|
||||
| 回归 | 表 7-3 M1/M2、表 7-6 八类用例 M0~M2 部分;本文件表 15-14 的 M0 五项须持续全绿 |
|
||||
| 延后 | `IT-9`~`IT-11` 消费 / 充值 / 菜品,本期不开 `/apt/ledger/**`、不建 `com.ruoyi.apt.ledger` 包 |
|
||||
@@ -33,6 +33,7 @@
|
||||
| `12-消费充值与菜品管理.md` | **消费、充值与菜品管理**:人员消费(选餐加菜显示金额 → 查双额度 → 补贴优先 / 现金补足 → 上报并同步平台账户)、人员线下充值(扫码识别 → 管理员核对 → 计入现金额度 → 同步平台)、公寓菜品管理(阶段性食谱发布 → 终端查看并加入选择菜单 → 远程点菜预留);四条状态链、`gy_ykt_*` / `YKT_MEALS` 补列、N-48~N-58 请求 / 响应字段、Q-41~Q-45、表 12-1~12-34 | CR-02 §14(CR-C31~CR-C35、F-L / F-M / F-N、验收 21~23) |
|
||||
| `13-公寓端小步迭代开发计划.md` | **实施排期设计稿(非设计变更)**:`dev/` 实测上游、`IT-0`~`IT-8` 迭代目标 / 范围 / 依赖 / 验收、里程碑 `MS-0`~`MS-5`、交付物 `DV-0`~`DV-8`、设计文档 × 代码模块对齐矩阵、6 项 P0 排期前提与 `IR-01`~`IR-14` 风险台账、跨迭代联调与回归基线、`IT-9`~`IT-11` 消费 / 充值 / 菜品衔接;表 13-1~13-32、图 13-1 | 开发排期与联调(**阶段 / 验收口径仍以 `07` 为准**) |
|
||||
| `14-第一轮开发(IT-0)交付说明.md` | **第一轮实现交付说明**:模块职责边界与依赖图、59 张表全量字段 / 主键 / 唯一键 / 索引设计、DDL 脚本与迁移顺序、自检接口的请求参数 / 返回结构 / 错误码约定、连接失败 / 超时 / 数据异常处理矩阵、20 项实测验证结果、口径偏离与后续衔接;表 14-1~14-23、图 14-1 | `IT-0` 落地与验收(代码在 `dev/RuoYi-Vue-master`、`dev/sql/apt_rev01`) |
|
||||
| `15-第二轮开发(IT-1)交付说明.md` | **第二轮实现交付说明**:运行时基座 M0 的四前缀治理与鉴权、统一错误码与表 4-4 HTTP 状态映射、审计切面落 `duty_operation_log`、分页上限 100、springdoc 分组、权限点种子与只读列表骨架、41 个单测与端到端 M0 5/5 验证结果、14 项口径偏离登记与后续衔接;表 15-1~15-17、图 15-1 | `IT-1` 落地与验收(代码在 `dev/RuoYi-Vue-master`、`dev/sql/apt_rev01`、`dev/scripts`) |
|
||||
| `../设计变更/03-三端功能设计修订(CR-02).md` | **权威增量**:三端覆盖范围、交互协议、交互数据、模块与数据表、关键流程变更、施工单 | CR-02 |
|
||||
|
||||
## 1. 编号与引用约定
|
||||
@@ -106,6 +107,7 @@ flowchart LR
|
||||
L --> M["12 消费、充值与菜品管理"]
|
||||
M --> N["13 小步迭代开发计划(排期 / 联调)"]
|
||||
N --> O["14 第一轮开发 IT-0 交付说明(实现 / 验收)"]
|
||||
O --> P["15 第二轮开发 IT-1 交付说明(运行时基座 M0)"]
|
||||
```
|
||||
|
||||
图 0-1 阅读路径
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
| v1.5 | 2026-11-10 | **补遗 §14**:新增「消费、充值与菜品管理」完整设计(`docs/公寓端/12`,`CF-01`~`CF-03`:人员消费、人员线下充值、公寓菜品管理),含四条状态链、双额度扣减规则、复用表补列结构、端内接口 N-48~N-58(请求 / 响应字段)与跨端 #1 / #4 / #10 / #16 的 payload 扩展。新增裁决 CR-C31~CR-C35、流程 F-L / F-M / F-N、错误码 40933~40940、决策点 Q-41~Q-45、验收 21~23、施工单 R-104~R-113;**不新增业务表(59 张不变)、不新增跨端编号、不新增 `bizType`、不新增状态字段**;仅线下充值不接线上支付,远程点菜仅预留入口 |
|
||||
| v1.6 | 2026-11-10 | **补遗 §15**:把公寓端已完成的四项调整(**平板叫乘、人脸签到、消费、菜品管理**)在应用端 / 平台端同步落地,统一业务规则、交互流程、权限控制、异常处理,并给出**三端状态映射与数据同步机制**唯一权威表(表 3-66);同时新增平台端「以公寓为单位的分类统计 / 月·季·年报表 / 公寓排名」能力(`docs/平台端/10`,`PS-01`~`PS-05`)。新增裁决 CR-C36~CR-C42、流程 F-O / F-P / F-Q、决策点 Q-46~Q-50、验收 24~26、施工单 R-114~R-125;**变更点与影响范围**见 §15 表 3-63。跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张**、不新增 `bizType`;统计能力全部落平台管理端 `/platform/v1/admin/stat/**`,新增 2 张 `pl_apt_stat_*` 属**平台库**(另计),统计结果不回写任何业务状态 |
|
||||
| v1.7 | 2026-11-10 | **非设计变更登记(索引同步)**:新增 `docs/公寓端/13-公寓端小步迭代开发计划.md`(v1.0,实施排期设计稿),以 `dev/` 的 RuoYi-Vue / Vue2 / MySQL 实测上游为物理起点,把 §11~§15 的设计成果切成 `IT-0`~`IT-8` 九个小步迭代(**P1 入住 + 叫乘核心闭环优先**)、里程碑 `MS-0`~`MS-5` 与交付物 `DV-0`~`DV-8`;**P2 消费 / 充值 / 菜品整体延后**为 `IT-9`~`IT-11`("设计已覆盖、实现延后",非删需求)。登记 6 项 P0 排期前提与 3 项口径偏离(表 13-4 / 表 13-6),须评审签字后方可执行。**变更点与影响范围**:新建 `公寓端/13` 1 篇,同步 `公寓端/00`、`公寓端/README.md`、`公寓端/07`、`公寓端/08`、`docs/README.md` 与本文表 0-2 / 0-3 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` / `M7` / 阶段六 / `D-4` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、平台库累计新增 5 张(含 2 张 `pl_apt_stat_*`)、`apt.duty.state` 单写入口、统计结果不回写全部不变;阶段与验收仍以 `docs/10` §9、`公寓端/07` 表 7-1~7-8 为权威,`13` 篇内 `IT-*` / `MS-*` / `DV-*` / `IR-*` 编号不纳入本文体系 |
|
||||
| v1.8 | 2026-11-10 | **非设计变更登记(索引同步)**:新增 `docs/公寓端/15-第二轮开发(IT-1)交付说明.md`(v1.0),登记 `IT-1 运行时基座 M0` 的实现与验收——四前缀治理与 `/apt/**` 鉴权、`gyid` 隔离、统一错误码与表 4-4 HTTP 状态映射、401/403 处理器、审计切面落 `duty_operation_log`、分页上限 100、springdoc 分组、权限点种子与只读列表骨架;41 个单测全绿,真实进程端到端 M0 **5/5 通过**(`dev/scripts/verify-it1-m0.ps1`)。**变更点与影响范围**:新建 `公寓端/15` 1 篇与 `dev/scripts/verify-it1-m0.ps1` 1 个脚本,同步 `公寓端/README.md`、`公寓端/14` 表 14-23、`docs/README.md` 与本文表 0-2 索引;**不改动** §12~§15 任何权威表述,**不新增**裁决 `CR-C*` / 流程 `F-*` / 决策点 `Q-*` / 验收 / 施工单 `R-*` 等既有编号;跨端仍 20 个编号、契约 `v2-draft`、平台零回调、公寓本地库 **59 张表**、`apt.duty.state` 单写入口、统计结果不回写全部不变;`IT-*` / `DV-*` 仍为 `13` 篇内编号,不纳入本文体系;契约冻结签字与 `D5` 签字仍为 P0 待办 |
|
||||
|
||||
表 0-3 本文件图表编号规则
|
||||
|
||||
|
||||
Reference in new issue
Block a user