feat: add project-scoped work assistant results
This commit is contained in:
+31
-5
@@ -29,6 +29,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.SatisfactionResponse;
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.ProjectResponse;
|
||||
import org.dromara.aihr.learning.AihrExamService;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest;
|
||||
import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse;
|
||||
@@ -112,20 +113,23 @@ public class AihrMobileController {
|
||||
public R<IdentityResponse> me() {
|
||||
String phone = currentAppUsername();
|
||||
if (phone.isBlank()) {
|
||||
return R.ok(new IdentityResponse("user", "员工端", "", "一线"));
|
||||
return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of()));
|
||||
}
|
||||
List<OrgPersonRow> people = List.of();
|
||||
var row = java.util.Optional.<OrgPersonRow>empty();
|
||||
try {
|
||||
var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 500, 500);
|
||||
row = snapshot.rows().stream()
|
||||
var snapshotPeople = snapshot.rows();
|
||||
people = snapshotPeople;
|
||||
row = snapshotPeople.stream()
|
||||
.filter(person -> "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel()))
|
||||
.findFirst()
|
||||
.or(() -> snapshot.rows().stream().findFirst());
|
||||
.or(() -> snapshotPeople.stream().findFirst());
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("mobile identity lookup failed, fallback to employee role(处理错误已隐藏)");
|
||||
}
|
||||
if (row.isEmpty()) {
|
||||
return R.ok(new IdentityResponse("user", "员工端", "", "一线"));
|
||||
return R.ok(new IdentityResponse("user", "员工端", "", "一线", List.of()));
|
||||
}
|
||||
var person = row.get();
|
||||
boolean supervisor = "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel());
|
||||
@@ -133,10 +137,32 @@ public class AihrMobileController {
|
||||
supervisor ? "supervisor" : "user",
|
||||
supervisor ? "主管端" : "员工端",
|
||||
person.positionName(),
|
||||
person.positionLevel()
|
||||
person.positionLevel(),
|
||||
projectOptions(people)
|
||||
));
|
||||
}
|
||||
|
||||
static List<ProjectResponse> projectOptions(List<OrgPersonRow> people) {
|
||||
if (people == null || people.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
var projects = new java.util.LinkedHashMap<String, String>();
|
||||
people.stream()
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.filter(person -> person.projectCode() != null && !person.projectCode().isBlank())
|
||||
.sorted(java.util.Comparator.comparing(
|
||||
person -> person.projectName() == null ? "" : person.projectName()))
|
||||
.forEach(person -> {
|
||||
String code = person.projectCode().trim();
|
||||
String name = person.projectName() == null || person.projectName().isBlank()
|
||||
? code : person.projectName().trim();
|
||||
projects.putIfAbsent(code, name);
|
||||
});
|
||||
return projects.entrySet().stream()
|
||||
.map(entry -> new ProjectResponse(entry.getKey(), entry.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/practice/history")
|
||||
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
|
||||
|
||||
+8
-1
@@ -14,7 +14,14 @@ public final class AihrMobileDto {
|
||||
String role,
|
||||
String roleLabel,
|
||||
String positionName,
|
||||
String positionLevel
|
||||
String positionLevel,
|
||||
List<ProjectResponse> projects
|
||||
) {
|
||||
}
|
||||
|
||||
public record ProjectResponse(
|
||||
String projectCode,
|
||||
String projectName
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+29
-3
@@ -6,8 +6,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot;
|
||||
import org.dromara.aihr.memory.AihrMemoryService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -29,6 +32,7 @@ public class AihrKnowledgeQueryController {
|
||||
|
||||
private final AihrKnowledgeQueryService queryService;
|
||||
private final ISysOssService ossService;
|
||||
private final AihrMemoryService memoryService;
|
||||
|
||||
@SaCheckLogin
|
||||
@PostMapping("/query")
|
||||
@@ -42,15 +46,37 @@ public class AihrKnowledgeQueryController {
|
||||
@RequestParam(value = "queryText", required = false) String queryText,
|
||||
@RequestParam(value = "position", required = false) String position,
|
||||
@RequestParam(value = "conversationId", required = false) String conversationId,
|
||||
@RequestParam(value = "contextVersion", required = false) Long contextVersion) {
|
||||
@RequestParam(value = "contextVersion", required = false) Long contextVersion,
|
||||
@RequestParam(value = "projectCode", required = false) String projectCode) {
|
||||
String question = queryText == null || queryText.isBlank()
|
||||
? "请根据附件现场情况,告诉我应该按什么 SOP 处理"
|
||||
: queryText.trim();
|
||||
QueryRequest request = new QueryRequest(
|
||||
question, List.of(), "sop", position, "mobile_uni_media", 5, null,
|
||||
conversationId, contextVersion
|
||||
conversationId, contextVersion, projectCode
|
||||
);
|
||||
return R.ok(queryService.queryInternalMedia(request, file));
|
||||
QueryResponse response = queryService.queryInternalMedia(request, file);
|
||||
if (response.memoryCandidate() == null) {
|
||||
return R.ok(response);
|
||||
}
|
||||
SysOssVo uploaded = ossService.upload(file);
|
||||
try {
|
||||
SourceSnapshot detected = response.memoryCandidate().source();
|
||||
SourceSnapshot source = new SourceSnapshot(
|
||||
detected == null ? null : detected.sourceType(),
|
||||
detected == null ? question : detected.transcript(),
|
||||
uploaded.getOssId(),
|
||||
file.getOriginalFilename(),
|
||||
file.getContentType(),
|
||||
file.getSize(),
|
||||
detected == null ? null : detected.recognizedSummary()
|
||||
);
|
||||
return R.ok(response.withMemoryCandidate(
|
||||
memoryService.bindCandidateSource(response.memoryCandidate().id(), source)));
|
||||
} catch (RuntimeException ex) {
|
||||
ossService.deleteWithValidByIds(List.of(uploaded.getOssId()), false);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
@SaCheckLogin
|
||||
|
||||
+10
-2
@@ -19,11 +19,19 @@ public final class AihrKnowledgeQueryDto {
|
||||
Integer limit,
|
||||
String toolCode,
|
||||
String conversationId,
|
||||
Long contextVersion
|
||||
Long contextVersion,
|
||||
String projectCode
|
||||
) {
|
||||
public QueryRequest(String queryText, List<String> spaceCodes, String category, String position,
|
||||
String source, Integer limit, String toolCode) {
|
||||
this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null);
|
||||
this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null, null);
|
||||
}
|
||||
|
||||
public QueryRequest(String queryText, List<String> spaceCodes, String category, String position,
|
||||
String source, Integer limit, String toolCode, String conversationId,
|
||||
Long contextVersion) {
|
||||
this(queryText, spaceCodes, category, position, source, limit, toolCode,
|
||||
conversationId, contextVersion, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-9
@@ -47,6 +47,7 @@ public class AihrKnowledgeConversationService {
|
||||
|
||||
public ConversationContext prepare(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) {
|
||||
String conversationId = clean(request.conversationId());
|
||||
String projectCode = clean(request.projectCode());
|
||||
if (conversationId.isBlank()) {
|
||||
return stateless(request.queryText(), request.toolCode());
|
||||
}
|
||||
@@ -62,7 +63,11 @@ public class AihrKnowledgeConversationService {
|
||||
log.warn("knowledge conversation unavailable, using stateless query(处理错误已隐藏)");
|
||||
RewritePlan plan = fallbackPlan(List.of(), request.queryText(), request.toolCode());
|
||||
return new ConversationContext(conversationId, false, false, 0L, List.of(),
|
||||
plan.rewrittenQuery(), plan.intent(), plan.toolCode());
|
||||
plan.rewrittenQuery(), plan.intent(), plan.toolCode(), projectCode);
|
||||
}
|
||||
|
||||
if (row != null && !clean(row.projectCode()).equals(projectCode)) {
|
||||
throw new ServiceException("当前项目已切换,请开始新对话", HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
@@ -76,7 +81,7 @@ public class AihrKnowledgeConversationService {
|
||||
List<Turn> turns = row == null || expired ? List.of() : readTurns(row.contextJson());
|
||||
RewritePlan plan = rewrite(turns, request.queryText(), request.toolCode());
|
||||
return new ConversationContext(conversationId, true, row != null, storedVersion, turns,
|
||||
plan.rewrittenQuery(), plan.intent(), plan.toolCode());
|
||||
plan.rewrittenQuery(), plan.intent(), plan.toolCode(), projectCode);
|
||||
}
|
||||
|
||||
public static ConversationContext stateless(String queryText, String explicitToolCode) {
|
||||
@@ -118,16 +123,17 @@ public class AihrKnowledgeConversationService {
|
||||
set version = ?, context_json = ?, expires_time = date_add(now(), interval 30 minute),
|
||||
update_time = now()
|
||||
where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? and version = ?
|
||||
and coalesce(project_code, '') = ?
|
||||
""", nextVersion, json, principal.tenantId(), app.id(), principal.userId(),
|
||||
context.conversationId(), context.storedVersion());
|
||||
context.conversationId(), context.storedVersion(), clean(context.projectCode()));
|
||||
} else {
|
||||
updated = jdbcTemplate.update("""
|
||||
insert into aihr_knowledge_conversation
|
||||
(tenant_id, app_id, user_id, conversation_id, version, context_json,
|
||||
(tenant_id, app_id, user_id, conversation_id, project_code, version, context_json,
|
||||
expires_time, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now())
|
||||
values (?, ?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now())
|
||||
""", principal.tenantId(), app.id(), principal.userId(), context.conversationId(),
|
||||
nextVersion, json);
|
||||
clean(context.projectCode()).isBlank() ? null : clean(context.projectCode()), nextVersion, json);
|
||||
}
|
||||
if (updated != 1) {
|
||||
throw conflict();
|
||||
@@ -145,11 +151,12 @@ public class AihrKnowledgeConversationService {
|
||||
|
||||
private ConversationRow findRow(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) {
|
||||
List<ConversationRow> rows = jdbcTemplate.query("""
|
||||
select version, context_json, expires_time
|
||||
select project_code, version, context_json, expires_time
|
||||
from aihr_knowledge_conversation
|
||||
where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ?
|
||||
limit 1
|
||||
""", (rs, rowNum) -> new ConversationRow(
|
||||
rs.getString("project_code"),
|
||||
rs.getLong("version"),
|
||||
rs.getString("context_json"),
|
||||
toLocalDateTime(rs.getTimestamp("expires_time"))
|
||||
@@ -345,8 +352,15 @@ public class AihrKnowledgeConversationService {
|
||||
List<Turn> turns,
|
||||
String rewrittenQuery,
|
||||
String intent,
|
||||
String toolCode
|
||||
String toolCode,
|
||||
String projectCode
|
||||
) {
|
||||
public ConversationContext(String conversationId, boolean stateful, boolean existingRow,
|
||||
long storedVersion, List<Turn> turns, String rewrittenQuery,
|
||||
String intent, String toolCode) {
|
||||
this(conversationId, stateful, existingRow, storedVersion, turns, rewrittenQuery,
|
||||
intent, toolCode, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Turn(
|
||||
@@ -366,6 +380,6 @@ public class AihrKnowledgeConversationService {
|
||||
record RewritePlan(String rewrittenQuery, String intent, String toolCode) {
|
||||
}
|
||||
|
||||
private record ConversationRow(long version, String contextJson, LocalDateTime expiresTime) {
|
||||
private record ConversationRow(String projectCode, long version, String contextJson, LocalDateTime expiresTime) {
|
||||
}
|
||||
}
|
||||
|
||||
+49
-8
@@ -13,6 +13,7 @@ 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.SourceSnapshot;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
|
||||
import org.dromara.aihr.memory.AihrMemoryService;
|
||||
import org.dromara.aihr.service.AihrSopSeedService;
|
||||
@@ -27,6 +28,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -48,7 +50,7 @@ public class AihrKnowledgeQueryService {
|
||||
public QueryResponse queryInternal(QueryRequest rawRequest) {
|
||||
long queryStarted = System.nanoTime();
|
||||
QueryRequest request = validate(rawRequest);
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode());
|
||||
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
|
||||
ConversationContext context = conversationContext(principal, app, request);
|
||||
QueryRequest routed = routedRequest(request, context);
|
||||
@@ -93,7 +95,9 @@ public class AihrKnowledgeQueryService {
|
||||
}
|
||||
}
|
||||
MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
|
||||
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId());
|
||||
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId(),
|
||||
new SourceSnapshot(sourceType(request.source()), truncate(request.queryText(), 4000),
|
||||
null, null, null, null, null));
|
||||
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
|
||||
QueryResponse result = completed.withMemoryCandidate(candidate);
|
||||
if (finalizeAudit || candidate != null) {
|
||||
@@ -111,7 +115,7 @@ public class AihrKnowledgeQueryService {
|
||||
if (hasText(request.toolCode())) {
|
||||
throw new ServiceException("媒体提问不能调用内部数据工具", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode());
|
||||
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
|
||||
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
|
||||
principal, app, request.spaceCodes(), "READ");
|
||||
@@ -125,14 +129,21 @@ public class AihrKnowledgeQueryService {
|
||||
+ "\n附件现场信息:" + truncate(extractedContext, 520);
|
||||
QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest(
|
||||
combinedQuery, request.spaceCodes(), request.category(), request.position(),
|
||||
request.source(), request.limit(), null, request.conversationId(), request.contextVersion()
|
||||
request.source(), request.limit(), null, request.conversationId(), request.contextVersion(),
|
||||
request.projectCode()
|
||||
), request.queryText());
|
||||
return complete(principal, app, spaceIds, request.queryText(), context, response);
|
||||
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
|
||||
MemoryCandidateResponse candidate = memoryService.detectForQuery(
|
||||
principal, combinedQuery, context.conversationId(), completed.requestId(),
|
||||
new SourceSnapshot(mediaSourceType(file), truncate(request.queryText(), 4000), null,
|
||||
file.getOriginalFilename(), file.getContentType(), file.getSize(), truncate(extractedContext, 2000)));
|
||||
return completed.withMemoryCandidate(candidate);
|
||||
}
|
||||
|
||||
public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) {
|
||||
QueryRequest request = validate(rawRequest);
|
||||
if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null) {
|
||||
if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null
|
||||
|| hasText(request.projectCode())) {
|
||||
throw new ServiceException("外部调用应用不支持数据工具或对话状态", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return TenantHelper.dynamic(app.tenantId(), () -> {
|
||||
@@ -335,7 +346,8 @@ public class AihrKnowledgeQueryService {
|
||||
private static QueryRequest routedRequest(QueryRequest request, ConversationContext context) {
|
||||
return new QueryRequest(
|
||||
context.rewrittenQuery(), request.spaceCodes(), request.category(), request.position(), request.source(),
|
||||
request.limit(), context.toolCode(), request.conversationId(), request.contextVersion()
|
||||
request.limit(), context.toolCode(), request.conversationId(), request.contextVersion(),
|
||||
request.projectCode()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,14 +414,43 @@ public class AihrKnowledgeQueryService {
|
||||
if (request.contextVersion() != null && request.contextVersion() < 0) {
|
||||
throw new ServiceException("contextVersion 不能小于 0", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
String projectCode = request.projectCode() == null ? "" : request.projectCode().trim();
|
||||
if (projectCode.length() > 100) {
|
||||
throw new ServiceException("projectCode 长度不能超过 100", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit,
|
||||
request.toolCode(), request.conversationId(), request.contextVersion());
|
||||
request.toolCode(), request.conversationId(), request.contextVersion(),
|
||||
projectCode.isBlank() ? null : projectCode);
|
||||
}
|
||||
|
||||
private static AihrKnowledgePrincipal projectPrincipal(AihrKnowledgePrincipal principal, String projectCode) {
|
||||
if (!hasText(projectCode)) {
|
||||
return principal;
|
||||
}
|
||||
String selected = projectCode.trim();
|
||||
if (!principal.projectCodes().contains(selected)) {
|
||||
throw new ServiceException("无权使用当前项目,请重新选择", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return new AihrKnowledgePrincipal(
|
||||
principal.tenantId(), principal.userId(), principal.userType(), principal.extPartyId(),
|
||||
principal.roles(), Set.of(selected), principal.clientKey());
|
||||
}
|
||||
|
||||
private static long elapsedMillis(long started) {
|
||||
return Math.max(0, (System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
|
||||
private static String sourceType(String source) {
|
||||
String value = source == null ? "" : source.toLowerCase(Locale.ROOT);
|
||||
return value.contains("voice") ? "VOICE" : "TEXT";
|
||||
}
|
||||
|
||||
private static String mediaSourceType(MultipartFile file) {
|
||||
String type = file == null || file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
String name = file == null || file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
|
||||
return type.startsWith("video/") || name.matches(".*\\.(mp4|mov|avi|mkv|webm|m4v)$") ? "VIDEO" : "IMAGE";
|
||||
}
|
||||
|
||||
private static String placeholders(int size) {
|
||||
return String.join(",", java.util.Collections.nCopies(size, "?"));
|
||||
}
|
||||
|
||||
+32
@@ -1,12 +1,16 @@
|
||||
package org.dromara.aihr.memory;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
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.aihr.memory.AihrMemoryDto.AssistantCaptureResponse;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.UpdateCaptureStatusRequest;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.CaptureStatusHistoryResponse;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -16,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.io.IOException;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,6 +28,7 @@ import java.util.List;
|
||||
public class AihrMemoryController {
|
||||
|
||||
private final AihrMemoryService memoryService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@SaCheckLogin
|
||||
@GetMapping("/memory-candidates")
|
||||
@@ -52,4 +58,30 @@ public class AihrMemoryController {
|
||||
) {
|
||||
return R.ok(memoryService.assistantCaptures(saveScope, limit));
|
||||
}
|
||||
|
||||
@SaCheckLogin
|
||||
@GetMapping("/assistant-captures/{id}/source")
|
||||
public void source(@PathVariable Long id, HttpServletResponse response) throws IOException {
|
||||
Long ossId = memoryService.authorizedCaptureSourceOssId(id);
|
||||
if (ossId == null) {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND, "原始来源不存在或无权访问");
|
||||
return;
|
||||
}
|
||||
ossService.download(ossId, response);
|
||||
}
|
||||
|
||||
@SaCheckLogin
|
||||
@PostMapping("/assistant-captures/{id}/status")
|
||||
public R<AssistantCaptureResponse> updateStatus(
|
||||
@PathVariable Long id,
|
||||
@RequestBody UpdateCaptureStatusRequest request
|
||||
) {
|
||||
return R.ok(memoryService.updateCaptureStatus(id, request));
|
||||
}
|
||||
|
||||
@SaCheckLogin
|
||||
@GetMapping("/assistant-captures/{id}/status-history")
|
||||
public R<List<CaptureStatusHistoryResponse>> statusHistory(@PathVariable Long id) {
|
||||
return R.ok(memoryService.captureStatusHistory(id));
|
||||
}
|
||||
}
|
||||
|
||||
+48
-2
@@ -21,7 +21,26 @@ public final class AihrMemoryDto {
|
||||
String detail,
|
||||
String status,
|
||||
String occurredAt,
|
||||
String followUpAt
|
||||
String followUpAt,
|
||||
String workDate,
|
||||
String businessStatus
|
||||
) {
|
||||
public MemoryDraft(String projectCode, String buildingName, String unitName, String roomNo,
|
||||
String category, String title, String summary, String detail, String status,
|
||||
String occurredAt, String followUpAt) {
|
||||
this(projectCode, buildingName, unitName, roomNo, category, title, summary, detail, status,
|
||||
occurredAt, followUpAt, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record SourceSnapshot(
|
||||
String sourceType,
|
||||
String transcript,
|
||||
Long ossId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
Long fileSize,
|
||||
String recognizedSummary
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,11 +52,18 @@ public final class AihrMemoryDto {
|
||||
String targetDomain,
|
||||
MemoryDraft draft,
|
||||
List<String> missingFields,
|
||||
String expiresAt
|
||||
String expiresAt,
|
||||
SourceSnapshot source
|
||||
) {
|
||||
public MemoryCandidateResponse {
|
||||
missingFields = missingFields == null ? List.of() : List.copyOf(missingFields);
|
||||
}
|
||||
|
||||
public MemoryCandidateResponse(Long id, Integer version, String status, String memoryType,
|
||||
String targetDomain, MemoryDraft draft, List<String> missingFields,
|
||||
String expiresAt) {
|
||||
this(id, version, status, memoryType, targetDomain, draft, missingFields, expiresAt, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record ConfirmMemoryRequest(
|
||||
@@ -73,7 +99,27 @@ public final class AihrMemoryDto {
|
||||
String detail,
|
||||
String occurredAt,
|
||||
String followUpAt,
|
||||
String workDate,
|
||||
String businessStatus,
|
||||
String deliveryStatus,
|
||||
String createdAt,
|
||||
SourceSnapshot source
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateCaptureStatusRequest(
|
||||
String businessStatus,
|
||||
String note,
|
||||
String idempotencyKey
|
||||
) {
|
||||
}
|
||||
|
||||
public record CaptureStatusHistoryResponse(
|
||||
Long id,
|
||||
String fromStatus,
|
||||
String toStatus,
|
||||
String note,
|
||||
Long operatorUserId,
|
||||
String createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
+339
-67
@@ -15,6 +15,9 @@ import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryResponse;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.UpdateCaptureStatusRequest;
|
||||
import org.dromara.aihr.memory.AihrMemoryDto.CaptureStatusHistoryResponse;
|
||||
import org.dromara.aihr.service.AihrSensitiveText;
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
@@ -39,6 +42,7 @@ import java.sql.Types;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
@@ -62,6 +66,8 @@ public class AihrMemoryService {
|
||||
private static final Set<String> CAPTURE_SCOPES = Set.of("PRIVATE", "COMPANY");
|
||||
private static final Set<String> CANDIDATE_STATUSES = Set.of("DRAFT", "NEEDS_INPUT");
|
||||
private static final Set<String> MEMORY_STATUSES = Set.of("OPEN", "IN_PROGRESS", "RESOLVED", "VOID");
|
||||
private static final Set<String> BUSINESS_STATUSES = Set.of("RECORDED", "PENDING", "IN_PROGRESS", "COMPLETED", "VOID");
|
||||
private static final ZoneId WORK_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final Pattern IDEMPOTENCY_KEY = Pattern.compile("[A-Za-z0-9._:-]{8,100}");
|
||||
private static final Pattern BUILDING = Pattern.compile("([A-Za-z0-9一二三四五六七八九十-]{1,8})(栋|幢|号楼)");
|
||||
private static final Pattern UNIT = Pattern.compile("([0-9一二三四五六七八九十]{1,4})单元");
|
||||
@@ -96,6 +102,13 @@ public class AihrMemoryService {
|
||||
/** Query-path integration degrades to ordinary knowledge search until the migration is installed. */
|
||||
public MemoryCandidateResponse detectForQuery(AihrKnowledgePrincipal principal, String queryText,
|
||||
String conversationId, String requestId) {
|
||||
return detectForQuery(principal, queryText, conversationId, requestId,
|
||||
new SourceSnapshot("TEXT", truncate(clean(queryText), 4000), null, null, null, null, null));
|
||||
}
|
||||
|
||||
public MemoryCandidateResponse detectForQuery(AihrKnowledgePrincipal principal, String queryText,
|
||||
String conversationId, String requestId,
|
||||
SourceSnapshot source) {
|
||||
if (!hasText(requestId)) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,13 +117,13 @@ public class AihrMemoryService {
|
||||
if (continued != null) {
|
||||
return continued;
|
||||
}
|
||||
CandidatePlan plan = detectPlan(principal, queryText, LocalDateTime.now().withNano(0));
|
||||
CandidatePlan plan = detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0));
|
||||
if (plan == null) {
|
||||
return null;
|
||||
}
|
||||
return saveCandidate(principal, plan, conversationId, requestId);
|
||||
return saveCandidate(principal, plan, conversationId, requestId, normalizeSource(source));
|
||||
} catch (RuntimeException ex) {
|
||||
if (detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null) {
|
||||
if (detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0)) != null) {
|
||||
throw new ServiceException("记忆功能暂不可用,请稍后重试", 503);
|
||||
}
|
||||
log.warn("service memory candidate unavailable; query answer remains usable");
|
||||
@@ -120,7 +133,7 @@ public class AihrMemoryService {
|
||||
|
||||
/** Pure intent check used only to decide whether a no-knowledge-space query may continue to confirmation. */
|
||||
public boolean isProjectMemoryCandidateIntent(AihrKnowledgePrincipal principal, String queryText) {
|
||||
return detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null;
|
||||
return detectPlan(principal, queryText, LocalDateTime.now(WORK_ZONE).withNano(0)) != null;
|
||||
}
|
||||
|
||||
public List<MemoryCandidateResponse> candidates(String rawStatus) {
|
||||
@@ -131,7 +144,7 @@ public class AihrMemoryService {
|
||||
}
|
||||
expireCandidates(principal);
|
||||
return jdbcTemplate.query("""
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json,
|
||||
target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at
|
||||
from aihr_memory_candidate
|
||||
where tenant_id = ? and owner_user_id = ? and status = ? and expires_at > now()
|
||||
@@ -261,24 +274,122 @@ public class AihrMemoryService {
|
||||
try {
|
||||
return jdbcTemplate.query("""
|
||||
select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no,
|
||||
category, summary, detail, occurred_at, follow_up_at, delivery_status, create_time
|
||||
category, summary, detail, occurred_at, follow_up_at, work_date, business_status,
|
||||
delivery_status, create_time, source_snapshot_json
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and owner_user_id = ?%s
|
||||
order by create_time desc, id desc
|
||||
limit ?
|
||||
""".formatted(scopeClause), (rs, rowNum) -> new AssistantCaptureResponse(
|
||||
rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"),
|
||||
rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"),
|
||||
rs.getString("room_no"), rs.getString("category"), rs.getString("summary"),
|
||||
rs.getString("detail"), format(rs.getTimestamp("occurred_at")),
|
||||
format(rs.getTimestamp("follow_up_at")),
|
||||
rs.getString("delivery_status"), format(rs.getTimestamp("create_time"))
|
||||
), args.toArray());
|
||||
""".formatted(scopeClause), (rs, rowNum) -> mapAssistantCapture(rs), args.toArray());
|
||||
} catch (DataAccessException ex) {
|
||||
throw new ServiceException("统一采集数据库迁移未执行", 503);
|
||||
}
|
||||
}
|
||||
|
||||
public MemoryCandidateResponse bindCandidateSource(Long candidateId, SourceSnapshot rawSource) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
requireCandidateId(candidateId);
|
||||
SourceSnapshot source = normalizeSource(rawSource);
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_memory_candidate
|
||||
set source_snapshot_json = ?, update_time = now()
|
||||
where tenant_id = ? and owner_user_id = ? and id = ?
|
||||
and status in ('DRAFT', 'NEEDS_INPUT')
|
||||
""", json(source), principal.tenantId(), principal.userId(), candidateId);
|
||||
if (updated != 1) {
|
||||
throw conflict("确认卡已变化,无法绑定现场来源");
|
||||
}
|
||||
CandidateRow row = requireCandidateForUpdate(principal, candidateId);
|
||||
return candidateResponse(row);
|
||||
}
|
||||
|
||||
public Long authorizedCaptureSourceOssId(Long captureId) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
if (captureId == null || captureId <= 0) return null;
|
||||
List<String> rows = jdbcTemplate.query("""
|
||||
select source_snapshot_json
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and id = ?
|
||||
and (owner_user_id = ? or project_code in (%s))
|
||||
limit 1
|
||||
""".formatted(projectPlaceholders(principal)), (rs, rowNum) -> rs.getString(1),
|
||||
captureAccessArgs(principal, captureId));
|
||||
if (rows.isEmpty()) return null;
|
||||
SourceSnapshot source = readSource(rows.get(0));
|
||||
return source == null ? null : source.ossId();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AssistantCaptureResponse updateCaptureStatus(Long captureId, UpdateCaptureStatusRequest request) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
if (captureId == null || captureId <= 0) throw new ServiceException("工作记录不存在或无权访问", 404);
|
||||
String next = normalizeBusinessStatus(request == null ? null : request.businessStatus(), null);
|
||||
String note = field(request == null ? null : request.note(), 500, "note");
|
||||
String key = clean(request == null ? null : request.idempotencyKey());
|
||||
if (!IDEMPOTENCY_KEY.matcher(key).matches()) {
|
||||
throw badRequest("idempotencyKey 长度必须为 8-100,且只能包含字母、数字、点、下划线、冒号或连字符");
|
||||
}
|
||||
List<CaptureStatusRow> rows = jdbcTemplate.query("""
|
||||
select id, project_code, business_status
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and id = ?
|
||||
and (owner_user_id = ? or project_code in (%s))
|
||||
limit 1 for update
|
||||
""".formatted(projectPlaceholders(principal)), (rs, rowNum) -> new CaptureStatusRow(
|
||||
rs.getLong("id"), rs.getString("project_code"), rs.getString("business_status")),
|
||||
captureAccessArgs(principal, captureId));
|
||||
if (rows.isEmpty()) throw new ServiceException("工作记录不存在或无权访问", 404);
|
||||
CaptureStatusRow row = rows.get(0);
|
||||
List<CaptureStatusReplay> replays = jdbcTemplate.query("""
|
||||
select capture_id, to_status
|
||||
from aihr_assistant_capture_status_log
|
||||
where tenant_id = ? and operator_user_id = ? and idempotency_key = ?
|
||||
limit 1
|
||||
""", (rs, rowNum) -> new CaptureStatusReplay(
|
||||
rs.getLong("capture_id"), rs.getString("to_status")),
|
||||
principal.tenantId(), principal.userId(), key);
|
||||
if (!replays.isEmpty()) {
|
||||
CaptureStatusReplay replay = replays.get(0);
|
||||
if (!captureId.equals(replay.captureId()) || !next.equals(replay.toStatus())) {
|
||||
throw conflict("该 idempotencyKey 已用于其他状态请求");
|
||||
}
|
||||
return assistantCapture(principal, captureId);
|
||||
}
|
||||
if (!clean(row.businessStatus()).equals(next)) {
|
||||
jdbcTemplate.update("""
|
||||
update aihr_assistant_capture
|
||||
set business_status = ?, version = version + 1, update_time = now()
|
||||
where tenant_id = ? and id = ? and business_status = ?
|
||||
""", next, principal.tenantId(), captureId, row.businessStatus());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.update("""
|
||||
insert into aihr_assistant_capture_status_log
|
||||
(tenant_id, capture_id, project_code, from_status, to_status, note,
|
||||
operator_user_id, idempotency_key, create_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
""", principal.tenantId(), captureId, row.projectCode(), row.businessStatus(), next,
|
||||
hasText(note) ? note : null, principal.userId(), key);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
throw conflict("状态请求正在并发处理,请按原请求重试");
|
||||
}
|
||||
return assistantCapture(principal, captureId);
|
||||
}
|
||||
|
||||
public List<CaptureStatusHistoryResponse> captureStatusHistory(Long captureId) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
assistantCapture(principal, captureId);
|
||||
return jdbcTemplate.query("""
|
||||
select id, from_status, to_status, note, operator_user_id, create_time
|
||||
from aihr_assistant_capture_status_log
|
||||
where tenant_id = ? and capture_id = ?
|
||||
order by create_time desc, id desc
|
||||
""", (rs, rowNum) -> new CaptureStatusHistoryResponse(
|
||||
rs.getLong("id"), rs.getString("from_status"), rs.getString("to_status"),
|
||||
rs.getString("note"), rs.getLong("operator_user_id"), format(rs.getTimestamp("create_time"))),
|
||||
principal.tenantId(), captureId);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MemoryCandidateResponse dismiss(Long candidateId) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
@@ -531,13 +642,14 @@ public class AihrMemoryService {
|
||||
if (!hasText(projectCode)) {
|
||||
missing.add("projectCode");
|
||||
}
|
||||
if (!hasText(building)) {
|
||||
boolean needsResidentLocation = Set.of("SERVICE_LEAD", "RESIDENT_PROFILE").contains(memoryType);
|
||||
if (needsResidentLocation && !hasText(building)) {
|
||||
missing.add("buildingName");
|
||||
}
|
||||
if (!hasText(unit)) {
|
||||
if (needsResidentLocation && !hasText(unit)) {
|
||||
missing.add("unitName");
|
||||
}
|
||||
if (!hasText(room)) {
|
||||
if (needsResidentLocation && !hasText(room)) {
|
||||
missing.add("roomNo");
|
||||
}
|
||||
String summary = query;
|
||||
@@ -551,6 +663,8 @@ public class AihrMemoryService {
|
||||
missing.add("summary");
|
||||
}
|
||||
LocalDateTime occurredAt = (now == null ? LocalDateTime.now() : now).withNano(0);
|
||||
LocalDate workDate = inferWorkDate(query, occurredAt.toLocalDate());
|
||||
String businessStatus = initialBusinessStatus(memoryType);
|
||||
String category = suggestedCategory(memoryType);
|
||||
MemoryDraft draft = new MemoryDraft(
|
||||
projectCode, building, unit, room,
|
||||
@@ -558,7 +672,7 @@ public class AihrMemoryService {
|
||||
defaultTitle("", memoryType, new MemoryDraft(projectCode, building, unit, room,
|
||||
category, "", summary, "", "", format(occurredAt), null)),
|
||||
summary, "", "FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN",
|
||||
format(occurredAt), null);
|
||||
format(occurredAt), null, workDate.toString(), businessStatus);
|
||||
return new CandidatePlan(projectCode, memoryType, "DRAFT", draft, List.copyOf(missing), explicit ? 0.92 : 0.78);
|
||||
}
|
||||
|
||||
@@ -606,13 +720,14 @@ public class AihrMemoryService {
|
||||
if (!hasText(summary)) missing.add("summary");
|
||||
MemoryDraft mergedSeed = new MemoryDraft(
|
||||
resolvedProject, building, unit, room, existing.category(), existing.title(), summary,
|
||||
existing.detail(), existing.status(), existing.occurredAt(), existing.followUpAt());
|
||||
existing.detail(), existing.status(), existing.occurredAt(), existing.followUpAt(),
|
||||
existing.workDate(), existing.businessStatus());
|
||||
String title = hasText(existing.title()) && !existing.title().contains("待补充位置")
|
||||
? existing.title() : defaultTitle("", memoryType, mergedSeed);
|
||||
MemoryDraft merged = new MemoryDraft(
|
||||
resolvedProject, building, unit, room, defaultCategory(existing.category(), memoryType), title,
|
||||
summary, existing.detail(), initialStatus(existing.status(), memoryType),
|
||||
existing.occurredAt(), existing.followUpAt());
|
||||
existing.occurredAt(), existing.followUpAt(), existing.workDate(), existing.businessStatus());
|
||||
return new CandidatePlan(resolvedProject, fresh == null ? memoryType : fresh.memoryType(), "DRAFT",
|
||||
merged, List.copyOf(missing), 0.92);
|
||||
}
|
||||
@@ -624,7 +739,7 @@ public class AihrMemoryService {
|
||||
return null;
|
||||
}
|
||||
List<ContinuationRow> rows = jdbcTemplate.query("""
|
||||
select id, project_code, memory_type, draft_json, version, expires_at
|
||||
select id, project_code, memory_type, draft_json, source_snapshot_json, version, expires_at
|
||||
from aihr_memory_candidate
|
||||
where tenant_id = ? and owner_user_id = ? and source_conversation_id = ?
|
||||
and status in ('DRAFT', 'NEEDS_INPUT') and expires_at > now()
|
||||
@@ -632,7 +747,8 @@ public class AihrMemoryService {
|
||||
limit 1
|
||||
""", (rs, rowNum) -> new ContinuationRow(
|
||||
rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"),
|
||||
rs.getString("draft_json"), rs.getInt("version"), toLocalDateTime(rs.getTimestamp("expires_at"))
|
||||
rs.getString("draft_json"), rs.getString("source_snapshot_json"), rs.getInt("version"),
|
||||
toLocalDateTime(rs.getTimestamp("expires_at"))
|
||||
), principal.tenantId(), principal.userId(), conversation);
|
||||
if (rows.isEmpty()) {
|
||||
return null;
|
||||
@@ -647,10 +763,11 @@ public class AihrMemoryService {
|
||||
merged.memoryType(), merged.projectCode(), merged.draft()));
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_memory_candidate
|
||||
set project_code = ?, status = ?, draft_json = ?, missing_fields_json = ?,
|
||||
set project_code = ?, work_date = ?, status = ?, draft_json = ?, missing_fields_json = ?,
|
||||
candidate_hash = ?, version = version + 1, update_time = now()
|
||||
where tenant_id = ? and owner_user_id = ? and id = ? and status in ('DRAFT', 'NEEDS_INPUT') and version = ?
|
||||
""", hasText(merged.projectCode()) ? merged.projectCode() : null, merged.status(),
|
||||
""", hasText(merged.projectCode()) ? merged.projectCode() : null,
|
||||
parseWorkDate(merged.draft().workDate(), merged.draft().occurredAt()), merged.status(),
|
||||
json(merged.draft()), json(merged.missingFields()), candidateHash,
|
||||
principal.tenantId(), principal.userId(), row.id(), row.version());
|
||||
if (updated != 1) {
|
||||
@@ -658,24 +775,24 @@ public class AihrMemoryService {
|
||||
}
|
||||
return new MemoryCandidateResponse(
|
||||
row.id(), row.version() + 1, merged.status(), merged.memoryType(), ASSISTANT_CAPTURE,
|
||||
merged.draft(), merged.missingFields(), format(row.expiresAt()));
|
||||
merged.draft(), merged.missingFields(), format(row.expiresAt()), readSource(row.sourceSnapshotJson()));
|
||||
}
|
||||
|
||||
private MemoryCandidateResponse saveCandidate(AihrKnowledgePrincipal principal, CandidatePlan plan,
|
||||
String conversationId, String requestId) {
|
||||
String conversationId, String requestId, SourceSnapshot source) {
|
||||
String sourceConversationId = truncate(clean(conversationId), 64);
|
||||
String sourceRequestId = truncate(clean(requestId), 64);
|
||||
String candidateHash = hash(new CandidateFingerprint(plan.memoryType(), plan.projectCode(), plan.draft()));
|
||||
LocalDateTime expiresAt = LocalDateTime.now().withNano(0).plusHours(24);
|
||||
LocalDateTime expiresAt = LocalDateTime.now(WORK_ZONE).withNano(0).plusHours(24);
|
||||
KeyHolder key = new GeneratedKeyHolder();
|
||||
try {
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
insert into aihr_memory_candidate
|
||||
(tenant_id, owner_user_id, source_conversation_id, source_request_id, candidate_hash,
|
||||
project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
|
||||
confidence, version, expires_at, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now())
|
||||
project_code, work_date, memory_type, target_domain, status, draft_json, missing_fields_json,
|
||||
source_snapshot_json, confidence, version, expires_at, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now())
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setString(1, principal.tenantId());
|
||||
statement.setLong(2, principal.userId());
|
||||
@@ -687,13 +804,15 @@ public class AihrMemoryService {
|
||||
} else {
|
||||
statement.setNull(6, Types.VARCHAR);
|
||||
}
|
||||
statement.setString(7, plan.memoryType());
|
||||
statement.setString(8, ASSISTANT_CAPTURE);
|
||||
statement.setString(9, plan.status());
|
||||
statement.setString(10, json(plan.draft()));
|
||||
statement.setString(11, json(plan.missingFields()));
|
||||
statement.setDouble(12, plan.confidence());
|
||||
statement.setTimestamp(13, Timestamp.valueOf(expiresAt));
|
||||
statement.setObject(7, parseWorkDate(plan.draft().workDate(), plan.draft().occurredAt()));
|
||||
statement.setString(8, plan.memoryType());
|
||||
statement.setString(9, ASSISTANT_CAPTURE);
|
||||
statement.setString(10, plan.status());
|
||||
statement.setString(11, json(plan.draft()));
|
||||
statement.setString(12, json(plan.missingFields()));
|
||||
statement.setString(13, json(source));
|
||||
statement.setDouble(14, plan.confidence());
|
||||
statement.setTimestamp(15, Timestamp.valueOf(expiresAt));
|
||||
return statement;
|
||||
}, key);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
@@ -704,13 +823,13 @@ public class AihrMemoryService {
|
||||
throw new ServiceException("创建记忆候选失败", 500);
|
||||
}
|
||||
return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), ASSISTANT_CAPTURE,
|
||||
plan.draft(), plan.missingFields(), format(expiresAt));
|
||||
plan.draft(), plan.missingFields(), format(expiresAt), source);
|
||||
}
|
||||
|
||||
private MemoryCandidateResponse findCandidateBySource(AihrKnowledgePrincipal principal, String sourceRequestId,
|
||||
String candidateHash) {
|
||||
List<CandidateRow> rows = jdbcTemplate.query("""
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json,
|
||||
target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at
|
||||
from aihr_memory_candidate
|
||||
where tenant_id = ? and owner_user_id = ? and source_request_id = ? and candidate_hash = ?
|
||||
@@ -725,7 +844,7 @@ public class AihrMemoryService {
|
||||
|
||||
private CandidateRow requireCandidateForUpdate(AihrKnowledgePrincipal principal, Long id) {
|
||||
List<CandidateRow> rows = jdbcTemplate.query("""
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
|
||||
select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, source_snapshot_json,
|
||||
target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at
|
||||
from aihr_memory_candidate
|
||||
where tenant_id = ? and owner_user_id = ? and id = ?
|
||||
@@ -749,6 +868,8 @@ public class AihrMemoryService {
|
||||
throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
LocalDateTime occurredAt = parseDateTime(draft.occurredAt(), "occurredAt", false);
|
||||
LocalDate workDate = parseWorkDate(draft.workDate(), draft.occurredAt()).toLocalDate();
|
||||
String businessStatus = normalizeBusinessStatus(draft.businessStatus(), candidate.memoryType());
|
||||
LocalDateTime followUpAt = Boolean.TRUE.equals(request.enableReminder())
|
||||
? parseDateTime(draft.followUpAt(), "followUpAt", false)
|
||||
: null;
|
||||
@@ -757,7 +878,7 @@ public class AihrMemoryService {
|
||||
long captureId;
|
||||
try {
|
||||
captureId = insertAssistantCapture(principal, candidate, draft, projectCode, saveScope,
|
||||
deliveryStatus, occurredAt, followUpAt, idempotencyKey, requestHash);
|
||||
deliveryStatus, businessStatus, workDate, occurredAt, followUpAt, idempotencyKey, requestHash);
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_memory_candidate
|
||||
set status = 'CONFIRMED', target_domain = ?, target_id = ?, target_version = 1,
|
||||
@@ -778,38 +899,42 @@ public class AihrMemoryService {
|
||||
|
||||
private long insertAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft,
|
||||
String projectCode, String saveScope, String deliveryStatus,
|
||||
LocalDateTime occurredAt, LocalDateTime followUpAt,
|
||||
String businessStatus, LocalDate workDate, LocalDateTime occurredAt,
|
||||
LocalDateTime followUpAt,
|
||||
String idempotencyKey, String requestHash) {
|
||||
KeyHolder key = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
insert into aihr_assistant_capture
|
||||
(tenant_id, owner_user_id, project_code, save_scope, suggested_type,
|
||||
(tenant_id, owner_user_id, project_code, work_date, save_scope, suggested_type,
|
||||
building_name, unit_name, room_no, category, summary, detail, occurred_at,
|
||||
follow_up_at, source_conversation_id, source_request_id, delivery_status,
|
||||
follow_up_at, source_conversation_id, source_request_id, source_snapshot_json,
|
||||
business_status, delivery_status,
|
||||
confirm_idempotency_key, confirm_request_hash, version, create_time, update_time)
|
||||
select tenant_id, owner_user_id, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
source_conversation_id, source_request_id, ?, ?, ?, 1, now(), now()
|
||||
select tenant_id, owner_user_id, ?, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
source_conversation_id, source_request_id, source_snapshot_json, ?, ?, ?, ?, 1, now(), now()
|
||||
from aihr_memory_candidate
|
||||
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT'
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
if (hasText(projectCode)) statement.setString(1, projectCode); else statement.setNull(1, Types.VARCHAR);
|
||||
statement.setString(2, saveScope);
|
||||
nullable(statement, 3, draft.buildingName());
|
||||
nullable(statement, 4, draft.unitName());
|
||||
nullable(statement, 5, draft.roomNo());
|
||||
statement.setString(6, defaultCategory(draft.category(), candidate.memoryType()));
|
||||
statement.setString(7, draft.summary());
|
||||
nullable(statement, 8, draft.detail());
|
||||
statement.setTimestamp(9, Timestamp.valueOf(occurredAt));
|
||||
if (followUpAt == null) statement.setNull(10, Types.TIMESTAMP);
|
||||
else statement.setTimestamp(10, Timestamp.valueOf(followUpAt));
|
||||
statement.setString(11, deliveryStatus);
|
||||
statement.setString(12, idempotencyKey);
|
||||
statement.setString(13, requestHash);
|
||||
statement.setString(14, principal.tenantId());
|
||||
statement.setLong(15, principal.userId());
|
||||
statement.setLong(16, candidate.id());
|
||||
statement.setObject(2, workDate);
|
||||
statement.setString(3, saveScope);
|
||||
nullable(statement, 4, draft.buildingName());
|
||||
nullable(statement, 5, draft.unitName());
|
||||
nullable(statement, 6, draft.roomNo());
|
||||
statement.setString(7, defaultCategory(draft.category(), candidate.memoryType()));
|
||||
statement.setString(8, draft.summary());
|
||||
nullable(statement, 9, draft.detail());
|
||||
statement.setTimestamp(10, Timestamp.valueOf(occurredAt));
|
||||
if (followUpAt == null) statement.setNull(11, Types.TIMESTAMP);
|
||||
else statement.setTimestamp(11, Timestamp.valueOf(followUpAt));
|
||||
statement.setString(12, businessStatus);
|
||||
statement.setString(13, deliveryStatus);
|
||||
statement.setString(14, idempotencyKey);
|
||||
statement.setString(15, requestHash);
|
||||
statement.setString(16, principal.tenantId());
|
||||
statement.setLong(17, principal.userId());
|
||||
statement.setLong(18, candidate.id());
|
||||
return statement;
|
||||
}, key);
|
||||
Number id = key.getKey();
|
||||
@@ -939,14 +1064,17 @@ public class AihrMemoryService {
|
||||
safeText(draft.detail(), 4000, "detail"),
|
||||
upper(draft.status()),
|
||||
normalizeDateTime(draft.occurredAt(), "occurredAt"),
|
||||
normalizeOptionalDateTime(draft.followUpAt(), "followUpAt")
|
||||
normalizeOptionalDateTime(draft.followUpAt(), "followUpAt"),
|
||||
normalizeWorkDate(draft.workDate(), draft.occurredAt()),
|
||||
normalizeBusinessStatus(draft.businessStatus(), memoryType)
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt(),
|
||||
draft.workDate(), draft.businessStatus());
|
||||
}
|
||||
|
||||
private static void requireLocation(MemoryDraft draft) {
|
||||
@@ -990,12 +1118,26 @@ public class AihrMemoryService {
|
||||
if (hasText(title)) {
|
||||
return title;
|
||||
}
|
||||
if (!Set.of("SERVICE_LEAD", "RESIDENT_PROFILE").contains(memoryType)) {
|
||||
return suggestedCategory(memoryType);
|
||||
}
|
||||
String location = clean(draft.buildingName()) + clean(draft.unitName()) + clean(draft.roomNo());
|
||||
return suggestedCategory(memoryType) + ":"
|
||||
+ (location.isEmpty() ? "待补充位置" : location);
|
||||
}
|
||||
|
||||
private static String suggestedType(String query) {
|
||||
if (containsAny(query, "晨会", "应到", "实到", "请假", "到岗", "出勤")) {
|
||||
return "ATTENDANCE";
|
||||
}
|
||||
if (containsAny(query, "巡检", "巡视", "卫生问题", "设施问题", "停车问题", "现场发现",
|
||||
"地面", "泥迹", "污渍", "垃圾", "积水", "异味", "破损", "损坏", "松动", "堵塞",
|
||||
"漏水", "故障", "停运", "乱停", "占道", "堆放")) {
|
||||
return "INSPECTION";
|
||||
}
|
||||
if (containsAny(query, "服务住户", "安抚住户", "协助业主", "接待业主", "业主服务")) {
|
||||
return "SERVICE_RECORD";
|
||||
}
|
||||
if (containsAny(query, "已解决", "处理完成", "已经恢复", "成功处理", "优秀案例", "经验案例")) {
|
||||
return "CASE";
|
||||
}
|
||||
@@ -1022,10 +1164,17 @@ public class AihrMemoryService {
|
||||
case "CASE" -> "优秀案例";
|
||||
case "PERSONAL_NOTE" -> "个人想法";
|
||||
case "FOLLOW_UP" -> "待跟进事项";
|
||||
case "ATTENDANCE" -> "到岗/晨会记录";
|
||||
case "INSPECTION" -> "巡检问题";
|
||||
case "SERVICE_RECORD" -> "业主服务记录";
|
||||
default -> "工作记录";
|
||||
};
|
||||
}
|
||||
|
||||
private static String initialBusinessStatus(String memoryType) {
|
||||
return Set.of("INSPECTION", "FOLLOW_UP").contains(memoryType) ? "PENDING" : "RECORDED";
|
||||
}
|
||||
|
||||
private static boolean sameCapture(String existingType, MemoryDraft existing, CandidatePlan fresh) {
|
||||
if (!clean(existingType).equals(fresh.memoryType())) return false;
|
||||
String oldBuilding = clean(existing.buildingName());
|
||||
@@ -1069,7 +1218,7 @@ public class AihrMemoryService {
|
||||
: ("服务需求".equals(category) ? "住户提出服务需求" : "住户提出" + category + "需求");
|
||||
return new MemoryDraft(
|
||||
input.projectCode(), input.buildingName(), input.unitName(), input.roomNo(), category, title, summary, "",
|
||||
input.status(), input.occurredAt(), input.followUpAt());
|
||||
input.status(), input.occurredAt(), input.followUpAt(), input.workDate(), input.businessStatus());
|
||||
}
|
||||
|
||||
private static String resolveProjectCode(Set<String> rawProjectCodes, String query) {
|
||||
@@ -1174,7 +1323,7 @@ public class AihrMemoryService {
|
||||
return new CandidateRow(
|
||||
rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"),
|
||||
rs.getString("target_domain"), rs.getString("status"), rs.getString("draft_json"),
|
||||
rs.getString("missing_fields_json"), (Long) rs.getObject("target_id"),
|
||||
rs.getString("missing_fields_json"), rs.getString("source_snapshot_json"), (Long) rs.getObject("target_id"),
|
||||
(Integer) rs.getObject("target_version"), rs.getInt("version"),
|
||||
rs.getString("confirm_idempotency_key"), rs.getString("confirm_request_hash"),
|
||||
toLocalDateTime(rs.getTimestamp("expires_at"))
|
||||
@@ -1184,7 +1333,63 @@ public class AihrMemoryService {
|
||||
private MemoryCandidateResponse candidateResponse(CandidateRow row) {
|
||||
return new MemoryCandidateResponse(row.id(), row.version(), row.status(), row.memoryType(),
|
||||
row.targetDomain(), readDraft(row.draftJson()), readStrings(row.missingFieldsJson()),
|
||||
format(row.expiresAt()));
|
||||
format(row.expiresAt()), readSource(row.sourceSnapshotJson()));
|
||||
}
|
||||
|
||||
private AssistantCaptureResponse assistantCapture(AihrKnowledgePrincipal principal, Long captureId) {
|
||||
List<AssistantCaptureResponse> rows = jdbcTemplate.query("""
|
||||
select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no,
|
||||
category, summary, detail, occurred_at, follow_up_at, work_date, business_status,
|
||||
delivery_status, create_time, source_snapshot_json
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and id = ?
|
||||
and (owner_user_id = ? or project_code in (%s))
|
||||
limit 1
|
||||
""".formatted(projectPlaceholders(principal)), (rs, rowNum) -> mapAssistantCapture(rs),
|
||||
captureAccessArgs(principal, captureId));
|
||||
if (rows.isEmpty()) throw new ServiceException("工作记录不存在或无权访问", 404);
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private AssistantCaptureResponse mapAssistantCapture(ResultSet rs) throws SQLException {
|
||||
return new AssistantCaptureResponse(
|
||||
rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"),
|
||||
rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"),
|
||||
rs.getString("room_no"), rs.getString("category"), rs.getString("summary"),
|
||||
rs.getString("detail"), format(rs.getTimestamp("occurred_at")),
|
||||
format(rs.getTimestamp("follow_up_at")), rs.getString("work_date"),
|
||||
rs.getString("business_status"), rs.getString("delivery_status"),
|
||||
format(rs.getTimestamp("create_time")), readSource(rs.getString("source_snapshot_json"))
|
||||
);
|
||||
}
|
||||
|
||||
private String projectPlaceholders(AihrKnowledgePrincipal principal) {
|
||||
return placeholders(Math.max(1, supervisorProjects(principal).size()));
|
||||
}
|
||||
|
||||
private Object[] captureAccessArgs(AihrKnowledgePrincipal principal, Long captureId) {
|
||||
List<Object> args = new ArrayList<>();
|
||||
args.add(principal.tenantId());
|
||||
args.add(captureId);
|
||||
args.add(principal.userId());
|
||||
Set<String> projects = supervisorProjects(principal);
|
||||
if (projects.isEmpty()) args.add("");
|
||||
else args.addAll(projects);
|
||||
return args.toArray();
|
||||
}
|
||||
|
||||
private Set<String> supervisorProjects(AihrKnowledgePrincipal principal) {
|
||||
boolean supervisor = principal.roles().stream().map(AihrMemoryService::upper)
|
||||
.anyMatch(role -> Set.of("SUPERVISOR", "PROJECT_MANAGER", "ADMIN", "SYS_ADMIN").contains(role));
|
||||
if (!supervisor) return Set.of();
|
||||
if (!"app_user".equals(principal.userType())) return principal.projectCodes();
|
||||
if (!hasText(principal.extPartyId())) return Set.of();
|
||||
return new java.util.LinkedHashSet<>(jdbcTemplate.queryForList("""
|
||||
select distinct project_code
|
||||
from aihr_org_snapshot
|
||||
where tenant_id = ? and ext_party_id = ? and employment_status = 'active'
|
||||
and position_level in ('主管', '项目经理')
|
||||
""", String.class, principal.tenantId(), principal.extPartyId()));
|
||||
}
|
||||
|
||||
private ServiceMemoryResponse mapServiceMemory(ResultSet rs, List<ServiceMemoryVersionResponse> versions)
|
||||
@@ -1246,6 +1451,34 @@ public class AihrMemoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private SourceSnapshot readSource(String json) {
|
||||
if (!hasText(json)) return null;
|
||||
try {
|
||||
return objectMapper.readValue(json, SourceSnapshot.class);
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException("工作来源快照无法读取", 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static SourceSnapshot normalizeSource(SourceSnapshot source) {
|
||||
SourceSnapshot value = source == null
|
||||
? new SourceSnapshot("TEXT", "", null, null, null, null, null)
|
||||
: source;
|
||||
String type = upper(value.sourceType());
|
||||
if (!Set.of("TEXT", "VOICE", "IMAGE", "VIDEO").contains(type)) type = "TEXT";
|
||||
Long ossId = value.ossId() != null && value.ossId() > 0 ? value.ossId() : null;
|
||||
Long size = value.fileSize() != null && value.fileSize() >= 0 ? value.fileSize() : null;
|
||||
return new SourceSnapshot(
|
||||
type,
|
||||
truncate(clean(value.transcript()), 4000),
|
||||
ossId,
|
||||
truncate(clean(value.fileName()), 255),
|
||||
truncate(clean(value.mimeType()), 100),
|
||||
size,
|
||||
truncate(clean(value.recognizedSummary()), 2000)
|
||||
);
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
@@ -1308,6 +1541,37 @@ public class AihrMemoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeWorkDate(String rawWorkDate, String occurredAt) {
|
||||
return parseWorkDate(rawWorkDate, occurredAt).toLocalDate().toString();
|
||||
}
|
||||
|
||||
private static java.sql.Date parseWorkDate(String rawWorkDate, String occurredAt) {
|
||||
LocalDate date = parseDate(rawWorkDate, "workDate");
|
||||
if (date == null) {
|
||||
LocalDateTime occurred = parseDateTime(occurredAt, "occurredAt", false);
|
||||
date = occurred.toLocalDate();
|
||||
}
|
||||
return java.sql.Date.valueOf(date);
|
||||
}
|
||||
|
||||
private static LocalDate inferWorkDate(String query, LocalDate fallback) {
|
||||
if (containsAny(query, "前天")) return fallback.minusDays(2);
|
||||
if (containsAny(query, "昨天", "昨日")) return fallback.minusDays(1);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static String normalizeBusinessStatus(String raw, String memoryType) {
|
||||
String value = upper(raw);
|
||||
if (value.isEmpty()) {
|
||||
if (memoryType == null) throw badRequest("businessStatus 必填");
|
||||
value = initialBusinessStatus(memoryType);
|
||||
}
|
||||
if (!BUSINESS_STATUSES.contains(value)) {
|
||||
throw badRequest("businessStatus 仅支持 RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String format(Timestamp value) {
|
||||
return value == null ? null : format(value.toLocalDateTime());
|
||||
}
|
||||
@@ -1437,6 +1701,7 @@ public class AihrMemoryService {
|
||||
String status,
|
||||
String draftJson,
|
||||
String missingFieldsJson,
|
||||
String sourceSnapshotJson,
|
||||
Long targetId,
|
||||
Integer targetVersion,
|
||||
int version,
|
||||
@@ -1458,8 +1723,15 @@ public class AihrMemoryService {
|
||||
String projectCode,
|
||||
String memoryType,
|
||||
String draftJson,
|
||||
String sourceSnapshotJson,
|
||||
int version,
|
||||
LocalDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
private record CaptureStatusRow(Long id, String projectCode, String businessStatus) {
|
||||
}
|
||||
|
||||
private record CaptureStatusReplay(Long captureId, String toStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
+115
-33
@@ -101,27 +101,31 @@ public class AihrOrgSyncService {
|
||||
Map<String, CompanyInfo> companies = companyMap(companyItems);
|
||||
Map<String, DepartmentInfo> departments = departmentMap(departmentItems);
|
||||
Map<String, ProjectInfo> projects = projectMap(projectItems);
|
||||
Map<String, AssignmentInfo> assignments = assignmentMap(assignmentItems);
|
||||
Map<String, List<AssignmentInfo>> assignmentGroups = assignmentGroups(assignmentItems);
|
||||
|
||||
List<OrgRow> rows = new ArrayList<>();
|
||||
int skipped = 0;
|
||||
int maskedPhone = 0;
|
||||
int phoneLinked = 0;
|
||||
for (JsonNode employee : employeeItems) {
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, assignments);
|
||||
if (row == null) {
|
||||
List<OrgRow> employeeRows = orgRows(employee, companies, departments, projects, assignmentGroups);
|
||||
if (employeeRows.isEmpty()) {
|
||||
skipped++;
|
||||
} else {
|
||||
if (row.personPhone().isBlank() && hasUnusablePhoneCandidate(employee)) {
|
||||
if (employeeRows.get(0).personPhone().isBlank() && hasUnusablePhoneCandidate(employee)) {
|
||||
maskedPhone++;
|
||||
}
|
||||
rows.add(row);
|
||||
if (!employeeRows.get(0).personPhone().isBlank()) {
|
||||
phoneLinked++;
|
||||
}
|
||||
rows.addAll(employeeRows);
|
||||
}
|
||||
}
|
||||
int phoneLinked = (int) rows.stream().filter(row -> !row.personPhone().isBlank()).count();
|
||||
int suspectText = (int) rows.stream().filter(OrgRow::suspectText).count();
|
||||
int duplicatePartyIds = countDuplicatePartyIds(rows.stream().map(OrgRow::extPartyId).toList());
|
||||
if (!rows.isEmpty() && phoneLinked < rows.size()) {
|
||||
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + rows.size() + ",移动端正式登录仅可映射已带手机号人员");
|
||||
int duplicateMemberships = countDuplicateMemberships(rows);
|
||||
int mappedEmployees = employeeItems.size() - skipped;
|
||||
if (mappedEmployees > 0 && phoneLinked < mappedEmployees) {
|
||||
warnings.add("外部员工快照手机号覆盖 " + phoneLinked + "/" + mappedEmployees + ",移动端正式登录仅可映射已带手机号人员");
|
||||
}
|
||||
if (suspectText > 0) {
|
||||
warnings.add("外部员工快照存在 " + suspectText + " 条疑似乱码字段,请检查开放接口编码或导入链路");
|
||||
@@ -129,17 +133,17 @@ public class AihrOrgSyncService {
|
||||
if (maskedPhone > 0) {
|
||||
warnings.add("外部员工快照存在 " + maskedPhone + " 条脱敏/不可用手机号,不能用于移动端正式登录映射");
|
||||
}
|
||||
if (duplicatePartyIds > 0) {
|
||||
warnings.add("外部员工快照存在 " + duplicatePartyIds + " 条重复 ext_party_id,写入会被唯一键折叠,请先修复上游快照");
|
||||
if (duplicateMemberships > 0) {
|
||||
warnings.add("外部员工快照存在 " + duplicateMemberships + " 条重复项目成员关系,请先修复上游快照");
|
||||
}
|
||||
if (!dryRun && replaceExisting && rows.isEmpty()) {
|
||||
throw new IllegalArgumentException("外部员工快照为空,已阻止覆盖本地组织人员快照");
|
||||
}
|
||||
if (!dryRun && duplicatePartyIds > 0) {
|
||||
throw new IllegalArgumentException("外部员工快照存在重复 ext_party_id,已阻止写入组织人员快照;请先修复上游身份数据");
|
||||
if (!dryRun && duplicateMemberships > 0) {
|
||||
throw new IllegalArgumentException("外部员工快照存在重复项目成员关系,已阻止写入组织人员快照;请先修复上游身份数据");
|
||||
}
|
||||
if (!dryRun && replaceExisting && !allowPartialReplace
|
||||
&& hasUnsafeReplaceData(employeeItems.size(), rows.size(), skipped, phoneLinked, maskedPhone, suspectText)) {
|
||||
&& hasUnsafeReplaceData(employeeItems.size(), mappedEmployees, skipped, phoneLinked, maskedPhone, suspectText)) {
|
||||
throw new IllegalArgumentException("外部员工快照存在不完整或疑似异常数据,已阻止覆盖本地组织人员快照;请先 dry-run,确认后显式传 allowPartialReplace=true");
|
||||
}
|
||||
if (!dryRun && !rows.isEmpty()) {
|
||||
@@ -182,6 +186,17 @@ public class AihrOrgSyncService {
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
private static int countDuplicateMemberships(List<OrgRow> rows) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
int duplicates = 0;
|
||||
for (OrgRow row : rows) {
|
||||
if (!seen.add(row.projectCode() + "\u0000" + row.extPartyId())) {
|
||||
duplicates++;
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
public OrgSnapshotResponse snapshot(String keyword, String projectCode, String positionLevel, String status,
|
||||
Integer pageNum, Integer pageSize, Integer limit) {
|
||||
requireSnapshotTable();
|
||||
@@ -364,7 +379,6 @@ public class AihrOrgSyncService {
|
||||
position_name, position_level, employment_status, hire_date, snapshot_date, create_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now())
|
||||
on duplicate key update
|
||||
project_code = values(project_code),
|
||||
project_name = values(project_name),
|
||||
dept_name = values(dept_name),
|
||||
person_phone = values(person_phone),
|
||||
@@ -464,6 +478,34 @@ public class AihrOrgSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private List<OrgRow> orgRows(JsonNode employee, Map<String, CompanyInfo> companies,
|
||||
Map<String, DepartmentInfo> departments, Map<String, ProjectInfo> projects,
|
||||
Map<String, List<AssignmentInfo>> assignmentGroups) {
|
||||
String employeeNumber = firstNonBlank(text(employee, "employee_number", "employeeNumber", "employeeNo"));
|
||||
String employeeId = firstNonBlank(text(employee, "employee_id", "employeeId", "id", "user_id", "userId"));
|
||||
String extPartyId = firstNonBlank(employeeNumber, employeeId);
|
||||
if (extPartyId.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<AssignmentInfo> assignments = assignmentList(assignmentGroups, employeeId, employeeNumber, extPartyId);
|
||||
if (assignments.isEmpty()) {
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, Map.of());
|
||||
return row == null ? List.of() : List.of(row);
|
||||
}
|
||||
List<OrgRow> rows = new ArrayList<>();
|
||||
for (AssignmentInfo assignment : assignments) {
|
||||
Map<String, AssignmentInfo> selected = new TreeMap<>();
|
||||
putAssignment(selected, employeeId, assignment);
|
||||
putAssignment(selected, employeeNumber, assignment);
|
||||
putAssignment(selected, extPartyId, assignment);
|
||||
OrgRow row = orgRow(employee, companies, departments, projects, selected);
|
||||
if (row != null) {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<String, CompanyInfo> companyMap(List<JsonNode> items) {
|
||||
Map<String, CompanyInfo> map = new TreeMap<>();
|
||||
for (JsonNode item : items) {
|
||||
@@ -522,28 +564,68 @@ public class AihrOrgSyncService {
|
||||
if ("departed".equals(status(text(item, "status")))) {
|
||||
continue;
|
||||
}
|
||||
String employeeId = firstNonBlank(text(item, "employee_id", "employeeId"));
|
||||
String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber"));
|
||||
AssignmentInfo info = new AssignmentInfo(
|
||||
employeeId,
|
||||
employeeNumber,
|
||||
firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""),
|
||||
firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""),
|
||||
firstNonBlank(text(item, "project_id", "projectId"), ""),
|
||||
firstNonBlank(text(item, "project_code", "projectCode"), ""),
|
||||
firstNonBlank(text(item, "project_name", "projectName"), ""),
|
||||
firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""),
|
||||
firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""),
|
||||
firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""),
|
||||
firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""),
|
||||
bool(item, "is_primary", "isPrimary") || "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType"))
|
||||
);
|
||||
putAssignment(map, employeeId, info);
|
||||
putAssignment(map, employeeNumber, info);
|
||||
AssignmentInfo info = assignmentInfo(item);
|
||||
putAssignment(map, info.employeeId(), info);
|
||||
putAssignment(map, info.employeeNumber(), info);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, List<AssignmentInfo>> assignmentGroups(List<JsonNode> items) {
|
||||
Map<String, List<AssignmentInfo>> groups = new TreeMap<>();
|
||||
for (JsonNode item : items) {
|
||||
if ("departed".equals(status(text(item, "status")))) {
|
||||
continue;
|
||||
}
|
||||
AssignmentInfo info = assignmentInfo(item);
|
||||
putAssignmentGroup(groups, info.employeeId(), info);
|
||||
putAssignmentGroup(groups, info.employeeNumber(), info);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static AssignmentInfo assignmentInfo(JsonNode item) {
|
||||
String employeeId = firstNonBlank(text(item, "employee_id", "employeeId"));
|
||||
String employeeNumber = firstNonBlank(text(item, "employee_number", "employeeNumber"));
|
||||
return new AssignmentInfo(
|
||||
employeeId,
|
||||
employeeNumber,
|
||||
firstNonBlank(text(item, "employee_department_id", "employeeDepartmentId"), ""),
|
||||
firstNonBlank(text(item, "employee_department_name", "employeeDepartmentName"), ""),
|
||||
firstNonBlank(text(item, "project_id", "projectId"), ""),
|
||||
firstNonBlank(text(item, "project_code", "projectCode"), ""),
|
||||
firstNonBlank(text(item, "project_name", "projectName"), ""),
|
||||
firstNonBlank(text(item, "project_department_id", "projectDepartmentId"), ""),
|
||||
firstNonBlank(text(item, "project_department_name", "projectDepartmentName"), ""),
|
||||
firstNonBlank(text(item, "project_position_name", "projectPositionName"), ""),
|
||||
firstNonBlank(text(item, "project_position_level", "projectPositionLevel"), ""),
|
||||
bool(item, "is_primary", "isPrimary")
|
||||
|| "primary".equalsIgnoreCase(text(item, "assignment_type", "assignmentType"))
|
||||
);
|
||||
}
|
||||
|
||||
private static List<AssignmentInfo> assignmentList(Map<String, List<AssignmentInfo>> groups, String... keys) {
|
||||
for (String key : keys) {
|
||||
List<AssignmentInfo> assignments = groups.get(clean(key));
|
||||
if (assignments != null) {
|
||||
return assignments;
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static void putAssignmentGroup(Map<String, List<AssignmentInfo>> groups, String key,
|
||||
AssignmentInfo info) {
|
||||
String cleanKey = clean(key);
|
||||
if (cleanKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
List<AssignmentInfo> assignments = groups.computeIfAbsent(cleanKey, ignored -> new ArrayList<>());
|
||||
if (!assignments.contains(info)) {
|
||||
assignments.add(info);
|
||||
}
|
||||
}
|
||||
|
||||
private static AssignmentInfo assignment(Map<String, AssignmentInfo> assignments, String... keys) {
|
||||
for (String key : keys) {
|
||||
AssignmentInfo info = assignments.get(clean(key));
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package org.dromara.aihr.workresult;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.ProjectWorkResultResponse;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@SaCheckLogin
|
||||
@RequestMapping("/api/aihr/work-results")
|
||||
public class AihrWorkResultController {
|
||||
|
||||
private final AihrWorkResultService workResultService;
|
||||
|
||||
@PostMapping("/mine/generate")
|
||||
public R<WorkResultResponse> generateMine(
|
||||
@RequestParam("projectCode") String projectCode,
|
||||
@RequestParam(value = "workDate", required = false) String workDate
|
||||
) {
|
||||
return R.ok(workResultService.generateMine(projectCode, workDate));
|
||||
}
|
||||
|
||||
@GetMapping("/project")
|
||||
public R<ProjectWorkResultResponse> project(
|
||||
@RequestParam("projectCode") String projectCode,
|
||||
@RequestParam(value = "workDate", required = false) String workDate
|
||||
) {
|
||||
return R.ok(workResultService.project(projectCode, workDate));
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package org.dromara.aihr.workresult;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AihrWorkResultDto {
|
||||
|
||||
private AihrWorkResultDto() {
|
||||
}
|
||||
|
||||
public record WorkResultItem(
|
||||
Long captureId,
|
||||
String suggestedType,
|
||||
String category,
|
||||
String summary,
|
||||
String businessStatus,
|
||||
String deliveryStatus,
|
||||
String occurredAt,
|
||||
String sourceType,
|
||||
String sourceUrl
|
||||
) {
|
||||
}
|
||||
|
||||
public record WorkResultResponse(
|
||||
Long ownerUserId,
|
||||
String ownerName,
|
||||
String projectCode,
|
||||
String projectName,
|
||||
String workDate,
|
||||
Integer version,
|
||||
String generatedAt,
|
||||
Map<String, List<WorkResultItem>> sections,
|
||||
Integer total,
|
||||
Integer pending
|
||||
) {
|
||||
}
|
||||
|
||||
public record ProjectWorkResultResponse(
|
||||
String projectCode,
|
||||
String projectName,
|
||||
String workDate,
|
||||
Integer contributorCount,
|
||||
Integer resultCount,
|
||||
Integer pendingCount,
|
||||
Integer highPriorityCount,
|
||||
List<WorkResultResponse> members
|
||||
) {
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package org.dromara.aihr.workresult;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.ProjectWorkResultResponse;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultItem;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse;
|
||||
import org.dromara.common.core.constant.HttpStatus;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.HexFormat;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AihrWorkResultService {
|
||||
|
||||
private static final ZoneId WORK_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final Set<String> SUPERVISOR_ROLES = Set.of("SUPERVISOR", "PROJECT_MANAGER", "ADMIN", "SYS_ADMIN");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AihrKnowledgePrincipalResolver principalResolver;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public WorkResultResponse generateMine(String rawProjectCode, String rawWorkDate) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
String projectCode = requireProject(principal, rawProjectCode);
|
||||
LocalDate workDate = workDate(rawWorkDate);
|
||||
WorkResultResponse draft = build(principal.tenantId(), principal.userId(), projectCode, workDate, 0, "");
|
||||
List<Long> sourceIds = allItems(draft).stream().map(WorkResultItem::captureId).toList();
|
||||
String contentHash = contentHash(draft);
|
||||
List<Snapshot> snapshots = jdbcTemplate.query("""
|
||||
select version, content_hash, generated_at
|
||||
from aihr_daily_work_result
|
||||
where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ?
|
||||
""", (rs, rowNum) -> new Snapshot(
|
||||
rs.getInt("version"), rs.getString("content_hash"), rs.getTimestamp("generated_at")),
|
||||
principal.tenantId(), principal.userId(), projectCode, workDate);
|
||||
if (!snapshots.isEmpty() && contentHash.equals(snapshots.get(0).contentHash())) {
|
||||
Snapshot snapshot = snapshots.get(0);
|
||||
return build(principal.tenantId(), principal.userId(), projectCode, workDate,
|
||||
snapshot.version(), format(snapshot.generatedAt()));
|
||||
}
|
||||
int version = snapshots.isEmpty() ? 1 : snapshots.get(0).version() + 1;
|
||||
LocalDateTime generatedAtValue = LocalDateTime.now(WORK_ZONE).withNano(0);
|
||||
String generatedAt = generatedAtValue.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
WorkResultResponse result = build(principal.tenantId(), principal.userId(), projectCode,
|
||||
workDate, version, generatedAt);
|
||||
jdbcTemplate.update("""
|
||||
insert into aihr_daily_work_result
|
||||
(tenant_id, owner_user_id, project_code, work_date, report_json,
|
||||
source_capture_ids_json, content_hash, version, generated_at, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
on duplicate key update report_json = values(report_json),
|
||||
source_capture_ids_json = values(source_capture_ids_json),
|
||||
content_hash = values(content_hash), version = values(version),
|
||||
generated_at = values(generated_at), update_time = now()
|
||||
""", principal.tenantId(), principal.userId(), projectCode, workDate,
|
||||
json(result), json(sourceIds), contentHash, version, Timestamp.valueOf(generatedAtValue));
|
||||
return result;
|
||||
}
|
||||
|
||||
public ProjectWorkResultResponse project(String rawProjectCode, String rawWorkDate) {
|
||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||
String projectCode = requireProject(principal, rawProjectCode);
|
||||
requireSupervisor(principal, projectCode);
|
||||
LocalDate date = workDate(rawWorkDate);
|
||||
List<Long> owners = jdbcTemplate.queryForList("""
|
||||
select distinct owner_user_id
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and project_code = ? and work_date = ?
|
||||
order by owner_user_id
|
||||
""", Long.class, principal.tenantId(), projectCode, date);
|
||||
List<WorkResultResponse> members = owners.stream()
|
||||
.map(owner -> build(principal.tenantId(), owner, projectCode, date, snapshotVersion(
|
||||
principal.tenantId(), owner, projectCode, date)))
|
||||
.toList();
|
||||
int pending = members.stream().mapToInt(WorkResultResponse::pending).sum();
|
||||
int high = members.stream().flatMap(item -> allItems(item).stream())
|
||||
.mapToInt(item -> "INSPECTION".equals(item.suggestedType())
|
||||
&& !Set.of("COMPLETED", "VOID").contains(item.businessStatus()) ? 1 : 0).sum();
|
||||
return new ProjectWorkResultResponse(
|
||||
projectCode, projectName(principal.tenantId(), projectCode), date.toString(), owners.size(),
|
||||
members.stream().mapToInt(item -> item.total() > 0 ? 1 : 0).sum(), pending, high, members);
|
||||
}
|
||||
|
||||
private WorkResultResponse build(String tenantId, Long ownerUserId, String projectCode,
|
||||
LocalDate workDate, int version) {
|
||||
return build(tenantId, ownerUserId, projectCode, workDate, version,
|
||||
LocalDateTime.now(WORK_ZONE).withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
}
|
||||
|
||||
private WorkResultResponse build(String tenantId, Long ownerUserId, String projectCode,
|
||||
LocalDate workDate, int version, String generatedAt) {
|
||||
Map<String, List<WorkResultItem>> sections = emptySections();
|
||||
List<WorkResultItem> rows = jdbcTemplate.query("""
|
||||
select id, suggested_type, category, summary, business_status, delivery_status,
|
||||
occurred_at, source_snapshot_json
|
||||
from aihr_assistant_capture
|
||||
where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ?
|
||||
order by occurred_at, id
|
||||
""", (rs, rowNum) -> mapItem(rs), tenantId, ownerUserId, projectCode, workDate);
|
||||
for (WorkResultItem item : rows) {
|
||||
sections.get(section(item)).add(item);
|
||||
}
|
||||
int pending = (int) rows.stream()
|
||||
.filter(item -> Set.of("PENDING", "IN_PROGRESS").contains(item.businessStatus())).count();
|
||||
return new WorkResultResponse(
|
||||
ownerUserId, ownerName(tenantId, ownerUserId), projectCode, projectName(tenantId, projectCode),
|
||||
workDate.toString(), version, generatedAt,
|
||||
sections, rows.size(), pending);
|
||||
}
|
||||
|
||||
private WorkResultItem mapItem(ResultSet rs) throws SQLException {
|
||||
String sourceType = "TEXT";
|
||||
try {
|
||||
Map<?, ?> source = objectMapper.readValue(rs.getString("source_snapshot_json"), Map.class);
|
||||
Object detected = source.get("sourceType");
|
||||
sourceType = detected == null ? "TEXT" : String.valueOf(detected);
|
||||
} catch (Exception ignored) {
|
||||
// Old records have no source snapshot and are treated as text records.
|
||||
}
|
||||
long id = rs.getLong("id");
|
||||
return new WorkResultItem(
|
||||
id, rs.getString("suggested_type"), rs.getString("category"), rs.getString("summary"),
|
||||
rs.getString("business_status"), rs.getString("delivery_status"),
|
||||
format(rs.getTimestamp("occurred_at")), sourceType,
|
||||
"/api/aihr/personal-assistant/assistant-captures/" + id + "/source");
|
||||
}
|
||||
|
||||
private static Map<String, List<WorkResultItem>> emptySections() {
|
||||
Map<String, List<WorkResultItem>> sections = new LinkedHashMap<>();
|
||||
for (String key : List.of("completed", "inspection", "services", "leads", "attendance", "pending")) {
|
||||
sections.put(key, new ArrayList<>());
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
static String section(WorkResultItem item) {
|
||||
if (Set.of("PENDING", "IN_PROGRESS").contains(item.businessStatus())) return "pending";
|
||||
return switch (item.suggestedType()) {
|
||||
case "ATTENDANCE" -> "attendance";
|
||||
case "INSPECTION" -> "inspection";
|
||||
case "SERVICE_RECORD" -> "services";
|
||||
case "SERVICE_LEAD", "RESIDENT_PROFILE" -> "leads";
|
||||
default -> "completed";
|
||||
};
|
||||
}
|
||||
|
||||
private static List<WorkResultItem> allItems(WorkResultResponse response) {
|
||||
return response.sections().values().stream().flatMap(List::stream).toList();
|
||||
}
|
||||
|
||||
private String projectName(String tenantId, String projectCode) {
|
||||
List<String> rows = jdbcTemplate.queryForList("""
|
||||
select project_name from aihr_org_snapshot
|
||||
where tenant_id = ? and project_code = ? and employment_status = 'active'
|
||||
order by id limit 1
|
||||
""", String.class, tenantId, projectCode);
|
||||
return rows.isEmpty() ? "当前项目" : rows.get(0);
|
||||
}
|
||||
|
||||
private String ownerName(String tenantId, Long ownerUserId) {
|
||||
List<String> rows = jdbcTemplate.queryForList("""
|
||||
select coalesce(nullif(nick_name, ''), user_name)
|
||||
from sys_user where tenant_id = ? and user_id = ? limit 1
|
||||
""", String.class, tenantId, ownerUserId);
|
||||
return rows.isEmpty() ? "员工" : rows.get(0);
|
||||
}
|
||||
|
||||
private int snapshotVersion(String tenantId, Long owner, String projectCode, LocalDate date) {
|
||||
List<Integer> rows = jdbcTemplate.queryForList("""
|
||||
select version from aihr_daily_work_result
|
||||
where tenant_id = ? and owner_user_id = ? and project_code = ? and work_date = ?
|
||||
""", Integer.class, tenantId, owner, projectCode, date);
|
||||
return rows.isEmpty() ? 0 : rows.get(0);
|
||||
}
|
||||
|
||||
private static String requireProject(AihrKnowledgePrincipal principal, String rawProjectCode) {
|
||||
String projectCode = rawProjectCode == null ? "" : rawProjectCode.trim();
|
||||
if (projectCode.isEmpty()) throw new ServiceException("请先选择当前项目", HttpStatus.BAD_REQUEST);
|
||||
if (!principal.projectCodes().contains(projectCode)) {
|
||||
throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return projectCode;
|
||||
}
|
||||
|
||||
private void requireSupervisor(AihrKnowledgePrincipal principal, String projectCode) {
|
||||
boolean allowed = principal.roles().stream().map(value -> value.toUpperCase(Locale.ROOT))
|
||||
.anyMatch(SUPERVISOR_ROLES::contains);
|
||||
if (!allowed) throw new ServiceException("无权查看团队工作成果", HttpStatus.FORBIDDEN);
|
||||
if (!"app_user".equals(principal.userType())) return;
|
||||
Integer rows = jdbcTemplate.queryForObject("""
|
||||
select count(*) from aihr_org_snapshot
|
||||
where tenant_id = ? and ext_party_id = ? and project_code = ?
|
||||
and employment_status = 'active' and position_level in ('主管', '项目经理')
|
||||
""", Integer.class, principal.tenantId(), principal.extPartyId(), projectCode);
|
||||
if (rows == null || rows < 1) {
|
||||
throw new ServiceException("当前项目没有主管权限", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate workDate(String raw) {
|
||||
if (raw == null || raw.isBlank()) return LocalDate.now(WORK_ZONE);
|
||||
try {
|
||||
return LocalDate.parse(raw.trim());
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException("workDate 必须为 yyyy-MM-dd", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException("工作成果序列化失败", 500);
|
||||
}
|
||||
}
|
||||
|
||||
String contentHash(WorkResultResponse response) {
|
||||
Map<String, Object> content = new LinkedHashMap<>();
|
||||
content.put("sections", response.sections());
|
||||
content.put("total", response.total());
|
||||
content.put("pending", response.pending());
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(json(content).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (Exception ex) {
|
||||
throw new ServiceException("工作成果指纹生成失败", 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static String format(java.sql.Timestamp value) {
|
||||
return value == null ? null : value.toLocalDateTime().withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
}
|
||||
|
||||
private record Snapshot(int version, String contentHash, Timestamp generatedAt) {
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import org.dromara.aihr.domain.AihrOrgSyncDto.OrgPersonRow;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrMobileControllerTest {
|
||||
|
||||
@Test
|
||||
void projectOptionsUseNamesAndDeduplicateCodes() {
|
||||
var rows = List.of(
|
||||
row(1L, "P2", "星河湾二期"),
|
||||
row(2L, "P1", "星河湾一期"),
|
||||
row(3L, "P1", "星河湾一期"),
|
||||
row(4L, "P3", "")
|
||||
);
|
||||
|
||||
var projects = AihrMobileController.projectOptions(rows);
|
||||
|
||||
assertEquals(List.of("P3", "P1", "P2"), projects.stream().map(item -> item.projectCode()).toList());
|
||||
assertEquals("P3", projects.get(0).projectName());
|
||||
assertEquals("星河湾一期", projects.get(1).projectName());
|
||||
}
|
||||
|
||||
private static OrgPersonRow row(Long id, String projectCode, String projectName) {
|
||||
return new OrgPersonRow(id, projectCode, projectName, "客服部", "employee-1", "王敏",
|
||||
"生活顾问", "一线", "active", "2026-07-21");
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -48,6 +48,25 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
@Tag("dev")
|
||||
class AihrKnowledgeQueryServiceTest {
|
||||
|
||||
@Test
|
||||
void selectedProjectMustBelongToCurrentEmployee() {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
var appService = mock(AihrKnowledgeAppService.class);
|
||||
var sop = mock(AihrSopSeedService.class);
|
||||
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
|
||||
Set.of("employee"), Set.of("P1"), "app");
|
||||
when(resolver.current()).thenReturn(principal);
|
||||
var service = service(resolver, appService, mock(AihrKnowledgeAccessService.class), sop);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class, () -> service.queryInternal(
|
||||
new QueryRequest("记一下巡检问题", List.of(), "sop", null, "mobile", 5, null,
|
||||
"conversation_123", 0L, "P2")));
|
||||
|
||||
assertEquals(403, error.getCode());
|
||||
verify(appService, never()).requireSessionApp(anyString(), anyString());
|
||||
verify(sop, never()).searchAuthorized(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyEffectiveScopeStopsBeforeRag() {
|
||||
var resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
@@ -264,7 +283,7 @@ class AihrKnowledgeQueryServiceTest {
|
||||
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);
|
||||
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString(), any())).thenReturn(candidate);
|
||||
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
|
||||
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
|
||||
mock(AihrKnowledgeConversationService.class), memory);
|
||||
@@ -279,7 +298,7 @@ class AihrKnowledgeQueryServiceTest {
|
||||
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)
|
||||
.contains("\"memoryCandidate\""));
|
||||
verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"),
|
||||
any(), eq(result.requestId()));
|
||||
any(), eq(result.requestId()), 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"));
|
||||
}
|
||||
@@ -307,7 +326,7 @@ class AihrKnowledgeQueryServiceTest {
|
||||
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);
|
||||
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString(), any())).thenReturn(candidate);
|
||||
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit,
|
||||
mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
|
||||
mock(AihrKnowledgeConversationService.class), memory);
|
||||
|
||||
+24
@@ -12,7 +12,9 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -97,6 +99,28 @@ class AihrKnowledgeConversationServiceTest {
|
||||
assertEquals(409, error.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingConversationCannotBeReusedAcrossProjects() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
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("project_code")).thenReturn("P1");
|
||||
when(rs.getLong("version")).thenReturn(1L);
|
||||
when(rs.getString("context_json")).thenReturn("[]");
|
||||
when(rs.getTimestamp("expires_time")).thenReturn(java.sql.Timestamp.valueOf("2099-01-01 00:00:00"));
|
||||
return List.of(mapper.mapRow(rs, 0));
|
||||
});
|
||||
var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class));
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class, () -> service.prepare(principal(), app(),
|
||||
new QueryRequest("继续", List.of(), "sop", null, "test", 5, null,
|
||||
"conversation_123", 1L, "P2")));
|
||||
|
||||
assertEquals(409, error.getCode());
|
||||
assertTrue(error.getMessage().contains("项目已切换"));
|
||||
}
|
||||
|
||||
private static AihrKnowledgePrincipal principal() {
|
||||
return new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
|
||||
Set.of("employee"), Set.of("P1"), "app");
|
||||
|
||||
+13
-1
@@ -138,7 +138,7 @@ class AihrMemoryServiceTest {
|
||||
|
||||
assertNotNull(plan);
|
||||
assertEquals("", plan.draft().roomNo());
|
||||
assertTrue(plan.missingFields().contains("roomNo"));
|
||||
assertFalse(plan.missingFields().contains("roomNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,6 +147,18 @@ class AihrMemoryServiceTest {
|
||||
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "电梯困人应该怎么处理?", NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propertyInspectionLanguageCreatesPendingInspectionWithoutRoomRequirement() {
|
||||
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
|
||||
"C5栋大厅地面有泥迹,请安排处理,帮我记一下", NOW);
|
||||
|
||||
assertNotNull(plan);
|
||||
assertEquals("INSPECTION", plan.memoryType());
|
||||
assertEquals("巡检问题", plan.draft().title());
|
||||
assertEquals("PENDING", plan.draft().businessStatus());
|
||||
assertTrue(plan.missingFields().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameConversationLocationFollowupUpgradesOriginalNeedsInputCandidate() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
+30
@@ -121,6 +121,36 @@ public class AihrOrgSyncServiceTest {
|
||||
assertEquals("主管", ReflectionTestUtils.getField(row, "positionLevel"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oneEmployeeCanProduceMembershipRowsForMultipleProjects() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
AihrOrgSyncService service = new AihrOrgSyncService(
|
||||
new ObjectMapper(), mock(JdbcTemplate.class), mock(TransactionTemplate.class));
|
||||
ReflectionTestUtils.setField(service, "storeDisplayFields", true);
|
||||
var employee = mapper.readTree("""
|
||||
{"id":"EMP-1","employee_number":"YC001","name":"测试员工","phone":"13900001111"}
|
||||
""");
|
||||
List<?> assignments = List.of(
|
||||
mapper.readTree("""
|
||||
{"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-1",
|
||||
"project_code":"FW001","project_name":"翡翠湾","project_position_name":"生活顾问","status":"active"}
|
||||
"""),
|
||||
mapper.readTree("""
|
||||
{"employee_id":"EMP-1","employee_number":"YC001","project_id":"P-2",
|
||||
"project_code":"YSF001","project_name":"云山府","project_position_name":"生活顾问","status":"active"}
|
||||
""")
|
||||
);
|
||||
|
||||
Object groups = ReflectionTestUtils.invokeMethod(service, "assignmentGroups", assignments);
|
||||
List<?> rows = ReflectionTestUtils.invokeMethod(service, "orgRows", employee,
|
||||
Map.of(), Map.of(), Map.of(), groups);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(List.of("FW001", "YSF001"), rows.stream()
|
||||
.map(row -> String.valueOf(ReflectionTestUtils.getField(row, "projectCode"))).toList());
|
||||
assertTrue(rows.stream().allMatch(row -> "YC001".equals(ReflectionTestUtils.getField(row, "extPartyId"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numericRawPositionLevelFallsBackToPositionName() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package org.dromara.aihr.workresult;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
||||
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultItem;
|
||||
import org.dromara.aihr.workresult.AihrWorkResultDto.WorkResultResponse;
|
||||
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 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.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrWorkResultServiceTest {
|
||||
|
||||
@Test
|
||||
void sectionUsesBusinessStatusBeforeSuggestedType() {
|
||||
assertEquals("pending", AihrWorkResultService.section(item(1L, "ATTENDANCE", "IN_PROGRESS")));
|
||||
assertEquals("attendance", AihrWorkResultService.section(item(2L, "ATTENDANCE", "COMPLETED")));
|
||||
assertEquals("inspection", AihrWorkResultService.section(item(3L, "INSPECTION", "RECORDED")));
|
||||
assertEquals("services", AihrWorkResultService.section(item(4L, "SERVICE_RECORD", "RECORDED")));
|
||||
assertEquals("leads", AihrWorkResultService.section(item(5L, "SERVICE_LEAD", "RECORDED")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentHashIgnoresSnapshotVersionAndGenerationTimeButTracksStatus() {
|
||||
AihrWorkResultService service = new AihrWorkResultService(null, new ObjectMapper(), null);
|
||||
WorkResultResponse first = response(1, "2026-07-21T08:00:00", "RECORDED");
|
||||
WorkResultResponse reopened = response(9, "2026-07-21T10:00:00", "RECORDED");
|
||||
WorkResultResponse changed = response(9, "2026-07-21T10:00:00", "COMPLETED");
|
||||
|
||||
assertEquals(service.contentHash(first), service.contentHash(reopened));
|
||||
assertNotEquals(service.contentHash(first), service.contentHash(changed));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supervisorRoleFromAnotherProjectDoesNotGrantTeamResultAccess() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
|
||||
when(resolver.current()).thenReturn(new AihrKnowledgePrincipal(
|
||||
"000000", 7L, "app_user", "EMP-1", Set.of("employee", "supervisor"), Set.of("P1", "P2"), "app"));
|
||||
when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(0);
|
||||
AihrWorkResultService service = new AihrWorkResultService(jdbc, new ObjectMapper(), resolver);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class, () -> service.project("P2", "2026-07-21"));
|
||||
|
||||
assertEquals(403, error.getCode());
|
||||
assertEquals("当前项目没有主管权限", error.getMessage());
|
||||
}
|
||||
|
||||
private static WorkResultResponse response(int version, String generatedAt, String status) {
|
||||
Map<String, List<WorkResultItem>> sections = new LinkedHashMap<>();
|
||||
sections.put("completed", List.of(item(1L, "SERVICE_RECORD", status)));
|
||||
sections.put("inspection", List.of());
|
||||
sections.put("services", List.of());
|
||||
sections.put("leads", List.of());
|
||||
sections.put("attendance", List.of());
|
||||
sections.put("pending", List.of());
|
||||
return new WorkResultResponse(7L, "王敏", "P1", "星河湾一期", "2026-07-21",
|
||||
version, generatedAt, sections, 1, 0);
|
||||
}
|
||||
|
||||
private static WorkResultItem item(long id, String type, String status) {
|
||||
return new WorkResultItem(id, type, "工作记录", "合成测试记录", status, "LOCAL",
|
||||
"2026-07-21T08:00:00", "TEXT", "/source/" + id);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` (
|
||||
`app_id` bigint NOT NULL COMMENT '内部调用应用ID',
|
||||
`user_id` bigint NOT NULL COMMENT '内部用户ID',
|
||||
`conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '当前项目编码;多项目会话严格隔离',
|
||||
`version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本',
|
||||
`context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文',
|
||||
`expires_time` datetime NOT NULL COMMENT '不活跃过期时间',
|
||||
|
||||
@@ -20,7 +20,8 @@ CREATE TABLE `aihr_org_snapshot` (
|
||||
`snapshot_date` date DEFAULT NULL COMMENT '快照日期',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `ext_party_id`),
|
||||
UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`),
|
||||
KEY `idx_aihr_org_party` (`tenant_id`, `ext_party_id`),
|
||||
KEY `idx_aihr_org_phone` (`tenant_id`, `person_phone`),
|
||||
KEY `idx_aihr_org_project` (`tenant_id`, `project_code`, `position_level`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织人员静态快照(Demo)';
|
||||
|
||||
@@ -8,11 +8,13 @@ CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
|
||||
`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 '经当前身份解析的真实项目编码',
|
||||
`work_date` date DEFAULT NULL COMMENT 'Asia/Shanghai 自然工作日期',
|
||||
`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 '待补充字段',
|
||||
`source_snapshot_json` json DEFAULT NULL COMMENT '最小必要来源快照与受保护OSS引用',
|
||||
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
|
||||
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
|
||||
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
|
||||
@@ -83,6 +85,7 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '记录员工',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码',
|
||||
`work_date` date DEFAULT NULL COMMENT 'Asia/Shanghai 自然工作日期',
|
||||
`save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY',
|
||||
`suggested_type` varchar(30) NOT NULL COMMENT 'SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE',
|
||||
`building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋',
|
||||
@@ -95,6 +98,8 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
|
||||
`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',
|
||||
`source_snapshot_json` json DEFAULT NULL COMMENT '最小必要来源快照与受保护OSS引用',
|
||||
`business_status` varchar(30) NOT NULL DEFAULT 'RECORDED' COMMENT 'RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID',
|
||||
`delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED',
|
||||
`delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数',
|
||||
`next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间',
|
||||
@@ -108,9 +113,44 @@ CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
|
||||
KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`),
|
||||
KEY `idx_aihr_assistant_capture_daily` (`tenant_id`, `project_code`, `work_date`, `owner_user_id`),
|
||||
KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture_status_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '状态变更ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`capture_id` bigint NOT NULL COMMENT '统一采集记录ID',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '项目编码',
|
||||
`from_status` varchar(30) NOT NULL COMMENT '变更前业务状态',
|
||||
`to_status` varchar(30) NOT NULL COMMENT '变更后业务状态',
|
||||
`note` varchar(500) DEFAULT NULL COMMENT '处理备注',
|
||||
`operator_user_id` bigint NOT NULL COMMENT '操作人',
|
||||
`idempotency_key` varchar(100) NOT NULL COMMENT '状态更新幂等键',
|
||||
`create_time` datetime NOT NULL COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_capture_status_idempotency` (`tenant_id`, `operator_user_id`, `idempotency_key`),
|
||||
KEY `idx_aihr_capture_status_capture` (`tenant_id`, `capture_id`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手业务状态变更历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_daily_work_result` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '今日工作成果ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '员工用户ID',
|
||||
`project_code` varchar(100) NOT NULL COMMENT '项目编码',
|
||||
`work_date` date NOT NULL COMMENT 'Asia/Shanghai 自然工作日期',
|
||||
`report_json` json NOT NULL COMMENT '确定性聚合后的成果快照',
|
||||
`source_capture_ids_json` json NOT NULL COMMENT '来源采集记录ID',
|
||||
`content_hash` char(64) NOT NULL DEFAULT '' COMMENT '忽略生成时间和版本的成果内容指纹',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '重新生成版本',
|
||||
`generated_at` datetime NOT NULL COMMENT '生成时间',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_daily_work_result` (`tenant_id`, `owner_user_id`, `project_code`, `work_date`),
|
||||
KEY `idx_aihr_daily_work_result_project` (`tenant_id`, `project_code`, `work_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工按项目按自然日工作成果快照';
|
||||
|
||||
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
|
||||
SET @aihr_memory_collation := (
|
||||
SELECT collation_name
|
||||
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` (
|
||||
`app_id` bigint NOT NULL COMMENT '内部调用应用ID',
|
||||
`user_id` bigint NOT NULL COMMENT '内部用户ID',
|
||||
`conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '当前项目编码;多项目会话严格隔离',
|
||||
`version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本',
|
||||
`context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文',
|
||||
`expires_time` datetime NOT NULL COMMENT '不活跃过期时间',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 同一员工可属于多个项目;身份仍以 ext_party_id 为主,成员关系以项目隔离。
|
||||
-- 可重复执行,先替换旧的 tenant+ext_party_id 唯一键,再补身份查询索引。
|
||||
|
||||
SET @aihr_org_table_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot'
|
||||
);
|
||||
SET @aihr_org_unique_columns := (
|
||||
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot'
|
||||
AND index_name = 'uk_aihr_org_party'
|
||||
);
|
||||
SET @ddl := IF(
|
||||
@aihr_org_table_exists = 1 AND COALESCE(@aihr_org_unique_columns, '') <> 'tenant_id,project_code,ext_party_id',
|
||||
IF(
|
||||
@aihr_org_unique_columns IS NULL,
|
||||
'ALTER TABLE `aihr_org_snapshot` ADD UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`)',
|
||||
'ALTER TABLE `aihr_org_snapshot` DROP INDEX `uk_aihr_org_party`, ADD UNIQUE KEY `uk_aihr_org_party` (`tenant_id`, `project_code`, `ext_party_id`)'
|
||||
),
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
@aihr_org_table_exists = 1 AND NOT EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot'
|
||||
AND index_name = 'idx_aihr_org_party'
|
||||
),
|
||||
'ALTER TABLE `aihr_org_snapshot` ADD KEY `idx_aihr_org_party` (`tenant_id`, `ext_party_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := NULL;
|
||||
SET @aihr_org_table_exists := NULL;
|
||||
SET @aihr_org_unique_columns := NULL;
|
||||
@@ -0,0 +1,118 @@
|
||||
-- 工作助手项目隔离、自然日成果、来源追溯与本地状态闭环。
|
||||
-- 可重复执行;外部流转仍沿用 PENDING,不在本迁移中伪造 DELIVERED。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` varchar(20) NOT NULL,
|
||||
`app_id` bigint NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`conversation_id` varchar(64) NOT NULL,
|
||||
`project_code` varchar(100) DEFAULT NULL,
|
||||
`version` bigint NOT NULL DEFAULT 0,
|
||||
`context_json` json DEFAULT NULL,
|
||||
`expires_time` datetime NOT NULL,
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_knowledge_conversation` (`tenant_id`, `app_id`, `user_id`, `conversation_id`),
|
||||
KEY `idx_aihr_knowledge_conversation_expire` (`tenant_id`, `expires_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识问答短期会话';
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_conversation' AND column_name = 'project_code'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_knowledge_conversation` ADD COLUMN `project_code` varchar(100) DEFAULT NULL COMMENT ''当前项目编码;多项目会话严格隔离'' AFTER `conversation_id`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate' AND column_name = 'work_date'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_memory_candidate` ADD COLUMN `work_date` date DEFAULT NULL COMMENT ''Asia/Shanghai 自然工作日期'' AFTER `project_code`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate' AND column_name = 'source_snapshot_json'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_memory_candidate` ADD COLUMN `source_snapshot_json` json DEFAULT NULL COMMENT ''最小必要来源快照与受保护OSS引用'' AFTER `missing_fields_json`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'work_date'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `work_date` date DEFAULT NULL COMMENT ''Asia/Shanghai 自然工作日期'' AFTER `project_code`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'source_snapshot_json'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `source_snapshot_json` json DEFAULT NULL COMMENT ''最小必要来源快照与受保护OSS引用'' AFTER `source_request_id`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND column_name = 'business_status'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `business_status` varchar(30) NOT NULL DEFAULT ''RECORDED'' COMMENT ''RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID'' AFTER `source_snapshot_json`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `aihr_memory_candidate`
|
||||
SET `work_date` = DATE(`create_time`)
|
||||
WHERE `work_date` IS NULL;
|
||||
UPDATE `aihr_assistant_capture`
|
||||
SET `work_date` = DATE(COALESCE(`occurred_at`, `create_time`))
|
||||
WHERE `work_date` IS NULL;
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture' AND index_name = 'idx_aihr_assistant_capture_daily'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_assistant_capture` ADD KEY `idx_aihr_assistant_capture_daily` (`tenant_id`, `project_code`, `work_date`, `owner_user_id`)'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture_status_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` varchar(20) NOT NULL,
|
||||
`capture_id` bigint NOT NULL,
|
||||
`project_code` varchar(100) DEFAULT NULL,
|
||||
`from_status` varchar(30) NOT NULL,
|
||||
`to_status` varchar(30) NOT NULL,
|
||||
`note` varchar(500) DEFAULT NULL,
|
||||
`operator_user_id` bigint NOT NULL,
|
||||
`idempotency_key` varchar(100) NOT NULL,
|
||||
`create_time` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_capture_status_idempotency` (`tenant_id`, `operator_user_id`, `idempotency_key`),
|
||||
KEY `idx_aihr_capture_status_capture` (`tenant_id`, `capture_id`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手业务状态变更历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_daily_work_result` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`tenant_id` varchar(20) NOT NULL,
|
||||
`owner_user_id` bigint NOT NULL,
|
||||
`project_code` varchar(100) NOT NULL,
|
||||
`work_date` date NOT NULL,
|
||||
`report_json` json NOT NULL,
|
||||
`source_capture_ids_json` json NOT NULL,
|
||||
`content_hash` char(64) NOT NULL DEFAULT '',
|
||||
`version` int NOT NULL DEFAULT 1,
|
||||
`generated_at` datetime NOT NULL,
|
||||
`create_time` datetime NOT NULL,
|
||||
`update_time` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_daily_work_result` (`tenant_id`, `owner_user_id`, `project_code`, `work_date`),
|
||||
KEY `idx_aihr_daily_work_result_project` (`tenant_id`, `project_code`, `work_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工按项目按自然日工作成果快照';
|
||||
|
||||
SET @ddl := IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_daily_work_result' AND column_name = 'content_hash'),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_daily_work_result` ADD COLUMN `content_hash` char(64) NOT NULL DEFAULT '''' COMMENT ''成果内容指纹;用于幂等生成'' AFTER `source_capture_ids_json`'
|
||||
);
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := NULL;
|
||||
Reference in New Issue
Block a user