Commit ceb45728 by DaiJiezhang

feat: 设备资产图片改用缩略图,并支持按顺序生成设备编号

图片性能:列表里 40px 的小图原本加载的是完整原图。实测单张 1672x941 的 PNG
约 1.8MB,一页 20 条最多 40 张,需要传输约 72MB、浏览器解码后常驻内存约
250MB;每页条数还能选到 50,翻倍到 180MB / 630MB。图片接口又没有任何缓存头,
每次刷新、翻页回来都要重下一遍。

改法分四层:
- 保存时用 ImageReader 降采样解码,一次完成格式校验和长边 240px 缩略图生成。
  实测 1890KB -> 4KB,耗时 82ms,比原先只做校验的全图解码(119ms)还快。
  缩略图先写临时文件再原子替换,并发补生成不会读到半截文件。
- 列表和弹窗预览改用缩略图,只有点开大图才请求原图;历史图片首次被请求时
  自动补生成一次并落盘。
- 图片接口加 Cache-Control: max-age=1年, private, immutable。文件名是随机
  UUID、内容永不改写,换图必然换标识,所以缓存安全;用 private 而非 public,
  避免受登录态保护的图片被共享代理缓存后发给别人。
- 列表图加 loading=lazy、悬停时预取并解码原图,点开大图几乎无等待。

上传交互:上传控件此前没有 accept 属性,系统文件框要枚举目录下所有类型的
文件;预览又把原图直接塞进 104x78 的框,浏览器同步解码整张图会明显卡顿。
现在限定图片类型,并用 createImageBitmap 异步解码后缩到 320px 再显示,
上传的仍是未经处理的原始文件。

设备编号:设备名称输入框加一键生成下一个「前缀N号机」。编号由后端在全库范围
计算,不按当前页推算——一页只有 20 条,最大编号很可能在别的页上,按当前页
算会撞上已存在的名字被唯一性校验打回。前缀限定 1-20 位中英文数字,因为它要
拼进 LIKE 条件,% 和 _ 会被当通配符。

列表同时去掉编号列、图片挪到首列并支持展示两张、无图与加载失败统一显示占位框、
更新时间只保留日期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d8736010
......@@ -5,8 +5,10 @@ import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.time.Duration;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
......@@ -36,6 +38,20 @@ public class DeviceAssetController {
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { permissions.requireAdministrator(); Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
/** 代码作用(白话):返回按前缀顺延的下一个设备编号名称,供新增弹窗一键填充。关联文件:DeviceNameSuggestionResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):一键编号 -> 全库最大编号 -> prefix+N+号机 -> 输入框。 */
@GetMapping("/lookups/next-device-name") public ApiResponse<DeviceNameSuggestionResponse> nextDeviceName(@RequestParam(defaultValue="") String prefix) { permissions.requireAdministrator(); return ApiResponse.success(new DeviceNameSuggestionResponse(service.suggestNextDeviceName(prefix))); }
/**
* 代码作用(白话):按不透明标识读取设备图片,variant=thumb 时返回列表用的小缩略图,不返回服务器路径。
* 关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。
* 关联逻辑(调用链/数据流):img URL -> findImage/findThumbnail -> Resource -> 浏览器预览。
* 缓存头是安全的:文件名是随机 UUID,同一个标识的内容永远不会被改写,换图必然换标识。
* 用 private 而不是 public:这些图受登录态保护,不能被共享代理缓存后发给别人。
*/
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier, @RequestParam(required=false) String variant) {
permissions.requireAdministrator();
Resource resource="thumb".equals(variant)?service.findThumbnail(identifier):service.findImage(identifier);
MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePrivate().immutable()).contentType(type).body(resource);
}
}
......@@ -2,7 +2,12 @@ package com.xyw.console.asset.dto;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */
/**
* 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。
* 缩略图 URL 与原图 URL 分开返回:列表里的 40px 小图和弹窗预览用缩略图(约几十 KB),
* 只有点开大图才请求原图(可达 20MB),否则一页 20 条会拉几十兆图片把页面拖垮。
*/
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
String imageAttachment1ThumbUrl, String imageAttachment2ThumbUrl,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载"下一个可用设备编号名称"的建议值,供新增弹窗一键填充。 */
public record DeviceNameSuggestionResponse(String deviceName) {}
......@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
......@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service;
public class DeviceAssetService {
private static final Set<String> USAGE_STATUSES = Set.of("\u4f7f\u7528\u4e2d", "\u95f2\u7f6e", "\u7ef4\u4fee\u4e2d", "\u505c\u7528");
private static final Set<String> RELATION_STATUSES = Set.of("\u5df2\u5173\u8054", "\u672a\u5173\u8054", "\u5f85\u786e\u8ba4");
/** \u672a\u6307\u5b9a\u524d\u7f00\u65f6\u7684\u9ed8\u8ba4\u7f16\u53f7\u524d\u7f00\uff0c\u5bf9\u5e94\u6700\u5e38\u89c1\u7684\u4e00\u6279\u8bbe\u5907\u3002 */
private static final String DEFAULT_DEVICE_NAME_PREFIX = "\u5b66\u7ba1\u5e08";
private static final Pattern SAFE_PREFIX = Pattern.compile("[\\u4e00-\\u9fa5A-Za-z0-9]{1,20}");
private static final Pattern NUMBERED_SUFFIX = Pattern.compile("(\\d{1,6})\u53f7\u673a");
private final AssetDeviceMapper deviceMapper;
private final CompanyPersonMapper personMapper;
private final PhoneAssetMapper phoneMapper;
......@@ -97,9 +103,35 @@ public class DeviceAssetService {
.stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList();
}
/**
* Plain purpose: suggest the next free "<prefix>N号机" name so the dialog can fill a sequential number in one click.
* Related files: DeviceAssetController.java, DeviceAssetView.js.
* Flow: 一键编号 -> prefix 前缀匹配查询 -> 取现有最大编号 +1 -> 输入框。
* 必须查数据库而不是只看当前页:列表一页只有 20 条,最大编号很可能在别的页上,
* 只按当前页推算会算出一个已存在的名字,保存时被唯一性校验直接打回。
*/
public String suggestNextDeviceName(String prefix) {
String base=hasText(prefix)?prefix.trim():DEFAULT_DEVICE_NAME_PREFIX;
// 前缀直接拼进 LIKE 条件,必须限定字符集:% 和 _ 会被当通配符,其他符号也没有作为设备前缀的意义。
if(!SAFE_PREFIX.matcher(base).matches()) throw new DeviceAssetValidationException("设备名称前缀只能是 1-20 位中文、字母或数字");
int largest=0;
// 用 likeRight 让 SQL 只捞前缀命中的行,剩下的形状判断放在 Java 侧:LIKE 表达不了"后面必须是纯数字加号机"。
for(AssetDeviceEntity item:deviceMapper.selectList(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).likeRight(AssetDeviceEntity::getDeviceName,base))) {
String name=item.getDeviceName();
if(name==null||!name.startsWith(base)) continue;
Matcher matcher=NUMBERED_SUFFIX.matcher(name.substring(base.length()));
// 位数上限交给正则:设备编号不会有七位数,放开会让脏数据把 parseInt 撑爆。
if(matcher.matches()) largest=Math.max(largest,Integer.parseInt(matcher.group(1)));
}
return base+(largest+1)+"号机";
}
/** Plain purpose: resolve a safe image identifier to a controlled resource. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: image URL -> service -> storage -> response body. */
public Resource findImage(String identifier) { return fileStorage.resolve(identifier); }
/** Plain purpose: resolve the small list/preview thumbnail, generating it once for images stored before thumbnails existed. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: thumb URL -> service -> storage -> cached small JPEG. */
public Resource findThumbnail(String identifier) { return fileStorage.resolveThumbnail(identifier); }
/** Plain purpose: combine active-only and optional page filters. Related files: DeviceAssetPageQuery.java, AssetDeviceEntity.java. Flow: query DTO -> LambdaQueryWrapper -> SQL WHERE. */
private LambdaQueryWrapper<AssetDeviceEntity> activeQuery(DeviceAssetPageQuery query) {
return new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).like(hasText(query.deviceName()),AssetDeviceEntity::getDeviceName,query.deviceName()).eq(query.userPersonId()!=null,AssetDeviceEntity::getUserPersonId,query.userPersonId()).eq(hasText(query.userUsageStatus()),AssetDeviceEntity::getUserUsageStatus,query.userUsageStatus()).eq(hasText(query.assetRelationStatus()),AssetDeviceEntity::getAssetRelationStatus,query.assetRelationStatus()).orderByDesc(AssetDeviceEntity::getId);
......@@ -146,9 +178,11 @@ public class DeviceAssetService {
/** Plain purpose: resolve person IDs in one query to avoid row-by-row lookups. Related files: CompanyPersonEntity.java, DeviceAssetResponse.java. Flow: IDs -> mapper IN query -> name map -> response. */
private Map<Long,String> personNames(Set<Long> ids) { if(ids.isEmpty()) return Map.of(); Map<Long,String> result=new HashMap<>(); personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().in(CompanyPersonEntity::getId,ids).eq(CompanyPersonEntity::getDeleteTime,0L)).forEach(item->result.put(item.getId(),item.getPersonName())); return result; }
/** Plain purpose: expose safe URLs rather than internal image identifiers or paths. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: entity -> URL conversion -> API JSON -> image tag. */
private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); }
private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),thumbnailUrl(entity.getImageAttachment1()),thumbnailUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); }
/** Plain purpose: translate a stored opaque image identifier into a controlled URL. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: identifier -> imageUrl -> browser GET. */
private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; }
/** Plain purpose: point the list and dialog preview at the small derived image instead of the full-size original. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: identifier -> thumbnailUrl -> browser GET with variant=thumb. */
private String thumbnailUrl(String identifier) { String url=imageUrl(identifier); return url==null?null:url+"?variant=thumb"; }
/** Plain purpose: copy editable request fields into a database entity. Related files: DeviceAssetSaveRequest.java, AssetDeviceEntity.java. Flow: DTO -> entity -> mapper insert/update. */
private void applyEditableFields(AssetDeviceEntity entity,DeviceAssetSaveRequest request,String image1,String image2) { entity.setDeviceName(request.getDeviceName().trim()); entity.setUserPersonId(request.getUserPersonId()); entity.setUserUsageStatus(request.getUserUsageStatus()); entity.setAssetRelationStatus(request.getAssetRelationStatus()); entity.setImageAttachment1(image1); entity.setImageAttachment2(image2); }
/** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */
......
......@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
......@@ -47,7 +48,7 @@ class DeviceAssetControllerTest {
}
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码资产引用,不能删除")).when(service).softDelete(1L);
DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码管理引用,不能删除")).when(service).softDelete(1L);
mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400));
}
/** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */
......@@ -61,6 +62,15 @@ class DeviceAssetControllerTest {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8}));
}
/** Plain purpose: prove variant=thumb serves the derived small image and that both variants carry a long private cache header. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: thumb URL -> findThumbnail -> cached small JPEG bytes. */
@Test void servesThumbnailVariantWithLongLivedPrivateCache() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);
when(service.findThumbnail("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{1,2}) { @Override public String getFilename(){return "opaque.thumb.jpg";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png").param("variant","thumb")).andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{1,2}))
.andExpect(header().string("Cache-Control","max-age=31536000, private, immutable"));
verify(service,org.mockito.Mockito.never()).findImage(any());
}
/** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */
@Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
......@@ -76,7 +86,8 @@ class DeviceAssetControllerTest {
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", null));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", "thumb"));
verifyNoInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
......@@ -84,5 +95,5 @@ class DeviceAssetControllerTest {
/** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);}
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,"/api/device-assets/files/image.png?variant=thumb",null,9L,"张三","使用中","未关联",null,null);}
}
......@@ -32,9 +32,38 @@ class DeviceAssetFileStorageServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1])));
}
/** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png")); }
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png"));assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail("../secret.png")); }
/** 代码作用(白话):验证保存时会产出一张长边不超过 240 像素、体积远小于原图的缩略图。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):大图 -> store -> 缩略图文件 -> 列表小图请求。 */
@Test void writesBoundedThumbnailAlongsideOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","big.png","image/png",image("png",1600,900)));
// 必须关流:Windows 下未关闭的文件句柄会锁住 @TempDir,导致测试结束时删不掉临时目录。
BufferedImage thumbnail;
try (var stream=storage.resolveThumbnail(identifier).getInputStream()) { thumbnail=ImageIO.read(stream); }
assertTrue(Math.max(thumbnail.getWidth(),thumbnail.getHeight())<=240,"缩略图长边应被限制在 240 像素内");
assertTrue(Math.abs(thumbnail.getWidth()/(double)thumbnail.getHeight()-1600/900.0)<0.05,"缩略图应保持原图宽高比");
assertTrue(storage.resolveThumbnail(identifier).contentLength()<storage.resolve(identifier).contentLength(),"缩略图应明显小于原图");
}
/** 代码作用(白话):验证缩略图出现前上传的历史图片,在首次请求时会被补生成并落盘复用。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):缩略图缺失 -> resolveThumbnail -> 即时生成 -> 写盘 -> 后续直接命中。 */
@Test void regeneratesThumbnailForImagesStoredBeforeThisFeature(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","legacy.png","image/png",image("png",800,600)));
Path thumbnail=directory.resolve(identifier.substring(0,identifier.lastIndexOf('.'))+".thumb.jpg");
Files.delete(thumbnail);
assertTrue(storage.resolveThumbnail(identifier).exists());
assertTrue(Files.exists(thumbnail),"补生成的缩略图应落盘,避免每次请求都重新解码");
}
/** 代码作用(白话):验证原图被清理后请求缩略图会报图片不存在,而不是抛出解码异常。关联文件:DeviceAssetFileStorageService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):原图缺失 -> resolveThumbnail -> 业务异常 -> 400。 */
@Test void rejectsThumbnailRequestWhenOriginalIsGone(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","gone.png","image/png",png()));
storage.cleanupNewFile(identifier);
assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail(identifier));
}
/** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */
private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
/** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/
private byte[] image(String format) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
private byte[] image(String format) { return image(format,1,1); }
/** 代码作用(白话):按指定尺寸生成可解码图片,用来验证缩略图的等比缩放和长边上限。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):宽高 -> 图片字节 -> store -> 缩略图断言。*/
private byte[] image(String format,int width,int height) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
}
......@@ -47,7 +47,7 @@ class DeviceAssetServiceTest {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备")));
}
/** 代码作用(白话):验证手机号码资产仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/
/** 代码作用(白话):验证手机号码管理仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/
@Test void blocksDeleteWhenPhoneAssetStillReferencesDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(1L));when(phones.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L));
......@@ -70,7 +70,32 @@ class DeviceAssetServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(3L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);
DeviceAssetSaveRequest request=validRequest("update-failure");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));
assertThrows(com.xyw.console.asset.exception.DeviceAssetNotFoundException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(3L,request));
try(var files=Files.list(directory)){assertEquals(1,files.count());}assertTrue(Files.exists(directory.resolve(original)));
assertEquals(1,countOriginals(directory));assertTrue(Files.exists(directory.resolve(original)));
}
/** Plain purpose: suggest the number after the largest existing one, ignoring rows whose suffix is not a plain 号机 number. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 一键编号 -> 前缀匹配行 -> 最大编号+1。 */
@Test void suggestsTheNumberAfterTheLargestExistingDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师1号机"),named("学管师7号机"),named("学管师3号机"),named("学管师备用机"),named("学管师12号机备注")));
assertEquals("学管师8号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
}
/** Plain purpose: start at one when nothing matches the prefix yet, and honour a caller-supplied prefix. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 空结果 -> prefix+1号机。 */
@Test void startsNumberingAtOneAndHonoursCustomPrefix() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectList(any(Wrapper.class))).thenReturn(List.of());
assertEquals("学管师1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
assertEquals("班主任1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(" 班主任 "));
}
/** Plain purpose: refuse prefixes that would smuggle LIKE wildcards or unbounded text into the query. Related files: DeviceAssetService.java, DeviceAssetExceptionHandler.java. Flow: 非法前缀 -> 校验 -> 400。 */
@Test void rejectsUnsafeDeviceNamePrefix() {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("%"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学管师_"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学".repeat(21)));
}
/** Plain purpose: ignore absurdly long digit runs so a bad row cannot overflow the counter parse. Related files: DeviceAssetService.java. Flow: 七位以上编号 -> 正则不匹配 -> 忽略该行。 */
@Test void ignoresDeviceNumbersBeyondSixDigits() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师2号机"),named("学管师99999999999999999999号机")));
assertEquals("学管师3号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName("学管师"));
}
/** Plain purpose: reject a supplied company-person ID when that person is no longer active. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: POST userPersonId -> active-person count -> validation error before insert. */
@Test void rejectsDeletedUserPersonBeforeCreate() {
......@@ -98,7 +123,7 @@ class DeviceAssetServiceTest {
@Test void updateReplacesImageWithoutDeletingOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(5L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1);
DeviceAssetSaveRequest request=validRequest("replace-image");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));String responseUrl=service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(5L,request).imageAttachment1Url();
assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));try(var files=Files.list(directory)){assertEquals(2,files.count());}
assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));assertEquals(2,countOriginals(directory));
}
/** Plain purpose: verify explicit removal clears the database-facing URL but retains the original physical file for recovery. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT remove flag -> null reference -> mapper update; source file stays. */
@Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception {
......@@ -122,4 +147,8 @@ class DeviceAssetServiceTest {
private AssetDeviceEntity activeDevice(Long id){AssetDeviceEntity device=new AssetDeviceEntity();device.setId(id);device.setDeviceName("删除测试设备");device.setUserUsageStatus("使用中");device.setAssetRelationStatus("未关联");device.setDeleteTime(0L);return device;}
/** 代码作用(白话):生成 ImageIO 可解码的 PNG 字节,确保失败清理测试验证的是业务流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> MultipartFile -> 文件保存 -> 异常清理。*/
private byte[] png(){try{ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray();}catch(Exception exception){throw new IllegalStateException(exception);}}
/** 代码作用(白话):生成一条只带名称的设备行,用于编号建议的纯计算断言。关联文件:AssetDeviceEntity.java、DeviceAssetService.java。关联逻辑(调用链/数据流):Mapper.selectList -> 名称解析 -> 下一个编号。*/
private AssetDeviceEntity named(String deviceName){AssetDeviceEntity device=new AssetDeviceEntity();device.setDeviceName(deviceName);return device;}
/** 代码作用(白话):只统计原图数量,忽略每张图派生出的缩略图。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):上传目录 -> 过滤 .thumb.jpg -> 原图张数断言。这里刻意不数总文件数:派生文件是实现细节,测试要断言的是"新图被清理、原图还在"。*/
private long countOriginals(Path directory){try(var files=Files.list(directory)){return files.filter(file->!file.getFileName().toString().endsWith(".thumb.jpg")).count();}catch(Exception exception){throw new IllegalStateException(exception);}}
}
......@@ -11,3 +11,5 @@ export function updateDeviceAsset(id, form) { return request('/api/device-assets
export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */
export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); }
/** 代码作用(白话):问后端"这个前缀下一个空编号是几号机"。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):一键编号按钮 -> GET next-device-name -> 全库最大编号+1 -> 设备名称输入框。 */
export function suggestNextDeviceName(prefix) { return request('/api/device-assets/lookups/next-device-name?prefix=' + encodeURIComponent(prefix || '')); }
/* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.device-asset-page__header h2{margin:0;font-size:28px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}.device-asset-page__images{display:flex;gap:14px;flex-wrap:wrap}.device-asset-page__image-slot{width:150px}.device-asset-page__preview{display:flex;width:132px;height:96px;margin:8px 0;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc}.device-asset-page__preview img{width:100%;height:100%;object-fit:cover}.device-asset-page__image-cell img{width:44px;height:44px;object-fit:cover;border-radius:6px;cursor:pointer}/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */
/* 页头、新增按钮和弹窗刻意复用 app.css 里的 .phone-asset-list-page__/.phone-asset-modal__ 类,
与手机号码管理页保持同一套外观;这里只补设备页独有的图片缩略图、占位框和上传位。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}
/* 列表图片列:一行最多两张,鼠标移上去压一层暗色遮罩并露出眼睛图标提示可放大。
图标用内联 SVG,和手机号页的 ICCID 图标一致,避免为几个图标引入 @element-plus/icons-vue(不在依赖里)。 */
.device-asset-page__thumbs{display:flex;align-items:center;gap:6px}
.device-asset-page__thumb{position:relative;display:block;flex:0 0 auto;width:40px;height:40px;padding:0;overflow:hidden;border:1px solid #e5e5e8;border-radius:6px;background:#fff;cursor:pointer}
.device-asset-page__thumb img{display:block;width:100%;height:100%;object-fit:cover}
.device-asset-page__thumb-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.55);color:#fff;opacity:0;transition:opacity .16s ease}
.device-asset-page__thumb:hover .device-asset-page__thumb-mask{opacity:1}
.device-asset-page__thumb-mask svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 无图占位:虚线灰框,保证有图和无图两种行的高度完全一致,列表不会忽高忽低。 */
.device-asset-page__thumb--empty{display:flex;align-items:center;justify-content:center;border-style:dashed;border-color:#dcdce0;background:#fafafa;color:#c4c4cc;cursor:default}
.device-asset-page__thumb--empty svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
/* 设备名称右侧的"一键编号"按钮:贴在输入框 suffix 位,样式与手机号页 ICCID 图标按钮保持一致。 */
.device-asset-modal__auto-name{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:5px;background:transparent;color:#a1a1aa;cursor:pointer;transition:color .16s ease,background .16s ease}
.device-asset-modal__auto-name:hover:not(:disabled){background:#f1f1f3;color:#18181b}
.device-asset-modal__auto-name:disabled{opacity:.5;cursor:not-allowed}
.device-asset-modal__auto-name svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.35;stroke-linejoin:round}
/* 弹窗图片行:标签仍右对齐在 96px 栏位里,但内容要顶部对齐,否则两个上传框会被 36px 行高压偏。 */
.device-asset-modal__images-row.el-form-item{align-items:start}
.device-asset-modal__images-row .el-form-item__label{line-height:78px}
.device-asset-modal__images-row .el-form-item__content{line-height:normal}
.device-asset-modal__images{display:flex;gap:12px}
.device-asset-modal__preview{position:relative;display:flex;width:104px;height:78px;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #d4d4d8;border-radius:8px;background:#fafafa}
.device-asset-modal__preview--filled{border-style:solid;border-color:#e4e4e7;background:#fff}
.device-asset-modal__preview img{display:block;width:100%;height:100%;object-fit:cover}
/* 整块图都是预览触发区:之前只有遮罩里那个小眼睛能点,点图片中间毫无反应,很容易被当成"坏了"。 */
.device-asset-modal__preview-open{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;border:0;background:none;cursor:zoom-in}
.device-asset-modal__preview-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;background:rgba(24,24,27,.55);opacity:0;transition:opacity .16s ease}
.device-asset-modal__preview:hover .device-asset-modal__preview-mask{opacity:1}
.device-asset-modal__preview-mask svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 移除角标常驻显示,不藏在 hover 里:删除是低频但要命的操作,找不到入口比多一个角标更糟。 */
.device-asset-modal__preview-remove{position:absolute;top:4px;right:4px;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:rgba(24,24,27,.62);color:#fff;cursor:pointer;transition:background .16s ease}
.device-asset-modal__preview-remove:hover{background:#e5484d}
.device-asset-modal__preview-remove svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
/* 空位整块都是上传触发区:省掉一个独立的“选择图片”按钮,弹窗第一行才放得下两个图位。 */
.device-asset-modal__upload,.device-asset-modal__upload .el-upload{display:block;width:100%;height:100%}
.device-asset-modal__placeholder{display:flex;width:100%;height:100%;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#a1a1aa;font-size:12px;line-height:1}
.device-asset-modal__placeholder svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round}
.device-asset-modal__preview:hover .device-asset-modal__placeholder{color:#71717a}
/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */
@media(min-width:641px){/* 用 min-height 而不是 height:正常一屏不出外层滚动条,窗口过矮时页面被撑高改由内容区整页滚动。 */.device-asset-page{display:flex;flex-direction:column;min-height:100%;padding-bottom:24px}.device-asset-page__header,.device-asset-page__panel,.device-asset-page .app-pagination{flex:0 0 auto}.device-asset-page__table{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}/* 表格必须绝对定位:el-table 会用自身内容高度反向撑开父级,留在文档流里 flex 就收缩不下去。 *//* min-height 220px 是兜底:表头约 40px,再留三行左右可视区,低于这个高度就不再压缩表格。 */.device-asset-page__grid-wrap{position:relative;flex:1 1 auto;min-height:220px}/* height 必须显式写:Element Plus 自带 .el-table{height:fit-content},只给 inset 会被它按内容高度顶掉。 */.device-asset-page__grid-wrap > .el-table{position:absolute;inset:0;height:100%}.device-asset-page__grid-wrap > .el-table > .el-table__inner-wrapper{height:100%}.device-asset-page__grid-wrap .el-table__body-wrapper{flex:1 1 auto;min-height:0}.device-asset-page__grid-wrap .el-table__body-wrapper > .el-scrollbar{height:100%}}
@media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__header{align-items:stretch;flex-direction:column;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}}
@media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}}
@media(max-width:600px){.device-asset-modal__images-row .el-form-item__label{line-height:36px}}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment