第二次任务推进。
IT-0 verify / verify (push) Waiting to run

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

No files matched your search

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