union-ark-web 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/PROJECT_OVERVIEW.md +405 -0
  2. package/README.md +177 -0
  3. package/ark-control/pom.xml +142 -0
  4. package/ark-control/src/local-mock/java/com/epcc/commons/securityproxy/api/SecurityResult.java +14 -0
  5. package/ark-control/src/local-mock/java/com/epcc/commons/securityproxy/api/SymmetricalSecurityService.java +11 -0
  6. package/ark-control/src/local-mock/java/com/epcc/dubbo/result/Result.java +29 -0
  7. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/annotation/EnDecryptField.java +16 -0
  8. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/annotation/EnDecryptFieldLong.java +17 -0
  9. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/annotation/EnDecryptFieldWithTag.java +16 -0
  10. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/exception/BaseDataErrorCode.java +26 -0
  11. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/exception/CheckException.java +33 -0
  12. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/exception/ErrorCode.java +9 -0
  13. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/redis/RedisCacheService.java +131 -0
  14. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/util/Constant.java +24 -0
  15. package/ark-control/src/local-mock/java/com/nucc/channel/ark/common/util/ResultUtil.java +9 -0
  16. package/ark-control/src/local-mock/java/com/union/control/local/sensitive/LocalMockSensitiveProxy.java +40 -0
  17. package/ark-control/src/local-mock/java/com/union/control/local/sensitive/LocalRedisCacheService.java +45 -0
  18. package/ark-control/src/local-mock/java/com/union/control/local/sensitive/LocalSensitiveHostConfiguration.java +27 -0
  19. package/ark-control/src/local-mock/java/com/union/control/local/sensitive/LocalSensitiveProxyConfiguration.java +16 -0
  20. package/ark-control/src/main/java/com/union/control/ArkControlApplication.java +17 -0
  21. package/ark-control/src/main/java/com/union/control/dubbo/ProviderAccessLogFilter.java +45 -0
  22. package/ark-control/src/main/java/com/union/control/mapper/AgentExecutionMapper.java +35 -0
  23. package/ark-control/src/main/java/com/union/control/mapper/ConversationMapper.java +44 -0
  24. package/ark-control/src/main/java/com/union/control/mapper/MemoryStoreMapper.java +41 -0
  25. package/ark-control/src/main/java/com/union/control/mapper/ScheduledTaskMapper.java +96 -0
  26. package/ark-control/src/main/java/com/union/control/mapper/SensitiveAddressBookDemo.java +31 -0
  27. package/ark-control/src/main/java/com/union/control/mapper/SensitiveDataDemoMapper.java +24 -0
  28. package/ark-control/src/main/java/com/union/control/mapper/interceptor/AESInterceptor.java +265 -0
  29. package/ark-control/src/main/java/com/union/control/mapper/interceptor/AddressBookHandler.java +105 -0
  30. package/ark-control/src/main/java/com/union/control/schedule/ScheduledExecutionToken.java +55 -0
  31. package/ark-control/src/main/java/com/union/control/schedule/ScheduledTaskScheduler.java +206 -0
  32. package/ark-control/src/main/java/com/union/control/service/impl/AgentExecutionServiceImpl.java +212 -0
  33. package/ark-control/src/main/java/com/union/control/service/impl/AgentProxyServiceImpl.java +143 -0
  34. package/ark-control/src/main/java/com/union/control/service/impl/ConversationServiceImpl.java +245 -0
  35. package/ark-control/src/main/java/com/union/control/service/impl/MemoryStoreServiceImpl.java +252 -0
  36. package/ark-control/src/main/java/com/union/control/service/impl/RunningAnalysisMockServiceImpl.java +301 -0
  37. package/ark-control/src/main/java/com/union/control/service/impl/ScheduledTaskServiceImpl.java +576 -0
  38. package/ark-control/src/main/java/com/union/control/service/impl/SensitiveDataDemoServiceImpl.java +144 -0
  39. package/ark-control/src/main/java/com/union/control/service/sensitive/RedisRevealTokenStore.java +90 -0
  40. package/ark-control/src/main/java/com/union/control/service/sensitive/SensitiveRevealProcessor.java +319 -0
  41. package/ark-control/src/main/java/com/union/control/service/sensitive/SensitiveRevealServiceImpl.java +72 -0
  42. package/ark-control/src/main/java/com/union/control/utils/AgentSupport.java +103 -0
  43. package/ark-control/src/main/java/com/union/control/utils/security/SymmetricalSecurityUtils.java +160 -0
  44. package/ark-control/src/main/resources/META-INF/dubbo/com.alibaba.dubbo.rpc.Filter +1 -0
  45. package/ark-control/src/main/resources/application.properties +4 -0
  46. package/ark-control/src/main/resources/application.yml +32 -0
  47. package/ark-control/src/main/resources/dubbo-provider.xml +20 -0
  48. package/ark-control/src/main/resources/mapper/AgentExecutionMapper.xml +75 -0
  49. package/ark-control/src/main/resources/mapper/ConversationMapper.xml +110 -0
  50. package/ark-control/src/main/resources/mapper/MemoryStoreMapper.xml +74 -0
  51. package/ark-control/src/main/resources/mapper/ScheduledTaskMapper.xml +286 -0
  52. package/ark-control/src/main/resources/mapper/SensitiveDataDemoMapper.xml +55 -0
  53. package/ark-control/src/main/resources/schema.sql +146 -0
  54. package/ark-control/src/test/java/com/union/control/AgentExecutionServiceTest.java +170 -0
  55. package/ark-control/src/test/java/com/union/control/ArkControlStructureContractTest.java +107 -0
  56. package/ark-control/src/test/java/com/union/control/ConversationServiceTest.java +89 -0
  57. package/ark-control/src/test/java/com/union/control/MemoryStoreServiceTest.java +26 -0
  58. package/ark-control/src/test/java/com/union/control/PydanticAiControlContractTest.java +52 -0
  59. package/ark-control/src/test/java/com/union/control/TestJson.java +45 -0
  60. package/ark-control/src/test/java/com/union/control/dubbo/ProviderAccessLogFilterTest.java +53 -0
  61. package/ark-control/src/test/java/com/union/control/local/sensitive/LocalSensitiveProxyConfigurationTest.java +23 -0
  62. package/ark-control/src/test/java/com/union/control/mapper/interceptor/AESInterceptorCompatibilityTest.java +505 -0
  63. package/ark-control/src/test/java/com/union/control/schedule/ScheduledExecutionTokenTest.java +27 -0
  64. package/ark-control/src/test/java/com/union/control/schedule/ScheduledTaskSchedulerTest.java +51 -0
  65. package/ark-control/src/test/java/com/union/control/service/AgentProxyServiceTest.java +111 -0
  66. package/ark-control/src/test/java/com/union/control/service/ScheduledTaskServiceTest.java +483 -0
  67. package/ark-control/src/test/java/com/union/control/service/impl/SensitiveDataDemoServiceImplTest.java +104 -0
  68. package/ark-control/src/test/java/com/union/control/service/sensitive/RedisRevealTokenStoreTest.java +65 -0
  69. package/ark-control/src/test/java/com/union/control/service/sensitive/SensitiveRevealBoundaryTest.java +99 -0
  70. package/ark-control/src/test/java/com/union/control/service/sensitive/SensitiveRevealProcessorCodecTest.java +111 -0
  71. package/ark-control/src/test/java/com/union/control/service/sensitive/SensitiveRevealProcessorTest.java +111 -0
  72. package/ark-control/src/test/java/com/union/control/utils/security/SymmetricalSecurityUtilsCompatibilityTest.java +87 -0
  73. package/ark-control-facade/pom.xml +16 -0
  74. package/ark-control-facade/src/main/java/com/union/control/service/AgentExecutionService.java +8 -0
  75. package/ark-control-facade/src/main/java/com/union/control/service/AgentProxyService.java +7 -0
  76. package/ark-control-facade/src/main/java/com/union/control/service/AgentResponse.java +22 -0
  77. package/ark-control-facade/src/main/java/com/union/control/service/ConversationService.java +12 -0
  78. package/ark-control-facade/src/main/java/com/union/control/service/MemoryStoreService.java +12 -0
  79. package/ark-control-facade/src/main/java/com/union/control/service/RunningAnalysisMockService.java +10 -0
  80. package/ark-control-facade/src/main/java/com/union/control/service/ScheduledTaskService.java +40 -0
  81. package/ark-control-facade/src/main/java/com/union/control/service/SensitiveDataDemoService.java +13 -0
  82. package/ark-control-facade/src/main/java/com/union/control/service/sensitive/SensitiveRevealService.java +18 -0
  83. package/ark-web/README.md +80 -0
  84. package/ark-web/pom.xml +105 -0
  85. package/ark-web/src/local-mock/java/com/epcc/arkweb/mock/ArkAuthServiceMock.java +105 -0
  86. package/ark-web/src/local-mock/java/com/epcc/arkweb/mock/LocalKaptchaConfiguration.java +26 -0
  87. package/ark-web/src/main/java/com/epcc/arkweb/Application.java +13 -0
  88. package/ark-web/src/main/java/com/epcc/arkweb/config/InterceptorConfig.java +20 -0
  89. package/ark-web/src/main/java/com/epcc/arkweb/config/Realm.java +64 -0
  90. package/ark-web/src/main/java/com/epcc/arkweb/config/ShiroConfig.java +129 -0
  91. package/ark-web/src/main/java/com/epcc/arkweb/filter/LoginFormFilter.java +43 -0
  92. package/ark-web/src/main/java/com/epcc/arkweb/helper/AuthContextHolder.java +39 -0
  93. package/ark-web/src/main/java/com/epcc/arkweb/helper/AuthenticatedRequest.java +91 -0
  94. package/ark-web/src/main/java/com/epcc/arkweb/model/ShiroUser.java +21 -0
  95. package/ark-web/src/main/java/com/epcc/arkweb/utils/ResultMsg.java +19 -0
  96. package/ark-web/src/main/java/com/epcc/arkweb/vo/llm/ScheduledTaskCommandVO.java +30 -0
  97. package/ark-web/src/main/java/com/epcc/arkweb/vo/llm/ScheduledTaskQueryVO.java +20 -0
  98. package/ark-web/src/main/java/com/epcc/arkweb/web/CommonController.java +25 -0
  99. package/ark-web/src/main/java/com/epcc/arkweb/web/llm/AgentAuthorizationInterceptor.java +229 -0
  100. package/ark-web/src/main/java/com/epcc/arkweb/web/llm/AgentController.java +165 -0
  101. package/ark-web/src/main/java/com/epcc/arkweb/web/llm/AgentPermission.java +13 -0
  102. package/ark-web/src/main/java/com/epcc/arkweb/web/llm/LlmController.java +209 -0
  103. package/ark-web/src/main/java/com/epcc/arkweb/web/llm/ScheduledTaskController.java +160 -0
  104. package/ark-web/src/main/java/com/epcc/arkweb/web/sensitive/reveal/SensitiveCaptchaService.java +146 -0
  105. package/ark-web/src/main/java/com/epcc/arkweb/web/sensitive/reveal/SensitiveRevealController.java +163 -0
  106. package/ark-web/src/main/resources/application.properties +1 -0
  107. package/ark-web/src/main/resources/application.yml +23 -0
  108. package/ark-web/src/main/resources/dubbo-consumer.xml +19 -0
  109. package/ark-web/src/production-overlay/java/com/epcc/arkweb/config/CasShiroConfig.java +229 -0
  110. package/ark-web/src/production-overlay/java/com/epcc/arkweb/config/InterceptorConfig.java +35 -0
  111. package/ark-web/src/production-overlay/java/com/epcc/arkweb/config/ShiroConfig.java +161 -0
  112. package/ark-web/src/test/java/com/epcc/arkweb/ArkWebStructureContractTest.java +290 -0
  113. package/ark-web/src/test/java/com/epcc/arkweb/ShiroTestSupport.java +32 -0
  114. package/ark-web/src/test/java/com/epcc/arkweb/config/LocalShiroIntegrationTest.java +121 -0
  115. package/ark-web/src/test/java/com/epcc/arkweb/helper/AuthenticatedRequestTest.java +131 -0
  116. package/ark-web/src/test/java/com/epcc/arkweb/local/LocalArkAuthServiceMockTest.java +17 -0
  117. package/ark-web/src/test/java/com/epcc/arkweb/web/llm/AgentAuthorizationInterceptorTest.java +134 -0
  118. package/ark-web/src/test/java/com/epcc/arkweb/web/llm/AgentControllerAuthorizationTest.java +97 -0
  119. package/ark-web/src/test/java/com/epcc/arkweb/web/llm/LlmControllerTest.java +113 -0
  120. package/ark-web/src/test/java/com/epcc/arkweb/web/sensitive/reveal/SensitiveRevealControllerTest.java +273 -0
  121. package/package.json +18 -0
  122. package/pom.xml +16 -0
@@ -0,0 +1,111 @@
1
+ package com.union.control.service;
2
+
3
+ import ch.qos.logback.classic.spi.ILoggingEvent;
4
+ import ch.qos.logback.core.read.ListAppender;
5
+ import org.junit.Test;
6
+ import org.slf4j.LoggerFactory;
7
+ import org.springframework.http.HttpStatus;
8
+ import org.springframework.http.HttpMethod;
9
+ import org.springframework.http.MediaType;
10
+ import com.union.control.service.impl.AgentProxyServiceImpl;
11
+ import com.fasterxml.jackson.databind.ObjectMapper;
12
+ import org.springframework.test.web.client.MockRestServiceServer;
13
+ import org.springframework.web.client.RestTemplate;
14
+
15
+ import java.util.stream.Collectors;
16
+
17
+ import static org.assertj.core.api.Assertions.assertThat;
18
+ import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
19
+ import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
20
+ import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
21
+ import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
22
+
23
+ public class AgentProxyServiceTest {
24
+ @Test
25
+ public void logsModelCallMetadataWithoutCredentialsOrPayload() {
26
+ RestTemplate http = new RestTemplate();
27
+ MockRestServiceServer server = MockRestServiceServer.createServer(http);
28
+ server.expect(requestTo("http://py-app/agent/v1/runs/sync"))
29
+ .andExpect(method(HttpMethod.POST))
30
+ .andRespond(withSuccess("{ \"content\" : \"model-secret\" }", MediaType.APPLICATION_JSON));
31
+ ListAppender<ILoggingEvent> logs = logs();
32
+ String payload = "{\"prompt\":\"plaintext-secret\"}";
33
+
34
+ AgentResponse response = new AgentProxyServiceImpl("http://py-app", http, new ObjectMapper())
35
+ .sync("CASSESSIONID=cookie-secret", payload);
36
+
37
+ String messages = logs.list.stream()
38
+ .map(ILoggingEvent::getFormattedMessage)
39
+ .collect(Collectors.joining("\n"));
40
+ assertThat(response.getBody()).isEqualTo("{\"content\":\"model-secret\"}");
41
+ assertThat(messages).contains("Agent call started mode=sync payload_chars=" + payload.length())
42
+ .contains("Agent call completed mode=sync status=200 duration_ms=")
43
+ .doesNotContain("cookie-secret")
44
+ .doesNotContain("plaintext-secret")
45
+ .doesNotContain("model-secret");
46
+ server.verify();
47
+ }
48
+
49
+ @Test
50
+ public void cancellationPassesThroughPyAppResponse() {
51
+ RestTemplate http = new RestTemplate();
52
+ MockRestServiceServer server = MockRestServiceServer.createServer(http);
53
+ server.expect(requestTo("http://py-app/agent/v1/runs/cancel"))
54
+ .andExpect(method(HttpMethod.POST))
55
+ .andRespond(withStatus(HttpStatus.CONFLICT)
56
+ .contentType(MediaType.APPLICATION_JSON)
57
+ .body("{\"detail\":\"already finished\"}"));
58
+
59
+ AgentResponse response = new AgentProxyServiceImpl("http://py-app", http, new ObjectMapper())
60
+ .cancel("CASSESSIONID=session", "thread-1", "run-1");
61
+
62
+ assertThat(response.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
63
+ assertThat(response.getBody()).isEqualTo("{\"detail\":\"already finished\"}");
64
+ server.verify();
65
+ }
66
+
67
+ @Test(timeout = 5000)
68
+ public void allProxyModesStopWaitingForASilentUpstream() throws Exception {
69
+ com.sun.net.httpserver.HttpServer server = com.sun.net.httpserver.HttpServer.create(
70
+ new java.net.InetSocketAddress("127.0.0.1", 0), 0);
71
+ server.createContext("/", exchange -> {
72
+ try { Thread.sleep(150); }
73
+ catch (InterruptedException error) { Thread.currentThread().interrupt(); }
74
+ finally { exchange.close(); }
75
+ });
76
+ server.start();
77
+ try {
78
+ AgentProxyServiceImpl service = new AgentProxyServiceImpl(
79
+ "http://127.0.0.1:" + server.getAddress().getPort(), new ObjectMapper(), 0.05);
80
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.sync("session", "{}"))
81
+ .isInstanceOf(org.springframework.web.client.ResourceAccessException.class)
82
+ .hasCauseInstanceOf(java.net.SocketTimeoutException.class);
83
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.scheduled("Scheduled token"))
84
+ .isInstanceOf(org.springframework.web.client.ResourceAccessException.class)
85
+ .hasCauseInstanceOf(java.net.SocketTimeoutException.class);
86
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.cancel("session", "thread", "run"))
87
+ .isInstanceOf(org.springframework.web.client.ResourceAccessException.class)
88
+ .hasCauseInstanceOf(java.net.SocketTimeoutException.class);
89
+ } finally {
90
+ server.stop(0);
91
+ }
92
+ }
93
+
94
+ @Test
95
+ public void rejectsUnboundedOrOverflowingTimeouts() {
96
+ for (double seconds : new double[]{0, -1, Double.NaN, Double.POSITIVE_INFINITY, Integer.MAX_VALUE}) {
97
+ org.assertj.core.api.Assertions.assertThatThrownBy(() ->
98
+ new AgentProxyServiceImpl("http://py-app", new ObjectMapper(), seconds))
99
+ .isInstanceOf(IllegalArgumentException.class);
100
+ }
101
+ }
102
+
103
+ private static ListAppender<ILoggingEvent> logs() {
104
+ ch.qos.logback.classic.Logger logger =
105
+ (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(AgentProxyServiceImpl.class);
106
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
107
+ appender.start();
108
+ logger.addAppender(appender);
109
+ return appender;
110
+ }
111
+ }
@@ -0,0 +1,483 @@
1
+ package com.union.control.service.impl;
2
+
3
+ import com.fasterxml.jackson.databind.ObjectMapper;
4
+ import com.union.control.TestJson;
5
+ import com.union.control.mapper.ScheduledTaskMapper;
6
+ import com.union.control.service.ScheduledTaskService;
7
+ import com.union.control.schedule.ScheduledExecutionToken;
8
+ import org.junit.Before;
9
+ import org.junit.Test;
10
+ import org.mockito.ArgumentCaptor;
11
+
12
+ import java.nio.charset.StandardCharsets;
13
+ import java.nio.file.Files;
14
+ import java.nio.file.Paths;
15
+ import java.time.Instant;
16
+ import java.time.LocalDateTime;
17
+ import java.time.ZoneId;
18
+ import java.util.Arrays;
19
+ import java.util.Collections;
20
+ import java.util.LinkedHashMap;
21
+ import java.util.List;
22
+ import java.util.Map;
23
+
24
+ import static org.assertj.core.api.Assertions.assertThat;
25
+ import static org.assertj.core.api.Assertions.assertThatThrownBy;
26
+ import static org.mockito.Matchers.any;
27
+ import static org.mockito.Matchers.anyString;
28
+ import static org.mockito.Matchers.contains;
29
+ import static org.mockito.Matchers.eq;
30
+ import static org.mockito.Mockito.doAnswer;
31
+ import static org.mockito.Mockito.mock;
32
+ import static org.mockito.Mockito.never;
33
+ import static org.mockito.Mockito.times;
34
+ import static org.mockito.Mockito.verify;
35
+ import static org.mockito.Mockito.when;
36
+
37
+ public class ScheduledTaskServiceTest {
38
+ private ScheduledTaskMapper mapper;
39
+ private JsonScheduledTaskService service;
40
+ private ConversationServiceImpl conversationService;
41
+
42
+ @Before
43
+ public void setUp() {
44
+ mapper = mock(ScheduledTaskMapper.class);
45
+ conversationService = mock(ConversationServiceImpl.class);
46
+ service = new JsonScheduledTaskService(
47
+ mapper, new ObjectMapper(), conversationService, 960, 930);
48
+ }
49
+
50
+ @Test
51
+ public void timezoneMustBeAZoneInfoCompatibleTzdbIdentifier() {
52
+ assertThat(ScheduledTaskServiceImpl.zone("UTC")).isEqualTo(ZoneId.of("UTC"));
53
+ assertThat(ScheduledTaskServiceImpl.zone("Asia/Shanghai"))
54
+ .isEqualTo(ZoneId.of("Asia/Shanghai"));
55
+ for (String value : Arrays.asList("+08:00", "GMT+08:00", "UTC+08:00")) {
56
+ assertThatThrownBy(() -> ScheduledTaskServiceImpl.zone(value))
57
+ .isInstanceOf(IllegalArgumentException.class)
58
+ .hasMessageContaining("timezone");
59
+ }
60
+ }
61
+
62
+ @Test
63
+ public void resolvesRawTokenByHashingInsideControl() {
64
+ ScheduledExecutionToken token = ScheduledExecutionToken.issue();
65
+ Map<String, Object> row = new LinkedHashMap<>();
66
+ row.put("runId", 7L);
67
+ when(mapper.resolveScheduledToken(token.hash())).thenReturn(row);
68
+
69
+ assertThat(service.resolveScheduledToken(
70
+ token.authorizationHeader().substring("Scheduled ".length())))
71
+ .isSameAs(row);
72
+ verify(mapper).resolveScheduledToken(token.hash());
73
+ }
74
+
75
+ @Test
76
+ public void calculatesCronAndIntervalAfterTheReferenceInstant() {
77
+ Instant now = Instant.parse("2026-08-10T01:00:01Z");
78
+
79
+ assertThat(service.nextCron("0 0 9 * * MON", ZoneId.of("Asia/Shanghai"), now))
80
+ .isEqualTo(Instant.parse("2026-08-17T01:00:00Z"));
81
+ assertThat(service.nextInterval(
82
+ Instant.parse("2026-08-10T00:00:00Z"), 3600, now))
83
+ .isEqualTo(Instant.parse("2026-08-10T02:00:00Z"));
84
+ }
85
+
86
+ @Test
87
+ public void rejectsTooFrequentOrInvalidSchedules() {
88
+ assertThatThrownBy(() -> service.nextInterval(Instant.now(), 59, Instant.now()))
89
+ .isInstanceOf(IllegalArgumentException.class);
90
+ assertThatThrownBy(() -> service.validateCron("bad cron", ZoneId.of("UTC")))
91
+ .isInstanceOf(IllegalArgumentException.class);
92
+ assertThatThrownBy(() -> service.validateCron("* * * * * *", ZoneId.of("UTC")))
93
+ .isInstanceOf(IllegalArgumentException.class)
94
+ .hasMessageContaining("60");
95
+
96
+ service.validateCron("0 * * * * *", ZoneId.of("UTC"));
97
+ }
98
+
99
+ @Test
100
+ public void createRejectsMoreThanOneHundredRunnableTasksPerUser() {
101
+ when(mapper.countRunnableTasks(TestJson.USER_ID)).thenReturn(100);
102
+ Map<String, Object> payload = new LinkedHashMap<>();
103
+ payload.put("title", "超限任务");
104
+ payload.put("prompt", "执行分析");
105
+ payload.put("scheduleType", "ONCE");
106
+ payload.put("runAt", "2030-01-01T00:00:00Z");
107
+ payload.put("timezone", "UTC");
108
+
109
+ assertThatThrownBy(() -> service.create(payload))
110
+ .isInstanceOf(ScheduledTaskService.ConflictException.class)
111
+ .hasMessageContaining("100");
112
+ verify(mapper).lockUserTasks(TestJson.USER_ID);
113
+ }
114
+
115
+ @Test
116
+ public void createSnapshotsTheAuthenticatedOrganizationAndRole() {
117
+ Map<String, Object> payload = oncePayload();
118
+ doAnswer(invocation -> {
119
+ @SuppressWarnings("unchecked") Map<String, Object> task =
120
+ (Map<String, Object>) invocation.getArguments()[0];
121
+ task.put("id", 7L);
122
+ return 1;
123
+ }).when(mapper).insertTask(org.mockito.Matchers.<Map<String, Object>>any());
124
+ when(mapper.findTaskDetail(7L, TestJson.USER_ID))
125
+ .thenReturn(Collections.<String, Object>singletonMap("id", 7L));
126
+
127
+ service.create(payload);
128
+
129
+ ArgumentCaptor<Map> inserted = ArgumentCaptor.forClass(Map.class);
130
+ verify(mapper).insertTask(inserted.capture());
131
+ assertThat(inserted.getValue())
132
+ .containsEntry("userId", TestJson.USER_ID)
133
+ .containsEntry("orgCode", TestJson.ORG_CODE)
134
+ .containsEntry("roleId", TestJson.ROLE_ID);
135
+ }
136
+
137
+ @Test
138
+ public void updateValidatesAndPersistsOnlyTheOwnedTaskDefinition() {
139
+ Map<String, Object> owned = new LinkedHashMap<>();
140
+ owned.put("status", "ACTIVE");
141
+ when(mapper.findTaskOwner(7L, TestJson.USER_ID, true)).thenReturn(owned);
142
+ Map<String, Object> payload = new LinkedHashMap<>();
143
+ payload.put("taskId", 7L);
144
+ payload.put("title", "新标题");
145
+ payload.put("prompt", "新的可信任务提示");
146
+ payload.put("scheduleType", "ONCE");
147
+ payload.put("runAt", "2099-08-12T15:00:00+08:00");
148
+ payload.put("timezone", "Asia/Shanghai");
149
+ when(mapper.updateTask(org.mockito.Matchers.<Map<String, Object>>any())).thenReturn(1);
150
+ when(mapper.findTaskDetail(7L, TestJson.USER_ID)).thenReturn(Collections.<String, Object>singletonMap("id", 7L));
151
+
152
+ service.update(payload);
153
+
154
+ ArgumentCaptor<Map> updated = ArgumentCaptor.forClass(Map.class);
155
+ verify(mapper).updateTask(updated.capture());
156
+ assertThat(updated.getValue())
157
+ .containsEntry("taskId", 7L)
158
+ .containsEntry("userId", TestJson.USER_ID)
159
+ .containsEntry("orgCode", TestJson.ORG_CODE)
160
+ .containsEntry("roleId", TestJson.ROLE_ID)
161
+ .containsEntry("title", "新标题")
162
+ .containsEntry("prompt", "新的可信任务提示")
163
+ .containsEntry("scheduleType", "ONCE")
164
+ .containsEntry("timezone", "Asia/Shanghai");
165
+ }
166
+
167
+ @Test
168
+ public void updateRejectsTasksNotOwnedByTheCallerBeforeWriting() {
169
+ Map<String, Object> payload = new LinkedHashMap<>();
170
+ payload.put("taskId", 7L);
171
+ when(mapper.findTaskOwner(7L, TestJson.USER_ID, true)).thenReturn(null);
172
+
173
+ assertThatThrownBy(() -> service.update(payload))
174
+ .isInstanceOf(ScheduledTaskService.NotFoundException.class);
175
+
176
+ verify(mapper, never()).updateTask(org.mockito.Matchers.<Map<String, Object>>any());
177
+ }
178
+
179
+ @Test
180
+ public void runDetailScopesTheQueryToTheAuthenticatedOwner() {
181
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, false)).thenReturn(null);
182
+ assertThatThrownBy(() -> service.runDetail(7))
183
+ .isInstanceOf(ScheduledTaskService.NotFoundException.class);
184
+ verify(mapper).findOwnedRun(7L, TestJson.USER_ID, false);
185
+ }
186
+
187
+ @Test
188
+ public void runDetailSelectsAndReturnsTheStoredResultContent() {
189
+ Map<String, Object> run = run("SUCCEEDED");
190
+ run.put("resultPayload", "{\"content\":\"detail-only\"}");
191
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, false)).thenReturn(run);
192
+
193
+ @SuppressWarnings("unchecked") Map<String, Object> data = (Map<String, Object>)
194
+ service.runDetail(7).get("data");
195
+
196
+ assertThat(data).containsEntry("resultContent", "detail-only")
197
+ .doesNotContainKey("resultPayload");
198
+ }
199
+
200
+ @Test
201
+ public void unreadAcceptsMysqlBooleanReadFlag() {
202
+ Map<String, Object> run = run("SUCCEEDED");
203
+ run.put("readFlag", Boolean.FALSE);
204
+ run.put("resultPayload", "{\"content\":\"must-not-be-listed\"}");
205
+ when(mapper.countUnread(TestJson.USER_ID)).thenReturn(1L);
206
+ when(mapper.findUnread(TestJson.USER_ID)).thenReturn(Collections.singletonList(run));
207
+
208
+ @SuppressWarnings("unchecked") Map<String, Object> data = (Map<String, Object>)
209
+ service.unread().get("data");
210
+ @SuppressWarnings("unchecked") List<Map<String, Object>> items =
211
+ (List<Map<String, Object>>) data.get("items");
212
+
213
+ assertThat(items).hasSize(1);
214
+ assertThat(items.get(0).get("readFlag")).isEqualTo(false);
215
+ assertThat(items.get(0)).doesNotContainKeys("resultPayload", "resultContent");
216
+ verify(mapper).findUnread(TestJson.USER_ID);
217
+ }
218
+
219
+ @Test
220
+ public void runListDoesNotSelectOrExposeResultPayload() {
221
+ Map<String, Object> task = new LinkedHashMap<>();
222
+ task.put("id", 3L);
223
+ when(mapper.findTaskOwner(3L, TestJson.USER_ID, false)).thenReturn(task);
224
+ when(mapper.countRuns(3L, TestJson.USER_ID)).thenReturn(1L);
225
+ Map<String, Object> run = run("SUCCEEDED");
226
+ run.put("resultPayload", "{\"content\":\"must-not-be-listed\"}");
227
+ when(mapper.findRuns(3L, TestJson.USER_ID, 20, 0))
228
+ .thenReturn(Collections.singletonList(run));
229
+
230
+ @SuppressWarnings("unchecked") Map<String, Object> data = (Map<String, Object>)
231
+ service.runs(3, 1, 20).get("data");
232
+ @SuppressWarnings("unchecked") List<Map<String, Object>> items =
233
+ (List<Map<String, Object>>) data.get("items");
234
+
235
+ assertThat(items).hasSize(1);
236
+ assertThat(items.get(0)).doesNotContainKeys("resultPayload", "resultContent");
237
+ verify(mapper).findRuns(3L, TestJson.USER_ID, 20, 0);
238
+ }
239
+
240
+ @Test
241
+ public void openingAnAlreadyMaterializedRunIsIdempotent() {
242
+ Map<String, Object> run = run("SUCCEEDED");
243
+ run.put("resultConversationId", "scheduled-7");
244
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, true)).thenReturn(run);
245
+
246
+ @SuppressWarnings("unchecked") Map<String, Object> data = (Map<String, Object>)
247
+ service.open(7).get("data");
248
+
249
+ assertThat(data.get("conversationId")).isEqualTo("scheduled-7");
250
+ verify(mapper).markRunRead(7L);
251
+ verify(conversationService, never()).materializeCompletedConversation(
252
+ anyString(), anyString(), anyString(), anyString(), anyString());
253
+ }
254
+
255
+ @Test
256
+ public void onlyTerminalRunsCanBeOpened() {
257
+ for (String status : Arrays.asList("PENDING", "RUNNING")) {
258
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, true)).thenReturn(run(status));
259
+
260
+ assertThatThrownBy(() -> service.open(7))
261
+ .isInstanceOf(ScheduledTaskService.ConflictException.class)
262
+ .hasMessageContaining("尚未结束");
263
+ }
264
+ verify(conversationService, never()).materializeCompletedConversation(
265
+ anyString(), anyString(), anyString(), anyString(), anyString());
266
+ }
267
+
268
+ @Test
269
+ public void openingScopesTheLockedRunToTheAuthenticatedOwner() {
270
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, true)).thenReturn(null);
271
+
272
+ assertThatThrownBy(() -> service.open(7))
273
+ .isInstanceOf(ScheduledTaskService.NotFoundException.class);
274
+
275
+ verify(mapper).findOwnedRun(7L, TestJson.USER_ID, true);
276
+ verify(conversationService, never()).materializeCompletedConversation(
277
+ anyString(), anyString(), anyString(), anyString(), anyString());
278
+ }
279
+
280
+ @Test
281
+ public void openingCreatesTheExistingAguiConversationModelOnce() throws Exception {
282
+ Map<String, Object> run = run("SUCCEEDED");
283
+ run.put("title", "日报");
284
+ run.put("prompt", "可信任务提示");
285
+ run.put("resultPayload", new ObjectMapper().writeValueAsString(result()));
286
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, true)).thenReturn(run);
287
+ when(conversationService.materializeCompletedConversation(anyString(), anyString(),
288
+ anyString(), anyString(), anyString())).thenReturn("scheduled-7-shared");
289
+
290
+ @SuppressWarnings("unchecked") Map<String, Object> data = (Map<String, Object>)
291
+ service.open(7).get("data");
292
+
293
+ assertThat(data.get("conversationId")).isEqualTo("scheduled-7-shared");
294
+ verify(conversationService).materializeCompletedConversation(
295
+ TestJson.USER_ID, "日报", "可信任务提示", "日报内容", "ScheduledTaskAgent");
296
+ verify(mapper).attachConversation(7L, "scheduled-7-shared");
297
+ }
298
+
299
+ @Test
300
+ public void openingAFailedRunCreatesAFollowUpConversationWithTheSafeFailure() {
301
+ Map<String, Object> run = run("FAILED");
302
+ run.put("title", "失败任务");
303
+ run.put("prompt", "执行可信任务");
304
+ run.put("errorMessage", "定时任务执行失败");
305
+ when(mapper.findOwnedRun(7L, TestJson.USER_ID, true)).thenReturn(run);
306
+ when(conversationService.materializeCompletedConversation(anyString(), anyString(),
307
+ anyString(), anyString(), anyString())).thenReturn("scheduled-7-failed");
308
+
309
+ service.open(7);
310
+
311
+ verify(conversationService).materializeCompletedConversation(
312
+ TestJson.USER_ID, "失败任务", "执行可信任务", "定时任务执行失败", "ScheduledTaskAgent");
313
+ verify(mapper).attachConversation(7L, "scheduled-7-failed");
314
+ }
315
+
316
+ @Test
317
+ public void claimUsesSkipLockedAndSchemaEnforcesOccurrenceDeduplication() throws Exception {
318
+ String source = new String(Files.readAllBytes(Paths.get(
319
+ "src/main/resources/mapper/ScheduledTaskMapper.xml")),
320
+ StandardCharsets.UTF_8);
321
+ String schema = new String(Files.readAllBytes(Paths.get(
322
+ "src/main/resources/schema.sql")), StandardCharsets.UTF_8);
323
+
324
+ assertThat(source).contains("FOR UPDATE SKIP LOCKED");
325
+ assertThat(source).contains("r.status IN ('SUCCEEDED','FAILED') AND r.read_flag=0");
326
+ assertThat(schema).contains(
327
+ "UNIQUE KEY `uk_scheduled_run_occurrence` (`task_id`, `scheduled_at`)",
328
+ "`role_id` VARCHAR(64) COLLATE utf8mb4_bin NOT NULL");
329
+ }
330
+
331
+ @Test
332
+ public void claimingAOnceTaskExhaustsItBeforeTheRunIsDispatched() {
333
+ Map<String, Object> task = new LinkedHashMap<>();
334
+ task.put("id", 3L);
335
+ task.put("scheduleType", "ONCE");
336
+ task.put("nextRunAt", java.sql.Timestamp.from(Instant.parse("2026-08-11T00:00:00Z")));
337
+ when(mapper.findDueTask()).thenReturn(task);
338
+ doAnswer(invocation -> {
339
+ @SuppressWarnings("unchecked") Map<String, Object> run =
340
+ (Map<String, Object>) invocation.getArguments()[0];
341
+ run.put("id", 7L);
342
+ return 1;
343
+ }).when(mapper).insertRun(org.mockito.Matchers.<Map<String, Object>>any());
344
+
345
+ assertThat(service.claimDueRun()).isEqualTo(7L);
346
+
347
+ verify(mapper).completeOnceTask(3L);
348
+ }
349
+
350
+ @Test
351
+ public void convertsMysqlLocalDateTimeAsUtc() {
352
+ assertThat(ScheduledTaskServiceImpl.instant(
353
+ LocalDateTime.of(2026, 8, 10, 16, 45, 49, 780000000)))
354
+ .isEqualTo(Instant.parse("2026-08-10T16:45:49.780Z"));
355
+ }
356
+
357
+ @Test
358
+ public void writesDatabaseDateTimesAsUtcLiteralsWithoutJvmTimezoneConversion() {
359
+ assertThat(ScheduledTaskServiceImpl.databaseDateTime(
360
+ Instant.parse("2026-08-11T04:52:30Z")))
361
+ .isEqualTo("2026-08-11 04:52:30.000");
362
+ }
363
+
364
+ @Test
365
+ public void scheduledMapperNeverOwnsNormalConversationPersistence() throws Exception {
366
+ String scheduledMapper = new String(Files.readAllBytes(Paths.get(
367
+ "src/main/resources/mapper/ScheduledTaskMapper.xml")),
368
+ StandardCharsets.UTF_8);
369
+ assertThat(scheduledMapper)
370
+ .doesNotContain("INSERT INTO ai_conversation")
371
+ .doesNotContain("INSERT INTO ai_agent_execution")
372
+ .doesNotContain("INSERT INTO ai_conversation_message")
373
+ .contains("SET result_conversation_id=#{conversationId},read_flag=1");
374
+ }
375
+
376
+ @Test
377
+ public void storesOnlyTheResultFieldsNeededToOpenAConversation() throws Exception {
378
+ Map<String, Object> run = run("RUNNING");
379
+ when(mapper.findRunForUpdate(7L)).thenReturn(run);
380
+ Map<String, Object> result = result();
381
+ result.put("providerSecret", "must-not-be-stored");
382
+ ArgumentCaptor<String> stored = ArgumentCaptor.forClass(String.class);
383
+
384
+ service.completeFromProxy(7, result);
385
+
386
+ verify(mapper).completeRun(eq(7L), stored.capture());
387
+ String storedJson = stored.getValue();
388
+ @SuppressWarnings("unchecked") Map<String, Object> decoded =
389
+ new ObjectMapper().readValue(storedJson, Map.class);
390
+ assertThat(decoded).containsEntry("content", "日报内容")
391
+ .containsEntry("agentName", "ScheduledTaskAgent")
392
+ .doesNotContainKeys("messages", "providerSecret");
393
+ }
394
+
395
+ private static Map<String, Object> run(String status) {
396
+ Map<String, Object> run = new LinkedHashMap<>();
397
+ run.put("id", 7L);
398
+ run.put("taskId", 3L);
399
+ run.put("status", status);
400
+ run.put("readFlag", 0);
401
+ run.put("resultConversationId", null);
402
+ return run;
403
+ }
404
+
405
+ private static Map<String, Object> oncePayload() {
406
+ Map<String, Object> payload = new LinkedHashMap<>();
407
+ payload.put("title", "日报任务");
408
+ payload.put("prompt", "生成日报");
409
+ payload.put("scheduleType", "ONCE");
410
+ payload.put("runAt", "2099-01-01T00:00:00Z");
411
+ payload.put("timezone", "UTC");
412
+ return payload;
413
+ }
414
+
415
+ private static Map<String, Object> result() {
416
+ Map<String, Object> first = new LinkedHashMap<>();
417
+ first.put("id", "user-1");
418
+ first.put("role", "user");
419
+ first.put("content", "生成日报");
420
+ Map<String, Object> second = new LinkedHashMap<>();
421
+ second.put("id", "assistant-1");
422
+ second.put("role", "assistant");
423
+ second.put("content", "日报内容");
424
+ Map<String, Object> result = new LinkedHashMap<>();
425
+ result.put("content", "日报内容");
426
+ result.put("agentName", "ScheduledTaskAgent");
427
+ result.put("messages", Arrays.asList(first, second));
428
+ return result;
429
+ }
430
+ private static final class JsonScheduledTaskService extends ScheduledTaskServiceImpl {
431
+ JsonScheduledTaskService(ScheduledTaskMapper mapper, ObjectMapper json,
432
+ ConversationServiceImpl conversations, int ignoredTtl, int ignoredMax) {
433
+ super(mapper, json, conversations);
434
+ }
435
+
436
+ Map<String, Object> create(Map<String, Object> value) {
437
+ return super.create(TestJson.request(value));
438
+ }
439
+
440
+ Map<String, Object> update(Map<String, Object> value) {
441
+ return super.update(TestJson.request(value));
442
+ }
443
+
444
+ Map<String, Object> list(String keyword, String status, int page, int pageSize) {
445
+ return super.list(TestJson.request("keyword", keyword, "status", status,
446
+ "page", page, "pageSize", pageSize));
447
+ }
448
+
449
+ Map<String, Object> detail(long taskId) {
450
+ return super.detail(TestJson.request("taskId", taskId));
451
+ }
452
+
453
+ Map<String, Object> runs(long taskId, int page, int pageSize) {
454
+ return super.runs(TestJson.request("taskId", taskId, "page", page,
455
+ "pageSize", pageSize));
456
+ }
457
+
458
+ Map<String, Object> runDetail(long runId) {
459
+ return super.runDetail(TestJson.request("runId", runId));
460
+ }
461
+
462
+ Map<String, Object> unread() {
463
+ return super.unread(TestJson.request());
464
+ }
465
+
466
+ Map<String, Object> start(long taskId) {
467
+ return super.start(TestJson.request("taskId", taskId));
468
+ }
469
+
470
+ Map<String, Object> pause(long taskId) {
471
+ return super.pause(TestJson.request("taskId", taskId));
472
+ }
473
+
474
+ Map<String, Object> discard(long taskId) {
475
+ return super.discard(TestJson.request("taskId", taskId));
476
+ }
477
+
478
+ Map<String, Object> open(long runId) {
479
+ return super.open(TestJson.request("runId", runId));
480
+ }
481
+ }
482
+
483
+ }