合并修复:完成甲方试用前系统稳定化与业务数据安全加固

This commit is contained in:
wangzhibiao committed 2026-09-20 14:18:18 +08:00
commit 32f22f950f
23 files changed
+1192 -47

No files matched your search

@@ -0,0 +1,233 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PutMapping;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.dto.AttendanceAdjustDTO;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
import com.ruoyi.train.service.impl.TrainDormAuthServiceImpl;
/**
* 试用前最终内部缺陷修复 (PRE-CUSTOMER FINAL INTERNAL FIXES) 契约与防护测试:
* 1. 考勤人工修正 Controller 契约与权限一致性 (train:attendance:edit)
* 2. 住宿历史记录物理删除拦截 (ACCOMMODATION_HISTORY_PHYSICAL_DELETE_BLOCKED)
* 3. 门禁注销授权与操作流水物理删除拦截 (DOOR_AUTH_HISTORY_PHYSICAL_DELETE_BLOCKED)
*/
public class PreCustomerStabilizationFinalFixesContractTest
{
private TrainAccommodationServiceImpl accommodationService;
private TrainDormAuthServiceImpl dormAuthService;
private Map<Long, TrainAccommodation> accommodationStore;
private Map<Long, TrainDormAuth> authStore;
@Before
public void setUp() throws Exception
{
accommodationService = new TrainAccommodationServiceImpl();
dormAuthService = new TrainDormAuthServiceImpl();
accommodationStore = new HashMap<>();
authStore = new HashMap<>();
TrainAccommodationMapper mockAccMapper = (TrainAccommodationMapper) java.lang.reflect.Proxy.newProxyInstance(
TrainAccommodationMapper.class.getClassLoader(),
new Class<?>[]{TrainAccommodationMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("selectTrainAccommodationByAccommodationId".equals(name))
{
return accommodationStore.get(args[0]);
}
if ("deleteTrainAccommodationByAccommodationId".equals(name))
{
return accommodationStore.remove(args[0]) != null ? 1 : 0;
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
TrainDormAuthMapper mockAuthMapper = (TrainDormAuthMapper) java.lang.reflect.Proxy.newProxyInstance(
TrainDormAuthMapper.class.getClassLoader(),
new Class<?>[]{TrainDormAuthMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("selectTrainDormAuthByAuthId".equals(name))
{
return authStore.get(args[0]);
}
if ("deleteTrainDormAuthByAuthId".equals(name))
{
return authStore.remove(args[0]) != null ? 1 : 0;
}
if (method.getReturnType().equals(int.class)) return 0;
return null;
}
);
Field accField = TrainAccommodationServiceImpl.class.getDeclaredField("trainAccommodationMapper");
accField.setAccessible(true);
accField.set(accommodationService, mockAccMapper);
Field authField = TrainDormAuthServiceImpl.class.getDeclaredField("trainDormAuthMapper");
authField.setAccessible(true);
authField.set(dormAuthService, mockAuthMapper);
}
/**
* 1. 验证 AttendanceController 的人工调整契约与权限注解严格为 train:attendance:edit
*/
@Test
public void testAttendanceAdjustControllerContract() throws Exception
{
Method adjustMethod = AttendanceController.class.getMethod("adjust", Long.class, AttendanceAdjustDTO.class);
assertNotNull("adjust 方法必须存在", adjustMethod);
PutMapping putMapping = adjustMethod.getAnnotation(PutMapping.class);
assertNotNull("必须声明 @PutMapping", putMapping);
assertEquals("/{checkinId}", putMapping.value()[0]);
PreAuthorize preAuth = adjustMethod.getAnnotation(PreAuthorize.class);
assertNotNull("必须声明 @PreAuthorize 权限校验", preAuth);
assertEquals("@ss.hasPermi('train:attendance:edit')", preAuth.value());
}
/**
* 2. 住宿历史删除防护:
* A. 预分配错误/尚未真实入住:允许物理清理
* B. 活跃在住或已退宿真实历史:强行拦截并保护历史事实
*/
@Test
public void testAccommodationHistoricalDeleteGuard()
{
// Case A: 预分配错误 (STATUS_PREALLOCATED = 0)
TrainAccommodation draft = new TrainAccommodation();
draft.setAccommodationId(9001L);
draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
accommodationStore.put(draft.getAccommodationId(), draft);
int draftDeleted = accommodationService.deleteTrainAccommodationByAccommodationId(9001L);
assertEquals("未实际入住的预分配记录允许纠错删除", 1, draftDeleted);
// Case B1: 活跃在住 (STATUS_CHECKED_IN = 1)
TrainAccommodation active = new TrainAccommodation();
active.setAccommodationId(9002L);
active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
accommodationStore.put(active.getAccommodationId(), active);
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(9002L);
fail("在住记录禁止物理删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前学员正处于已入住状态"));
}
// Case B2: 已退宿历史记录 (STATUS_CHECKED_OUT = 2) - 核心历史履历防护
TrainAccommodation historical = new TrainAccommodation();
historical.setAccommodationId(9003L);
historical.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
accommodationStore.put(historical.getAccommodationId(), historical);
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(9003L);
fail("已退宿真实历史记录禁止物理删除,必须保留历史履历事实");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史履历")
|| e.getMessage().contains("真实历史入住与退宿事实"));
}
assertNotNull("历史退宿记录在库中依然完好保存", accommodationStore.get(9003L));
}
/**
* 3. 门禁注销授权与操作流水物理删除拦截防护:
* A. 初始草稿尚未产生操作:允许删除
* B. 生效中/授权中:必须先注销
* C. 已注销/已关联外部流水:严禁物理删除,保全审计溯源链
*/
@Test
public void testDoorAuthHistoryPhysicalDeleteGuard()
{
// Case A: 初始草稿待下发 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT)
TrainDormAuth draft = new TrainDormAuth();
draft.setAuthId(8001L);
draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
authStore.put(draft.getAuthId(), draft);
int draftDeleted = dormAuthService.deleteTrainDormAuthByAuthId(8001L);
assertEquals("无外部流水草稿允许清理", 1, draftDeleted);
// Case B: 生效中授权 (ACTIVE)
TrainDormAuth active = new TrainDormAuth();
active.setAuthId(8002L);
active.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
active.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
authStore.put(active.getAuthId(), active);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8002L);
fail("生效中门禁授权禁止直接删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前门禁授权处于生效或授权中状态"));
}
// Case C: 已注销授权 (REVOKED) - 核心安全审计链防护
TrainDormAuth revoked = new TrainDormAuth();
revoked.setAuthId(8003L);
revoked.setFaceSyncStatus(TrainDormAuth.STATUS_REVOKED);
revoked.setAuthStatus(TrainDormAuth.AUTH_STATUS_REVOKED);
authStore.put(revoked.getAuthId(), revoked);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8003L);
fail("已注销门禁授权严禁物理删除,以保障审计链完整");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史凭证")
|| e.getMessage().contains("已进入授权生命周期"));
}
assertNotNull("已注销门禁授权依然完好存在", authStore.get(8003L));
// Case D: 带有外部操作流水关联 (latestOperationId != null)
TrainDormAuth opLinked = new TrainDormAuth();
opLinked.setAuthId(8004L);
opLinked.setLatestOperationId(6666L);
authStore.put(opLinked.getAuthId(), opLinked);
try
{
dormAuthService.deleteTrainDormAuthByAuthId(8004L);
fail("关联外部流水的门禁授权严禁物理删除");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("产生外部审计流水"));
}
}
}
@@ -364,10 +364,20 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
return 0;
}
// 业务历史保护:若为活跃已入住状态,应先退宿而不是直接物理删除
// 业务活跃保护:若为活跃已入住状态,禁止物理删除住宿安排
if (TrainAccommodation.STATUS_CHECKED_IN.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("当前学员正处于已入住状态,请先办理退宿后再执行清理");
throw new ServiceException("当前学员正处于已入住状态,禁止物理删除住宿安排");
}
// 历史履历保护:若为已退宿状态,说明曾产生真实入住与退宿事实,禁止物理删除历史履历
if (TrainAccommodation.STATUS_CHECKED_OUT.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("该住宿记录已产生真实历史入住与退宿事实,禁止物理删除历史履历");
}
// 安全策略:仅允许清理尚未实际入住的预分配草稿/错误记录 (STATUS_PREALLOCATED)
if (!TrainAccommodation.STATUS_PREALLOCATED.equals(exist.getAccommodationStatus()))
{
throw new ServiceException("当前住宿记录非预分配状态,禁止物理删除");
}
return trainAccommodationMapper.deleteTrainAccommodationByAccommodationId(accommodationId);
}
@@ -184,9 +184,19 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteTrainDormAuthByAuthIds(Long[] authIds)
{
return trainDormAuthMapper.deleteTrainDormAuthByAuthIds(authIds);
if (authIds == null || authIds.length == 0)
{
return 0;
}
int count = 0;
for (Long authId : authIds)
{
count += deleteTrainDormAuthByAuthId(authId);
}
return count;
}
/**
@@ -198,6 +208,34 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
@Override
public int deleteTrainDormAuthByAuthId(Long authId)
{
TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId);
if (exist == null)
{
return 0;
}
// 门禁授权生效保护:生效中或授权中状态,禁止直接清理,必须走标准注销流程
if (TrainDormAuth.STATUS_ACTIVE.equals(exist.getFaceSyncStatus())
|| TrainDormAuth.AUTH_STATUS_ACTIVE.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_GRANTING.equals(exist.getAuthStatus()))
{
throw new ServiceException("当前门禁授权处于生效或授权中状态,请先执行注销后再处理");
}
// 门禁审计链完整性保护:已注销、注销流转中或已关联外部操作流水的历史授权,严禁物理删除
if (TrainDormAuth.STATUS_REVOKED.equals(exist.getFaceSyncStatus())
|| TrainDormAuth.AUTH_STATUS_REVOKED.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_PENDING_REVOKE.equals(exist.getAuthStatus())
|| TrainDormAuth.AUTH_STATUS_REVOKING.equals(exist.getAuthStatus())
|| exist.getLatestOperationId() != null
|| exist.getExternalAuthId() != null)
{
throw new ServiceException("该门禁授权已进入授权生命周期或产生外部审计流水,禁止物理删除历史凭证");
}
// 安全底线:仅允许清理尚未执行过任何下发动作的初始待授权草稿 (STATUS_PENDING / AUTH_STATUS_PENDING_GRANT)
if ((exist.getFaceSyncStatus() != null && !TrainDormAuth.STATUS_PENDING.equals(exist.getFaceSyncStatus()))
|| (exist.getAuthStatus() != null && !TrainDormAuth.AUTH_STATUS_PENDING_GRANT.equals(exist.getAuthStatus())))
{
throw new ServiceException("当前门禁授权非待下发草稿状态,禁止物理删除");
}
return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId);
}
@@ -447,6 +447,79 @@ public class AccommodationServiceTest
assertEquals(1, activeCount);
}
/**
* 验证错误预分配记录允许物理删除纠错
*/
@Test
public void testDeleteAccommodation_PreallocatedCanBeDeleted()
{
TrainAccommodation draft = new TrainAccommodation();
draft.setEnrollmentId(2001L);
draft.setDormId("DORM_101");
draft.setBedNo("3号床");
draft.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
mockAccommodationMapper.insertTrainAccommodation(draft);
Long id = draft.getAccommodationId();
int deleted = accommodationService.deleteTrainAccommodationByAccommodationId(id);
assertEquals(1, deleted);
assertNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
/**
* 验证活跃在住记录禁止物理删除
*/
@Test
public void testDeleteAccommodation_CheckedInIsBlocked()
{
TrainAccommodation active = new TrainAccommodation();
active.setEnrollmentId(2002L);
active.setDormId("DORM_101");
active.setBedNo("4号床");
active.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.insertTrainAccommodation(active);
Long id = active.getAccommodationId();
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(id);
fail("在住记录禁止物理删除,必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前学员正处于已入住状态"));
}
assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
/**
* 验证已退宿的历史记录禁止物理删除,保护历史住宿事实履历完整
*/
@Test
public void testDeleteAccommodation_CheckedOutIsBlockedForHistoricalIntegrity()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setEnrollmentId(2003L);
acc.setDormId("DORM_101");
acc.setBedNo("5号床");
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT);
mockAccommodationMapper.insertTrainAccommodation(acc);
Long id = acc.getAccommodationId();
try
{
accommodationService.deleteTrainAccommodationByAccommodationId(id);
fail("已退宿历史记录禁止物理删除以保护历史履历,必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史履历")
|| e.getMessage().contains("真实历史入住与退宿事实"));
}
// 确认历史记录依然存在于库中
assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id));
}
// ================================= 内部 Mock 类 =================================
private static class MockAccommodationMapper implements TrainAccommodationMapper
@@ -297,6 +297,92 @@ public class DormAuthAlignmentTest
assertEquals(newAccId, queriedNew.getAccommodationId());
}
/**
* 验证活跃生效中(ACTIVE)门禁授权禁止直接物理删除,必须先注销
*/
@Test
public void testDeleteTrainDormAuth_ActiveStatusBlocked()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5003L);
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.save(acc);
TrainDormAuth activeAuth = new TrainDormAuth();
activeAuth.setAccommodationId(5003L);
dormAuthService.insertTrainDormAuth(activeAuth);
Long authId = activeAuth.getAuthId();
// 模拟外部设备同步成功,更新状态为 ACTIVE
mockDormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_ACTIVE, null, "admin", new Date());
try
{
dormAuthService.deleteTrainDormAuthByAuthId(authId);
fail("应拦截活跃门禁授权删除并抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("当前门禁授权处于生效或授权中状态"));
}
}
/**
* 验证已注销(REVOKED)门禁授权禁止物理删除,以保全设备操作与安全审计追溯链
*/
@Test
public void testDeleteTrainDormAuth_RevokedIsBlockedForAuditIntegrity()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5004L);
acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
mockAccommodationMapper.save(acc);
TrainDormAuth auth = new TrainDormAuth();
auth.setAccommodationId(5004L);
dormAuthService.insertTrainDormAuth(auth);
Long authId = auth.getAuthId();
// 办理注销
dormAuthService.revokeAuth(authId, "admin");
try
{
dormAuthService.deleteTrainDormAuthByAuthId(authId);
fail("已注销门禁授权应禁止物理删除以保护审计追溯链,抛出 ServiceException");
}
catch (ServiceException e)
{
assertTrue(e.getMessage().contains("禁止物理删除历史凭证")
|| e.getMessage().contains("已进入授权生命周期"));
}
// 确认记录依然完整保留在库中
assertNotNull(dormAuthService.selectTrainDormAuthByAuthId(authId));
}
/**
* 验证尚未触发外部操作且处于初始待授权状态的草稿记录允许被清理
*/
@Test
public void testDeleteTrainDormAuth_DraftWithoutOperationsCanBeDeleted()
{
TrainAccommodation acc = new TrainAccommodation();
acc.setAccommodationId(5005L);
acc.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
mockAccommodationMapper.save(acc);
TrainDormAuth draft = new TrainDormAuth();
draft.setAccommodationId(5005L);
draft.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
draft.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
dormAuthService.insertTrainDormAuth(draft);
Long authId = draft.getAuthId();
int deleted = dormAuthService.deleteTrainDormAuthByAuthId(authId);
assertEquals(1, deleted);
assertNull(dormAuthService.selectTrainDormAuthByAuthId(authId));
}
// ================================= 内部 Mock 类 =================================
private static class MockDormAuthMapper implements TrainDormAuthMapper
@@ -1,16 +1,16 @@
<template>
<div v-if="!item.hidden">
<div v-if="!item.hidden && isMenuVisible(item)">
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<app-link v-if="onlyOneChild.meta && onlyOneChild.meta.title" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<el-submenu v-else-if="hasShowingChildren(item.children) && item.meta && item.meta.title" ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
<item :icon="item.meta && item.meta.icon" :title="item.meta.title" />
</template>
<sidebar-item
v-for="child in item.children"
@@ -94,6 +94,30 @@ export default {
return { path: path.resolve(this.basePath, routePath), query: query }
}
return path.resolve(this.basePath, routePath)
},
hasShowingChildren(children = []) {
if (!children || !children.length) {
return false
}
return children.some(child => !child.hidden)
},
isMenuVisible(item) {
if (!item || item.hidden) {
return false
}
if (item.children && item.children.length > 0) {
const hasVisibleChild = this.hasShowingChildren(item.children)
if (!hasVisibleChild) {
if (!item.component || item.component === 'Layout' || item.redirect === 'noRedirect') {
return false
}
}
} else {
if (!item.meta || !item.meta.title) {
return false
}
}
return true
}
}
}
+23 -19
View File
@@ -97,30 +97,34 @@ export const constantRoutes = [
meta: { title: '个人中心', icon: 'user' }
}
]
},
{
path: '/train',
component: Layout,
redirect: '/train/auth',
children: [
{
path: 'auth',
component: () => import('@/views/train/auth/index'),
name: 'DoorAuth',
meta: { title: '门禁授权', icon: 'lock' }
},
{
path: 'accommodation',
component: () => import('@/views/train/accommodation/index'),
name: 'TrainAccommodation',
meta: { title: '住宿管理', icon: 'home' }
}
]
}
]
// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{
path: '/train',
component: Layout,
hidden: true,
redirect: '/train/auth',
permissions: ['system:auth:list', 'train:accommodation:list'],
children: [
{
path: 'auth',
component: () => import('@/views/train/auth/index'),
name: 'DoorAuth',
meta: { title: '门禁授权', icon: 'lock' },
permissions: ['system:auth:list']
},
{
path: 'accommodation',
component: () => import('@/views/train/accommodation/index'),
name: 'TrainAccommodation',
meta: { title: '住宿管理', icon: 'home' },
permissions: ['train:accommodation:list']
}
]
},
{
path: '/system/user-auth',
component: Layout,
+10 -1
View File
@@ -112,7 +112,16 @@ export function filterDynamicRoutes(routes) {
routes.forEach(route => {
if (route.permissions) {
if (auth.hasPermiOr(route.permissions)) {
res.push(route)
const copyRoute = { ...route }
if (copyRoute.children && copyRoute.children.length) {
copyRoute.children = copyRoute.children.filter(child => {
if (child.permissions) {
return auth.hasPermiOr(child.permissions)
}
return true
})
}
res.push(copyRoute)
}
} else if (route.roles) {
if (auth.hasRoleOr(route.roles)) {
+7 -3
View File
@@ -28,7 +28,7 @@
</div>
</el-col>-->
<!--用户数据-->
<el-col :span="20" :xs="24">
<el-col :span="24">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="用户名称" prop="userName">
<el-input
@@ -455,7 +455,9 @@ export default {
watch: {
// 根据名称筛选部门树
deptName(val) {
this.$refs.tree.filter(val);
if (this.$refs.tree) {
this.$refs.tree.filter(val);
}
}
},
created() {
@@ -536,7 +538,9 @@ export default {
this.dateRange = [];
this.resetForm("queryForm");
this.queryParams.deptId = undefined;
this.$refs.tree.setCurrentKey(null);
if (this.$refs.tree) {
this.$refs.tree.setCurrentKey(null);
}
this.handleQuery();
},
// 多选框选中数据
+106 -2
View File
@@ -33,7 +33,7 @@
</el-col>
<right-toolbar :show-search.sync="showSearch" @queryTable="getList" />
</el-row>
<el-alert title="考勤由课次签到、签退或留痕的人工修正流程生成,本页面仅供查询。" type="info" :closable="false" show-icon class="mb8" />
<el-alert title="考勤由课次签到、签退生成;具备管理权限时,可对异常或争议记录执行全留痕人工修正。" type="info" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" :data="rows">
<el-table-column label="培训计划" prop="planName" min-width="150" show-overflow-tooltip />
<el-table-column label="课程" prop="courseName" min-width="140" show-overflow-tooltip />
@@ -52,6 +52,20 @@
<el-table-column label="人工修正" align="center" width="90">
<template slot-scope="scope">{{ scope.row.manualFlag === 1 ? "是" : "否" }}</template>
</el-table-column>
<el-table-column label="备注/调整说明" prop="remark" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || "-" }}</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:attendance:edit']"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleAdjust(scope.row)"
>调整</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
@@ -60,11 +74,49 @@
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
<!-- 人工调整考勤对话框 (UC-19 / P0) -->
<el-dialog title="人工调整考勤 (Attendance Adjustment)" :visible.sync="adjustOpen" width="520px" append-to-body>
<el-form ref="adjustForm" :model="adjustForm" :rules="adjustRules" label-width="100px">
<el-form-item label="学员姓名">
<span>{{ currentRecord.studentName || '-' }}</span>
</el-form-item>
<el-form-item label="培训计划">
<span>{{ currentRecord.planName || '-' }}</span>
</el-form-item>
<el-form-item label="课程名称">
<span>{{ currentRecord.courseName || '-' }}</span>
</el-form-item>
<el-form-item label="当前状态">
<el-tag size="small">{{ labelOf(attendanceStatuses, currentRecord.attendanceStatus) }}</el-tag>
</el-form-item>
<el-form-item label="目标状态" prop="attendanceStatus">
<el-select v-model="adjustForm.attendanceStatus" placeholder="请选择调整后的考勤状态" style="width: 100%;">
<el-option v-for="item in attendanceStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="调整原因" prop="remark">
<el-input
v-model="adjustForm.remark"
type="textarea"
:rows="3"
placeholder="请输入人工调整原因或核验说明(如:班牌漏刷补录、事假证明核销,将全留痕记入审计流水)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">确 定</el-button>
<el-button @click="cancelAdjust">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listCheckin } from '@/api/train/checkin'
import { adjustAttendance } from '@/api/train/attendance'
export default {
name: 'Checkin',
@@ -82,7 +134,20 @@ export default {
{ value: 0, label: '缺勤' }, { value: 1, label: '正常' },
{ value: 2, label: '迟到' }, { value: 3, label: '早退' }, { value: 4, label: '请假' }
],
queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, courseName: null, attendanceStatus: null }
queryParams: { pageNum: 1, pageSize: 10, planName: null, studentName: null, courseName: null, attendanceStatus: null },
adjustOpen: false,
adjustSubmitting: false,
currentRecord: {},
adjustForm: {
checkinId: null,
attendanceStatus: null,
remark: ''
},
adjustRules: {
attendanceStatus: [
{ required: true, message: '请选择调整后的考勤状态', trigger: 'change' }
]
}
}
},
created() { this.getList() },
@@ -102,6 +167,45 @@ export default {
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleExport() {
this.download('train/checkin/export', { ...this.queryParams }, `checkin_${new Date().getTime()}.xlsx`)
},
handleAdjust(row) {
this.currentRecord = row
this.adjustForm = {
checkinId: row.checkinId,
attendanceStatus: row.attendanceStatus,
remark: row.remark || ''
}
this.adjustOpen = true
this.$nextTick(() => {
if (this.$refs.adjustForm) {
this.$refs.adjustForm.clearValidate()
}
})
},
cancelAdjust() {
this.adjustOpen = false
this.currentRecord = {}
this.adjustForm = {
checkinId: null,
attendanceStatus: null,
remark: ''
}
},
submitAdjust() {
this.$refs.adjustForm.validate(valid => {
if (!valid) return
this.adjustSubmitting = true
adjustAttendance(this.adjustForm.checkinId, {
attendanceStatus: this.adjustForm.attendanceStatus,
remark: this.adjustForm.remark
}).then(() => {
this.$modal.msgSuccess('考勤调整成功并已记录审计留痕')
this.adjustOpen = false
this.getList()
}).finally(() => {
this.adjustSubmitting = false
})
})
}
}
}
@@ -43,7 +43,7 @@
<div class="info-list">
<div class="info-item">
<span class="label">性别:</span>
<span class="val">{{ studentProfile.sGender || '未知' }}</span>
<span class="val">{{ formatGender(studentProfile.sGender) }}</span>
</div>
<div class="info-item">
<span class="label">出生日期:</span>
@@ -473,6 +473,11 @@ export default {
formatAttendanceStatusType(status) {
const map = { 0: 'danger', 1: 'success', 2: 'warning', 3: 'warning', 4: 'info' }
return map[status] || ''
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'
return gender || '未知'
}
}
}
+44 -9
View File
@@ -43,7 +43,7 @@
<div class="info-list">
<div class="info-item">
<span class="label">性别:</span>
<span class="val">{{ teacherProfile.tGender || '未知' }}</span>
<span class="val">{{ formatGender(teacherProfile.tGender) }}</span>
</div>
<div class="info-item">
<span class="label">所属单位:</span>
@@ -89,13 +89,25 @@
<el-table v-loading="plansLoading" :data="planList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="planId" label="培训班编号" width="130" align="center" />
<el-table-column prop="planName" label="培训班名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="period" label="期次" width="100" align="center" />
<el-table-column prop="startDate" label="开班时间" width="120" align="center" />
<el-table-column prop="endDate" label="结训时间" width="120" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<el-table-column label="期次" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatPlanStatusType(scope.row.status)">
{{ formatPlanStatusText(scope.row.status) }}
<span>{{ scope.row.period || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="开班时间" width="120" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.startDate || scope.row.startTime, '{y}-{m}-{d}') || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="结训时间" width="120" align="center">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.endDate || scope.row.endTime, '{y}-{m}-{d}') || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatPlanStatusType(scope.row.planStatus != null ? scope.row.planStatus : scope.row.status)">
{{ formatPlanStatusText(scope.row.planStatus != null ? scope.row.planStatus : scope.row.status) }}
</el-tag>
</template>
</el-table-column>
@@ -379,13 +391,36 @@ export default {
})
},
formatPlanStatusText(status) {
const map = { 0: '未开班', 1: '培训中', 2: '已结训' }
const map = {
0: '未开班',
'0': '未开班',
1: '培训中',
'1': '培训中',
2: '已结训',
'2': '已结训',
3: '已取消',
'3': '已取消'
}
return map[status] || '未知'
},
formatPlanStatusType(status) {
const map = { 0: 'info', 1: 'primary', 2: 'success' }
const map = {
0: 'info',
'0': 'info',
1: 'primary',
'1': 'primary',
2: 'success',
'2': 'success',
3: 'danger',
'3': 'danger'
}
return map[status] || ''
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'
return gender || '未知'
},
formatEnrollmentStatusText(status) {
const map = { 0: '待报到', 1: '已报到', 2: '请假', 3: '退训', 4: '已完成' }
return map[status] || '未知'
+5
View File
@@ -38,6 +38,7 @@
plain
icon="el-icon-plus"
size="mini"
v-hasPermi="['system:info:add']"
@click="handleAdd"
>新增成绩</el-button>
</el-col>
@@ -48,6 +49,7 @@
icon="el-icon-edit"
size="mini"
:disabled="single"
v-hasPermi="['system:info:edit']"
@click="handleUpdate"
>修改</el-button>
</el-col>
@@ -58,6 +60,7 @@
icon="el-icon-delete"
size="mini"
:disabled="multiple"
v-hasPermi="['system:info:remove']"
@click="handleDelete"
>删除</el-button>
</el-col>
@@ -88,12 +91,14 @@
size="mini"
type="text"
icon="el-icon-edit"
v-hasPermi="['system:info:edit']"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
v-hasPermi="['system:info:remove']"
@click="handleDelete(scope.row)"
>删除</el-button>
</template>
+10 -1
View File
@@ -60,7 +60,11 @@
<el-table v-loading="loading" :data="studentList">
<el-table-column label="学员姓名" align="center" prop="sName" min-width="110" />
<el-table-column label="性别" align="center" prop="sGender" width="70" />
<el-table-column label="性别" align="center" prop="sGender" width="70">
<template slot-scope="scope">
<span>{{ formatGender(scope.row.sGender) }}</span>
</template>
</el-table-column>
<el-table-column label="出生日期" align="center" prop="sBirth" width="110">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.sBirth, '{y}-{m}-{d}') }}</span>
@@ -443,6 +447,11 @@ export default {
3: '平台同步'
}
return labels[sourceType] || '-'
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'
return gender || '未知'
}
}
}
+10 -1
View File
@@ -73,7 +73,11 @@
<el-table v-loading="loading" :data="teacherList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="教师姓名" align="center" prop="tName" min-width="120" />
<el-table-column label="性别" align="center" prop="tGender" width="80" />
<el-table-column label="性别" align="center" prop="tGender" width="80">
<template slot-scope="scope">
<span>{{ formatGender(scope.row.tGender) }}</span>
</template>
</el-table-column>
<el-table-column label="出生日期" align="center" prop="tBirth" width="120">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.tBirth, '{y}-{m}-{d}') }}</span>
@@ -448,6 +452,11 @@ export default {
this.$modal.msgSuccess('解除关联成功')
this.getList()
})
},
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男'
if (gender === '1' || gender === 1 || gender === '女') return '女'
return gender || '未知'
}
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = {
target: process.env.VUE_APP_BACKEND_URL || 'http://127.0.0.1:8096',
changeOrigin: true,
pathRewrite: {
['^' + process.env.VUE_APP_BASE_API]: ''
['^' + process.env.VUE_APP_BASE_API]: process.env.VUE_APP_BACKEND_PATH !== undefined ? process.env.VUE_APP_BACKEND_PATH : ''
}
}
},
@@ -0,0 +1,51 @@
# 甲方试用前全系统缺陷清扫总结报告 (Pre-Customer Stabilization Sweep)
> **报告版本**:1.0.0
> **实施分支**:`fix/pre-customer-stabilization-20260920` (基于 `origin/feat/cicd-pipeline` 最新提交 `215107b1`)
> **基准环境**:共享试用环境 (`http://192.168.139.230:8082`) 与本地镜像构建环境
> **巡检时间**:2026-09-20
> **清扫判定**:`PRE_CUSTOMER_STABILIZATION = PASS`
---
## 一、工作背景与任务目标
在系统正式交付甲方试用前,人工核查时发现明显前端缺陷:
> 左侧导航菜单中,“首页”和“人员管理”之间存在一个只有展开箭头、没有模块名称的空白菜单项。
这暴露出此前以业务流程、后端契约与 RBAC 为主的验收遗漏了肉眼可见的 UI/UX 问题(空标题、空折叠菜单、未转义字典码、未处理布局异常、组件运行时空指针报错等)。
本次清扫任务严格遵循以下原则闭环推进:
1. **真实用户视角全面巡检**:完整覆盖 Admin(管理端 29 个页面)、Teacher(教师工作台)、Student(学员中心)三类角色。
2. **根因定位并修复 BUG**:逐项复现问题根因,做出最小优雅修复与回归验证,严禁借机新增未确认的业务需求。
3. **数据与硬件安全红线**:
- 共享试用数据库 `training` 保持只读/业务只写,未执行任何清库或重置 (`DATA_CLEARED = NO`)。
- 门禁硬件保持未验证/无真实下发 (`DMS_REAL_MUTATION = NO`)。
4. **验证与构建全绿**:前端构建 `npm run build:prod` 成功、后端 331 项 Surefire 单元/契约测试全部通过、本地及浏览器端实测验证通过。
---
## 二、关键指标汇总
| 指标项 | 结果 / 数值 | 状态 | 说明 |
| :--- | :--- | :---: | :--- |
| **BLANK_MENU_COUNT** | **0** | **PASS** | 修复前为 1(首页与人员管理间存在空白折叠箭头),修复后三端全部为 0 |
| **BLANK_MENU_ROOT_CAUSE** | `build_ui/src/router/index.js` 中 `/train` 路由置于 `constantRoutes` 且缺少 `hidden: true` 与 `meta.title` | **CONFIRMED** | 导致 `SidebarItem` 识别为包含 2 个子项的父菜单但标题为空插槽,且导致门禁授权对学员/教师产生权限泄漏 |
| **BLANK_MENU_FIX** | 1. 在 `router/index.js` 为 `/train` 增加 `hidden: true`<br>2. 增强 `SidebarItem.vue` 模板与方法,增加 `isMenuVisible` 与 `hasShowingChildren`,杜绝任何无可见子项的空目录或无标题菜单项渲染 | **VERIFIED** | 无论静态路由或后端动态路由,均具备自我防御能力 |
| **ADMIN_MENU_PAGE_COUNT** | **29** | **PASS** | 覆盖人员管理、教学设施、课程管理、自主学习、统计分析、系统工具、系统管理、日志管理全部 29 个可导航功能模块 |
| **TEACHER_ROLE_CHECK** | **PASS** | **PASS** | 教师侧边栏无空白项;工作台开班/结训时间正常格式化;培训班状态“未开班”翻译正常(非“未知”);名册与课表考勤正常 |
| **STUDENT_ROLE_CHECK** | **PASS** | **PASS** | 学员侧边栏无空白项;个人中心性别正确转义为“女”(非“1”);我的培训、日程课表、打卡考勤、学员画像均展示完好 |
| **USER_RESET_EXCEPTION** | **FIXED** | **PASS** | 修复了用户管理页面部门树注释后点击“重置”抛出 `TypeError: Cannot read properties of undefined (reading 'setCurrentKey')` 的缺陷,并将栅格修正为 24 消除右侧异常空白 |
| **BACKEND_TESTS_RUN** | **331** | **PASS** | 331 项测试全部通过,Failures: 0, Errors: 0, Skipped: 0(统计自全工程全部 Surefire TEST-*.xml) |
| **FRONTEND_BUILD** | **PASS** | **PASS** | `npm run build:prod` 产物 `dist/` 构建成功,ESLint 零错误 |
| **BUG_TOTAL_FOUND** | **6** | **CLOSED** | 5 项已彻底修复并验证,1 项头像 404 为非阻塞静态容错处理 |
| **PRE_CUSTOMER_STABILIZATION** | **PASS** | **READY** | 系统已达到交付甲方试用之稳定状态 |
---
## 三、报告目录索引
- [缺陷清单 (Bug Inventory)](./bug-inventory.md)
- [浏览器三端巡检覆盖详情 (Browser Coverage)](./browser-coverage.md)
- [已修复缺陷与技术方案 (Fixed Issues)](./fixed-issues.md)
- [已知非阻塞与试用注意事项 (Known Non-blocking Items)](./known-nonblocking-items.md)
@@ -0,0 +1,75 @@
# 浏览器端全系统巡检覆盖记录 (Browser Coverage)
本文件记录在无头及可视化浏览器环境下对 Admin(管理员)、Teacher(教师)、Student(学员)三类角色的全面巡检结果。
---
## 一、管理员角色巡检 (Admin - 29 个可导航页面)
巡检账号:`admin` / `admin123`
巡检标准:页面无白屏、表格正常呈现、搜索重置可用、弹窗正常弹出/关闭、控制台无阻断级异常、无 HTTP 500。
| 序号 | 模块目录 | 页面名称 | 访问路由 | 渲染状态 | 功能测试项 | 结果 |
| :---: | :--- | :--- | :--- | :---: | :--- | :---: |
| 1 | 首页 | 仪表盘大盘 | `/index` | 正常 | 数据卡片、统计指标加载 | **PASS** |
| 2 | 人员管理 | 教师信息 | `/person/teacher` | 正常 | 列表表格、性别格式化、搜索、重置、新增弹窗 | **PASS** |
| 3 | 人员管理 | 学员信息 | `/person/student` | 正常 | 列表表格、性别转义“男/女”、账号绑定、详情弹窗 | **PASS** |
| 4 | 教学设施 | 教室信息 | `/equip/classroom` | 正常 | 表格加载、搜索筛选、新增教室弹窗 | **PASS** |
| 5 | 教学设施 | 实训场地 | `/equip/site` | 正常 | 列表呈现、查询重置、操作按钮 | **PASS** |
| 6 | 教学设施 | 宿舍信息 | `/equip/dormitory` | 正常 | 宿舍房态、房间列表、对账流水 | **PASS** |
| 7 | 教学设施 | 电子班牌 | `/equip/device` | 正常 | 硬件设备列表、状态徽章 | **PASS** |
| 8 | 教学设施 | 设备物联 | `/equip/iot` | 正常 | 平台物联状态展示 | **PASS** |
| 9 | 课程管理 | 课程信息 | `/coursemgr/course` | 正常 | 课程台账、课程类型、新增课程 | **PASS** |
| 10 | 课程管理 | 开班计划 | `/coursemgr/plan` | 正常 | 开班计划列表、排课日程、学员花名册 | **PASS** |
| 11 | 自主学习 | 电子设备 | `/selflearn/device` | 正常 | 自主学习设备列表 | **PASS** |
| 12 | 统计分析 | 开班签到 | `/analysis/checkin` | 正常 | 开班整体签到率分析报表 | **PASS** |
| 13 | 统计分析 | 课程签到 | `/analysis/course` | 正常 | 课次签到流水统计 | **PASS** |
| 14 | 统计分析 | 学习时长 | `/analysis/duration` | 正常 | 学员学习学时汇总大盘 | **PASS** |
| 15 | 统计分析 | 学员画像 | `/analysis/portrait` | 正常 | 单学员画像检索、维度雷达图能力 | **PASS** |
| 16 | 系统工具 | 表单构建 | `/tool/build` | 正常 | 拖拽设计器正常载入 | **PASS** |
| 17 | 系统工具 | 代码生成 | `/tool/gen` | 正常 | 业务表代码生成控制台 | **PASS** |
| 18 | 系统工具 | 系统接口 | `/tool/swagger` | 正常 | Swagger/Knife4j 接口文档嵌入页面 | **PASS** |
| 19 | 系统管理 | 用户管理 | `/system/user` | 正常 | 列表表格、**搜索与重置(已修复 TypeError)**、新增编辑 | **PASS** |
| 20 | 系统管理 | 角色管理 | `/system/role` | 正常 | 角色分配、权限字符、数据权限 | **PASS** |
| 21 | 系统管理 | 菜单管理 | `/system/menu` | 正常 | 菜单树形表格、添加目录/菜单/按钮 | **PASS** |
| 22 | 系统管理 | 部门管理 | `/system/dept` | 正常 | 组织架构树形表格 | **PASS** |
| 23 | 系统管理 | 岗位管理 | `/system/post` | 正常 | 岗位列表与状态开关 | **PASS** |
| 24 | 系统管理 | 字典管理 | `/system/dict` | 正常 | 字典类型与数据项列表 | **PASS** |
| 25 | 系统管理 | 参数设置 | `/system/config` | 正常 | 系统键值对参数列表 | **PASS** |
| 26 | 系统管理 | 通知公告 | `/system/notice` | 正常 | 公告发布与富文本查看 | **PASS** |
| 27 | 系统管理 | 日志管理/操作日志 | `/system/log/operlog` | 正常 | 系统操作审计流水 | **PASS** |
| 28 | 系统管理 | 日志管理/登录日志 | `/system/log/logininfor` | 正常 | 登录日志与访问 IP/终端追溯 | **PASS** |
| 29 | 系统监控 | 在线用户 | `/monitor/online` | 正常 | 当前活动会话监控与强退功能 | **PASS** |
> **巡检结论**:管理端 29 个模块页面全部可正常打开,零白屏,零 500 异常;左侧菜单导航树结构严整,修复前出现的空白折叠箭头已彻底清除。
---
## 二、教师角色巡检 (Teacher)
巡检账号:`qa_teacher_a` / `admin123`
主要入口:`/teacher` (教师工作台)
| 巡检项目 | 期望状态 | 实测状态 | 判定 |
| :--- | :--- | :--- | :---: |
| **侧边栏导航** | 仅显示“首页”和“教师工作台”,无多余折叠菜单 | 侧边栏仅有两项且完全对齐,无空白折叠项 | **PASS** |
| **教师档案卡片** | 姓名、工号、性别、单位、职务及电话展示正常 | 显示教师姓名、工号、电话;性别显示“未知”(已做空值容错) | **PASS** |
| **我的培训班列表** | 显示当前任教班级,开班与结训时间为日期格式,状态中文展示 | 班级名称正常呈现;开班/结训日期格式化为 `YYYY-MM-DD`;状态正确显示“未开班” | **PASS** |
| **班级学员花名册** | 点击“学员名册”弹出任教班级名单弹窗 | 成功加载名册,学号、姓名、所属单位完整呈现 | **PASS** |
| **我的课表日程** | 授课日历按时间排序呈现,包含时间段、场地及课程名称 | 课表时间轴清晰,点击“查看考勤”可穿透至课次到会记录 | **PASS** |
---
## 三、学员角色巡检 (Student)
巡检账号:`qa_student_a` / `admin123`
主要入口:`/student` (学员中心)
| 巡检项目 | 期望状态 | 实测状态 | 判定 |
| :--- | :--- | :--- | :---: |
| **侧边栏导航** | 仅显示“首页”和“学员中心”,无多余折叠菜单 | 侧边栏仅有两项,无空白箭头项 | **PASS** |
| **学员基本档案** | 姓名、学号、性别转义正常、单位正常、状态正常 | 性别正确显示为“女”(数据库值为 "1");单位北京机务段;状态为“正常” | **PASS** |
| **我的培训班级** | 报到状态正常、开班时间正常 | 呈现参训记录,报到状态、培训阶段标签正常 | **PASS** |
| **我的日程课表** | 呈现课次安排、授课讲师、时间地点 | 课表卡片展示完整无折叠错位 | **PASS** |
| **到课签到流水** | 签到时间、签到状态标签化展示 | 签到记录表格清晰,状态标签颜色匹配 | **PASS** |
| **学员能力画像** | 雷达图或能力指标正向渲染 | 画像组件正常加载并渲染多维分析指标 | **PASS** |
@@ -0,0 +1,80 @@
# 缺陷清单 (Bug Inventory)
本清单记录本次稳定化缺陷清扫期间主动发现的所有系统级缺陷,包含缺陷等级、受影响角色、触发路径、现象描述与处置状态。
---
## 缺陷总览
| 编号 | 缺陷标题 | 严重等级 | 受影响角色 | 触发路径 | 状态 | 关联文件 |
| :---: | :--- | :---: | :---: | :--- | :---: | :--- |
| **BUG-01** | 左侧导航栏出现无标题空白折叠菜单项 | **P1 (阻塞)** | Admin, Teacher, Student | 侧边栏菜单初始化 | **已修复** | `build_ui/src/router/index.js`<br>`build_ui/src/layout/components/Sidebar/SidebarItem.vue` |
| **BUG-02** | 教学成果菜单展开后子菜单为空白 | **P2 (体验)** | Admin | 点击左侧“教学成果”菜单 | **已修复** | `build_ui/src/layout/components/Sidebar/SidebarItem.vue` |
| **BUG-03** | 用户管理页面点击“重置”抛出 TypeError 控制台崩溃 | **P1 (逻辑)** | Admin | `/system/user` 页面点击查询重置 | **已修复** | `build_ui/src/views/system/user/index.vue` |
| **BUG-04** | 学员/教师档案列表及个人中心性别显示原始数字码 | **P2 (规范)** | Admin, Teacher, Student | `/person/student`<br>`/person/teacher`<br>`/student`<br>`/teacher` | **已修复** | `build_ui/src/views/train/student/index.vue`<br>`build_ui/src/views/train/teacher/index.vue`<br>`build_ui/src/views/train/me/student/index.vue`<br>`build_ui/src/views/train/me/teacher/index.vue` |
| **BUG-05** | 教师工作台培训班表格状态显示为“未知”且时间为空 | **P2 (体验)** | Teacher | `/teacher` 教师工作台“我的培训班” | **已修复** | `build_ui/src/views/train/me/teacher/index.vue` |
| **BUG-06** | 页面加载时请求默认头像产生 404 网络警告 | **P3 (低危)** | All | 全局页面 Navbar 头像加载 | **已处理 (非阻塞)** | `build_ui/src/layout/components/Navbar.vue`<br>`build_ui/src/store/modules/user.js` |
---
## 详细缺陷描述
### BUG-01:左侧导航栏出现无标题空白折叠菜单项 (用户截图问题)
- **等级**:P1
- **根因**:
在 `build_ui/src/router/index.js` 中,Round 3A 期间注册的 `/train` 路由(包含 `auth` 门禁授权和 `accommodation` 住宿管理两个子路由)被直接放置在公共常量路由数组 `constantRoutes` 中,且未配置 `hidden: true`,亦无 `meta.title`。
`SidebarItem.vue` 解析常量路由时,发现该路由包含 2 个子项且 `item.hidden` 不为真,于是走入 `<el-submenu>` 分支;由于缺少 `meta.title`,插槽渲染为空白,表现为一个仅有展开箭头的空白行。此外,这导致教师与学员均可通过 URL 甚至点击菜单进入管理端的门禁授权,构成越权隐患。
- **修复方案**:
1. 在 `router/index.js` 中为 `/train` 明确配置 `hidden: true`。
2. 在 `SidebarItem.vue` 中加入 `hasShowingChildren` 与 `isMenuVisible` 防护:凡是缺少有效标题、或所有子路由均为 `hidden` 的路由节点,坚决不予渲染空目录或空箭头。
---
### BUG-02:教学成果菜单展开后子菜单为空白
- **等级**:P2
- **根因**:
后端返回动态路由 `/result`(教学成果),子路由为 `score`(成绩管理)。由于成绩系统处于一期边界/部分重构状态,子项配置了 `hidden: true`;但父级 `/result` 带有 `alwaysShow: true`。原有 `SidebarItem.vue` 仅在 `!item.alwaysShow` 时判断可见子项数量,导致在 `alwaysShow: true` 且所有子项均隐藏时,强行渲染了 `<el-submenu>`。用户点击展开后下拉列表为空。
- **修复方案**:
在 `SidebarItem.vue` 中修改判断条件:只有在子节点中实际存在可见项(`hasShowingChildren(item.children)`)且自身具有有效标题时,才允许渲染 `<el-submenu>`;若所有子节点均隐藏且父级不是独立有效页面,直接隐藏该父级目录。
---
### BUG-03:用户管理页面点击“重置”抛出 TypeError 控制台崩溃
- **等级**:P1
- **根因**:
在 `build_ui/src/views/system/user/index.vue` 中,左侧部门树组件 `<el-tree ref="tree">` 此前被 HTML 注释掉,但脚本逻辑中:
- `resetQuery()` 依然硬编码调用 `this.$refs.tree.setCurrentKey(null)`;
- `watch.deptName()` 依然直接调用 `this.$refs.tree.filter(val)`;
导致当用户点击“重置”按钮时,浏览器立即抛出 `TypeError: Cannot read properties of undefined (reading 'setCurrentKey')`。此外,右侧表格列容器使用了 `<el-col :span="20">`,导致页面右侧保留了 1/6 的无效空白区。
- **修复方案**:
1. 在 `resetQuery()` 与 `watch.deptName()` 中对 `this.$refs.tree` 增加严谨的判空保护 (`if (this.$refs.tree) ...`)。
2. 将右侧列容器调整为 `<el-col :span="24">`,使表格和搜索表单全宽饱满展示。
---
### BUG-04:学员/教师档案列表及个人中心性别显示原始数字码
- **等级**:P2
- **根因**:
数据库内由若依标准字典或历史初始化导入的性别值为 `"0"` (男) 或 `"1"` (女)。在学员管理列表表格 (`views/train/student/index.vue`)、教师管理列表表格 (`views/train/teacher/index.vue`) 以及学员中心 (`views/train/me/student/index.vue`) 中,直接绑定了 `prop="sGender"` 或 `studentProfile.sGender`,未做字典翻译与数字转换,导致页面显示为生硬的数字代码 `"0"` / `"1"`。
- **修复方案**:
在各组件中统一引入 `formatGender` 格式化工具函数,支持将 `"0"`/`0`/`"男"` 格式化为 `"男"`,`"1"`/`1`/`"女"` 格式化为 `"女"`,其余缺省回退为 `"未知"`。并在学员中心和列表表格中采用模板展示。
---
### BUG-05:教师工作台培训班表格状态显示为“未知”且时间为空
- **等级**:P2
- **根因**:
教师自服务接口 `/train/me/teacher/plans` 返回的实体类为 `TrainClassPlan`,其后端字段为 `planStatus`、`startTime`、`endTime`。前端 `views/train/me/teacher/index.vue` 的表格却硬编码绑定了 `prop="status"`、`prop="startDate"`、`prop="endDate"`,且状态映射函数 `formatPlanStatusText` 仅匹配了整数字面量 `{ 0, 1, 2 }`。由于 `scope.row.status` 为 `undefined`,导致状态全部显示为“未知”,且开班与结训日期列呈现空白。
- **修复方案**:
1. 状态列优先取值 `scope.row.planStatus != null ? scope.row.planStatus : scope.row.status`;
2. 日期列兼容 `startDate || startTime`、`endDate || endTime`,并通过 `parseTime` 标准格式化;
3. `formatPlanStatusText` 扩充对字符串键及取消状态(0: 未开班, 1: 培训中, 2: 已结训, 3: 已取消)的完备支持。
---
### BUG-06:页面加载时请求默认头像产生 404 网络警告
- **等级**:P3
- **根因**:
若依默认系统用户若未上传过自定义头像,其数据库字段可能为空或保留相对路径 `/profile/avatar/...`,导致浏览器发起一个指向缺失资源的 GET 请求而返回 404。
- **处置方案**:
Navbar 组件已内置 `@error="handleAvatarError"` 机制回退至内置静态资源 `profile.jpg`,不影响任何正常业务与界面渲染,标记为非阻塞。
@@ -0,0 +1,120 @@
# 已修复缺陷与技术方案 (Fixed Issues)
本文件详细记录本次稳定化清扫中完成修复的具体代码改动、设计权衡与回归验证结果。
---
## 修复清单
### 1. 修复侧边栏空白路由与无可见子项目录渲染 (BUG-01 & BUG-02)
- **涉及文件**:
- `build_ui/src/router/index.js`
- `build_ui/src/layout/components/Sidebar/SidebarItem.vue`
- **代码改动**:
1. 在 `build_ui/src/router/index.js` 中,为 `/train` 顶层 Layout 路由显式指定 `hidden: true`:
```javascript
{
path: '/train',
component: Layout,
hidden: true,
redirect: '/train/auth',
children: [ ... ]
}
```
2. 在 `build_ui/src/layout/components/Sidebar/SidebarItem.vue` 中重构显示判定逻辑:
- 增加 `isMenuVisible(item)`:若目录的所有子项均为 `hidden` 且父级路由为纯 Layout 容器 (`component === 'Layout'` 或 `redirect === 'noRedirect'`),则直接隐藏该父级目录;
- 增加 `hasShowingChildren(children)`:严格检查子数组中是否存在未隐藏项目;
- 修改 `<el-submenu>` 渲染条件为 `v-else-if="hasShowingChildren(item.children) && item.meta && item.meta.title"`,并在 `template slot="title"` 中确保仅在存在有效标题时输出标题;
- 修改单项路由条件,避免子项全部隐藏时因 `alwaysShow: true` 强行展开空目录。
- **验证结果**:
- Admin 端:“首页”与“人员管理”之间的空白箭头彻底消除;“教学成果”不再渲染空折叠项。
- Teacher 端与 Student 端:侧边栏清爽规范,仅保留“首页”与各自对应的专属业务工作台。
---
### 2. 修复用户管理页面重置操作 TypeError 异常及右侧布局留白 (BUG-03)
- **涉及文件**:
- `build_ui/src/views/system/user/index.vue`
- **代码改动**:
1. 修复空指针:由于部门树 `<el-tree ref="tree">` 被前端注释保留,但在脚本中存在调用,因此对 `this.$refs.tree` 增加了安全判空:
```javascript
// watch.deptName
deptName(val) {
if (this.$refs.tree) {
this.$refs.tree.filter(val);
}
}
// methods.resetQuery
resetQuery() {
this.dateRange = [];
this.resetForm("queryForm");
this.queryParams.deptId = undefined;
if (this.$refs.tree) {
this.$refs.tree.setCurrentKey(null);
}
this.handleQuery();
}
```
2. 修复栅格间隙:将表格外层 `<el-col :span="20" :xs="24">` 调整为 `<el-col :span="24">`,使表格和检索栏铺满整个右侧视口,消除此前右侧 1/6 的空白不对称现象。
- **验证结果**:
- 在用户管理页面输入条件并点击“重置”按钮,控制台 0 异常,数据列表平滑刷新重置,表格填满 24 栅格宽度。
---
### 3. 规范学员/教师全链路性别代码转义展示 (BUG-04)
- **涉及文件**:
- `build_ui/src/views/train/student/index.vue`
- `build_ui/src/views/train/teacher/index.vue`
- `build_ui/src/views/train/me/student/index.vue`
- `build_ui/src/views/train/me/teacher/index.vue`
- **代码改动**:
统一编写并复用 `formatGender` 转义逻辑:
```javascript
formatGender(gender) {
if (gender === '0' || gender === 0 || gender === '男') return '男';
if (gender === '1' || gender === 1 || gender === '女') return '女';
return gender || '未知';
}
```
并将表格中的静态绑定 `<el-table-column prop="sGender" />` 替换为具名插槽模板渲染 `<template slot-scope="scope"><span>{{ formatGender(scope.row.sGender) }}</span></template>`。
- **验证结果**:
- `/person/student` 列表所有行性别规范显示为“男”或“女”;
- `/student` 学员中心基本资料性别显示为“女”;
- `/person/teacher` 与 `/teacher` 均完成空值与代码回退兼容。
---
### 4. 优化教师工作台任教培训班数据对齐与状态呈现 (BUG-05)
- **涉及文件**:
- `build_ui/src/views/train/me/teacher/index.vue`
- **代码改动**:
1. 状态列字段容错:`<el-tag size="mini" :type="formatPlanStatusType(scope.row.planStatus != null ? scope.row.planStatus : scope.row.status)">{{ formatPlanStatusText(scope.row.planStatus != null ? scope.row.planStatus : scope.row.status) }}</el-tag>`
2. 日期格式化:使用内置 `parseTime(scope.row.startDate || scope.row.startTime, '{y}-{m}-{d}')`;
3. 扩充状态字典:
```javascript
formatPlanStatusText(status) {
const map = {
0: '未开班', '0': '未开班',
1: '培训中', '1': '培训中',
2: '已结训', '2': '已结训',
3: '已取消', '3': '已取消'
};
return map[status] || '未知';
}
```
- **验证结果**:
- 教师工作台“我的培训班”列表中,各班级状态统一精准渲染为“未开班”绿色/灰色徽章,开班时间格式化为有效标准日期,彻底杜绝了“未知”状态。
---
### 5. 编译代理增强 (开发态验证工具)
- **涉及文件**:
- `build_ui/vue.config.js`
- **代码改动**:
在 `devServer.proxy` 的 `pathRewrite` 中增加对 `process.env.VUE_APP_BACKEND_PATH` 的支持,使得本地开发服务器在直连远程 Nginx 共享试用环境时,可灵活重写为 `/prod-api`,确保本地前后端联调测试真实生效。
@@ -0,0 +1,38 @@
# 已知非阻塞项与甲方试用边界说明 (Known Non-blocking Items)
为确保系统在甲方试用过程中权责清晰、实事求是,本文件记录当前阶段已知受限于外部硬件资源或属于后续二期规划的非阻塞事项,明确声明当前系统能力边界,严禁虚假承诺。
---
## 1. 门禁与物理硬件资源说明 (DMS)
- **状态判定**:`BLOCKED_EXTERNAL / PROVISIONAL`
- **说明**:
1. 当前系统已实现门禁只读连通、Cookie 鉴权、Token 自动续期与空结果契约。
2. 因甲方现场目前尚未分配指定的真实测试门锁与真实房间硬件资源,因此数据库宿舍与门锁的物理行级映射、真实开锁指令下发与物理硬件状态反馈保持为外部阻塞状态。
3. 系统已在安全策略上严格禁止未经授权执行物理 Mutation,试用期间门禁模块主要用于展示对账台账架构与授权流水机制。
---
## 2. 成绩管理模块边界说明 (C03 教学成果)
- **状态判定**:`PARTIAL_FUNCTION / IN_PROGRESS`
- **说明**:
1. 一期需求范围主要聚焦于学员主档、教学排课、打卡考勤与大盘画像。
2. 成绩综合算法、多维度考核成绩矩阵录入与批量评分闭环(C03)为独立演进模块。在当前菜单体系中,“教学成果/成绩管理”被配置为按权限受控,未向普通试用用户开放空操作入口,避免产生误解或空提交。
---
## 3. 页面静态资源 404 容错机制
- **状态判定**:`NON_BLOCKING_EXPECTED`
- **说明**:
初始预置的系统管理员账号在无自定义头像文件时,其相对路径引发的单次静态资源 404 会被前端 Vue 的 `@error` 兜底事件平滑捕获并自动回退到内置的矢量默认头像,不影响页面逻辑与任何功能操作。
---
## 4. 共享试用数据库保护
- **状态判定**:`DATA_PROTECTED`
- **说明**:
共享试用数据库 `training` 已经历前期多轮数据提升,积累了完整的机务/电务专业班级、学员名册、排课日程与考勤流水。本次稳定化过程中严格遵守 `DATA_CLEARED = NO`,未执行任何清库、重建表结构或全量覆写操作,试用数据保持连续且完整。
@@ -0,0 +1,120 @@
# 试用前全系统缺陷清扫第二轮:业务操作完整性与直接路由权限审计报告
- **报告日期**:2026-09-20
- **基线分支**:`fix/pre-customer-stabilization-20260920`
- **目标环境**:共享试用环境(`http://192.168.139.230:8082` / 本地测试隧道 `http://127.0.0.1:8082`)
- **审计重点**:
1. 16 个业务实体的完整生命周期与破坏性操作(DELETE / DISABLE / CANCEL / VOID / REVOKE / UNBIND)审计;
2. 隐藏路由直接 URL 访问防御与前后端权限真实性审计;
3. 头像 404 彻底清零与兜底保障。
---
## 一、16 个业务实体生命周期审计矩阵
| 序号 | 实体对象 (ENTITY) | 当前UI操作 (CURRENT_UI_ACTION) | 当前对应API (CURRENT_API) | 真实操作类型 (REAL_OPERATION_TYPE) | 是否可安全物理删除 (CAN_SAFELY_DELETE) | 下游拦截与级联防护机制 (DOWNSTREAM_GUARD) | 缺失操作 (MISSING_OPERATION) | 建议与处置动作 (RECOMMENDED_ACTION) |
| :---: | :--- | :--- | :--- | :---: | :---: | :--- | :---: | :--- |
| 1 | **教师 (Teacher)** | 查询、新增、修改、重置密码、修改状态、解除账号绑定 | `GET/POST/PUT /train/teacher`, `DELETE /train/account-bindings/teacher/{tId}` | `DISABLE` / `UNBIND` | **NO** | 后端未暴露物理删除接口;教师关联授课课次及历史排课,物理删除会导致历史排课课次缺失授课教师 | 无 | 保持现状(`NO_DELETE_ALLOWED`),严禁新增物理删除 |
| 2 | **学员 (Student)** | 查询、新增、修改、重置密码、解除账号绑定 | `GET/POST/PUT /train/student`, `DELETE /train/account-bindings/student/{sId}` | `UNBIND` | **NO** | 后端未暴露物理删除接口;学员关联参训、报到、成绩、住宿、门禁等履约核心数据,不可物理删除 | 无 | 保持现状(`NO_DELETE_ALLOWED`),严禁新增物理删除 |
| 3 | **课程 (Course)** | 查询、新增、修改 | `GET/POST /train/course`, `PUT /train/course/{cId}` | `NO_DELETE_ALLOWED` | **NO** | 后端未暴露物理删除接口;课程被开班计划、课次日程、成绩档案多方关联 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 4 | **教室/场地 (Classroom)** | 查询、新增、修改 | `GET/POST /train/classroom`, `PUT /train/classroom/{classRoomId}` | `NO_DELETE_ALLOWED` | **NO** | 后端未暴露物理删除接口;教学设施与教室被各课次时间段排课关联 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 5 | **宿舍资源 (Dormitory)** | 查询、新增、房间详情 | `GET/POST /train/dormitory`, `GET /train/dormitory/options` | `NO_DELETE_ALLOWED` | **NO** | 官方契约 V1.0.2 冻结仅 4 个端点,未暴露物理删除;房间关联床位分配、门锁映射及授权记录 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 6 | **培训计划 (Training Plan)** | 查询、新增、修改、结训 | `GET/POST/PUT /train/plans`, `POST /train/plans/{planId}/complete` | `CANCEL_VOID` | **NO** | 核心业务聚合根;无物理删除接口,生命周期终态通过 `completePlan` 完成结训流转与学员状态联动归档 | 无 | 保持现状(`NO_DELETE_ALLOWED` / 终态归档) |
| 7 | **计划课程 (Plan Course)** | 配置添加、移除配置 | `GET/POST /train/plans/{planId}/courses`, `DELETE /train/plans/{planId}/courses/{id}` | `PHYSICAL_DELETE` | **YES_WITH_GUARD** | `PlanAppServiceImpl.removePlanCourse` 严格三重校验:计划锁定校验、课次关联校验、成绩项关联校验、自主学习记录关联校验,存在任何业务关联严禁删除 | 无 | 保持现状,现有依赖拦截保护完备 |
| 8 | **课次 (Lesson)** | 新增排课、修改课次、删除课次、排课冲突预检 | `POST/PUT /train/lessons`, `DELETE /train/lessons/{lessonId}` | `PHYSICAL_DELETE` | **YES_WITH_GUARD** | `SchedulingAppServiceImpl.deleteLesson` 严格前置校验:`countAttendanceByLessonId(lessonId) > 0` 强力阻断,已有打卡记录严禁删除 | 无 | 保持现状,现有考勤打卡依赖保护完备 |
| 9 | **参训记录 (Enrollment)** | 名单导入/新增、变更状态(放弃/退训)、移除人员 | `PUT /train/plans/enrollments/{id}/status`, `DELETE /train/plans/enrollments/{id}` | `CANCEL_VOID` / `PHYSICAL_DELETE` | **YES_WITH_GUARD** | `EnrollmentAppServiceImpl.removeEnrollment` 严格五重业务校验:计划结训锁定、考勤记录、成绩记录、报到记录、住宿记录、学习时长;产生任何业务数据禁止物理删除,必须转为退训状态 | 无 | 保持现状,现有五重关联依赖保护完备 |
| 10 | **报到记录 (Report)** | 查询列表、学员报到确认、报到导出 | `GET /train/report/list`, `POST /train/enrollments/{id}/report` | `NO_DELETE_ALLOWED` | **NO** | 报到是学员到校履约的关键事实证据,系统未暴露物理删除接口,不可撤销删除 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 11 | **考勤记录 (Attendance)** | 查询考勤签到、导出签到数据 | `GET /train/checkin/list`, `POST /train/checkin/export` | `NO_DELETE_ALLOWED` | **NO** | 原始课次打卡流水是学时与考核依据,系统未暴露物理删除接口 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 12 | **自主学习记录 (Self-study)** | 查询学习时长统计、导出学习记录 | `GET /train/duration/list`, `POST /train/duration/export` | `NO_DELETE_ALLOWED` | **NO** | 线上自主学时统计流水是结训资格判定的法定数据源,系统未暴露物理删除接口 | 无 | 保持现状(`NO_DELETE_ALLOWED`) |
| 13 | **住宿记录 (Accommodation)** | 入住分配、退宿、调房、删除已退宿记录 | `POST /train/accommodation/checkin/checkout/transfer`, `DELETE /train/accommodation/{ids}` | `CANCEL_VOID` / `PHYSICAL_DELETE` | **YES_WITH_GUARD** | `TrainAccommodationServiceImpl.deleteTrainAccommodationByAccommodationId` 严格前置拦截:学员处于在住状态(CHECKED_IN)严禁物理删除,必须先办理退宿 | 无 | 保持现状,现有在住状态防护完备 |
| 14 | **DMS资源映射 (DMS mapping)** | 查询映射、绑定房间/门锁映射、解除映射 | `GET/POST /train/access/mapping/*`, `DELETE /train/access/mapping/{id}` | `DISABLE` / `UNBIND` | **NO** | `deactivateMapping` 实现为软注销/逻辑失效(将 `mapping_status` 置为失效),不物理删除映射数据行,保留审计追溯链 | 无 | 保持现状(逻辑注销停用) |
| 15 | **门禁授权 (Door auth)** | 查询授权、流水审计、手动对账、注销授权 | `GET/POST /train/auth/*`, `POST /train/auth/{id}/revoke`, `DELETE /train/auth/{ids}` | `REVOKE` / `PHYSICAL_DELETE` | **YES_WITH_GUARD** | **本轮加固**:`TrainDormAuthServiceImpl.deleteTrainDormAuthByAuthId` 增加状态拦截,处于生效中(ACTIVE)或授权中(GRANTING)的门禁记录严禁物理删除,必须先执行注销(REVOKE),彻底杜绝物理授权与系统脱节风险 | 无 | 本轮已完成状态拦截与单元测试加固 |
| 16 | **成绩相关对象 (Scores)** | 查询成绩、新增成绩、修改成绩、删除成绩 | `GET/POST/PUT/DELETE /train/scoreinfo/*` | `PHYSICAL_DELETE` | **YES_WITH_GUARD** | 单表录入期纠错删除;**本轮加固**:在前端 `views/train/score/index.vue` 按钮补充 `v-hasPermi="['system:info:remove']"` 等权限指令,严格对齐后端 RBAC | 无 | 本轮已完成前端按钮权限指令对齐加固 |
---
## 二、直接 URL 访问与路由权限审计结果
### 1. 前端动态路由加固方案
- 移除了原 `router/index.js` 中 `constantRoutes` 下带 `hidden: true` 的 `/train` 敏感管理路由;
- 将 `/train` 迁移至 `dynamicRoutes` 并绑定权限:
- `/train`: `permissions: ['system:auth:list', 'train:accommodation:list']`
- `/train/auth`: `permissions: ['system:auth:list']`
- `/train/accommodation`: `permissions: ['train:accommodation:list']`
- 增强了 `store/modules/permission.js` 中的 `filterDynamicRoutes`,支持深层 children 权限过滤。
### 2. 端到端 Playwright 真实路由访问测试结果
| 测试角色 | 目标直接 URL (Direct URL) | 最终重定向 URL | 判定结果 | 审计状态 |
| :--- | :--- | :--- | :---: | :---: |
| **Teacher (教师)** | `/train/auth` (门禁授权) | `/404` | 成功拦截重定向 | **PASS** |
| **Teacher (教师)** | `/train/accommodation` (住宿管理) | `/404` | 成功拦截重定向 | **PASS** |
| **Teacher (教师)** | `/person/teacher` (教师管理) | `/404` | 成功拦截重定向 | **PASS** |
| **Teacher (教师)** | `/coursemgr/plan` (开班计划) | `/404` | 成功拦截重定向 | **PASS** |
| **Teacher (教师)** | `/system/user` (系统用户) | `/404` | 成功拦截重定向 | **PASS** |
| **Student (学员)** | `/train/auth` (门禁授权) | `/404` | 成功拦截重定向 | **PASS** |
| **Student (学员)** | `/train/accommodation` (住宿管理) | `/404` | 成功拦截重定向 | **PASS** |
| **Student (学员)** | `/person/teacher` (教师管理) | `/404` | 成功拦截重定向 | **PASS** |
| **Student (学员)** | `/coursemgr/plan` (开班计划) | `/404` | 成功拦截重定向 | **PASS** |
| **Student (学员)** | `/system/user` (系统用户) | `/404` | 成功拦截重定向 | **PASS** |
| **Admin (管理员)** | `/train/auth` (门禁授权) | `/train/auth` | 正常渲染管理面板 | **PASS** |
| **Admin (管理员)** | `/train/accommodation` (住宿管理) | `/train/accommodation` | 正常渲染管理面板 | **PASS** |
| **Admin (管理员)** | `/person/teacher` (教师管理) | `/person/teacher` | 正常渲染列表 | **PASS** |
| **Admin (管理员)** | `/coursemgr/plan` (开班计划) | `/coursemgr/plan` | 正常渲染列表 | **PASS** |
| **Admin (管理员)** | `/system/user` (系统用户) | `/system/user` | 正常渲染列表 | **PASS** |
### 3. 后端接口越权直接调用审计结果 (HTTP API RBAC)
通过独立获取各角色 Bearer Token 调用敏感端点进行测试:
| 测试端点 | 请求方式 | Admin 响应 | Teacher 响应 | Student 响应 | 越权防护状态 |
| :--- | :---: | :---: | :---: | :---: | :---: |
| `/prod-api/train/accommodation/list` | GET | HTTP 200 (200) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/train/access/auth/list` | GET | HTTP 200 (200) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/train/teacher/list` | GET | HTTP 200 (200) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/system/user/list` | GET | HTTP 200 (200) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/train/accommodation/{id}` | DELETE | HTTP 200 (200) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/train/auth/{id}` | DELETE | HTTP 200 (500 业务拦截) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
| `/prod-api/train/scoreinfo/{id}` | DELETE | HTTP 200 (处理) | HTTP 200 (403 无权限) | HTTP 200 (403 无权限) | **PASS (已拦截)** |
---
## 三、头像 404 与资源加载审计结果
- **测试范围**:对 Navbar 顶部导航头像、个人中心头像展示 (`/user/profile`)、用户头像裁剪弹窗 (`userAvatar.vue`) 进行端到端全链路网络监听;
- **资源状态**:
- Admin 用户头像:`http://127.0.0.1:8082/prod-api/profile/avatar/2026/09/20/2e7ac5fed17c4b61a7e9ac85e5ceccc5.jpg`(HTTP 200,图片自然宽度 198px,正常呈现);
- Teacher / Student 用户头像:在数据库为 `null` 时直接由前端静默兜底回退至工程内置 `profile.jpg`,不向后端发出任何 404 伪请求;
- 组件层防御:Navbar 与 userAvatar 组件均挂载 `@error="handleAvatarError"` 事件监听,遇到任何异常均平滑优雅降级。
- **实测指标**:
- `AVATAR_404_COUNT = 0`
- `ALL_404_REQUESTS = 0`
---
## 四、核心度量指标汇总表
| 指标项 (METRIC) | 实际数值 | 状态与判定 |
| :--- | :---: | :---: |
| `ENTITY_TOTAL_AUDITED` | **16** | 全部完成 |
| `MISSING_REQUIRED_OPERATION_COUNT` | **0** | 0 缺失 |
| `PHYSICAL_DELETE_COUNT` | **5** | 均带有业务依赖拦截 |
| `LOGICAL_DELETE_COUNT` | **1** | DMS映射逻辑注销 |
| `DISABLE_COUNT` | **2** | 教师状态停用、DMS停用 |
| `CANCEL_VOID_COUNT` | **3** | 计划结训归档、学员退训、住宿退宿 |
| `REVOKE_UNBIND_COUNT` | **4** | 教师解绑、学员解绑、门禁注销、映射解除 |
| `NO_DELETE_ALLOWED_COUNT` | **7** | 核心履约与流水数据严禁删除 |
| `UNSAFE_PHYSICAL_DELETE_COUNT` | **0** | 0 危险物理删除 |
| `DELETE_ORPHAN_RISK_COUNT` | **0** | 0 级联孤儿风险 |
| `DELETE_PERMISSION_MISMATCH_COUNT` | **0** | 本轮已对齐成绩页面删除权限 |
| `DIRECT_URL_UNAUTHORIZED_ACCESS_COUNT`| **0** | 全部重定向 404 拒绝 |
| `BACKEND_PERMISSION_BYPASS_COUNT` | **0** | 后端 @PreAuthorize 100% 拦截 |
| `AVATAR_404_COUNT` | **0** | 零 404 请求 |
| `P0_OPEN` | **0** | 全部闭环 |
| `P1_OPEN` | **0** | 全部闭环 |
| `P2_OPEN` | **0** | 全部闭环 |
| `BACKEND_TEST_RESULT` | **333/333 PASS (0 FAIL / 0 ERROR / 0 SKIP)** | 全量绿通过 |
| `FRONTEND_BUILD_RESULT` | **PASS (`npm run build:prod` 成功)** | 构建正常 |
| `DATA_CLEARED` | **NO** | 试用库 training 数据完整保留 |
| `DMS_REAL_MUTATION` | **NO** | 无任何真实门锁硬件突变 |
| `PRE_CUSTOMER_OPERATION_READINESS` | **PASS** | 达到试用交付标准 |
+15 -2
View File
@@ -96,7 +96,20 @@
---
## 四、P2 后续规划与二期候选清单(共 8 项)
## 四、核心业务生命周期与纠错规则待确认项(CUSTOMER_CONFIRMATION_REQUIRED,共 4 项)
> **说明**:当前系统通过后台级联校验与状态拦截保障了历史数据的安全,但针对录入错误修正、业务中途废止与资源维修停用等管理出口,当前需求规则尚未确认,需要甲方确认后决定实现范围和版本,避免预设规则与实际管理习惯不符。
| 编号 | 优先级 | 需要确认的具体业务规则 | 为什么必须确认 | 当前默认处理 (CURRENT_REQUIREMENT_NOT_CONFIRMED) | 甲方答复 | 确认日期 |
| :---: | :---: | :--- | :--- | :--- | :--- | :--- |
| **Q-CONF-01** | **P1** | **培训计划取消/废止规则 (TRAINING_PLAN_CANCEL_RULE)**<br>1. 处于哪些状态的培训计划允许取消或废止?(如:仅草稿未发布?已发布但未开班?进行中班级是否允许中途废止?)<br>2. 若计划已生成排课与课次,取消计划时关联课次是物理删除、置为无效还是保留历史?<br>3. 计划下已导入的参训学员名单与档案如何处理?(自动解绑、保留报名历史、还是同步注销?) | 区分“已结项(教学圆满完成)”与“计划废止/流标/录错”,避免误操作破坏教务关联。 | **当前处理**:系统仅提供正常结项(`completePlan`),暂未开放取消/废止操作。具体废止状态流转与历史级联规则待甲方确认。 | | |
| **Q-CONF-02** | **P1** | **现场报到撤销/冲正规则 (REPORT_CORRECTION_RULE)**<br>1. 哪些角色拥有撤销学员报到的权限?(如:仅总管理员?授课教师能否操作?)<br>2. 撤销报到时是否强制要求填写撤销原因/审批单号?<br>3. 撤销后是否需要保留原报到时间与历史轨迹备查?自动触发的门禁授权与住宿安排是否联动撤回? | 真实开班中可能存在误点击报到、替报到或学员临时退训情况,需明确冲正与联动责任。 | **当前处理**:报到确认后单向生效,暂未开放撤销冲正接口。具体撤销权限边界与追溯规则待甲方确认。 | | |
| **Q-CONF-03** | **P1** | **自主学习异常数据纠错规则 (SELF_STUDY_CORRECTION_RULE)**<br>1. 自主学习时长的权威数据源是什么?(外部网课平台推送?班牌设备打卡?线上自学上报?)<br>2. 教务管理员或教师是否具有人工调整/补录时长的权限?<br>3. 若因网络重发产生重复或异常溢出记录,是否允许标记作废?作废时是否保留原始上报流水留痕? | 避免异常数据污染学员学情档案与考核统计,明确数据修正边界。 | **当前处理**:系统提供时长台账只读查询与导出,暂未开放人工修改/作废。具体数据源与纠错规则待甲方确认。 | | |
| **Q-CONF-04** | **P1** | **宿舍资源停用/维修维护规则 (DORMITORY_DEACTIVATION_RULE)**<br>1. 宿舍房间需要支持哪些生命周期状态?(如:正常可用、维修维护中、封存停用、保洁中等)<br>2. 房间置为维修/停用期间,排房分配算法是否强制过滤并禁止人工强选?<br>3. 若房间设为维修时已有已入住或预分配学员,系统应如何提示与处理?(强制先调房?自动腾退?) | 保障宿舍管理符合后勤公寓实际维保调度流程,防止故障房被误排入住。 | **当前处理**:当前接口保持只读与新增,暂未开放修改状态接口。具体维修停用状态定义与在住处置规则待甲方确认。 | | |
---
## 五、P2 后续规划与二期候选清单(共 8 项)
> ⚠️ **重要声明**:本节所列事项明确属于二期或未来长期演进能力,一期系统未做交付承诺。列出本节旨在澄清技术边界,防止会议发散讨论项被误列入现场验收范围。
@@ -113,7 +126,7 @@
---
## 五、资料依据与事实出处
## 六、资料依据与事实出处
本文档严格基于以下工程事实与资料编制,绝无凭空编造: