feat: add project-scoped work assistant results

This commit is contained in:
2026-07-21 04:44:15 +08:00
parent 53b15e506c
commit 4536d607e3
75 changed files with 3281 additions and 507 deletions
@@ -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)));
@@ -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
) {
}
@@ -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
@@ -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);
}
}
@@ -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) {
}
}
@@ -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, "?"));
}
@@ -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));
}
}
@@ -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
) {
}
@@ -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) {
}
}
@@ -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));
@@ -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));
}
}
@@ -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
) {
}
}
@@ -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) {
}
}
@@ -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");
}
}
@@ -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);
@@ -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");
@@ -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();
@@ -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();
@@ -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;