From 1b480f915f39becd0672b5833e05542cd1ab32ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com>
Date: Mon, 14 Sep 2026 09:40:56 +0800
Subject: [PATCH] =?UTF-8?q?test(G4):=20=E5=AE=9A=E6=97=B6=E5=8C=B9?=
=?UTF-8?q?=E9=85=8D/=E6=A0=BC=E5=BC=8F=E8=BD=AC=E6=8D=A2/=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E6=8B=86=E5=88=86=20=E8=A1=A5=2027=20=E4=B8=AA?=
=?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=EF=BC=88=E4=B8=89=E4=B8=AA?=
=?UTF-8?q?=E6=A8=A1=E5=9D=97=E6=AD=A4=E5=89=8D=E5=9D=87=E9=9B=B6=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ShopMatchResolveService 15:候选越权与幂等、匹配去重保序、国家偏好默认顺序与坏 JSON 兜底
- ConvertTemplateService 8:内置模板禁删(软禁用)、导入命名/后缀补全、设为默认时清掉其它默认位
- SplitRunService 4:下载/删除历史必须属于本人且模块匹配(含无结果文件不可下载)
至此审查点名的 6 个零测试模块全部有测试文件。
---
.../service/ConvertTemplateServiceTest.java | 171 ++++++++++++
.../service/ShopMatchResolveServiceTest.java | 264 ++++++++++++++++++
.../split/service/SplitRunServiceTest.java | 107 +++++++
3 files changed, 542 insertions(+)
create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java
create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java
create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java
new file mode 100644
index 00000000..8c56f6bb
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateServiceTest.java
@@ -0,0 +1,171 @@
+package com.nanri.aiimage.modules.convert.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.nanri.aiimage.common.exception.BusinessException;
+import com.nanri.aiimage.modules.convert.mapper.ConvertTemplateMapper;
+import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest;
+import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity;
+import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 格式转换的模板管理(2026-09 审查 G4:该模块此前零测试文件)。
+ *
+ *
重点:内置模板不可删(软禁用语义)、导入的自定义模板命名与文件名补全、
+ * 设为默认时其余模板的默认位必须被清掉。
+ */
+class ConvertTemplateServiceTest {
+
+ @BeforeAll
+ static void initializeMybatisMetadata() {
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+ ConvertTemplateEntity.class);
+ }
+
+ private final ConvertTemplateMapper templateMapper = mock(ConvertTemplateMapper.class);
+ private final ConvertTemplateService service = new ConvertTemplateService(templateMapper);
+
+ private static ConvertTemplateEntity template(long id, String code, int isDefault, int builtIn) {
+ ConvertTemplateEntity entity = new ConvertTemplateEntity();
+ entity.setId(id);
+ entity.setTemplateCode(code);
+ entity.setTemplateName(code + "模板");
+ entity.setOutputFilename(code + ".txt");
+ entity.setIsDefault(isDefault);
+ entity.setBuiltIn(builtIn);
+ entity.setEnabled(1);
+ return entity;
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void listEnabledTemplatesKeepsMapperOrder() {
+ when(templateMapper.selectList(any(LambdaQueryWrapper.class)))
+ .thenReturn(List.of(template(1L, "builtin_offer", 1, 1), template(2L, "user_1", 0, 0)));
+
+ List templates = service.listEnabledTemplates();
+
+ assertEquals(2, templates.size());
+ assertEquals("builtin_offer", templates.get(0).getTemplateCode());
+ assertTrue(templates.get(0).getIsDefault());
+ assertEquals("user_1", templates.get(1).getTemplateCode());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void getByCodeRejectsMissingOrDisabledTemplate() {
+ when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ BusinessException exception = assertThrows(BusinessException.class, () -> service.getByCode("gone"));
+
+ assertEquals("模板不存在或已禁用", exception.getMessage());
+ }
+
+ @Test
+ void importTemplateRejectsBlankContent() {
+ ConvertTemplateImportRequest request = new ConvertTemplateImportRequest();
+ request.setTemplateName("我的模板");
+ request.setTemplateContent(" ");
+
+ assertThrows(BusinessException.class, () -> service.importTemplate(request));
+ verify(templateMapper, never()).insert(any(ConvertTemplateEntity.class));
+ }
+
+ @Test
+ void importTemplateGeneratesUserCodeAndTxtFilename() {
+ ConvertTemplateImportRequest request = new ConvertTemplateImportRequest();
+ request.setTemplateName("我的模板");
+ request.setTemplateContent("TemplateType=Offer\tVersion=1.4\n");
+
+ ConvertTemplateVo vo = service.importTemplate(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConvertTemplateEntity.class);
+ verify(templateMapper).insert(captor.capture());
+ ConvertTemplateEntity saved = captor.getValue();
+ assertTrue(saved.getTemplateCode().startsWith("user_"), "自定义模板编码带 user_ 前缀: " + saved.getTemplateCode());
+ assertEquals("我的模板.txt", saved.getOutputFilename(), "文件名补全 .txt 后缀");
+ assertEquals(0, saved.getBuiltIn(), "导入的模板不是内置模板");
+ assertEquals(0, saved.getIsDefault(), "导入不自动成为默认模板");
+ assertEquals(saved.getTemplateCode(), vo.getTemplateCode());
+ }
+
+ @Test
+ void importTemplateKeepsGivenTxtSuffixAndFallsBackToDefaultName() {
+ ConvertTemplateImportRequest request = new ConvertTemplateImportRequest();
+ request.setTemplateName("自定义.txt");
+ request.setTemplateContent("x");
+
+ service.importTemplate(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ConvertTemplateEntity.class);
+ verify(templateMapper).insert(captor.capture());
+ assertEquals("自定义.txt", captor.getValue().getOutputFilename(), "已有 .txt 后缀不重复追加");
+
+ ConvertTemplateImportRequest unnamed = new ConvertTemplateImportRequest();
+ unnamed.setTemplateName(" ");
+ unnamed.setTemplateContent("x");
+ service.importTemplate(unnamed);
+
+ ArgumentCaptor second = ArgumentCaptor.forClass(ConvertTemplateEntity.class);
+ verify(templateMapper, times(2)).insert(second.capture());
+ assertEquals("自定义 Offer 模板.txt", second.getAllValues().get(1).getOutputFilename());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void setDefaultTemplateClearsOthers() {
+ ConvertTemplateEntity target = template(2L, "user_1", 0, 0);
+ ConvertTemplateEntity other = template(3L, "user_2", 1, 0);
+ when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target);
+ when(templateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(target, other));
+
+ service.setDefaultTemplate("user_1");
+
+ assertEquals(1, target.getIsDefault(), "目标模板置为默认");
+ assertEquals(0, other.getIsDefault(), "其余模板的默认位必须清掉,否则会同时存在多个默认模板");
+ verify(templateMapper, times(2)).updateById(any(ConvertTemplateEntity.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void deleteTemplateRejectsBuiltIn() {
+ when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(template(1L, "builtin_offer", 1, 1));
+
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.deleteTemplate("builtin_offer"));
+
+ assertEquals("内置模板不允许删除", exception.getMessage());
+ verify(templateMapper, never()).updateById(any(ConvertTemplateEntity.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void deleteTemplateSoftDisablesInsteadOfRemoving() {
+ ConvertTemplateEntity userTemplate = template(5L, "user_5", 0, 0);
+ when(templateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(userTemplate);
+
+ service.deleteTemplate("user_5");
+
+ assertEquals(0, userTemplate.getEnabled(), "删除是软禁用(enabled=0),保留历史可追溯");
+ verify(templateMapper).updateById(userTemplate);
+ verify(templateMapper, never()).deleteById(any(Long.class));
+ }
+}
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java
new file mode 100644
index 00000000..b916afaf
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchResolveServiceTest.java
@@ -0,0 +1,264 @@
+package com.nanri.aiimage.modules.shopmatch.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nanri.aiimage.common.exception.BusinessException;
+import com.nanri.aiimage.common.model.vo.ProductRiskCandidateVo;
+import com.nanri.aiimage.common.model.vo.ProductRiskCountryPreferenceVo;
+import com.nanri.aiimage.common.model.vo.ProductRiskMatchShopsVo;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
+import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchCountryPrefMapper;
+import com.nanri.aiimage.modules.shopmatch.mapper.ShopMatchShopCandidateMapper;
+import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchCountryPrefEntity;
+import com.nanri.aiimage.modules.shopmatch.model.entity.ShopMatchShopCandidateEntity;
+import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 定时匹配工具的备选店铺与国家偏好(2026-09 审查 G4:该模块此前零测试文件)。
+ *
+ * 重点:候选增删的越权保护与幂等、匹配去重保序、国家偏好的默认顺序与坏数据兜底。
+ */
+class ShopMatchResolveServiceTest {
+
+ @BeforeAll
+ static void initializeMybatisMetadata() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, ShopMatchShopCandidateEntity.class);
+ TableInfoHelper.initTableInfo(assistant, ShopMatchCountryPrefEntity.class);
+ }
+
+ private final ShopMatchShopCandidateMapper candidateMapper = mock(ShopMatchShopCandidateMapper.class);
+ private final ShopMatchCountryPrefMapper countryPrefMapper = mock(ShopMatchCountryPrefMapper.class);
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class);
+
+ private ShopMatchResolveService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new ShopMatchResolveService(candidateMapper, countryPrefMapper, objectMapper, shopSwitchService);
+ when(shopSwitchService.normalizeShopName(anyString())).thenAnswer(invocation ->
+ ((String) invocation.getArgument(0)).trim());
+ }
+
+ private static ProductRiskCandidateAddRequest addRequest(Long userId, String shopName) {
+ ProductRiskCandidateAddRequest request = new ProductRiskCandidateAddRequest();
+ request.setUserId(userId);
+ request.setShopName(shopName);
+ return request;
+ }
+
+ private static ProductRiskCountryPreferenceSaveRequest prefRequest(Long userId, List codes) {
+ ProductRiskCountryPreferenceSaveRequest request = new ProductRiskCountryPreferenceSaveRequest();
+ request.setUserId(userId);
+ request.setCountryCodes(codes);
+ return request;
+ }
+
+ private static ZiniaoShopMatchResultVo matched(String shopName) {
+ ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
+ vo.setMatched(true);
+ vo.setShopName(shopName);
+ vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED);
+ return vo;
+ }
+
+ @Test
+ void listCandidatesRejectsInvalidUser() {
+ assertThrows(BusinessException.class, () -> service.listCandidates(null));
+ assertThrows(BusinessException.class, () -> service.listCandidates(0L));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void listCandidatesMapsRowsToViews() {
+ ShopMatchShopCandidateEntity row = new ShopMatchShopCandidateEntity();
+ row.setId(21L);
+ row.setUserId(23L);
+ row.setShopName("匹配店铺");
+ row.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row));
+
+ List views = service.listCandidates(23L);
+
+ assertEquals(1, views.size());
+ assertEquals(21L, views.getFirst().getId());
+ assertEquals("匹配店铺", views.getFirst().getShopName());
+ }
+
+ @Test
+ void addCandidateRejectsBlankShopName() {
+ when(shopSwitchService.normalizeShopName(" ")).thenReturn("");
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, " ")));
+ verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class));
+ }
+
+ @Test
+ void addCandidateRejectsWhenIndexMisses() {
+ ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo();
+ miss.setMatched(false);
+ when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss);
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "未收录")));
+ verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class));
+ }
+
+ @Test
+ void addCandidateRejectsConflictStatus() {
+ ZiniaoShopMatchResultVo conflict = matched("同名店铺");
+ conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT);
+ when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict);
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺")));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void addCandidateIsIdempotentForExistingShop() {
+ when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在"));
+ ShopMatchShopCandidateEntity existing = new ShopMatchShopCandidateEntity();
+ existing.setId(66L);
+ existing.setUserId(23L);
+ existing.setShopName("已存在");
+ existing.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
+
+ ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, " 已存在 "));
+
+ assertEquals(66L, vo.getId());
+ verify(candidateMapper, never()).insert(any(ShopMatchShopCandidateEntity.class));
+ }
+
+ @Test
+ void deleteCandidateValidatesIdAndOwnership() {
+ assertThrows(BusinessException.class, () -> service.deleteCandidate(23L, null));
+ assertThrows(BusinessException.class, () -> service.deleteCandidate(23L, 0L));
+
+ ShopMatchShopCandidateEntity other = new ShopMatchShopCandidateEntity();
+ other.setId(8L);
+ other.setUserId(77L);
+ when(candidateMapper.selectById(8L)).thenReturn(other);
+
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.deleteCandidate(23L, 8L));
+
+ assertEquals("记录不存在", exception.getMessage(), "他人记录与不存在同文案,避免探测");
+ verify(candidateMapper, never()).deleteById(any(Long.class));
+ }
+
+ @Test
+ void deleteCandidateRemovesOwnRecord() {
+ ShopMatchShopCandidateEntity own = new ShopMatchShopCandidateEntity();
+ own.setId(8L);
+ own.setUserId(23L);
+ when(candidateMapper.selectById(8L)).thenReturn(own);
+
+ service.deleteCandidate(23L, 8L);
+
+ verify(candidateMapper).deleteById(8L);
+ }
+
+ @Test
+ void matchShopsRejectsEmptyAfterNormalization() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of(" "));
+
+ assertThrows(BusinessException.class, () -> service.matchShops(request));
+ }
+
+ @Test
+ void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of(" A ", "B", "A"));
+ when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation ->
+ matched((String) invocation.getArgument(0)));
+
+ ProductRiskMatchShopsVo vo = service.matchShops(request);
+
+ assertEquals(2, vo.getItems().size());
+ assertEquals("A", vo.getItems().get(0).getShopName());
+ assertEquals("B", vo.getItems().get(1).getShopName());
+ }
+
+ @Test
+ void countryPreferenceFallsBackToDefaultOrderWhenAbsent() {
+ when(countryPrefMapper.selectById(23L)).thenReturn(null);
+
+ ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L);
+
+ assertEquals(ShopMatchResolveService.DEFAULT_COUNTRY_PREFERENCE_ORDER, vo.getCountryCodes());
+ }
+
+ @Test
+ void countryPreferenceFallsBackToDefaultOrderOnBrokenJson() {
+ ShopMatchCountryPrefEntity broken = new ShopMatchCountryPrefEntity();
+ broken.setUserId(23L);
+ broken.setCountryCodesJson("{not-json");
+ when(countryPrefMapper.selectById(23L)).thenReturn(broken);
+
+ ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L);
+
+ assertEquals(ShopMatchResolveService.DEFAULT_COUNTRY_PREFERENCE_ORDER, vo.getCountryCodes(),
+ "坏数据不能让页面空白,回退默认顺序");
+ }
+
+ @Test
+ void countryPreferenceReadsStoredOrder() {
+ ShopMatchCountryPrefEntity stored = new ShopMatchCountryPrefEntity();
+ stored.setUserId(23L);
+ stored.setCountryCodesJson("[\"UK\",\"DE\"]");
+ when(countryPrefMapper.selectById(23L)).thenReturn(stored);
+
+ ProductRiskCountryPreferenceVo vo = service.getCountryPreference(23L);
+
+ assertEquals(List.of("UK", "DE"), vo.getCountryCodes(), "已保存顺序优先于默认顺序");
+ }
+
+ @Test
+ void saveCountryPreferenceRejectsEmptySelection() {
+ assertThrows(BusinessException.class, () -> service.saveCountryPreference(prefRequest(23L, List.of())));
+ assertThrows(BusinessException.class, () -> service.saveCountryPreference(prefRequest(23L, null)));
+ }
+
+ @Test
+ void saveCountryPreferenceInsertsThenUpdates() {
+ when(countryPrefMapper.selectById(23L)).thenReturn(null);
+ service.saveCountryPreference(prefRequest(23L, List.of("UK", "DE")));
+ verify(countryPrefMapper).insert(any(ShopMatchCountryPrefEntity.class));
+
+ ShopMatchCountryPrefEntity existing = new ShopMatchCountryPrefEntity();
+ existing.setUserId(23L);
+ existing.setCountryCodesJson("[\"DE\"]");
+ when(countryPrefMapper.selectById(23L)).thenReturn(existing);
+ ProductRiskCountryPreferenceVo updated = service.saveCountryPreference(prefRequest(23L, List.of("DE", "UK")));
+
+ assertEquals(List.of("DE", "UK"), updated.getCountryCodes());
+ verify(countryPrefMapper).updateById(existing);
+ }
+}
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java
new file mode 100644
index 00000000..a3c60ef4
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/split/service/SplitRunServiceTest.java
@@ -0,0 +1,107 @@
+package com.nanri.aiimage.modules.split.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.nanri.aiimage.common.exception.BusinessException;
+import com.nanri.aiimage.config.StorageProperties;
+import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
+import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
+import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
+import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 数据拆分的结果文件归属校验(2026-09 审查 G4:该模块此前零测试文件)。
+ *
+ * 只覆盖"能安全断言、且与安全相关"的部分:下载与删除的历史记录必须属于本人且模块匹配。
+ * `run(...)` 依赖真实 workbook 与 OSS 上传,另需集成环境,此处不构造。
+ */
+class SplitRunServiceTest {
+
+ private static final String MODULE_TYPE = "SPLIT";
+
+ @BeforeAll
+ static void initializeMybatisMetadata() {
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+ FileResultEntity.class);
+ }
+
+ private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
+ private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
+ private final StorageProperties storageProperties = mock(StorageProperties.class);
+ private final OssStorageService ossStorageService = mock(OssStorageService.class);
+
+ private SplitRunService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new SplitRunService(fileTaskMapper, fileResultMapper, storageProperties, ossStorageService);
+ }
+
+ private static FileResultEntity result(Long id, String moduleType, Long userId, String url) {
+ FileResultEntity entity = new FileResultEntity();
+ entity.setId(id);
+ entity.setModuleType(moduleType);
+ entity.setUserId(userId);
+ entity.setResultFileUrl(url);
+ return entity;
+ }
+
+ @Test
+ void deleteHistoryRejectsMissingOrForeignOrOtherModuleRecord() {
+ when(fileResultMapper.selectById(99L)).thenReturn(null);
+ assertThrows(BusinessException.class, () -> service.deleteHistory(99L, 23L), "记录不存在");
+
+ when(fileResultMapper.selectById(100L)).thenReturn(result(100L, MODULE_TYPE, 88L, "oss/x.xlsx"));
+ assertThrows(BusinessException.class, () -> service.deleteHistory(100L, 23L), "他人记录不可删");
+
+ when(fileResultMapper.selectById(101L)).thenReturn(result(101L, "DEDUPE", 23L, "oss/x.xlsx"));
+ assertThrows(BusinessException.class, () -> service.deleteHistory(101L, 23L), "跨模块记录不可删");
+
+ verify(fileResultMapper, never()).deleteById(anyLong());
+ }
+
+ @Test
+ void deleteHistoryRemovesOwnSplitRecord() {
+ when(fileResultMapper.selectById(102L)).thenReturn(result(102L, MODULE_TYPE, 23L, "oss/x.xlsx"));
+
+ service.deleteHistory(102L, 23L);
+
+ verify(fileResultMapper).deleteById(102L);
+ }
+
+ @Test
+ void downloadUrlRejectsForeignOrMissingFile() {
+ when(fileResultMapper.selectById(200L)).thenReturn(result(200L, MODULE_TYPE, 88L, "oss/x.xlsx"));
+ assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(200L, 23L));
+
+ when(fileResultMapper.selectById(201L)).thenReturn(result(201L, MODULE_TYPE, 23L, null));
+ assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(201L, 23L), "无结果文件时不可下载");
+
+ when(fileResultMapper.selectById(202L)).thenReturn(result(202L, "CONVERT", 23L, "oss/x.xlsx"));
+ assertThrows(BusinessException.class, () -> service.getResultDownloadUrl(202L, 23L));
+
+ verify(ossStorageService, never()).generateFreshDownloadUrl(any());
+ }
+
+ @Test
+ void downloadUrlReturnsFreshSignedUrlForOwnRecord() {
+ when(fileResultMapper.selectById(203L)).thenReturn(result(203L, MODULE_TYPE, 23L, "oss/split.xlsx"));
+ when(ossStorageService.generateFreshDownloadUrl("oss/split.xlsx")).thenReturn("https://oss.example/split.xlsx?sig=1");
+
+ assertEquals("https://oss.example/split.xlsx?sig=1", service.getResultDownloadUrl(203L, 23L));
+ }
+}