feat(personal-assistant): add confirmed project memory

This commit is contained in:
2026-07-19 08:19:24 +08:00
parent 151cc26caf
commit 24da833fa6
31 changed files with 4037 additions and 60 deletions
@@ -1,6 +1,7 @@
package org.dromara.aihr.knowledge.domain;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import java.util.List;
@@ -32,8 +33,17 @@ public final class AihrKnowledgeQueryDto {
String docId,
String title,
String snippet,
Long fragmentId
Long fragmentId,
String domain,
String status,
String occurredAt,
String updatedAt
) {
public Citation(String spaceCode, String sourceType, String docId, String title, String snippet,
Long fragmentId) {
this(spaceCode, sourceType, docId, title, snippet, fragmentId,
"DOCUMENT".equals(sourceType) ? "ENTERPRISE" : sourceType, null, null, null);
}
}
public record Resource(
@@ -58,13 +68,28 @@ public final class AihrKnowledgeQueryDto {
Long contextVersion,
String intent,
String rewrittenQuery,
List<Resource> resources
List<Resource> resources,
MemoryCandidateResponse memoryCandidate
) {
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data, String conversationId, Long contextVersion,
String intent, String rewrittenQuery, List<Resource> resources) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, conversationId, contextVersion, intent, rewrittenQuery, resources, null);
}
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, null, null, "QA", queryText, List.of());
legacy, data, null, null, "QA", queryText, List.of(), null);
}
public QueryResponse withMemoryCandidate(MemoryCandidateResponse candidate) {
return new QueryResponse(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence,
promptVersion, legacy, data, conversationId, contextVersion, intent, rewrittenQuery,
resources, candidate);
}
}
}
@@ -37,6 +37,27 @@ public class AihrKnowledgeQueryAuditService {
}
}
/** Updates an earlier document audit or inserts the final fused-source audit when no document query ran. */
public void recordFinal(String requestId, AihrKnowledgePrincipal principal, AuthenticatedApp app, String question,
List<String> spaceCodes, List<String> sourceTypes, String status, long latencyMs,
String promptVersion) {
try {
int updated = jdbcTemplate.update("""
update aihr_knowledge_query_log
set used_space_codes_json = ?, source_types_json = ?, status = ?,
latency_ms = greatest(latency_ms, ?), prompt_version = ?
where request_id = ? and tenant_id = ? and app_id = ?
""", json(spaceCodes), json(sourceTypes), status, latencyMs, promptVersion,
requestId, app.tenantId(), app.id());
if (updated == 0) {
record(requestId, principal, app, question, spaceCodes, sourceTypes, status, latencyMs,
promptVersion);
}
} catch (DataAccessException ex) {
log.warn("knowledge query final audit write failed for request {}", requestId);
}
}
private String json(List<String> values) {
try {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
@@ -12,6 +12,9 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
@@ -40,23 +43,64 @@ public class AihrKnowledgeQueryService {
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeDataToolService dataToolService;
private final AihrKnowledgeConversationService conversationService;
private final AihrMemoryService memoryService;
public QueryResponse queryInternal(QueryRequest rawRequest) {
long queryStarted = System.nanoTime();
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
ConversationContext context = conversationContext(principal, app, request);
QueryRequest routed = routedRequest(request, context);
Set<Long> spaceIds = Set.of();
QueryResponse response;
ServiceMemoryRecall memoryRecall = ServiceMemoryRecall.empty();
boolean finalizeAudit = false;
QueryResponse response = null;
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
memoryRecall = "QA".equals(context.intent())
? memoryService.recall(principal, routed.queryText(), routed.limit())
: ServiceMemoryRecall.empty();
if (memoryRecall == null) {
memoryRecall = ServiceMemoryRecall.empty();
}
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
try {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
} catch (ServiceException ex) {
if (ex.getCode() != null && ex.getCode() == HttpStatus.FORBIDDEN
&& request.spaceCodes().isEmpty()
&& (!memoryRecall.items().isEmpty() || candidateIntent)) {
response = emptyProjectResponse(request.queryText());
finalizeAudit = true;
} else {
throw ex;
}
spaceIds = Set.of();
}
if (response == null) {
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
}
return complete(principal, app, spaceIds, request.queryText(), context, response);
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), completed.requestId());
QueryResponse result = completed.withMemoryCandidate(candidate);
if (finalizeAudit || candidate != null) {
List<String> sourceTypes = finalSourceTypes(result, memoryRecall, candidate);
auditService.recordFinal(
result.requestId(), principal, app, request.queryText(), result.usedSpaceCodes(), sourceTypes,
result.noEvidence() && candidate == null ? "NO_EVIDENCE" : "SUCCESS",
elapsedMillis(queryStarted), result.promptVersion());
}
return result;
}
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
@@ -184,6 +228,49 @@ public class AihrKnowledgeQueryService {
);
}
private QueryResponse mergeServiceMemories(ServiceMemoryRecall recall, QueryResponse response) {
List<Citation> citations = new ArrayList<>(response.citations());
recall.items().forEach(item -> citations.add(new Citation(
PROJECT_SERVICE, PROJECT_SERVICE, "service-memory:" + item.id(), item.title(), item.summary(), null,
PROJECT_SERVICE, item.status(), item.occurredAt(), item.updatedAt()
)));
LinkedHashSet<String> usedScopes = new LinkedHashSet<>(response.usedSpaceCodes());
usedScopes.add(PROJECT_SERVICE);
String answer = response.noEvidence() || response.citations().isEmpty()
? recall.answer()
: response.answer() + "\n\n" + recall.answer();
return new QueryResponse(
response.requestId(), response.queryText(), answer, List.copyOf(citations), List.copyOf(usedScopes),
false, response.promptVersion(), response.legacy(), response.data(), response.conversationId(),
response.contextVersion(), response.intent(), response.rewrittenQuery(), response.resources(),
response.memoryCandidate()
);
}
private static QueryResponse emptyProjectResponse(String queryText) {
return new QueryResponse(
UUID.randomUUID().toString().replace("-", ""), queryText, "", List.of(), List.of(),
true, "service-memory-v1", null, null
);
}
private static List<String> finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall,
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> !PROJECT_SERVICE.equals(citation.domain()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (recall != null && !recall.items().isEmpty()) {
sourceTypes.add(PROJECT_SERVICE);
}
if (candidate != null) {
sourceTypes.add("MEMORY_CANDIDATE");
}
return List.copyOf(sourceTypes);
}
private List<Resource> resources(String tenantId, Set<Long> spaceIds, List<Citation> citations, String intent) {
if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) {
return List.of();
@@ -337,6 +424,8 @@ public class AihrKnowledgeQueryService {
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v");
}
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private record AttachmentResource(Long attachmentId, String title) {
}
}
@@ -0,0 +1,45 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/personal-assistant")
public class AihrMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping("/memory-candidates")
public R<List<MemoryCandidateResponse>> candidates(
@RequestParam(value = "status", defaultValue = "DRAFT") String status
) {
return R.ok(memoryService.candidates(status));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/confirm")
public R<ConfirmMemoryResponse> confirm(@PathVariable Long id, @RequestBody ConfirmMemoryRequest request) {
return R.ok(memoryService.confirm(id, request));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/dismiss")
public R<MemoryCandidateResponse> dismiss(@PathVariable Long id) {
return R.ok(memoryService.dismiss(id));
}
}
@@ -0,0 +1,125 @@
package org.dromara.aihr.memory;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class AihrMemoryDto {
private AihrMemoryDto() {
}
public record MemoryDraft(
String projectCode,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt
) {
}
public record MemoryCandidateResponse(
Long id,
Integer version,
String status,
String memoryType,
String targetDomain,
MemoryDraft draft,
List<String> missingFields,
String expiresAt
) {
public MemoryCandidateResponse {
missingFields = missingFields == null ? List.of() : List.copyOf(missingFields);
}
}
public record ConfirmMemoryRequest(
Long expectedVersion,
String idempotencyKey,
MemoryDraft draft,
Boolean enableReminder
) {
}
public record ConfirmMemoryResponse(
String targetDomain,
Long targetId,
Integer version
) {
}
public record ServiceMemoryResponse(
Long id,
String projectCode,
String memoryType,
String subjectType,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt,
String sourceConversationId,
String sourceRequestId,
Long createdBy,
Long confirmedBy,
Integer version,
String createdAt,
String updatedAt,
List<ServiceMemoryVersionResponse> versions
) {
public ServiceMemoryResponse {
versions = versions == null ? List.of() : List.copyOf(versions);
}
}
public record ServiceMemoryVersionResponse(
Integer version,
Map<String, Object> snapshot,
String changeType,
String changeReason,
Long operatorUserId,
String createdAt
) {
public ServiceMemoryVersionResponse {
snapshot = snapshot == null ? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(snapshot));
}
}
public record ServiceMemoryRecall(
List<ServiceMemoryRecallItem> items,
String answer
) {
public ServiceMemoryRecall {
items = items == null ? List.of() : List.copyOf(items);
answer = answer == null ? "" : answer;
}
public static ServiceMemoryRecall empty() {
return new ServiceMemoryRecall(List.of(), "");
}
}
public record ServiceMemoryRecallItem(
Long id,
String projectCode,
String title,
String summary,
String status,
String occurredAt,
String updatedAt
) {
}
}
@@ -0,0 +1,43 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/service-memories")
public class AihrServiceMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping
public R<List<ServiceMemoryResponse>> list(
@RequestParam(value = "projectCode", required = false) String projectCode,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "dateFrom", required = false) String dateFrom,
@RequestParam(value = "dateTo", required = false) String dateTo,
@RequestParam(value = "buildingName", required = false) String buildingName,
@RequestParam(value = "unitName", required = false) String unitName,
@RequestParam(value = "roomNo", required = false) String roomNo,
@RequestParam(value = "limit", defaultValue = "50") Integer limit
) {
return R.ok(memoryService.serviceMemories(
projectCode, status, dateFrom, dateTo, buildingName, unitName, roomNo, limit));
}
@SaCheckLogin
@GetMapping("/{id}")
public R<ServiceMemoryResponse> detail(@PathVariable Long id) {
return R.ok(memoryService.serviceMemory(id));
}
}
@@ -14,6 +14,11 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
@@ -29,7 +34,10 @@ import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -108,7 +116,8 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class),
mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
mock(AihrMemoryService.class));
var result = service.queryExternal(app,
new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null));
@@ -129,7 +138,7 @@ class AihrKnowledgeQueryServiceTest {
when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500));
var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools,
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY")));
@@ -157,7 +166,7 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
var result = service.queryInternalMedia(
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
@@ -213,7 +222,8 @@ class AihrKnowledgeQueryServiceTest {
return List.of(mapper.mapRow(rs, 0));
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations);
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations,
mock(AihrMemoryService.class));
var result = service.queryInternal(request);
@@ -227,6 +237,198 @@ class AihrKnowledgeQueryServiceTest {
assertEquals("/api/knowledge/resources/321/content", result.resources().get(0).contentUrl());
}
@Test
void unifiedQueryReturnsMemoryCandidateAtResponseRoot() throws Exception {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("住户需求", "sop", "当前资料中没有足够依据", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)
.contains("\"memoryCandidate\""));
verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"),
any(), eq(result.requestId()));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of("sop")),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void firstCandidateCanBeCreatedWithoutEnterpriseKnowledgeSpace() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(principal,
"记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true);
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit,
mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
verify(sop, never()).searchAuthorized(any(), any(), any());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of()),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("service-memory-v1"));
}
@Test
void explicitUnauthorizedKnowledgeSpaceStillRejectsCandidateIntent() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of("private-space"), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(any(), anyString())).thenReturn(true);
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
assertThrows(ServiceException.class, () -> service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of("private-space"),
"sop", null, "mobile", 5, null)));
verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString());
}
@Test
void confirmedProjectMemoryIsRecalledWithCurrentStatusAndProjectCitation() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("跟进", "sop", "按照养老咨询 SOP 进行回访", "", List.of(),
List.of(new SnippetResponse("养老咨询 SOP", "记录需求并安排回访", 88L)),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("space_code")).thenReturn("sop");
when(rs.getString("doc_id")).thenReturn("elder-care-sop");
when(rs.getString("title")).thenReturn("养老咨询 SOP");
when(rs.getString("content")).thenReturn("记录需求并安排回访");
when(rs.getLong("fragment_id")).thenReturn(88L);
return List.of(mapper.mapRow(rs, 0));
});
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
var projectCitation = result.citations().stream()
.filter(citation -> "PROJECT_SERVICE".equals(citation.domain()))
.findFirst().orElseThrow();
assertEquals("IN_PROGRESS", projectCitation.status());
assertEquals("service-memory:501", projectCitation.docId());
assertTrue(result.usedSpaceCodes().contains("PROJECT_SERVICE"));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("sop", "PROJECT_SERVICE")), eq(List.of("DOCUMENT", "PROJECT_SERVICE")),
eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void projectOnlyRecallWithoutKnowledgeSpaceStillWritesFinalAudit() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), audit, mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
assertEquals(List.of("PROJECT_SERVICE"), result.usedSpaceCodes());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("PROJECT_SERVICE")), eq(List.of("PROJECT_SERVICE")), eq("SUCCESS"), anyLong(),
eq("service-memory-v1"));
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -242,7 +444,7 @@ class AihrKnowledgeQueryServiceTest {
when(jdbc.queryForList(anyString(), eq(Long.class), any(Object[].class))).thenReturn(List.of(777L));
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(777L, service.authorizedResourceOssId(321L));
verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ");
@@ -262,7 +464,7 @@ class AihrKnowledgeQueryServiceTest {
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of());
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(null, service.authorizedResourceOssId(321L));
verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class));
@@ -276,6 +478,6 @@ class AihrKnowledgeQueryServiceTest {
) {
return new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
}
}
@@ -0,0 +1,553 @@
package org.dromara.aihr.memory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrMemoryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 19, 8, 30);
@Test
void singleProjectCompleteResidentNeedBecomesDraftWithoutInventingIdentity() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下,3栋2单元1201室住户希望了解养老服务,电话13812345678", NOW);
assertNotNull(plan);
assertEquals("DRAFT", plan.status());
assertEquals("RESIDENT_NEED", plan.memoryType());
assertEquals("P1", plan.projectCode());
assertEquals("3栋", plan.draft().buildingName());
assertEquals("2单元", plan.draft().unitName());
assertEquals("1201", plan.draft().roomNo());
assertFalse(plan.draft().summary().contains("13812345678"));
assertTrue(plan.missingFields().isEmpty());
}
@Test
void emptyOrAmbiguousProjectScopeFailsClosedAsNeedsInput() {
var noProject = AihrMemoryService.detectPlan(principal(Set.of()),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var ambiguous = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var explicit = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,P2项目3栋2单元1201室住户希望了解养老服务", NOW);
var overlappingCode = AihrMemoryService.detectPlan(principal(Set.of("P1", "P10")),
"记一下,P10项目3栋2单元1201室住户希望了解养老服务", NOW);
assertEquals("NEEDS_INPUT", noProject.status());
assertEquals("NEEDS_INPUT", ambiguous.status());
assertTrue(noProject.missingFields().contains("projectCode"));
assertTrue(ambiguous.missingFields().contains("projectCode"));
assertEquals("DRAFT", explicit.status());
assertEquals("P2", explicit.projectCode());
assertEquals("DRAFT", overlappingCode.status());
assertEquals("P10", overlappingCode.projectCode());
}
@Test
void ordinaryQuestionDoesNotCreateCandidate() {
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "住户有哪些跟进需求?", NOW));
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "电梯困人应该怎么处理?", NOW));
}
@Test
void sameConversationLocationFollowupUpgradesOriginalNeedsInputCandidate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
ContinuationJdbc jdbc = new ContinuationJdbc(mapper.writeValueAsString(incomplete));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.detectForQuery(
principal(Set.of("P1")), "3栋2单元1201室", "conversation_123", "request-next");
assertNotNull(response);
assertEquals(33L, response.id());
assertEquals(2, response.version());
assertEquals("DRAFT", response.status());
assertEquals("3栋", response.draft().buildingName());
assertEquals("2单元", response.draft().unitName());
assertEquals("1201", response.draft().roomNo());
assertEquals(1, jdbc.continuationUpdates);
assertEquals(0, jdbc.candidateInserts);
}
@Test
void continuationDoesNotConsumeQuestionsOrFreshMemoryIntents() {
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋电梯怎么处理?"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "记一下3栋2单元1201室住户报修漏水"));
assertNotNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋2单元1201室"));
}
@Test
void confirmLocksCandidateCreatesVersionOneAndReplaysSameKeyOnlyOnce() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
MemoryDraft clientDraft = copyWithProjectCode(draft, null);
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ConfirmMemoryRequest request = new ConfirmMemoryRequest(1L, "memory-confirm-001", clientDraft, false);
String recallQuery = "查一下3栋2单元1201室住户养老需求";
assertTrue(service.recall(principal(Set.of("P1")), recallQuery, 5).items().isEmpty());
var first = service.confirm(7L, request);
var replay = service.confirm(7L, request);
var recalled = service.recall(principal(Set.of("P1")), recallQuery, 5);
assertEquals(1, recalled.items().size());
assertTrue(recalled.answer().contains("[待处理]"));
assertTrue(recalled.answer().contains("2026-07-19 08:30"));
assertFalse(recalled.answer().contains("[OPEN]"));
assertTrue(service.recall(principal(Set.of("P1", "P2")), recallQuery, 5).items().isEmpty());
assertEquals(1,
service.recall(principal(Set.of("P1", "P2")), "查一下P1项目3栋2单元1201室住户养老需求", 5)
.items().size());
assertEquals(first, replay);
assertEquals(501L, first.targetId());
assertEquals(1, first.version());
assertEquals(1, jdbc.memoryInsertCount);
assertEquals(1, jdbc.versionInsertCount);
assertTrue(jdbc.selectedForUpdate);
assertEquals("000000", jdbc.lastSelectArgs[0]);
assertEquals(7L, jdbc.lastSelectArgs[1]);
ConfirmMemoryRequest changed = new ConfirmMemoryRequest(
1L, "memory-confirm-001",
copyWithProjectCode(draft("住户希望了解养老和助餐服务"), null), false);
ServiceException conflict = assertThrows(ServiceException.class, () -> service.confirm(7L, changed));
assertEquals(409, conflict.getCode());
assertEquals(1, jdbc.memoryInsertCount);
}
@Test
void ordinarySopQuestionsNeverSweepRecentProjectMemories() {
ObjectMapper mapper = new ObjectMapper();
ConfirmJdbc jdbc = new ConfirmJdbc("{}", 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertTrue(service.recall(principal, "住户需求如何处理?", 5).items().isEmpty());
assertTrue(service.recall(principal, "业主投诉怎么处理?", 5).items().isEmpty());
assertEquals(0, jdbc.recallQueryCount);
assertTrue(service.recall(principal, "3栋2单元1201室住户有什么需求?", 5).items().isEmpty());
assertEquals(1, jdbc.recallQueryCount);
}
@Test
void versionSnapshotPreservesNullableReminderWithoutAllowingMutation() {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("status", "OPEN");
snapshot.put("followUpAt", null);
ServiceMemoryVersionResponse response = new ServiceMemoryVersionResponse(
1, snapshot, "CONFIRM", null, 7L, "2026-07-19T08:30:00");
assertTrue(response.snapshot().containsKey("followUpAt"));
assertNull(response.snapshot().get("followUpAt"));
assertThrows(UnsupportedOperationException.class,
() -> response.snapshot().put("status", "RESOLVED"));
}
@Test
void staleExpectedVersionCannotOverwriteDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 2);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-002", draft, false)));
assertEquals(409, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void expiredConfirmationRejectsWithoutPerformingARolledBackStatusUpdate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(
mapper.writeValueAsString(draft), 1, LocalDateTime.now().minusMinutes(1));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-expired", draft, false)));
assertEquals(409, error.getCode());
assertEquals("DRAFT", jdbc.status);
assertEquals(0, jdbc.expiredUpdateCount);
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsSensitiveValueDisguisedAsRoomNumber() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", "3栋", "2单元", "13812345678", persistedDraft.category(), persistedDraft.title(),
persistedDraft.summary(), persistedDraft.detail(), persistedDraft.status(),
persistedDraft.occurredAt(), persistedDraft.followUpAt());
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-unsafe", unsafeDraft, false)));
assertEquals(400, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsResidentNameAndHealthInformationFromSearchableText() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
List<String> unsafeSummaries = List.of(
"住户姓名:张三,希望了解养老服务",
"住户患有高血压,希望了解养老服务"
);
for (int i = 0; i < unsafeSummaries.size(); i++) {
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", persistedDraft.buildingName(), persistedDraft.unitName(), persistedDraft.roomNo(),
persistedDraft.category(), persistedDraft.title(), unsafeSummaries.get(i), persistedDraft.detail(),
persistedDraft.status(), persistedDraft.occurredAt(), persistedDraft.followUpAt());
int index = i;
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-private-" + index, unsafeDraft, false)));
assertEquals(400, error.getCode());
}
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void normalServiceSummariesAreNotMistakenForResidentNames() throws Exception {
List<String> normalSummaries = List.of(
"业主家里漏水", "住户养老咨询", "业主报修漏水", "业主马桶需要维修");
for (int i = 0; i < normalSummaries.size(); i++) {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft(normalSummaries.get(i));
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-normal-" + i, draft, false));
assertEquals(501L, response.targetId());
assertEquals(1, jdbc.memoryInsertCount);
}
}
@Test
void sharedSearchDraftNeverCopiesClientFreeText() {
MemoryDraft raw = new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "张三高血压跟进",
"张三患有高血压,希望了解养老服务", "联系电话13812345678",
"OPEN", "2026-07-19T08:30:00", null);
MemoryDraft searchable = AihrMemoryService.projectSearchDraft("RESIDENT_NEED", raw);
assertEquals("养老服务", searchable.category());
assertEquals("3栋2单元1201养老服务", searchable.title());
assertEquals("住户提出养老服务需求", searchable.summary());
assertEquals("", searchable.detail());
assertFalse((searchable.title() + searchable.summary()).contains("张三"));
assertFalse((searchable.title() + searchable.summary()).contains("高血压"));
}
@Test
void dismissReturnsNoRetainedDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.dismiss(7L);
assertEquals("DISMISSED", response.status());
assertNull(response.draft());
assertTrue(response.missingFields().isEmpty());
assertTrue(jdbc.dismissClearedDraft);
}
@Test
void schemaAndControllersKeepSecurityAndLifecycleContracts() throws Exception {
String serviceSource = Files.readString(source("AihrMemoryService.java"));
String schema = Files.readString(repoPath("backend/script/sql/aihr_service_memory_mysql8.sql"));
String migration = Files.readString(repoPath(
"backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql"));
assertTrue(serviceSource.contains("limit 1 for update"));
assertTrue(serviceSource.contains("plusHours(24)"));
assertTrue(serviceSource.contains("@Scheduled"));
assertTrue(serviceSource.contains("draft_json = null"));
assertTrue(serviceSource.contains("where tenant_id = ? and owner_user_id = ? and id = ?"));
assertTrue(serviceSource.contains("project_code in (%s)"));
assertFalse(serviceSource.contains("project_id"));
assertTrue(schema.contains("`project_code` varchar(100) NOT NULL"));
assertTrue(schema.contains("source_conversation_id"));
assertTrue(schema.contains("source_request_id"));
assertTrue(schema.contains("uk_aihr_memory_candidate_confirm_key"));
assertTrue(schema.contains("idx_aihr_memory_candidate_expiry"));
assertTrue(schema.contains("idx_aihr_memory_candidate_cleanup"));
assertTrue(migration.contains("table_name = 'aihr_knowledge_info'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_expiry'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_cleanup'"));
assertTrue(migration.contains("CONVERT TO CHARACTER SET utf8mb4 COLLATE"));
assertTrue(AihrMemoryController.class.getMethod("confirm", Long.class, ConfirmMemoryRequest.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
assertTrue(AihrServiceMemoryController.class.getMethod("detail", Long.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
}
private static MemoryDraft draft(String summary) {
return new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "3栋2单元1201住户需求",
summary, "", "OPEN", "2026-07-19T08:30:00", null);
}
private static MemoryDraft copyWithProjectCode(MemoryDraft draft, String projectCode) {
return new MemoryDraft(
projectCode, draft.buildingName(), draft.unitName(), draft.roomNo(), draft.category(), draft.title(),
draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt());
}
private static AihrKnowledgePrincipal principal(Set<String> projectCodes) {
return new AihrKnowledgePrincipal(
"000000", 7L, "app_user", "employee-7", Set.of("employee"), projectCodes, "app");
}
private static Path source(String fileName) {
Path direct = Path.of("src/main/java/org/dromara/aihr/memory", fileName);
return Files.exists(direct) ? direct
: repoPath("backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/" + fileName);
}
private static Path repoPath(String value) {
Path cursor = Path.of("").toAbsolutePath();
for (int depth = 0; depth < 8 && cursor != null; depth++, cursor = cursor.getParent()) {
Path candidate = cursor.resolve(value);
if (Files.exists(candidate)) {
return candidate;
}
}
throw new IllegalStateException("repository file not found: " + value);
}
private static final class ConfirmJdbc extends JdbcTemplate {
private final String draftJson;
private int version;
private final LocalDateTime expiresAt;
private String status = "DRAFT";
private String confirmKey;
private String confirmHash;
private Long targetId;
private Integer targetVersion;
private boolean selectedForUpdate;
private Object[] lastSelectArgs;
private int memoryInsertCount;
private int versionInsertCount;
private int expiredUpdateCount;
private boolean dismissClearedDraft;
private int recallQueryCount;
private ConfirmJdbc(String draftJson, int version) {
this(draftJson, version, LocalDateTime.now().plusHours(12));
}
private ConfirmJdbc(String draftJson, int version, LocalDateTime expiresAt) {
this.draftJson = draftJson;
this.version = version;
this.expiresAt = expiresAt;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("from aihr_service_memory")) {
recallQueryCount++;
if (memoryInsertCount == 0) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("title")).thenReturn("3栋2单元1201住户需求");
when(rs.getString("summary")).thenReturn("住户希望了解养老服务");
when(rs.getString("status")).thenReturn("OPEN");
when(rs.getTimestamp("occurred_at")).thenReturn(Timestamp.valueOf(NOW));
when(rs.getTimestamp("update_time")).thenReturn(Timestamp.valueOf(NOW));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
if (!sql.contains("from aihr_memory_candidate")) {
return List.of();
}
try {
selectedForUpdate = sql.contains("for update");
lastSelectArgs = args;
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(7L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("target_domain")).thenReturn("PROJECT_SERVICE");
when(rs.getString("status")).thenReturn(status);
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getString("missing_fields_json")).thenReturn("[]");
when(rs.getObject("target_id")).thenReturn(targetId);
when(rs.getObject("target_version")).thenReturn(targetVersion);
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("confirm_idempotency_key")).thenReturn(confirmKey);
when(rs.getString("confirm_request_hash")).thenReturn(confirmHash);
when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.valueOf(expiresAt));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
memoryInsertCount++;
generatedKeyHolder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
return 1;
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("insert into aihr_service_memory_version")) {
versionInsertCount++;
return 1;
}
if (sql.contains("set status = 'EXPIRED'")) {
status = "EXPIRED";
version++;
expiredUpdateCount++;
return 1;
}
if (sql.contains("set status = 'DISMISSED'")) {
status = "DISMISSED";
version++;
dismissClearedDraft = sql.contains("draft_json = null");
return 1;
}
if (sql.contains("set status = 'CONFIRMED'")) {
status = "CONFIRMED";
targetId = ((Number) args[0]).longValue();
targetVersion = 1;
confirmKey = String.valueOf(args[1]);
confirmHash = String.valueOf(args[2]);
version++;
return 1;
}
return 1;
}
}
private static final class ContinuationJdbc extends JdbcTemplate {
private final String draftJson;
private int continuationUpdates;
private int candidateInserts;
private ContinuationJdbc(String draftJson) {
this.draftJson = draftJson;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (!sql.contains("source_conversation_id = ?")) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(33L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getInt("version")).thenReturn(1);
when(rs.getTimestamp("expires_at"))
.thenReturn(Timestamp.valueOf(LocalDateTime.now().plusHours(12)));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("update aihr_memory_candidate")) {
continuationUpdates++;
}
return 1;
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
candidateInserts++;
return 1;
}
}
}
@@ -0,0 +1,100 @@
-- 个人助理项目服务记忆最小切片:候选确认、项目事实与版本快照。
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
`unit_name` varchar(100) NOT NULL COMMENT '单元',
`room_no` varchar(100) NOT NULL COMMENT '房号',
`category` varchar(50) NOT NULL COMMENT '需求分类',
`title` varchar(500) NOT NULL COMMENT '标题',
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
`detail` text COMMENT '必要说明',
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`created_by` bigint NOT NULL COMMENT '记录人',
`confirmed_by` bigint NOT NULL COMMENT '确认人',
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
`version` int NOT NULL COMMENT '版本号',
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
`operator_user_id` bigint NOT NULL COMMENT '操作人',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
SET @aihr_memory_collation := (
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_knowledge_info'
AND column_name = 'tenant_id'
LIMIT 1
);
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;
@@ -0,0 +1,128 @@
-- 2026-07-19 项目服务记忆增量迁移。与初始化脚本保持同构,可重复执行。
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
`unit_name` varchar(100) NOT NULL COMMENT '单元',
`room_no` varchar(100) NOT NULL COMMENT '房号',
`category` varchar(50) NOT NULL COMMENT '需求分类',
`title` varchar(500) NOT NULL COMMENT '标题',
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
`detail` text COMMENT '必要说明',
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`created_by` bigint NOT NULL COMMENT '记录人',
`confirmed_by` bigint NOT NULL COMMENT '确认人',
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
`version` int NOT NULL COMMENT '版本号',
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
`operator_user_id` bigint NOT NULL COMMENT '操作人',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
AND index_name = 'idx_aihr_memory_candidate_expiry'
),
'SELECT 1',
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`)'
);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
AND index_name = 'idx_aihr_memory_candidate_cleanup'
),
'SELECT 1',
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)'
);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
-- 清理早于本迁移产生的终态候选正文;幂等键与来源 ID 保留用于防重。
UPDATE `aihr_memory_candidate`
SET `draft_json` = NULL, `missing_fields_json` = NULL
WHERE `status` IN ('CONFIRMED', 'DISMISSED', 'EXPIRED')
AND `draft_json` IS NOT NULL
AND `update_time` <= DATE_SUB(NOW(), INTERVAL 24 HOUR);
SET @aihr_memory_collation := (
SELECT collation_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'aihr_knowledge_info'
AND column_name = 'tenant_id'
LIMIT 1
);
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;